Files
Scoreboard/web/src/admin/components/baseData/entrySelection.ts
T
AskaEth d11cab31db fix(entry): 补报名弹窗的搜索框支持「号码布」;版本 dev_0.5.7
## 现场问题(主人原话)
> 成绩录入界面的补报名按钮窗口里面的搜索框只能查姓名和工号,不能查号码布,加一下。

号码布是本系统里**运动员的唯一标识**(定稿口径),现场拿号码布找人是常态,所以这条是刚需。

## 改了什么
- 抽出纯函数 `athleteMatchesKeyword(athlete, keyword)` 作为**选人搜索的唯一口径**:姓名、工号、**号码布** 三维并列;
  关键字归一(`trim()` + `toLowerCase()`)与仓库既有写法逐字一致,空搜索仍不筛人。
- `EntryPicker.vue` **两处**搜索都改成走它:
  - `filteredAthletes`(个人报名名单)
  - `membersByTeam`(团体队员名单)
- 两个搜索框的提示文字从「搜索姓名 / 工号」改成「**搜索姓名 / 工号 / 号码布**」。
- ⚠️ 收口成一个函数的意义:两个框**不会再各走各的** —— 这次就是因为两处各写了一份过滤条件,改一处漏一处。

## 验证
- **先红后绿**:新增 `web/src/admin/tests/athlete-search-bib.test.ts`(号码布能搜到、姓名/工号不回归、大小写与空格处理、空搜索不筛人)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 929 例 0 fail / Console 68 例 0 fail。

## 顺带
`debug/使用说明/` 里落后的 4 份(使用手册 / 升级说明 / 报名名单导入规范 / 使用说明.txt)已补刷,与 `docs/` 逐份一致;
`docs/升级说明.md` 新增 `dev_0.5.7` 一节。

## 版本
`dev_0.5.6` → **`dev_0.5.7`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 21:54:05 +08:00

764 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 报名相关的纯函数:勾选转 CSV、报名行文案,以及报名导入冲突的差异 / 决策计算。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
* (纯团体行是 (组别, 单位, 队伍),见迁移 0025)与行级报错回显都与文件导入完全一致,
* 不必为选择器新增服务端接口。
*/
import type {
ConflictAction,
EntryAthlete,
EntryAthleteInput,
ImportConflict,
ImportConflictDecision,
ImportConflictField,
} from '@/shared'
import { entryRosterHead, teamSquadLabel } from '@/shared'
/**
* 队伍列(squad)的规范名:同一个单位在同一组别可以派多个队(迁移 0025),
* 这一列就是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
*
* 声明在列清单之前:它要出现在清单里(列在最后),而清单是模块加载期求值的。
*/
export const SQUAD_COLUMN = 'squad'
/**
* 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。
*
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
* 队伍(squad)同样可选、同样在最后:留空 = 不区分队伍,老数据与单队单位都是它,
* 报名行为与升级前完全一致(服务端那一列也是 optional,缺列不报错)。
* 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名;
* 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。
*/
export const REQUIRED_COLUMNS = [
'event_code',
'category',
'team_code',
'athlete_name',
'id_card',
'gender',
'age',
'bib',
'seed',
'heat',
'status',
'member_names',
SQUAD_COLUMN,
] as const
/** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */
export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number]
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat', SQUAD_COLUMN] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly ImportColumnName[] = OPTIONAL_COLUMNS
export const MANDATORY_COLUMNS: readonly ImportColumnName[] = REQUIRED_COLUMNS.filter(
(column) => !OPTIONAL_SET.includes(column),
)
// ── 表头:英文规范名 + 中文别名 ──────────────────────────────────────────────
/**
* 每一列的中文别名(第一项即中文模板里的表头写法)。
*
* 与服务端 handlers_config.go 的 importColumns 逐列一致:多一个少一个别名都会造成
* 「示例数据模式能导入、连上服务端却报缺列」,两侧必须同改。
* 英文名是规范名(取值、报错、纯英文模板都用它),中文名只是给现场填表用的别名。
*/
export const COLUMN_ALIASES: Record<ImportColumnName, readonly string[]> = {
event_code: ['项目编码', '小项编码'],
category: ['组别', '组别名称'],
team_code: ['单位编码', '参赛单位编码'],
athlete_name: ['姓名', '运动员姓名'],
id_card: ['证件号', '身份证号'],
gender: ['性别'],
age: ['年龄'],
bib: ['号码布'],
seed: ['种子', '道次'],
heat: ['批次', '批次号'],
status: ['状态', '报名状态'],
member_names: ['队员名单'],
squad: ['队伍', '队名', '队伍名'],
}
/**
* 表头格归一:去首尾空白、全角 ASCII 与全角空格转半角、丢掉 U+FEFF、转小写。
* 与服务端 normalizeImportHeader 同一口径——现场的表是中文 Excel 存的,
* 列名常带全角空格 / 被输入法写成全角字母 / 第一格粘着 BOM。
*/
export function normalizeHeaderCell(raw: string): string {
let folded = ''
for (const char of raw) {
const code = char.codePointAt(0) ?? 0
if (code === 0xfeff) continue
else if (code === 0x3000) folded += ' '
else if (code >= 0xff01 && code <= 0xff5e) folded += String.fromCharCode(code - 0xfee0)
else folded += char
}
return folded.trim().toLowerCase()
}
/** 认一格表头,返回规范列名;认不出的(多余列、备注列)返回 undefined 由调用方忽略。 */
export function lookupImportColumn(raw: string): ImportColumnName | undefined {
const header = normalizeHeaderCell(raw)
if (header === '') return undefined
for (const column of REQUIRED_COLUMNS) {
if (header === column) return column
if (COLUMN_ALIASES[column].some((alias) => header === alias)) return column
}
return undefined
}
/**
* 表头行 → 「规范列名 → 下标」。中英混用允许(每列各自认名字),
* 但同一列出现两种写法(既有 event_code 又有 项目编码)直接抛错:
* 两列都可能有数据,静默取其中一列等于把另一列的内容丢掉。文案与服务端逐字一致。
*/
export function importColumnIndex(header: readonly string[]): Map<ImportColumnName, number> {
const index = new Map<ImportColumnName, number>()
const spelled = new Map<ImportColumnName, string>()
header.forEach((cell, position) => {
const column = lookupImportColumn(cell)
if (column === undefined) return
const previous = spelled.get(column)
if (previous !== undefined) {
throw new Error(
`报名表表头重复:第 1 行里同一列出现了两种写法(${previous}、${cell.trim()}),` +
'请只保留一列再导入(列名中英文都可以,但不能一列写两遍)',
)
}
spelled.set(column, cell.trim())
index.set(column, position)
})
return index
}
/** 缺列提示(与服务端同一句话):中英并列,手拿中文模板的人一眼对得上自己缺的是哪一格。 */
export function missingColumnMessage(column: ImportColumnName): string {
return (
`报名表缺少列 ${column}(${COLUMN_ALIASES[column][0]}):` +
'第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),' +
'请确认没有在表头上方加标题行、也没有把表头写成别的名字'
)
}
// ── CSV 模板:中英两套表头,列顺序与列数完全相同 ──────────────────────────────
export type TemplateLanguage = 'zh' | 'en'
/** 模板表头:en 用英文规范名,zh 用中文别名;两种模板逐列对应,只有表头语言不同。 */
export function templateHeader(language: TemplateLanguage): string[] {
return REQUIRED_COLUMNS.map((column) => (language === 'zh' ? COLUMN_ALIASES[column][0] : column))
}
/** 模板文件名带语言后缀,下载下来一眼分得清是中文还是英文模板。 */
export function templateFileName(language: TemplateLanguage): string {
return `entries-template-${language}.csv`
}
/**
* 分工作表模板(xlsx)的文件名:与单表 CSV 模板同一个命名口径,后缀区分成 xlsx。
*
* 两种模板**不是二选一**:单表 CSV 照样能用(老填表人不用改习惯),分工作表模板只是多了一种
* 「一个小项组别一张表」的组织方式,见 docs/报名名单导入规范.md。
*/
export function templateSheetFileName(language: TemplateLanguage): string {
return `entries-template-${language}.xlsx`
}
/**
* 小项名 / 组别名里出现 @ 的说明,没出现时返回 null。
*
* 与服务端 atSignNameMessage(entries_sheet_template.go)说的是同一件事、同一个理由:
* @ 是分工作表报名模板里「小项名称@组别名称」的分隔符,名字里再出现它,工作表名就拆不开。
* 只说「不能含 @」现场会以为是输入法问题,一定要把「为什么」写出来。
*/
export function atSignNameError(kind: string, name: string): string | null {
if (!name.includes('@') && !name.includes('@')) return null
return `${kind}「${name}」里不能有 @:@ 是分工作表模板中小项与组别的分隔符(表名要写成「小项名称@组别名称」)`
}
/**
* 示例数据模式下点「分工作表模板」的提示。
*
* 分工作表工作簿要按当前赛事的小项 × 组别逐张建表,浏览器里造不出 xlsx(也不该为此引一个库),
* 所以这里明确说「连上服务端才有」,而不是给一份假的下载文件。
*/
export const TEMPLATE_SHEET_MOCK_HINT =
'示例数据模式无法生成分工作表模板:它由服务端按当前赛事的小项与组别逐张建表。请连接服务端后重试,或继续用单表 CSV 模板'
/** 模板里的示例行:两种语言共用,只有表头不同。队名列留空(示例行本来就该被填表人删掉)。 */
const TEMPLATE_ROWS: readonly string[][] = [
['M100', '男子组', 'F1', '张伟', '110101199001011234', '男', '26', '101', '', '1', 'registered', '', ''],
['W100', '女子组', 'F2', '王芳', '', '女', '', '202', '', '2', 'registered', '', ''],
['BB', '混合组', 'F2', '', '', '', '', '', '种子1', '', 'registered', '第一分公司代表队', ''],
]
/** 模板 CSV 文本(不含 BOM,下载时由调用方补)。 */
export function buildTemplateCsv(language: TemplateLanguage): string {
return toCsv([templateHeader(language), ...TEMPLATE_ROWS.map((row) => [...row])])
}
export type SelectionMode = 'individual' | 'team'
/**
* 「加入报名」是否放行。
*
* 个人项目一人一条报名,必须勾人;团体 / 对抗项目一条报名只挂单位(服务端 athlete_id 为 NULL
* 的报名合法),队员名单可以留空后补,所以 0 勾选也要放行。
*/
export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number): boolean {
return mode === 'team' || chosenCount > 0
}
/**
* 选人搜索框的匹配口径:**姓名 / 工号 / 号码布**任一命中即算(大小写不敏感、首尾空格忽略)。
*
* 号码布必须在内:它是本系统里人员的唯一标识(先认证件号,没有证件号就看号码布),
* 现场拿着号码布找人是常态,只能按姓名 / 工号搜等于搜不到人。
*
* 工号与号码布都可能为空(老数据两个都没填、老服务端也不下发),一律当空串处理。
* 关键字全为空白 = 没在搜,返回 true(不能把名单筛空)。
*
* 搜索框是文本框,这里只做字符串比较;**不要**把它的取值喂给 `type="number"` 的输入框
* (那种框的 v-model 会被 Vue 转成 number,清空才留 '',字符串方法会在渲染期抛错)。
*/
export function athleteMatchesKeyword(
athlete: { name: string; employeeNo?: string | null; bib?: string | null },
keyword: string,
): boolean {
const needle = keyword.trim().toLowerCase()
if (needle === '') return true
return (
athlete.name.toLowerCase().includes(needle) ||
(athlete.employeeNo ?? '').toLowerCase().includes(needle) ||
(athlete.bib ?? '').toLowerCase().includes(needle)
)
}
/**
* 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。
*
* 团体行带上队名(`第一分公司二队`):同一个单位的两个队只差这几个字,
* 少了它,防呆报错与批量确认框上的两行会写成同一个名字。
*
* 「首人」的口径没变(一行只念一个人):有队员关联时它是首名队员 `张伟(A001)`——
* 号码布让人对得上是谁(重名时唯一的线索);没有关联时就是文字名单的第一个人,与升级前逐字一样。
*/
export function entryRowLabel(row: {
athleteName?: string | null
memberNames?: string[] | null
athletes?: readonly EntryAthlete[] | null
teamShort?: string | null
teamName?: string | null
squad?: string | null
}): string {
return (
row.athleteName ||
entryRosterHead(row) ||
teamSquadLabel(row.teamShort || row.teamName || '', row.squad)
)
}
export interface SelectionInput {
eventCode: string
categoryName: string
teamCode: string
status?: string
mode: SelectionMode
/**
* 个人项目按 (姓名, 单位) 匹配已有运动员,带上证件号 / 号码布才能命中对应匹配规则。
* `id` 是人员档案的 id:团体项目靠它把勾中的人提交成**队员关联**(写侧只认 athleteId)。
*/
athletes: { id?: number; name: string; idCard?: string | null; bib?: string | null }[]
/**
* 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。
*
* 这是**文字名单**(导入表的 member_names 列),与队员关联并存:没录档案的人只能留文字。
*/
memberNames?: string[]
}
/**
* 生成与导入表头一致的二维数组。
*
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名;
* 队名列留空——个人报名不带队伍(服务端也只对纯团体报名写它)。
*/
export function buildSelectionRows(input: SelectionInput): string[][] {
const status = input.status ?? 'registered'
const rows: string[][] = [[...REQUIRED_COLUMNS]]
if (input.mode === 'individual') {
for (const athlete of input.athletes) {
rows.push([
input.eventCode,
input.categoryName,
input.teamCode,
athlete.name,
athlete.idCard ?? '',
'',
'',
athlete.bib ?? '',
'',
// 批次留空 = 未分批:批次在录入页按现场编排(批量设置 / 一键均分)。
'',
status,
'',
// 队伍留空:个人报名按人走,没有「这个单位的第几支队」这回事。
'',
])
}
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
return buildTeamSelectionRows({
eventCode: input.eventCode,
categoryName: input.categoryName,
status,
teams: [
{
teamCode: input.teamCode,
memberNames: names,
// 文字名单可以覆盖(人手写的名单),队员关联只认勾中的、带档案 id 的那几个人。
athletes: teamAthletesOf(input.athletes),
},
],
})
}
/**
* 勾中的人 → 队员关联(只有带 id 的才算:没有 id 的人只在文字名单里,见 devdocs/20 §五)。
*
* 顺序就是传入顺序(界面上的列表顺序),写进去的 sort_order 跟着它,队里的排列才稳定。
*/
function teamAthletesOf(
athletes: readonly { id?: number; name: string; bib?: string | null }[],
): EntryAthlete[] {
const out: EntryAthlete[] = []
const seen = new Set<number>()
for (const athlete of athletes) {
const athleteId = athlete.id
if (athleteId === undefined || !Number.isInteger(athleteId) || athleteId <= 0) continue
if (seen.has(athleteId)) continue
seen.add(athleteId)
out.push({ athleteId, name: athlete.name, bib: athlete.bib ?? null })
}
return out
}
/** 一个单位的团体报名:单位编码 + 可选队员名单 + 可选的队员关联。 */
export interface TeamSelection {
/**
* 单位 id:提交后要靠它(连同组别与队名)找回刚写进去的那条报名,
* 才谈得上把队员关联挂上去。缺省 = 不打算同步队员(例如只想拿这份选举行生成 CSV)。
*/
teamId?: number
teamCode: string
/**
* 队员姓名(导入表的 member_names 列):空数组表示只报单位,名单之后可以再补。
*
* 与 `athletes` **并存**:这是给没录档案的人留的文字名单,也是老数据唯一有的东西。
*/
memberNames: string[]
/**
* 队员关联(运动员档案里的人):勾中的人进这里,提交后走 PUT /api/entries/{id}/athletes。
* 缺省 / 空数组 = 这条报名不挂任何队员(只报单位)。
*/
athletes?: EntryAthlete[]
/**
* 队伍(自由文本,如「一队」):同一个单位在同一组别派出的第几支队。
* 留空 / 缺省 = 不区分队伍 —— 老数据与单队单位的默认,报名行为与升级前完全一致。
*/
squad?: string
}
export interface TeamSelectionInput {
eventCode: string
categoryName: string
status?: string
/** 一次提交可以覆盖多个单位,勾了哪些单位就产出哪些行。 */
teams: TeamSelection[]
}
/**
* 多单位团体报名:每个单位一行,队员姓名进 member_names,队伍进最后一列。
*
* 服务端对 athlete_name 为空的行按 (组别, 单位, 队伍) upsert,
* 所以同组别 + 同单位 + 同队伍仍然只留一条报名(重复提交是更新队员名单而不是新增),
* 而同一个单位换个队名再提交就是**第二条报名** —— 一个单位派两个队正是这样报的。
* 队伍留空 = 不区分队伍,与服务端那一列(optional)同口径。
*/
export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] {
const status = input.status ?? 'registered'
const rows: string[][] = [[...REQUIRED_COLUMNS]]
for (const team of input.teams) {
rows.push([
input.eventCode,
input.categoryName,
team.teamCode,
'',
'',
'',
'',
'',
'',
// 批次留空 = 未分批,与个人项目一致。
'',
status,
team.memberNames.join('/'),
// 队名两侧空白去掉再写:服务端读表时就 TrimSpace,这里先归一,免得「一队 」与「一队」成了两个队。
(team.squad ?? '').trim(),
])
}
return rows
}
// ── 团体多单位勾选:状态与产出 ──────────────────────────────────────────────
/**
* 团体报名的勾选状态:哪些单位参加 + 每个单位要登记的队员。
*
* 单位与队员分开存:取消勾选单位不清队员名单,重新勾上即恢复,避免手滑丢名单;
* 产出(buildTeamSelections)只取已勾选单位的队员。
*/
export interface TeamPickState {
teamIds: number[]
memberIds: Record<number, number[]>
/**
* 每个单位填的队名(输入框原文):没填 / 不在表里 = 空串 = 不区分队伍。
*
* 可选字段是为了让「不填队名」这条老路径原样保留:老代码构造的状态里根本没有它,
* 产出的报名行也就与改造前一模一样。
*/
squads?: Record<number, string>
}
export function emptyTeamPick(): TeamPickState {
return { teamIds: [], memberIds: {} }
}
/** 勾选 / 取消一个要参加的单位。 */
export function toggleTeamPick(state: TeamPickState, teamId: number, picked: boolean): TeamPickState {
const teamIds = picked
? state.teamIds.includes(teamId)
? state.teamIds
: [...state.teamIds, teamId]
: state.teamIds.filter((id) => id !== teamId)
return { ...state, teamIds }
}
/** 勾选 / 取消某单位的一名队员;单位没勾选也可以先登记名单。 */
export function toggleMemberPick(
state: TeamPickState,
teamId: number,
athleteId: number,
picked: boolean,
): TeamPickState {
const current = state.memberIds[teamId] ?? []
const next = picked
? current.includes(athleteId)
? current
: [...current, athleteId]
: current.filter((id) => id !== athleteId)
return { ...state, memberIds: { ...state.memberIds, [teamId]: next } }
}
/** 全选 / 清空单位勾选(传空数组即清空);已登记的队员名单与队名保留。 */
export function setPickedTeams(state: TeamPickState, teamIds: readonly number[]): TeamPickState {
return { ...state, teamIds: [...teamIds] }
}
/**
* 记下某个单位填的队名(输入框原文)。
*
* 存原文不存处理过的值:界面上「空」与「填了一队」是两件事(前者 = 不区分队伍),
* 归一留给产出报名行的那一步(buildTeamSelections / buildTeamSelectionRows)。
*/
export function setTeamPickSquad(state: TeamPickState, teamId: number, squad: string): TeamPickState {
return { ...state, squads: { ...state.squads, [teamId]: squad } }
}
/** 「全选」的范围:启用中且还没报名的单位。已停用单位要单独勾,避免批量报错。 */
export function selectableTeamIds(
teams: readonly { id: number; active: boolean }[],
registered: ReadonlySet<number>,
): number[] {
return teams.filter((team) => team.active && !registered.has(team.id)).map((team) => team.id)
}
/**
* 勾选状态 → 提交用的单位列表。
*
* 单位按传入顺序(即界面列表顺序)产出,队员也只按运动员列表顺序取启用中的人,
* 保证同一份勾选每次生成的 member_names 完全一致。
* 队名只在填了的时候挂上去:不填时产出的 selection 与改造前逐字段相同(CSV 也不多那一列)。
*
* 勾中的人同时产出**队员关联**(athleteId,写侧只认它)——姓名文字继续进 member_names
* (导出、大屏、录入页都读它),身份进 athletes:个人分与个人奖牌挂的就是后者。
*/
export function buildTeamSelections(
state: TeamPickState,
teams: readonly { id: number; code: string }[],
athletes: readonly { id: number; teamId: number; name: string; active: boolean; bib?: string | null }[],
): TeamSelection[] {
const picked = new Set(state.teamIds)
const selections: TeamSelection[] = []
for (const team of teams) {
if (!picked.has(team.id)) continue
const memberIds = new Set(state.memberIds[team.id] ?? [])
const members = athletes.filter(
(athlete) => athlete.active && athlete.teamId === team.id && memberIds.has(athlete.id),
)
const memberNames = members.map((athlete) => athlete.name)
const squad = (state.squads?.[team.id] ?? '').trim()
const selection: TeamSelection = {
teamId: team.id,
teamCode: team.code,
memberNames,
athletes: teamAthletesOf(members),
}
selections.push(squad === '' ? selection : { ...selection, squad })
}
return selections
}
/** 一条要写回去的队员关联:先按 组别 + 单位 + 队伍 找到那条报名,再整组替换它的队员。 */
export interface TeamAthleteSync {
entryId: number
athletes: EntryAthleteInput[]
}
/**
* 补报名提交后要发的队员写请求:勾了人的单位、以及**原来挂着队员而这次一个都没勾**的单位。
*
* 为什么不是「所有勾选的单位都发一次」:队员是整组替换,发空数组 = 清空。一个单位都没勾队员的
* 单位本来就没关联,也发一次只是白发一趟请求(「全选」几十个单位就是几十次写请求,会撞写限流)。
* 反过来,原来有队员、这次一个都没勾的必须先写空数组:那条报名在界面上会显示成清空了名单,
* 不写就要留着一个谁都没在里面的队员关联,个人榜继续给早就换掉的人发分。
*
* 队名要一模一样才算同一条报名(同一个单位的一队与二队是两条报名,见迁移 0025),
* 所以这里按 组别 + 单位 + 归一后的队名 找;组别对不上、找不到(导入那一步没写进去)的一律跳过,
* 由导入那边的行级报错去说,不在这里猜。
*/
export function planTeamAthleteSync(
selections: readonly TeamSelection[],
entries: readonly {
id: number
categoryId: number
teamId: number
squad?: string | null
athletes?: readonly EntryAthlete[] | null
}[],
categoryId: number | undefined,
): TeamAthleteSync[] {
const plan: TeamAthleteSync[] = []
if (categoryId === undefined) return plan
for (const selection of selections) {
const teamId = selection.teamId
if (teamId === undefined) continue
const squad = (selection.squad ?? '').trim()
const entry = entries.find(
(item) =>
item.categoryId === categoryId &&
item.teamId === teamId &&
(item.squad ?? '').trim() === squad,
)
if (entry === undefined) continue
const athletes = selection.athletes ?? []
if (athletes.length === 0 && (entry.athletes ?? []).length === 0) continue
plan.push({ entryId: entry.id, athletes: athletes.map((athlete) => ({ athleteId: athlete.athleteId })) })
}
return plan
}
/** 多单位团体报名是否放行:至少要勾一个要参加的单位(队员可以为 0 人)。 */
export function teamSelectionAllowsSubmit(pickedTeamCount: number): boolean {
return pickedTeamCount > 0
}
/** 组别下已报名的单位 id;团体行按 组别 + 单位 判断,与列表里的「已报名」徽标同源。 */
export function registeredTeamIds(
entries: readonly { categoryId: number; teamId: number }[],
categoryId: number | undefined,
): Set<number> {
const ids = new Set<number>()
if (categoryId === undefined) return ids
for (const entry of entries) {
if (entry.categoryId === categoryId) ids.add(entry.teamId)
}
return ids
}
/**
* 组别下各单位**已报名的队名**(去重、按报名表出现顺序)。
*
* 「已报名」这一个徽标在有了队伍之后不够用了:同一个单位可以再报第二支队,
* 操作员得先看见它已经报过哪些队,才不会把「二队」又写成「一队」(那样是更新原来那条,不是新增)。
* 空串(不区分队伍的老报名)不进表,那一行的徽标仍只说「已报名」,与升级前一样。
*/
export function registeredTeamSquads(
entries: readonly { categoryId: number; teamId: number; squad?: string | null }[],
categoryId: number | undefined,
): Map<number, string[]> {
const out = new Map<number, string[]>()
if (categoryId === undefined) return out
for (const entry of entries) {
if (entry.categoryId !== categoryId) continue
const squad = (entry.squad ?? '').trim()
if (squad === '') continue
const list = out.get(entry.teamId)
if (list === undefined) out.set(entry.teamId, [squad])
else if (!list.includes(squad)) list.push(squad)
}
return out
}
/** 组别 + 单位下已报名的运动员 id;团体行 athleteId 为空,不会进集合。 */
export function registeredAthleteIds(
entries: readonly { categoryId: number; teamId: number; athleteId: number | null }[],
categoryId: number | undefined,
teamId: number | undefined,
): Set<number> {
const ids = new Set<number>()
if (categoryId === undefined || teamId === undefined) return ids
for (const entry of entries) {
if (entry.categoryId !== categoryId || entry.teamId !== teamId) continue
if (entry.athleteId !== null) ids.add(entry.athleteId)
}
return ids
}
/** 转成 CSV 文本;服务端用标准 csv 解析器读取,转义必须符合 RFC 4180。 */
export function toCsv(rows: string[][]): string {
return rows.map((row) => row.map(escapeField).join(',')).join('\n')
}
/** 字段含逗号 / 引号 / 换行时用双引号包裹,内部引号翻倍。 */
function escapeField(field: string): string {
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
}
// ── 导入冲突:差异计算与决策 ────────────────────────────────────────────────
const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
name: '姓名',
gender: '性别',
age: '年龄',
teamCode: '单位',
employeeNo: '工号 / 学号',
idCard: '证件号',
bib: '号码布',
}
/** 一条冲突里某个字段的两侧取值,changed 为 true 时界面高亮。 */
export interface ConflictFieldDiff {
field: ImportConflictField
label: string
existing: string
incoming: string
changed: boolean
}
/** 逐字段展开 existing / incoming;顺序固定,界面与测试都不依赖对象键序。 */
export function conflictDiffs(conflict: ImportConflict): ConflictFieldDiff[] {
return (Object.keys(CONFLICT_FIELD_LABELS) as ImportConflictField[]).map((field) => ({
field,
label: CONFLICT_FIELD_LABELS[field],
existing: formatFieldValue(conflict.existing[field]),
incoming: formatFieldValue(conflict.incoming[field]),
changed: conflict.changedFields.includes(field),
}))
}
function formatFieldValue(value: string | number | null | undefined): string {
return value === null || value === undefined || value === '' ? '—' : String(value)
}
/**
* 冲突的决策键:证件号优先,没有证件号时用号码布,两样都没有时回落姓名 + 单位。
*
* 证件号才是人员的唯一标识;同一份表里重复出现同一证件号 = 同一个人(多半是报了多个组别),
* 所以同一条决策正好覆盖那个人的所有行。与后端 repo_import.go 的 athleteImportRow.decisionKey()
* 必须逐字一致,否则用户点的决策会落到别的冲突上(或两条冲突并成一条)。
*
* 这个键只管面板内的分组与勾选:发给服务端的载荷带的是 idCard + bib 两个字段,
* 服务端按同一口径重算,键本身不出现在载荷里。
*/
export function decisionKeyOf(conflict: ImportConflict): string {
if (conflict.idCard !== '') return conflict.idCard
if (conflict.bib !== '') return conflict.bib
return `${conflict.incoming.name}·${conflict.incoming.teamCode}`
}
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
export function conflictLabel(conflict: ImportConflict): string {
const key = decisionKeyOf(conflict)
return key === '' ? '(无证件号 / 号码布 / 姓名)' : key
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
export type DecisionMap = Record<string, ConflictAction>
/** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏键混进载荷。 */
export function recordDecisions(
current: DecisionMap,
conflicts: readonly ImportConflict[],
keys: readonly string[],
action: ConflictAction,
): DecisionMap {
const known = new Set(conflicts.map(decisionKeyOf))
const next: DecisionMap = { ...current }
for (const key of keys) {
if (known.has(key)) next[key] = action
}
return next
}
/** 丢弃已不存在的决策键,换文件或 resolve 之后调用。 */
export function pruneDecisions(current: DecisionMap, conflicts: readonly ImportConflict[]): DecisionMap {
const known = new Set(conflicts.map(decisionKeyOf))
return Object.fromEntries(Object.entries(current).filter(([key]) => known.has(key)))
}
/** 按冲突在结果里的顺序生成 resolve 载荷,服务端回显顺序与界面一致。 */
export function decisionsPayload(
current: DecisionMap,
conflicts: readonly ImportConflict[],
): ImportConflictDecision[] {
return conflicts
.filter((conflict) => current[decisionKeyOf(conflict)] !== undefined)
.map((conflict) => ({
idCard: conflict.idCard,
bib: conflict.bib,
action: current[decisionKeyOf(conflict)] as ConflictAction,
}))
}
export interface DecisionSummary {
keepExisting: number
keepNew: number
undecided: number
}
/** 面板工具条上的计数:已决策分组 + 未决策数,未决策的冲突必须保持可见。 */
export function summarizeDecisions(
current: DecisionMap,
conflicts: readonly ImportConflict[],
): DecisionSummary {
const summary: DecisionSummary = { keepExisting: 0, keepNew: 0, undecided: 0 }
for (const conflict of conflicts) {
const action = current[decisionKeyOf(conflict)]
if (action === 'keep-existing') summary.keepExisting += 1
else if (action === 'keep-new') summary.keepNew += 1
else summary.undecided += 1
}
return summary
}