feat(admin): 报名导入新增「从已有名单勾选报名」

原来只能上传 CSV/XLSX。现在同一个 tab 上方多一个选择器:选小项 → 组别 → 单位,
从该单位已有运动员里勾人,一键加入报名。

- 个人项目:勾 N 人 → N 条报名(一人一行)
- 团体/对抗项目:勾 N 人 → 1 条报名,姓名拼进 member_names(再次提交即更新队员名单)
- 已报名的人/单位会标「已报名」并禁止重复勾选
- 复用服务端唯一报名入口 POST /entries/import:勾选结果转成同格式 CSV 提交,
  字段校验、按 (组别,单位,运动员) upsert、行级报错回显与表格导入完全一致
- 纯函数 entrySelection.ts(表头唯一来源 + RFC 4180 转义)+ 4 个单测;
  表头定义从 ImportSection/mockBaseData 收敛到一处
- 号码布/种子仍走表格导入,界面与文档都写明

自检:vue-tsc + pnpm build 通过,前端 node --test 30/30。
This commit is contained in:
AskaEth committed 2026-09-10 02:03:16 +08:00
1 parent 7531ea29e1
commit c9d5b8ea89
6 files changed
+499 -77

No files matched your search

@@ -0,0 +1,62 @@
/**
* 「从已有名单勾选报名」的纯函数部分。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
* 与行级报错回显都与文件导入完全一致,不必为选择器新增服务端接口。
*/
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
export const REQUIRED_COLUMNS = [
'event_code',
'category',
'team_code',
'athlete_name',
'bib',
'seed',
'status',
'member_names',
] as const
export type SelectionMode = 'individual' | 'team'
export interface SelectionInput {
eventCode: string
categoryName: string
teamCode: string
status?: string
mode: SelectionMode
athletes: { name: string }[]
/** 团体项目队员名单;缺省时按 athletes 的列表顺序取姓名。 */
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, '', '', status, ''])
}
return rows
}
const names = input.memberNames ?? input.athletes.map((athlete) => athlete.name)
rows.push([input.eventCode, input.categoryName, input.teamCode, '', '', '', status, names.join('/')])
return rows
}
/** 转成 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
}