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:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+307 -10
View File
@@ -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;
}
+137
View File
@@ -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
}