feat(import): 报名导入支持中文表头,并给出中/英两套模板下载

需求:主人要「CSV 模板的表头能改一下,或者提供两个按钮,一个中文表头一个英文表头」。

- 解析器加**中文别名**(英文仍是规范名:取值、报错、纯英文模板、勾选导出都用它)。
  12 列,顺序即模板列序:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 /
  号码布 / 种子 / 批次 / 状态 / 队员名单;另认一批同义写法(小项编码、参赛单位编码、
  运动员姓名、身份证号、道次、批次号、报名状态…)。
  Go 别名表 == 前端 COLUMN_ALIASES == 导入规范文档表格,三方逐列逐名一致(脚本核对)。
- **向后兼容**:纯英文表头一字不改照常导入;回归用**字面量**(不复用常量)钉死老 11 列表头
  与大写/空格变体,避免有人改常量把回归一起改掉。
- **中英可任意混排**,但**同一列出现两种写法(或写两遍)整份拦下**并说明——两列都可能带数据,
  静默取其一等于丢另一列。容错沿用既有先例:BOM(含格子中间的 U+FEFF)、全角空格/全角 ASCII、
  首尾空格、大小写。
- 缺列报错**中英并列**(「缺少列 athlete_name(姓名)」),现场拿中文模板的人一眼看懂。
- 前端两个下载按钮:「下载模板(中文表头)」「下载模板(英文表头)」,列序与列数完全一致,
  文件名可区分;示例数据模式(mock)同步,避免两条路径产出不同模板。
- 文档 `docs/报名名单导入规范.md` 补两套表头与「两套都能导入」的说明(原内容未删)。

端到端(真服务端 + curl):中文模板填两行 → {"created":2} 且库中值取自中文列;
英文模板同一份数据 → {"updated":2} 不重复建档;缺列与表头重复分别给出上述人话报错。
自检:gofmt / go build / go vet / go test 全绿;vue-tsc + 前端 754 例全过。
This commit is contained in:
AskaEth committed 2026-09-13 21:55:37 +08:00
1 parent b1c6d5d54a
commit 9951a69938
8 files changed
+713 -53

No files matched your search

@@ -14,9 +14,11 @@ import type {
} from '@/shared'
/**
* 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。
* 列顺序即 CSV 模板的列顺序,与服务端 handlers_config.go 的 importColumns 一致。
*
* heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。
* 勾选转 CSV(buildSelectionRows / buildTeamSelectionRows)一律用这套英文规范名;
* 下载的 CSV 模板才有中英两套表头(见 templateHeader),服务端两套都认。
*/
export const REQUIRED_COLUMNS = [
'event_code',
@@ -33,15 +35,129 @@ export const REQUIRED_COLUMNS = [
'member_names',
] as const
/** 取消勾选 / 取值的列名类型,保证别名表一列都不漏。 */
export type ImportColumnName = (typeof REQUIRED_COLUMNS)[number]
/** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */
export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const
/** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */
const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS
export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter(
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: ['队员名单'],
}
/**
* 表头格归一:去首尾空白、全角 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`
}
/** 模板里的示例行:两种语言共用,只有表头不同。 */
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'
/**