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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user