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
@@ -1,20 +1,28 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
COLUMN_ALIASES,
|
||||
MANDATORY_COLUMNS,
|
||||
OPTIONAL_COLUMNS,
|
||||
REQUIRED_COLUMNS,
|
||||
buildSelectionRows,
|
||||
buildTeamSelectionRows,
|
||||
buildTemplateCsv,
|
||||
buildTeamSelections,
|
||||
emptyTeamPick,
|
||||
entryRowLabel,
|
||||
importColumnIndex,
|
||||
lookupImportColumn,
|
||||
missingColumnMessage,
|
||||
registeredAthleteIds,
|
||||
registeredTeamIds,
|
||||
selectableTeamIds,
|
||||
selectionAllowsSubmit,
|
||||
setPickedTeams,
|
||||
teamSelectionAllowsSubmit,
|
||||
templateFileName,
|
||||
templateHeader,
|
||||
toggleMemberPick,
|
||||
toggleTeamPick,
|
||||
toCsv,
|
||||
@@ -304,3 +312,93 @@ test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
|
||||
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
|
||||
assert.equal(entryRowLabel({}), '')
|
||||
})
|
||||
|
||||
// ── CSV 模板:中英两套表头,列顺序与列数完全一致 ──────────────────────────────
|
||||
|
||||
test('两种语言的模板表头逐列对应:列数、顺序一致,只有语言不同', () => {
|
||||
const zh = templateHeader('zh')
|
||||
const en = templateHeader('en')
|
||||
assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名')
|
||||
assert.equal(zh.length, en.length, '两种模板列数必须一样')
|
||||
assert.deepEqual(zh, [
|
||||
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
|
||||
])
|
||||
// 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。
|
||||
assert.deepEqual(
|
||||
zh,
|
||||
REQUIRED_COLUMNS.map((column) => COLUMN_ALIASES[column][0]),
|
||||
)
|
||||
assert.equal(new Set(zh).size, zh.length, '中文表头不能有两列写成同一个词')
|
||||
assert.equal(new Set(en).size, en.length)
|
||||
})
|
||||
|
||||
test('模板 CSV:只有表头语言不同,示例行与列数完全相同', () => {
|
||||
const zh = buildTemplateCsv('zh').split('\n')
|
||||
const en = buildTemplateCsv('en').split('\n')
|
||||
assert.equal(zh.length, en.length, '两种模板行数一致')
|
||||
assert.equal(zh[0], templateHeader('zh').join(','))
|
||||
assert.equal(en[0], templateHeader('en').join(','))
|
||||
assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言')
|
||||
for (const line of zh) {
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 12 列')
|
||||
}
|
||||
})
|
||||
|
||||
test('模板文件名带语言后缀,下载下来分得清中英', () => {
|
||||
assert.equal(templateFileName('zh'), 'entries-template-zh.csv')
|
||||
assert.equal(templateFileName('en'), 'entries-template-en.csv')
|
||||
assert.notEqual(templateFileName('zh'), templateFileName('en'), '两个文件名不能重名')
|
||||
})
|
||||
|
||||
// ── 表头识别:英文规范名与中文别名同等对待 ────────────────────────────────────
|
||||
|
||||
test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM / 首尾空格', () => {
|
||||
assert.equal(lookupImportColumn('event_code'), 'event_code')
|
||||
assert.equal(lookupImportColumn('项目编码'), 'event_code')
|
||||
assert.equal(lookupImportColumn('小项编码'), 'event_code')
|
||||
assert.equal(lookupImportColumn('队员名单'), 'member_names')
|
||||
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感')
|
||||
assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格')
|
||||
assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上')
|
||||
assert.equal(lookupImportColumn('\u3000姓名 '), 'athlete_name', '中文输入法的全角空格')
|
||||
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '全角字母与全角下划线')
|
||||
assert.equal(lookupImportColumn('报名项目'), undefined, '认不出的多余列忽略')
|
||||
assert.equal(lookupImportColumn(''), undefined)
|
||||
})
|
||||
|
||||
test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => {
|
||||
const index = importColumnIndex([
|
||||
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
|
||||
])
|
||||
assert.equal(index.get('event_code'), 0)
|
||||
assert.equal(index.get('category'), 1)
|
||||
assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取')
|
||||
assert.equal(index.size, REQUIRED_COLUMNS.length)
|
||||
|
||||
for (const header of [
|
||||
['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
|
||||
['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
|
||||
]) {
|
||||
assert.throws(() => importColumnIndex(header), /表头重复/, '同一列两种写法要拦下,不能静默丢一列')
|
||||
}
|
||||
})
|
||||
|
||||
test('缺列提示中英并列,且点到表头行', () => {
|
||||
const message = missingColumnMessage('athlete_name')
|
||||
assert.match(message, /缺少列 athlete_name(姓名)/)
|
||||
assert.match(message, /第 1 行/)
|
||||
assert.match(message, /中文与英文表头都认/)
|
||||
})
|
||||
|
||||
// ImportSection 的两个下载按钮:现场人多手快,文案必须一眼能分(SFC 跑不进 node --test,
|
||||
// 这里用源码契约断言接线:按钮确实在、点了确实按语言下载)。
|
||||
test('ImportSection 有两个模板下载按钮,分别下载中英表头', () => {
|
||||
const source = readFileSync(new URL('../components/baseData/ImportSection.vue', import.meta.url), 'utf8')
|
||||
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*中文表头\s*</, '有「中文表头」按钮')
|
||||
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*英文表头\s*</, '有「英文表头」按钮')
|
||||
assert.match(source, /downloadTemplate\('zh'\)/)
|
||||
assert.match(source, /downloadTemplate\('en'\)/)
|
||||
assert.match(source, /anchor\.download = templateFileName\(language\)/, '文件名按语言取,两个文件不重名')
|
||||
assert.match(source, /buildTemplateCsv\(language\)/)
|
||||
assert.doesNotMatch(source, /entries-template\.csv/, '不再用不分语言的老文件名')
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
||||
import type { AthleteInput, ImportConflict, ImportConflictFields } from '@/shared'
|
||||
import { createApiClient } from '@/shared'
|
||||
import {
|
||||
COLUMN_ALIASES,
|
||||
REQUIRED_COLUMNS,
|
||||
conflictDiffs,
|
||||
decisionKeyOf,
|
||||
@@ -622,3 +623,62 @@ test('MockBaseData 新增人员时证件号、号码布重复分别报错', () =
|
||||
/号码布 .* 已被其他人员使用/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── 示例数据模式:中文表头与服务端同口径 ──────────────────────────────────────
|
||||
|
||||
/** 自定义表头的报名表(csv() 固定用英文表头,这里要换语言 / 换列序)。 */
|
||||
function fileWithHeader(header: readonly string[], rows: string[][], bom = false): File {
|
||||
return new File([`${bom ? '\uFEFF' : ''}${toCsv([[...header], ...rows])}`], 'import.csv', { type: 'text/csv' })
|
||||
}
|
||||
|
||||
/** 中文表头:与服务端的一列一别名逐字对应。 */
|
||||
const HEADER_ZH = REQUIRED_COLUMNS.map((column) => COLUMN_ALIASES[column][0])
|
||||
|
||||
test('示例数据模式:中文表头照常导入(值取自中文列)', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const result = await mock.importEntries(
|
||||
fileWithHeader(HEADER_ZH, [row('中文表头甲', '110101199001010011', 'F1', '男', '3301')]),
|
||||
)
|
||||
assert.deepEqual([result.created, result.skipped, result.errors], [1, 0, []])
|
||||
})
|
||||
|
||||
test('示例数据模式:带 BOM 的中文表头也认(BOM 粘在第一列列名上)', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const result = await mock.importEntries(
|
||||
fileWithHeader(HEADER_ZH, [row('中文表头乙', '110101199001010012', 'F1', '男', '3302')], true),
|
||||
)
|
||||
assert.deepEqual([result.created, result.skipped], [1, 0])
|
||||
})
|
||||
|
||||
test('示例数据模式:中英混用每列各自认', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const header = ['event_code', 'category', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单']
|
||||
const result = await mock.importEntries(
|
||||
fileWithHeader(header, [row('混用甲', '110101199001010013', 'F1', '男', '3303')]),
|
||||
)
|
||||
assert.deepEqual([result.created, result.skipped], [1, 0])
|
||||
})
|
||||
|
||||
test('示例数据模式:老的 11 列英文表头(无 heat)仍照常导入', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const legacy = [
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'status', 'member_names',
|
||||
]
|
||||
const legacyRow = ['M100', '男子组', 'F1', '老英文表头甲', '110101199001010014', '男', '26', '3304', '', 'registered', '']
|
||||
const result = await mock.importEntries(fileWithHeader(legacy, [legacyRow]))
|
||||
assert.deepEqual([result.created, result.skipped], [1, 0])
|
||||
})
|
||||
|
||||
test('示例数据模式:同一列写两遍与缺列都给出与服务端同一句话', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const duplicated = ['event_code', '项目编码', 'category', 'team_code', 'athlete_name', '号码布', '种子', '状态', '队员名单']
|
||||
await assert.rejects(
|
||||
() => mock.importEntries(fileWithHeader(duplicated, [row('重复甲', '110101199001010015', 'F1')])),
|
||||
/表头重复/,
|
||||
)
|
||||
const missingName = HEADER_ZH.filter((cell) => cell !== '姓名')
|
||||
await assert.rejects(
|
||||
() => mock.importEntries(fileWithHeader(missingName, [row('缺列甲', '110101199001010016', 'F1')])),
|
||||
/缺少列 athlete_name(姓名)/,
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user