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:
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'
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user