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:
AskaEth committed 2026-09-13 21:55:37 +08:00
1 parent b1c6d5d54a
commit 9951a69938
8 files changed
+713 -53

No files matched your search

+123 -10
View File
@@ -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)
}
}