From 9951a699380fae458331391f007570f646a0ba8d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 13 Sep 2026 21:55:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(import):=20=E6=8A=A5=E5=90=8D=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E6=8C=81=E4=B8=AD=E6=96=87=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E7=BB=99=E5=87=BA=E4=B8=AD/=E8=8B=B1?= =?UTF-8?q?=E4=B8=A4=E5=A5=97=E6=A8=A1=E6=9D=BF=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:主人要「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 例全过。 --- docs/报名名单导入规范.md | 69 ++++-- server/internal/httpapi/handlers_config.go | 133 +++++++++++- .../internal/httpapi/handlers_config_test.go | 203 ++++++++++++++++++ .../components/baseData/ImportSection.vue | 65 ++++-- .../components/baseData/entrySelection.ts | 122 ++++++++++- .../admin/components/baseData/mockBaseData.ts | 16 +- web/src/admin/tests/entry-selection.test.ts | 98 +++++++++ web/src/admin/tests/import-conflicts.test.ts | 60 ++++++ 8 files changed, 713 insertions(+), 53 deletions(-) diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index b908f68..6fe35c8 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -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. 每人要有**证件号或号码布**,至少一个;**证件号是系统认人的第一依据**(有就优先填、填准、别和人重号);没有证件号时号码布顶替它认人,**号码布也不能给两个人**; diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index bd42be6..a71a158 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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) } } diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index a005608..2b7bbce 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -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) +} diff --git a/web/src/admin/components/baseData/ImportSection.vue b/web/src/admin/components/baseData/ImportSection.vue index 88e3928..c14f4a1 100644 --- a/web/src/admin/components/baseData/ImportSection.vue +++ b/web/src/admin/components/baseData/ImportSection.vue @@ -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(null) const picked = ref(null) const dragging = ref(false) @@ -173,13 +168,17 @@ async function runImport(): Promise { } } -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 {
报名导入 - +
+ 下载模板 + + +
- 表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省, + 表头中英两套都认,大小写不敏感、全角与首尾空格也会自动纠正; + 中文表头为:{{ HEADER_ZH }};英文表头为:{{ HEADER_EN }}。 + 两套模板列数与列顺序完全一样,只有表头语言不同,按手上表格的样子下载即可; + 下载按钮上的「中文表头 / 英文表头」就是文件里的表头语言。 + 同一列只能写一次:既有 event_code 又有「项目编码」会被拦下并提示,请删掉多余的那一列。 + id_card、gender、age、heat 可缺省, 但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。 heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。 证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配; @@ -406,6 +426,19 @@ function downloadTemplate(): void {