/** * 报名相关的纯函数:勾选转 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 = { 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 { const index = new Map() const spelled = new Map() 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() 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 /** * 每个单位填的队名(输入框原文):没填 / 不在表里 = 空串 = 不区分队伍。 * * 可选字段是为了让「不填队名」这条老路径原样保留:老代码构造的状态里根本没有它, * 产出的报名行也就与改造前一模一样。 */ squads?: Record } 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[] { 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 { const ids = new Set() 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 { const out = new Map() 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 { const ids = new Set() 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 = { 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 /** 把选中人员批量记为某个决策;只接受仍存在的冲突,避免脏键混进载荷。 */ 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 }