fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -13,6 +13,7 @@ import {
|
||||
type Event,
|
||||
type ResultState,
|
||||
type ResultStatus,
|
||||
type TeamRanksView,
|
||||
} from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
@@ -28,6 +29,15 @@ import {
|
||||
type ResultCellHint,
|
||||
} from './entryGuards'
|
||||
import { entryProgress } from './entryProgress'
|
||||
import {
|
||||
hasTeamManualChannel,
|
||||
teamRankFlaggedTeams,
|
||||
teamRankRowsOf,
|
||||
teamRankWarnings,
|
||||
teamRanksDirty,
|
||||
teamRanksPayload,
|
||||
type TeamRankRow,
|
||||
} from './teamRanks'
|
||||
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
|
||||
import {
|
||||
entryBatchOutcome,
|
||||
@@ -168,6 +178,34 @@ const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
|
||||
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
|
||||
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
|
||||
|
||||
/**
|
||||
* 「团体名次」区块(设计 17 §八第 3 条):只有该组别配了「团体 + 手动名次」通道时才出现。
|
||||
*
|
||||
* 这类通道读的是 channel_manual_ranks(按单位录),与成绩表的个人名次是两套数据,
|
||||
* 所以这里单开一块,而不是塞进成绩表格里。
|
||||
*/
|
||||
const showTeamRanks = computed(() => hasTeamManualChannel(channels.value))
|
||||
/**
|
||||
* 每个组别各自的编辑中行:组别间互不影响,切走再切回时未保存的输入还在
|
||||
* (成绩表也是这个口径——切换组别不会丢正在编辑的成绩)。
|
||||
*/
|
||||
const teamRanksByCategory = ref(new Map<number, TeamRankRow[]>())
|
||||
/** 录入接口随行下发的团体名次,按组别 id 缓存;0 号键放服务端没带 categoryId 的那一份。 */
|
||||
const embeddedTeamRanks = ref(new Map<number, TeamRanksView>())
|
||||
const teamRanksLoading = ref(false)
|
||||
const teamRanksSaving = ref(false)
|
||||
|
||||
/** 当前组别的团体名次行(表格直接绑它)。 */
|
||||
const teamRankRows = computed<TeamRankRow[]>(() => {
|
||||
const categoryId = selectedCategoryId.value
|
||||
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? [])
|
||||
})
|
||||
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
|
||||
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
|
||||
const teamRankIssues = computed(() => teamRankWarnings(teamRankRows.value))
|
||||
/** 疑似笔误的行:给输入框描红,理由由 teamRankIssues 说清。 */
|
||||
const teamRankIssueTeams = computed(() => teamRankFlaggedTeams(teamRankRows.value))
|
||||
|
||||
function unitOf(type: Event['resultType']): string {
|
||||
if (type === 'time') return 's'
|
||||
if (type === 'distance') return 'm'
|
||||
@@ -354,10 +392,23 @@ const pendingSettlement = computed<SettlementGroup | null>(() => {
|
||||
) ?? null
|
||||
)
|
||||
})
|
||||
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
||||
/**
|
||||
* dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。
|
||||
*
|
||||
* 团体名次的编辑也算在内:它是独立保存的(PUT team-ranks),
|
||||
* 不并入这里的话切页会把它静默丢掉,而它同样要重算名次与积分。
|
||||
*/
|
||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)) || teamRankDirty.value)
|
||||
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
||||
|
||||
/** 丢弃确认框的明细:成绩与团体名次分开说,别让人看到「0 行」以为没有改动。 */
|
||||
const discardDetails = computed(() => {
|
||||
const details = [`未保存的成绩行:${dirtyCount.value} 行`]
|
||||
if (teamRankDirty.value) details.push('还有未保存的团体名次(该组「团体名次」区块里)')
|
||||
details.push('如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页')
|
||||
return details
|
||||
})
|
||||
|
||||
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
|
||||
const leaveGuard = createLeaveGuard(() => dirty.value)
|
||||
|
||||
@@ -678,6 +729,7 @@ function selectCategory(id: number): void {
|
||||
selectedCategoryId.value = id
|
||||
selectedHeat.value = 'all'
|
||||
selectedEntryIds.value = []
|
||||
void loadTeamRanks()
|
||||
}
|
||||
|
||||
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
|
||||
@@ -692,21 +744,30 @@ async function loadEvent(id: number): Promise<void> {
|
||||
const seq = (requestSeq += 1)
|
||||
loading.value = true
|
||||
try {
|
||||
const [event, entries, records] = await Promise.all([
|
||||
// 用 entryBoard 而不是 entries:同一次请求就会带回团体手工名次(设计 17 §七),
|
||||
// 不必再多发一轮 GET team-ranks。
|
||||
const [event, board, records] = await Promise.all([
|
||||
adminApi.eventDetail(id),
|
||||
adminApi.entries(id),
|
||||
adminApi.entryBoard(id),
|
||||
adminApi.records(),
|
||||
])
|
||||
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
|
||||
if (seq !== requestSeq) return
|
||||
detail.value = event
|
||||
avgMode.value = loadAvgMode(id, event.avgMode)
|
||||
// 换项目等于换一份数据:团体名次的草稿与随行视图都先清掉,再归位新的一份。
|
||||
// 归位必须赶在下面改 selectedCategoryId 之前——组别 watch 会立刻按新组别取数据。
|
||||
teamRanksByCategory.value = new Map()
|
||||
collectEmbeddedTeamRanks(board.teamRanks)
|
||||
selectedCategoryId.value = event.categories?.[0]?.id ?? null
|
||||
selectedHeat.value = 'all'
|
||||
selectedEntryIds.value = []
|
||||
allRows.value = entries.map(toRow)
|
||||
allRows.value = board.entries.map(toRow)
|
||||
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
|
||||
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
|
||||
// 组别已定、随行视图已归位,这时候取团体名次:有随行数据就走缓存,否则发一次 GET。
|
||||
// 不用 watch(selectedCategoryId) 是为了不依赖「赋值后回调晚于归位」这种时序巧合。
|
||||
void loadTeamRanks()
|
||||
} catch (caught) {
|
||||
if (seq !== requestSeq) return
|
||||
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
|
||||
@@ -769,6 +830,114 @@ async function reload(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
|
||||
|
||||
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
|
||||
function collectEmbeddedTeamRanks(views: readonly TeamRanksView[]): void {
|
||||
const next = new Map<number, TeamRanksView>()
|
||||
for (const view of views) next.set(view.categoryId ?? 0, view)
|
||||
embeddedTeamRanks.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个组别的视图:优先用录入接口随行下发的那一份(省一轮请求)。
|
||||
*
|
||||
* 服务端不带 categoryId 时无从归位,只有一个组别的事件才敢直接用它——
|
||||
* 猜错组别会把别的组的名次显示成本组的名次,比多一次请求危险得多,宁可回退到 GET。
|
||||
*/
|
||||
function embeddedTeamRanksFor(categoryId: number): TeamRanksView | null {
|
||||
const exact = embeddedTeamRanks.value.get(categoryId)
|
||||
if (exact !== undefined) return exact
|
||||
return categories.value.length === 1 ? (embeddedTeamRanks.value.get(0) ?? null) : null
|
||||
}
|
||||
|
||||
/** 把最新视图写进缓存:无 categoryId 的旧视图可能就是这个组别的,一并清掉免得盖过新值。 */
|
||||
function cacheTeamRanksView(categoryId: number, view: TeamRanksView): void {
|
||||
const next = new Map(embeddedTeamRanks.value)
|
||||
next.delete(0)
|
||||
next.set(categoryId, view)
|
||||
embeddedTeamRanks.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 载入当前组别的团体名次。
|
||||
*
|
||||
* 已经有未保存输入的组别不重载——否则切走再切回会把操作员刚填的名次冲掉。
|
||||
*/
|
||||
async function loadTeamRanks(): Promise<void> {
|
||||
const eventId = selectedEventId.value
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (eventId === null || categoryId === null || !showTeamRanks.value) {
|
||||
// 该组别没有团体手工通道:即使以前填过也不显示(服务端不会读它)。
|
||||
return
|
||||
}
|
||||
const draft = teamRanksByCategory.value.get(categoryId)
|
||||
if (draft !== undefined && teamRanksDirty(draft)) return
|
||||
const embedded = embeddedTeamRanksFor(categoryId)
|
||||
if (embedded !== null) {
|
||||
writeTeamRanks(categoryId, teamRankRowsOf(embedded, data.teams))
|
||||
return
|
||||
}
|
||||
const seq = requestSeq
|
||||
teamRanksLoading.value = true
|
||||
try {
|
||||
const view = await adminApi.teamRanks(eventId, categoryId)
|
||||
if (seq !== requestSeq || categoryId !== selectedCategoryId.value) return
|
||||
cacheTeamRanksView(categoryId, view)
|
||||
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
|
||||
} catch (caught) {
|
||||
if (seq !== requestSeq) return
|
||||
// 读不到就按空表处理:这是辅助区块,不能挡住成绩录入。
|
||||
writeTeamRanks(categoryId, [])
|
||||
toast.error('读取团体名次失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
if (seq === requestSeq) teamRanksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function writeTeamRanks(categoryId: number, rows: TeamRankRow[]): void {
|
||||
const next = new Map(teamRanksByCategory.value)
|
||||
next.set(categoryId, rows)
|
||||
teamRanksByCategory.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前组别的团体名次(PUT .../team-ranks,整组替换)。
|
||||
*
|
||||
* 保存会重算该组的名次与积分(成绩表的名次列、积分预览、榜单都会变),
|
||||
* 所以成功后按「发布」同一套流程整页刷新一次;服务端回的就是保存后的视图,直接回填。
|
||||
*/
|
||||
async function saveTeamRanks(): Promise<void> {
|
||||
const eventId = selectedEventId.value
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (eventId === null || categoryId === null) return
|
||||
if (data.isMock) {
|
||||
// 与草稿 / 发布同一口径:示例数据模式下写接口没有后端可落库,提示成功等于谎报。
|
||||
toast.error('示例数据模式不能保存团体名次', '后端未就绪,改动不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
if (teamRankRows.value.length === 0) {
|
||||
toast.info('没有可录入的参赛单位')
|
||||
return
|
||||
}
|
||||
teamRanksSaving.value = true
|
||||
try {
|
||||
const view = await adminApi.saveTeamRanks(eventId, categoryId, teamRanksPayload(teamRankRows.value))
|
||||
cacheTeamRanksView(categoryId, view)
|
||||
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
|
||||
toast.success('团体名次已保存', '该组名次与积分榜会按新名次重算')
|
||||
await reloadAfterSettlementChange()
|
||||
} catch (caught) {
|
||||
toast.error('保存团体名次失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
teamRanksSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildDraftPayload(): DraftResultInput[] {
|
||||
// 已发布行由服务端锁定,不能再存草稿(服务端会 409 STATE_INVALID);
|
||||
// 分批发布时本组会同时存在「已发布」与「未发布」的行,只提交未发布的那些。
|
||||
@@ -1087,8 +1256,9 @@ function canBatch(): boolean {
|
||||
return false
|
||||
}
|
||||
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
|
||||
// 团体名次同样是「重算的输入」,也在这里挡住(dirty 已把它算进去)。
|
||||
if (dirty.value) {
|
||||
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
|
||||
toast.error('有未保存的修改', '批量操作会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -1793,6 +1963,72 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
团体名次:按单位录入,只喂给「团体 + 手动名次」的通道(设计 17 §八第 3 条)。
|
||||
与上面的成绩表是两套数据,所以单开一块,且不参与成绩表的行选择 / 批次筛选。
|
||||
-->
|
||||
<section v-if="showTeamRanks" class="mx-card team-ranks-card" :class="{ 'is-readonly': !canWrite }">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">团体名次</span>
|
||||
<div class="team-ranks-actions">
|
||||
<span v-if="teamRanksLoading" class="team-ranks-hint">读取中…</span>
|
||||
<span v-if="teamRankDirty" class="mx-badge mx-badge-warning">未保存</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || teamRanksSaving || !teamRankDirty"
|
||||
:title="teamRankDirty ? '保存本组团体名次并重算名次与积分' : '没有改动'"
|
||||
@click="saveTeamRanks"
|
||||
>
|
||||
<MxIcon name="check" :size="14" /> {{ teamRanksSaving ? '保存中…' : '保存团体名次' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<!--
|
||||
guest / 断线只读时整块置灰并说明原因:团体名次是写操作,
|
||||
读得到、改不了,必须让访客知道为什么输不进去。
|
||||
-->
|
||||
<div v-if="!canWrite" class="team-ranks-readonly-note">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ auth.readOnly ? '当前为访客只读视图,团体名次只能查看,无法录入。' : '服务端未连接,暂时不能录入团体名次。' }}</span>
|
||||
</div>
|
||||
<p class="team-ranks-desc">
|
||||
按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
|
||||
<strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
|
||||
</p>
|
||||
<ul class="team-rank-list">
|
||||
<li v-for="row in teamRankRows" :key="row.teamId" class="team-rank-item">
|
||||
<span class="team-rank-code mono">{{ row.code }}</span>
|
||||
<span class="team-rank-name">{{ row.shortName || row.name }}</span>
|
||||
<input
|
||||
v-model="row.input"
|
||||
class="mx-cell-input rank-input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="teamRankRows.length"
|
||||
:disabled="!canWrite"
|
||||
:class="{ 'is-invalid': teamRankIssueTeams.has(row.teamId) }"
|
||||
:placeholder="`1-${teamRankRows.length}`"
|
||||
:title="teamRankIssueTeams.has(row.teamId) ? '名次重复或超出参赛单位数,请确认' : ''"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="teamRankRows.length === 0" class="team-rank-empty">
|
||||
没有可录入的参赛单位(单位列表为空或都已停用)
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="teamRankIssues.length > 0" class="issue-list team-rank-issues">
|
||||
<li v-for="(message, index) in teamRankIssues" :key="index" class="warn">
|
||||
<MxIcon name="bell" :size="14" />
|
||||
<span>{{ message }}(只提示,不阻止保存)</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="teamRankRows.length > 0" class="team-ranks-foot">
|
||||
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="issues.length > 0" class="mx-card issues-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">防呆校验</span>
|
||||
@@ -1882,10 +2118,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
tone="warning"
|
||||
:title="DISCARD_CONFIRM_TITLE"
|
||||
:message="DISCARD_CONFIRM_MESSAGE"
|
||||
:details="[
|
||||
`未保存的行:${dirtyCount} 行`,
|
||||
'如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页',
|
||||
]"
|
||||
:details="discardDetails"
|
||||
confirm-text="丢弃并继续"
|
||||
cancel-text="留在本页"
|
||||
@confirm="confirmDiscard"
|
||||
@@ -2175,6 +2408,70 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
/*
|
||||
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
|
||||
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
|
||||
*/
|
||||
.team-ranks-card.is-readonly {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.team-ranks-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.team-ranks-readonly-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
font-size: 12px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.team-ranks-desc {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-rank-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
.team-rank-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s2);
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.team-rank-code {
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-rank-name {
|
||||
min-width: 72px;
|
||||
}
|
||||
.team-rank-empty {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-rank-issues {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.team-ranks-foot {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.team-ranks-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared'
|
||||
|
||||
/**
|
||||
* 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。
|
||||
*
|
||||
* 团体名次是**按单位**录入的,与成绩表(按报名项)不是一套数据:只有
|
||||
* `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。
|
||||
* 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」,
|
||||
* 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。
|
||||
*/
|
||||
|
||||
export interface TeamRankRow {
|
||||
teamId: number
|
||||
code: string
|
||||
name: string
|
||||
shortName: string | null
|
||||
/** 输入框里的原文;空串 = 未填(保存时按 0 = 清空发给服务端)。 */
|
||||
input: string
|
||||
/** 载入时的原文:用来判断这一行有没有被改过。 */
|
||||
original: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 该组别是否需要「团体名次」区块。
|
||||
*
|
||||
* 只看通道配置,不看有没有报名:纯团体项目(篮球等)走团体报名项的 results.rank 兜底,
|
||||
* 不需要在这里录;只有配了「团体 + 手动名次」通道的小项才出现。
|
||||
*/
|
||||
export function hasTeamManualChannel(channels: readonly ScoreChannel[] | null | undefined): boolean {
|
||||
return (channels ?? []).some((channel) => channel.scope === 'team' && channel.basis === 'manual')
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框原文 → 请求体里的名次。
|
||||
*
|
||||
* 空、0、负数、非整数一律归一为 0:服务端口径就是「rank 0 = 清空该单位」,
|
||||
* 页面上留空即清空,不需要再让操作员先填一个 0。
|
||||
*/
|
||||
export function parseTeamRankInput(raw: string): number {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return 0
|
||||
const value = Number(trimmed)
|
||||
if (!Number.isInteger(value) || value < 1) return 0
|
||||
return value
|
||||
}
|
||||
|
||||
/** 名次 → 输入框原文;0 / 负数视为未填。 */
|
||||
function inputOfRank(rank: number | undefined): string {
|
||||
return rank !== undefined && rank >= 1 ? String(rank) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装表格行。
|
||||
*
|
||||
* 单位名单优先用接口返回的 `teams`(服务端已经按「启用且未假删除」过滤过);
|
||||
* 接口没带单位时退回全局单位表,同样只列启用中的单位——假删除的单位不该出现在录入界面。
|
||||
*/
|
||||
export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[]): TeamRankRow[] {
|
||||
const ranks = new Map((view?.ranks ?? []).map((entry) => [entry.teamId, entry.rank]))
|
||||
const source =
|
||||
view !== null && view.teams.length > 0
|
||||
? view.teams.map((team) => ({
|
||||
teamId: team.teamId,
|
||||
code: team.code,
|
||||
name: team.name,
|
||||
shortName: team.shortName,
|
||||
}))
|
||||
: teams
|
||||
.filter((team) => team.active && (team.deletedAt === undefined || team.deletedAt === null))
|
||||
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName }))
|
||||
return source.map((team) => {
|
||||
const input = inputOfRank(ranks.get(team.teamId))
|
||||
return { ...team, input, original: input }
|
||||
})
|
||||
}
|
||||
|
||||
export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean {
|
||||
return rows.some((row) => row.input !== row.original)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存载荷:整组回传。
|
||||
*
|
||||
* 服务端按 (小项, 组别, scope, basis) 整组替换,所以每个单位都要出现,
|
||||
* 没填的显式给 0(清空)——漏掉单位等于把它原来的名次留在库里,页面看着却像已经清掉了。
|
||||
*/
|
||||
export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput {
|
||||
return {
|
||||
scope: 'team',
|
||||
basis: 'manual',
|
||||
ranks: rows.map((row) => ({ teamId: row.teamId, rank: parseTeamRankInput(row.input) })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非阻塞提示(只提示不拦截,风格与成绩表的「名次重复」一致)。
|
||||
*
|
||||
* - 名次重复:**允许**,服务端按 tieMode 处理并列,这里只提醒操作员确认;
|
||||
* - 名次超出参赛单位数:一定是笔误,但改不改仍由操作员决定,所以同样不挡保存。
|
||||
*/
|
||||
export function teamRankWarnings(rows: readonly TeamRankRow[]): string[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank >= 1) counts.set(rank, (counts.get(rank) ?? 0) + 1)
|
||||
}
|
||||
const warnings: string[] = []
|
||||
for (const [rank, count] of [...counts.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
if (count > 1) warnings.push(`名次 ${rank} 重复 ${count} 次,允许并列,请确认是否为想要的结果`)
|
||||
}
|
||||
const max = rows.length
|
||||
if (max > 0) {
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank > max) {
|
||||
warnings.push(`${row.shortName ?? row.name}:名次 ${rank} 超出参赛单位数 ${max}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
/** 有问题的单位(名次重复或超出参赛单位数):给输入框描红,理由由 teamRankWarnings 说。 */
|
||||
export function teamRankFlaggedTeams(rows: readonly TeamRankRow[]): Set<number> {
|
||||
const counts = new Map<number, number>()
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank >= 1) counts.set(rank, (counts.get(rank) ?? 0) + 1)
|
||||
}
|
||||
const max = rows.length
|
||||
const flagged = new Set<number>()
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank >= 1 && ((counts.get(rank) ?? 0) > 1 || (max > 0 && rank > max))) flagged.add(row.teamId)
|
||||
}
|
||||
return flagged
|
||||
}
|
||||
Reference in new issue
Block a user