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
+712
-52
No files matched your search
+50
-19
@@ -3,45 +3,75 @@
|
||||
> 这份文件说明**报名名单应该做成什么样子、每一列怎么填**,用于把各单位报上来的名单一次性导入积分榜系统。
|
||||
> 给负责收表、填表的同志看即可,不需要懂系统内部。
|
||||
>
|
||||
> 配套模板可在系统里直接下载:**基础数据 → 报名导入 → 下载 CSV 模板**。
|
||||
> 配套模板可在系统里直接下载:**基础数据 → 报名导入 →「中文表头」/「英文表头」**(两个文件列数与列顺序完全一样,只有表头语言不同,按手上表格的样子选)。
|
||||
|
||||
---
|
||||
|
||||
## 一、先看懂三件事
|
||||
|
||||
1. **用什么软件做表**:Excel 或 WPS 都行,做完存成 `.xlsx` 或 `.csv` 都可以(推荐 `.csv`,系统兼容性最好,不用管编码)。
|
||||
2. **表格长什么样**:**第一行是表头**(固定的 12 个英文列名),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。
|
||||
2. **表格长什么样**:**第一行是表头**(固定的 12 个列名,**中文、英文两套任选其一**,见第二节),**从第二行开始每人一行**。不要合并单元格,不要在表头上方再加标题行、说明行。
|
||||
3. **编码不能自己编**:表里的「小项编码」「单位编码」「组别名称」必须是系统里已经配好的,**照抄系统里的写法**。系统里没有的编码,这一行会被跳过并报错。
|
||||
|
||||
---
|
||||
|
||||
## 二、表头(第一行的列名)
|
||||
|
||||
一共 12 列。**列名请直接用下面这套英文名,不要翻译成中文**;列的**先后顺序可以随便调**,多出来的列系统会忽略。
|
||||
一共 12 列。系统**同时认两套表头**——英文一套、中文一套,**两套都能导入**,也可以**中英混着写**(每一列各自认名字,例如英文 `event_code` 配中文「姓名」)。**英文是规范列名**:老的英文模板一个字都不用改,照常导入;中文只是给习惯看中文的人用的同义写法。
|
||||
|
||||
下载模板时有两个按钮,按手上表格的样子选:
|
||||
|
||||
- 「**中文表头**」→ 文件 `entries-template-zh.csv`
|
||||
- 「**英文表头**」→ 文件 `entries-template-en.csv`
|
||||
|
||||
两个文件**列数与列顺序完全一样,只有表头语言不同**,示例行也一样。列的**先后顺序可以随便调**,多出来的列系统会忽略。
|
||||
|
||||
| 英文表头(规范列名) | 中文表头(同义写法) | 这一列是 |
|
||||
|---|---|---|
|
||||
| `event_code` | 项目编码(也认「小项编码」) | 小项编码 |
|
||||
| `category` | 组别(也认「组别名称」) | 组别名称 |
|
||||
| `team_code` | 单位编码(也认「参赛单位编码」) | 参赛单位编码 |
|
||||
| `athlete_name` | 姓名(也认「运动员姓名」) | 运动员姓名;团体项目这一列留空 |
|
||||
| `id_card` | 证件号(也认「身份证号」) | 证件号,可缺省 |
|
||||
| `gender` | 性别 | 可缺省 |
|
||||
| `age` | 年龄 | 可缺省 |
|
||||
| `bib` | 号码布 | 号码布 |
|
||||
| `seed` | 种子(也认「道次」) | 道次 / 种子 |
|
||||
| `heat` | 批次(也认「批次号」) | 批次,可缺省 |
|
||||
| `status` | 状态(也认「报名状态」) | 报名状态 |
|
||||
| `member_names` | 队员名单 | 团体项目的队员名单 |
|
||||
|
||||
> **同一列只能写一次**:一份表里同时出现 `event_code` 和「项目编码」(或者干脆把同一列写了两遍),会被整份拦下并提示「报名表表头重复」,删掉多余的那一列再导入即可——系统不会替你猜该信哪一列,免得静默丢掉另一列的数据。
|
||||
>
|
||||
> 表头识别对**大小写、首尾空格、全角字母 / 全角空格、文件开头的 BOM** 都容错:`Event_Code`、「项目编码 」、「EVENT_CODE」都认。
|
||||
|
||||
下面是逐列要填什么(列名用英文规范名,括号里是中文表头的同义写法):
|
||||
|
||||
| 列名 | 是否必须 | 填什么 | 示例 |
|
||||
|---|---|---|---|
|
||||
| `event_code` | **必须** | 小项编码,抄系统里配好的 | `M100` |
|
||||
| `category` | **必须** | 组别**名称**(中文,要跟系统里一字不差) | `男子30岁以下` |
|
||||
| `team_code` | **必须** | 参赛单位编码,抄系统里配好的 | `F1` |
|
||||
| `athlete_name` | **必须** | 运动员姓名。**团体项目这一列留空** | `张伟` |
|
||||
| `id_card` | 可选 | 证件号(身份证号)。**系统的第一认人依据**;一个证件号只能属于一个人 | `110101199001011234` |
|
||||
| `gender` | 可选 | 性别,只写「男」或「女」 | `男` |
|
||||
| `age` | 可选 | 年龄,0–120 的整数 | `26` |
|
||||
| `bib` | 可选 | 号码布;证件号为空时按它认人(同一个号码布不能给两个人) | `101` |
|
||||
| `seed` | 可选 | 道次 / 种子,随便写或留空 | `种子1` |
|
||||
| `heat` | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
|
||||
| `status` | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
|
||||
| `member_names` | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` |
|
||||
| `event_code`(项目编码) | **必须** | 小项编码,抄系统里配好的 | `M100` |
|
||||
| `category`(组别) | **必须** | 组别**名称**(中文,要跟系统里一字不差) | `男子30岁以下` |
|
||||
| `team_code`(单位编码) | **必须** | 参赛单位编码,抄系统里配好的 | `F1` |
|
||||
| `athlete_name`(姓名) | **必须** | 运动员姓名。**团体项目这一列留空** | `张伟` |
|
||||
| `id_card`(证件号) | 可选 | 证件号(身份证号)。**系统的第一认人依据**;一个证件号只能属于一个人 | `110101199001011234` |
|
||||
| `gender`(性别) | 可选 | 性别,只写「男」或「女」 | `男` |
|
||||
| `age`(年龄) | 可选 | 年龄,0–120 的整数 | `26` |
|
||||
| `bib`(号码布) | 可选 | 号码布;证件号为空时按它认人(同一个号码布不能给两个人) | `101` |
|
||||
| `seed`(种子) | 可选 | 道次 / 种子,随便写或留空 | `种子1` |
|
||||
| `heat`(批次) | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
|
||||
| `status`(状态) | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
|
||||
| `member_names`(队员名单) | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` |
|
||||
|
||||
> 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。
|
||||
|
||||
**表头大小写不敏感**:写 `Event_Code` 或 `event_code` 都认。
|
||||
**表头大小写不敏感**:写 `Event_Code` 或 `event_code` 都认;换成中文表头(`项目编码`)也认,两套混着写同样可以。
|
||||
|
||||
---
|
||||
|
||||
## 三、逐列怎么填(重点)
|
||||
|
||||
> 下面每一列的小标题写的是**英文规范名**;用中文表头时列名换成第二节对照表里的中文写法(`event_code` = 项目编码、`category` = 组别、`team_code` = 单位编码、`athlete_name` = 姓名、`id_card` = 证件号、`gender` = 性别、`age` = 年龄、`bib` = 号码布、`seed` = 种子、`heat` = 批次、`status` = 状态、`member_names` = 队员名单),**填法完全相同**。
|
||||
|
||||
### `event_code` 小项编码 —— 必须填
|
||||
在系统「基础数据 → 大项 / 小项」里给每个小项配的**编码**,比如 `M100`(男子100米)、`BB`(多人抓杆蛇形跑)。
|
||||
填了系统里没有的编码,这一行会被跳过,提示「小项 XXX 不存在」。
|
||||
@@ -166,7 +196,7 @@
|
||||
|
||||
## 五、导入前请先检查(自检清单)
|
||||
|
||||
- [ ] 第一行是那 12 个英文列名,没有被翻译、没有被合并单元格
|
||||
- [ ] 第一行是那 12 个列名(**英文一套、中文一套都行**,也可以中英混着写,见第二节),没有被合并单元格、**同一列没有写两遍**
|
||||
- [ ] 表头上方没有多余的标题行或空行
|
||||
- [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格
|
||||
- [ ] `category` 与系统里的组别名一字不差
|
||||
@@ -186,7 +216,8 @@
|
||||
| 提示 | 原因 | 怎么办 |
|
||||
|---|---|---|
|
||||
| 第 N 行:event_code / team_code 必填 | 这两格至少有一格是空的 | 每行都必须有小项编码和单位编码 |
|
||||
| 报名表缺少列 xxx | 表头少了这一列,或者列名被写成中文 / 有错别字 / 上面多了标题行 | 对照第二节的表头逐字核对;最稳的是重新下载模板,把数据粘进去 |
|
||||
| 报名表缺少列 xxx(yyy) | 表头少了这一列,或者列名有错别字 / 上面多了一行标题行。括号里的中文与前面的英文是**同一列**的两种写法(例:`报名表缺少列 athlete_name(姓名)`) | 对照第二节的对照表逐字核对(中英两套都认,混着写也行);最稳的是重新下载模板,把数据粘进去 |
|
||||
| 报名表表头重复:第 1 行里同一列出现了两种写法 | 同一列在表头里写了两次(例如既有 `event_code` 又有「项目编码」)——两列都可能带着数据,系统不知道该信哪一列 | 删掉多余的那一列(同一列只留一种写法),再重新上传 |
|
||||
| 中文变成乱码 | 编码问题,**现在已修好**:Excel 另存的 GB18030 / GBK / UTF-8(带 BOM) / UTF-16(带 BOM) 都认 | **直接传原文件即可,不用转码**。唯一例外见下面两条 |
|
||||
| 报名表编码无法识别:文件里混有无法解码的字节 | 这个文件**本身坏了**(复制粘贴、转换工具、半路截断都会这样):坏字节会被静默解成 `�`,导进去就是一串乱码人名,比报错更危险,所以系统直接拦下 | 用 Excel 打开**原文件**,另存为「CSV UTF-8」后再上传;如果另存后仍报同一句,说明原文件已经损坏,请找原始数据重新导出一份 |
|
||||
| 文件像 UTF-16 但缺 BOM | 用记事本「另存为 Unicode」会产出**不带 BOM** 的 UTF-16;这种文件以前会被误报成「缺少列 event_code」,指向完全错误的方向 | 多数情况系统能自动认出字节序并正确导入;仍报此提示时,用 Excel 另存为「CSV UTF-8」 |
|
||||
@@ -225,7 +256,7 @@
|
||||
|
||||
## 八、给填写人的最短提示(一页版)
|
||||
|
||||
> 1. 用系统里下载的模板,**第一行不要动**;
|
||||
> 1. 用系统里下载的模板(**中文表头 / 英文表头都能用**,见第二节),**第一行不要动**、同一列不要写两遍;
|
||||
> 2. **一个人一行**;一个人报几项就写几行;
|
||||
> 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编;
|
||||
> 4. 每人要有**证件号或号码布**,至少一个;**证件号是系统认人的第一依据**(有就优先填、填准、别和人重号);没有证件号时号码布顶替它认人,**号码布也不能给两个人**;
|
||||
|
||||
@@ -988,10 +988,118 @@ func decodeUTF16Table(data []byte, order textunicode.Endianness, missingBOM bool
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||||
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
|
||||
var importColumns = []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||
// importColumn 是一列表头的全部可接受写法。
|
||||
//
|
||||
// name 是规范名(英文):取值、报错、纯英文模板都用它;aliases 是中文别名,供现场用中文表头填表。
|
||||
// 一列同时认英文名与中文名,纯英文的老文件一字不改照常导入(别名只加不改)。
|
||||
type importColumn struct {
|
||||
name string
|
||||
aliases []string
|
||||
optional bool
|
||||
}
|
||||
|
||||
// importColumns 是报名表的全部列,顺序即模板的列顺序(与前端 REQUIRED_COLUMNS、
|
||||
// docs/报名名单导入规范.md 第二节的表格逐列一致)。
|
||||
//
|
||||
// 中文别名一律取导入规范文档里出现过的措辞;改这里必须同步改
|
||||
// web/src/admin/components/baseData/entrySelection.ts 的 COLUMN_ALIASES,
|
||||
// 否则会出现「示例数据模式能导入、连上服务端却报缺列」。
|
||||
var importColumns = []importColumn{
|
||||
{name: "event_code", aliases: []string{"项目编码", "小项编码"}},
|
||||
{name: "category", aliases: []string{"组别", "组别名称"}},
|
||||
{name: "team_code", aliases: []string{"单位编码", "参赛单位编码"}},
|
||||
{name: "athlete_name", aliases: []string{"姓名", "运动员姓名"}},
|
||||
// id_card、gender、age、heat 是可缺省的列:缺列不报错,按空值处理。
|
||||
{name: "id_card", aliases: []string{"证件号", "身份证号"}, optional: true},
|
||||
{name: "gender", aliases: []string{"性别"}, optional: true},
|
||||
{name: "age", aliases: []string{"年龄"}, optional: true},
|
||||
{name: "bib", aliases: []string{"号码布"}},
|
||||
{name: "seed", aliases: []string{"种子", "道次"}},
|
||||
{name: "heat", aliases: []string{"批次", "批次号"}, optional: true},
|
||||
{name: "status", aliases: []string{"状态", "报名状态"}},
|
||||
{name: "member_names", aliases: []string{"队员名单"}},
|
||||
}
|
||||
|
||||
// label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列,
|
||||
// 手拿中文模板的人看到「缺少列 athlete_name(姓名)」能立刻对上自己表头那一格。
|
||||
func (c importColumn) label() string {
|
||||
if len(c.aliases) == 0 {
|
||||
return c.name
|
||||
}
|
||||
return c.name + "(" + c.aliases[0] + ")"
|
||||
}
|
||||
|
||||
// normalizeImportHeader 把一格表头归一成可比对的写法:去首尾空白(含全角空格)、
|
||||
// 全角 ASCII / 全角空格转半角、U+FEFF 丢弃、转小写。
|
||||
//
|
||||
// 现场的表多半是中文 Excel 存出来的:列名可能带全角空格、被输入法写成全角字母,
|
||||
// 或者粘贴时把字节序标记 U+FEFF 带进了第一格。这些都不该变成「缺少列 xxx」——
|
||||
// 已有的编码处理(decodeTableText)只摘掉文件开头的 BOM,格子内部的还得在这里兜住。
|
||||
func normalizeImportHeader(raw string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case r == '\uFEFF':
|
||||
// BOM 粘在表头上正是「缺少列 event_code」的经典成因,直接丢掉。
|
||||
case r == '\u3000':
|
||||
builder.WriteRune(' ')
|
||||
case r >= '\uFF01' && r <= '\uFF5E':
|
||||
// 全角 ASCII 与半角一一对应,码点相差 0xFEE0。
|
||||
builder.WriteRune(r - 0xFEE0)
|
||||
default:
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(builder.String()))
|
||||
}
|
||||
|
||||
// lookupImportColumn 认一格表头,返回它的规范列名。
|
||||
// 英文名与中文别名同等对待,认不出的(多余列、备注列)返回 false 由调用方忽略。
|
||||
func lookupImportColumn(raw string) (string, bool) {
|
||||
header := normalizeImportHeader(raw)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, column := range importColumns {
|
||||
if header == column.name {
|
||||
return column.name, true
|
||||
}
|
||||
for _, alias := range column.aliases {
|
||||
if header == alias {
|
||||
return column.name, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importColumnIndex 把表头行转成「规范列名 → 下标」。
|
||||
//
|
||||
// 中英混用是允许的:每一列各自按名字认,互不影响(现场把一份中文表的一部分列换成英文名,
|
||||
// 或者拿英文模板补上几列中文,都还导得动)。
|
||||
//
|
||||
// 但**同一列在一份表里出现两种写法**(既有 event_code 又有 项目编码,或同一格重复出现两次)
|
||||
// 一律拦下:两列都可能有数据,系统无从判断该信哪一列,静默取其中一列等于把另一列的数据丢了。
|
||||
// 报错点名这一列与两种写法,让人删掉多余的那一列——这比猜一个更安全。
|
||||
func importColumnIndex(header []string) (map[string]int, error) {
|
||||
index := make(map[string]int, len(importColumns))
|
||||
spelled := make(map[string]string, len(importColumns))
|
||||
for position, raw := range header {
|
||||
name, ok := lookupImportColumn(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cell := strings.TrimSpace(raw)
|
||||
if prev, duplicate := spelled[name]; duplicate {
|
||||
return nil, badRequest(fmt.Sprintf(
|
||||
"报名表表头重复:第 1 行里同一列出现了两种写法(%s、%s),请只保留一列再导入(列名中英文都可以,但不能一列写两遍)",
|
||||
prev, cell), nil)
|
||||
}
|
||||
spelled[name] = cell
|
||||
index[name] = position
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
@@ -1007,15 +1115,20 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
index := make(map[string]int, len(rows[0]))
|
||||
for i, h := range rows[0] {
|
||||
index[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
index, err := importColumnIndex(rows[0])
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, col := range importColumns {
|
||||
if _, ok := index[col]; !ok {
|
||||
for _, column := range importColumns {
|
||||
if column.optional {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[column.name]; !ok {
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
return result, badRequest("报名表缺少列 "+col+":第 1 行应是表头(列名与导入模板一致),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
|
||||
return result, badRequest("报名表缺少列 "+column.label()+
|
||||
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2527,3 +2527,206 @@ func TestAthleteActivePresenceDefaults(t *testing.T) {
|
||||
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 表头别名:中文表头也要能导入 ──────────────────────────────────────────────
|
||||
//
|
||||
// 现场手里同时有两种表:老的纯英文模板、以及新下载的中文模板。英文是规范名
|
||||
// (取值、报错、纯英文模板都用它),中文只是别名;别名只加不改,纯英文的老表一字不改照常导入。
|
||||
|
||||
// importHeaderZH 是中文表头,与 importHeader 逐列对应(同样是 11 列,heat 列同样缺席)。
|
||||
const importHeaderZH = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
|
||||
|
||||
// importHeaderZHHeat 是带批次列的中文表头,与 importHeaderHeat 逐列对应。
|
||||
const importHeaderZHHeat = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,批次,状态,队员名单"
|
||||
|
||||
// importHeaderEnglishLegacy 是升级前就在用的纯英文表头,**逐字钉在这里**(故意不复用 importHeader 常量):
|
||||
// 别名解析只要碰坏了任何一个英文列名,这条立刻红。
|
||||
const importHeaderEnglishLegacy = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
|
||||
|
||||
// importEntryFields 读一条报名项的号码布 / 种子 / 批次 / 状态,用来验证值确实取自表里对应的那一列。
|
||||
func importEntryFields(t *testing.T, env *testEnv, idCard string, eventID int64) (string, string, sql.NullInt64, string) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
bib, seed, status string
|
||||
heat sql.NullInt64
|
||||
)
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COALESCE(bib, ''), COALESCE(seed, ''), heat, status FROM entries
|
||||
WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||||
eventID, idCard).Scan(&bib, &seed, &heat, &status); err != nil {
|
||||
t.Fatalf("读报名项(证件号 %s): %v", idCard, err)
|
||||
}
|
||||
return bib, seed, heat, status
|
||||
}
|
||||
|
||||
// 纯英文表头是既有现场文件的口径:别名功能上线后必须一字不改地照常导入。
|
||||
func TestImportKeepsEnglishHeaderUnchanged(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderEnglishLegacy + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
|
||||
bib, seed, heat, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
|
||||
if bib != "101" || seed != "种子1" || heat.Valid || entryStatus != "registered" {
|
||||
t.Fatalf("英文表头各列应原样落库,得到 bib=%q seed=%q heat=%+v status=%q", bib, seed, heat, entryStatus)
|
||||
}
|
||||
|
||||
// 大小写不敏感、首尾空格容错也是既有行为:表头识别的改动不能把它弄丢。
|
||||
t.Run("大写与首尾空格", func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
header := " Event_Code ,CATEGORY, team_code ,ATHLETE_NAME,ID_CARD,GENDER,AGE,BIB,SEED,STATUS,MEMBER_NAMES"
|
||||
body := header + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
})
|
||||
}
|
||||
|
||||
// 中文表头:列名认得出还不够,每一列的值都要真的从中文那一列取。
|
||||
func TestImportAcceptsChineseHeader(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
|
||||
bib, seed, _, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
|
||||
if bib != "101" || seed != "种子1" || entryStatus != "registered" {
|
||||
t.Fatalf("中文列的值应落进对应字段,得到 bib=%q seed=%q status=%q", bib, seed, entryStatus)
|
||||
}
|
||||
|
||||
var age sql.NullInt64
|
||||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&age); err != nil {
|
||||
t.Fatalf("读人员年龄: %v", err)
|
||||
}
|
||||
if !age.Valid || age.Int64 != 26 {
|
||||
t.Fatalf("「年龄」列应写进人员档案,得到 %+v", age)
|
||||
}
|
||||
}
|
||||
|
||||
// 批次列(heat)的中文写法:别名映射必须与英文列同等对待,值也要落进 entries.heat。
|
||||
func TestImportAcceptsChineseHeatColumn(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderZHHeat + "\nM100,男子,A,张三,110101199001011234,男,26,101,,2,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
|
||||
_, _, heat, _ := importEntryFields(t, env, "110101199001011234", ids.event)
|
||||
if !heat.Valid || heat.Int64 != 2 {
|
||||
t.Fatalf("「批次」列应写进 entries.heat=2,得到 %+v", heat)
|
||||
}
|
||||
}
|
||||
|
||||
// 中英混用:每一列各自认名字,列与列之间互不影响(现场常拿英文模板补几列中文)。
|
||||
func TestImportAcceptsMixedLanguageHeader(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
header := "event_code,category,team_code,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
|
||||
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
}
|
||||
|
||||
// 同一列在一份表里出现两种写法(中英各来一次,或同一格重复两次)一律拦下:
|
||||
// 两列都可能有数据,静默取其中一列等于丢掉另一列的内容,报错让人删掉重复的那一列更安全。
|
||||
func TestImportRejectsSameColumnTwice(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header string
|
||||
wantIn []string
|
||||
}{
|
||||
{"中英各一次", "event_code,项目编码,category,team_code,athlete_name,bib,seed,status,member_names",
|
||||
[]string{"表头重复", "event_code", "项目编码"}},
|
||||
{"同一写法两次", "event_code,category,team_code,athlete_name,event_code,bib,seed,status,member_names",
|
||||
[]string{"表头重复", "event_code"}},
|
||||
{"中文别名重复", "项目编码,小项编码,组别,单位编码,姓名,号码布,种子,状态,队员名单",
|
||||
[]string{"表头重复", "项目编码", "小项编码"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("同一列写两遍应回 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
for _, want := range tc.wantIn {
|
||||
if !strings.Contains(env2.Error.Message, want) {
|
||||
t.Fatalf("message 应包含 %q,得到 %q", want, env2.Error.Message)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "第 1 行") {
|
||||
t.Fatalf("message 应点到表头行,得到 %q", env2.Error.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 缺列提示中英并列:手拿中文模板的人看到「缺少列 athlete_name(姓名)」才知道自己缺的是哪一格。
|
||||
func TestImportMissingColumnMessageIsBilingual(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"中文表头缺姓名", "项目编码,组别,单位编码,证件号,性别,年龄,号码布,种子,状态,队员名单", "缺少列 athlete_name(姓名)"},
|
||||
{"中文表头缺队员名单", "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态", "缺少列 member_names(队员名单)"},
|
||||
{"英文表头缺小项编码", "category,team_code,athlete_name,bib,seed,status,member_names", "缺少列 event_code(项目编码)"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, tc.want) {
|
||||
t.Fatalf("message 应中英并列,期望包含 %q,得到 %q", tc.want, msg)
|
||||
}
|
||||
// 提示还要教人往哪看:别的名字(中文模板上没见过的写法)只是其中一种可能。
|
||||
if !strings.Contains(msg, "第 1 行") || !strings.Contains(msg, "表头") {
|
||||
t.Fatalf("message 应点到表头行,得到 %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "中文") || !strings.Contains(msg, "英文") {
|
||||
t.Fatalf("message 应说明中英表头都认,得到 %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 中文表头 + 各种编码:BOM 粘在中文列名上("\ufeff项目编码")是最容易翻车的组合,
|
||||
// 编码处理(decodeTableText)与表头归一(normalizeImportHeader)两层都要兜住。
|
||||
func TestImportAcceptsChineseHeaderInEveryEncoding(t *testing.T) {
|
||||
plain := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"utf8_bom", "\uFEFF" + plain},
|
||||
{"gb18030", encodeGB18030(t, plain)},
|
||||
{"utf16le_bom", encodeUTF16LE(t, plain)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", tc.body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 表头格自身的容错:中文输入法打出的全角字母、全角空格、格子中间混进的 BOM
|
||||
// 都不能退化成「缺少列 xxx」——那是升级前就踩过的坑。
|
||||
func TestImportToleratesFullWidthHeaderCells(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
header := "EVENT_CODE,\u3000category ,team_code,\uFEFF姓名 ,证件号,性别,年龄,号码布,种子,状态,队员名单"
|
||||
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
}
|
||||
@@ -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 模板
|
||||
<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