## 现场问题(主人原话)
> 成绩录入界面的补报名按钮窗口里面的搜索框只能查姓名和工号,不能查号码布,加一下。
号码布是本系统里**运动员的唯一标识**(定稿口径),现场拿号码布找人是常态,所以这条是刚需。
## 改了什么
- 抽出纯函数 `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` 两张)。
764 lines
31 KiB
TypeScript
764 lines
31 KiB
TypeScript
/**
|
||
* 报名相关的纯函数:勾选转 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
|
||
}
|