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
@@ -7,7 +7,7 @@ import { useBaseData } from './useBaseData'
|
||||
import { entryImportSizeError } from './importLimits'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import {
|
||||
REQUIRED_COLUMNS,
|
||||
buildTemplateCsv,
|
||||
conflictDiffs,
|
||||
conflictLabel,
|
||||
decisionKeyOf,
|
||||
@@ -15,20 +15,15 @@ import {
|
||||
pruneDecisions,
|
||||
recordDecisions,
|
||||
summarizeDecisions,
|
||||
templateFileName,
|
||||
templateHeader,
|
||||
type DecisionMap,
|
||||
type TemplateLanguage,
|
||||
} from './entrySelection'
|
||||
|
||||
const data = useBaseData()
|
||||
const toast = useToastStore()
|
||||
|
||||
// heat 列是批次:留空 = 未分批,也可以直接填正整数(如 1、2)。
|
||||
const TEMPLATE_CSV = [
|
||||
REQUIRED_COLUMNS.join(','),
|
||||
'M100,男子组,F1,张伟,110101199001011234,男,26,101,,1,registered,',
|
||||
'W100,女子组,F2,王芳,,女,,202,,2,registered,',
|
||||
'BB,混合组,F2,,,,,,种子1,,registered,第一分公司代表队',
|
||||
].join('\n')
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const picked = ref<File | null>(null)
|
||||
const dragging = ref(false)
|
||||
@@ -173,13 +168,17 @@ async function runImport(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate(): void {
|
||||
/** 中文 / 英文两套模板的表头,卡片上并列展示,让人一眼知道手上的表属于哪一套。 */
|
||||
const HEADER_EN = templateHeader('en').join(' / ')
|
||||
const HEADER_ZH = templateHeader('zh').join(' / ')
|
||||
|
||||
function downloadTemplate(language: TemplateLanguage): void {
|
||||
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
|
||||
const blob = new Blob([`\uFEFF${TEMPLATE_CSV}\n`], { type: 'text/csv;charset=utf-8' })
|
||||
const blob = new Blob([`\uFEFF${buildTemplateCsv(language)}\n`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'entries-template.csv'
|
||||
anchor.download = templateFileName(language)
|
||||
anchor.rel = 'noopener'
|
||||
anchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
@@ -193,16 +192,37 @@ function downloadTemplate(): void {
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">报名导入</span>
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="downloadTemplate">
|
||||
<MxIcon name="export" :size="14" /> 下载 CSV 模板
|
||||
</button>
|
||||
<div class="template-actions">
|
||||
<span class="bd-note template-actions-label">下载模板</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm template-button"
|
||||
type="button"
|
||||
title="表头为中文列名(项目编码 / 组别 / 单位编码…),给习惯看中文的填表人用;文件名 entries-template-zh.csv"
|
||||
@click="downloadTemplate('zh')"
|
||||
>
|
||||
<MxIcon name="export" :size="14" /> 中文表头
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm template-button"
|
||||
type="button"
|
||||
title="表头为英文列名(event_code / category / team_code…),给沿用老模板的填表人用;文件名 entries-template-en.csv"
|
||||
@click="downloadTemplate('en')"
|
||||
>
|
||||
<MxIcon name="export" :size="14" /> 英文表头
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-card-body">
|
||||
<div class="bd-warn">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span>
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
|
||||
表头中英两套都认,大小写不敏感、全角与首尾空格也会自动纠正;
|
||||
<strong>中文表头</strong>为:{{ HEADER_ZH }};<strong>英文表头</strong>为:{{ HEADER_EN }}。
|
||||
两套模板列数与列顺序完全一样,只有表头语言不同,按手上表格的样子下载即可;
|
||||
下载按钮上的「中文表头 / 英文表头」就是文件里的表头语言。
|
||||
同一列只能写一次:既有 event_code 又有「项目编码」会被拦下并提示,请删掉多余的那一列。
|
||||
id_card、gender、age、heat 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||
证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配;
|
||||
@@ -406,6 +426,19 @@ function downloadTemplate(): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 两个下载按钮并排、颜色不同、文案带「中文表头 / 英文表头」:现场人多手快,不能点错。 */
|
||||
.template-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.template-actions-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.template-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,12 @@ import type {
|
||||
Team,
|
||||
TeamInput,
|
||||
} from '@/shared'
|
||||
import { MANDATORY_COLUMNS } from './entrySelection'
|
||||
import {
|
||||
MANDATORY_COLUMNS,
|
||||
importColumnIndex,
|
||||
missingColumnMessage,
|
||||
type ImportColumnName,
|
||||
} from './entrySelection'
|
||||
|
||||
/**
|
||||
* 基础数据页的离线示例数据源。
|
||||
@@ -663,10 +668,11 @@ export class MockBaseData {
|
||||
if (extension !== '.csv') return empty([XLSX_HINT])
|
||||
const rows = parseCsv(await file.text())
|
||||
if (rows.length < 2) throw new Error('报名表为空')
|
||||
const header = (rows[0] ?? []).map((cell) => cell.replace(/^\uFEFF/, '').trim().toLowerCase())
|
||||
const index = new Map(header.map((name, position) => [name, position]))
|
||||
// 表头识别与服务端同一口径(英文规范名 + 中文别名、全角 / BOM / 空格容错、同一列写两遍拦下),
|
||||
// 文案也逐字对齐——否则现场断网降级时提示会变样。
|
||||
const index = importColumnIndex(rows[0] ?? [])
|
||||
for (const column of IMPORT_COLUMNS) {
|
||||
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
||||
if (!index.has(column)) throw new Error(missingColumnMessage(column))
|
||||
}
|
||||
|
||||
const result: EntryImportResult = {
|
||||
@@ -681,7 +687,7 @@ export class MockBaseData {
|
||||
rows.slice(1).forEach((row, offset) => {
|
||||
const line = offset + 2
|
||||
if (row.every((cell) => cell.trim() === '')) return
|
||||
const cell = (name: string): string => {
|
||||
const cell = (name: ImportColumnName): string => {
|
||||
const position = index.get(name)
|
||||
return position === undefined ? '' : (row[position] ?? '').trim()
|
||||
}
|
||||
|
||||
@@ -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