Files
Scoreboard/server/internal/httpapi/entries_sheet_template.go
T
AskaEth 55051fd633 fix(import): 导入提示改成「能照着改」的话 + 模板给「姓名」「队员名单」加说明;版本 dev_0.5.9
## 起因(主人原话)
> 我应该如何用表格导入团体报名? 我现在不管怎么填的话,它都提示:
> 「第2行填写了队伍 但是这一行有运动员姓名 按个人报名处理」。但实际上我们程序里,团体报名是可以包含运动员姓名的。

## 核实结论:不是功能 bug,是提示没用
系统用「**`姓名` 列有没有填**」区分个人 / 团体报名(数据模型决定的:一条报名要么属于某个人、要么属于一支队)。
**团体报名的队员要填在「队员名单」列**(写号码布),`姓名` 列留空;队员就是这条报名的成员。
原来那条提示**只说了后果、没说动作**,所以现场只能反复换填法重试。

## 改了什么
1. **两条行级提示改成「后果 + 两步动作」**(保留旧文案里的关键词,既有断言一字未改仍通过):
   - 队伍那条:`第 N 行填了队伍「一队」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,这一格没有写进库里。要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列;这一行确实是个人报名,把「队伍」这一格删掉即可`
   - 队员名单那条:同口径(`…队员只对团体报名生效,这一格只作文字名单保留、没有关联到人。要报团体请把姓名列留空、队员的号码布就填在「队员名单」列…`)
2. **导入模板加说明**:原来只有「单位编码」表头挂了批注;现在给 **`姓名`**(填了就是个人报名、团体请留空)与 **`队员名单`**(团体报名的队员写这里、**写号码布**才会关联到人)两个表头**各加一条批注 + 输入提示**,中英文两套。
3. ⚠️ **模板的表头文字、列序、13 列、工作表命名一个字没动**(动了模板就导不回去,有测试逐字钉着)。

## 文档
`docs/报名名单导入规范.md`(新增「个人报名 vs 团体报名」对照表 + 两条提示原文收进常见报错 + 队员名单分隔符说明)、
`docs/使用手册.md`(在工作台导入那节点明判据)、`docs/升级说明.md`(新增 `dev_0.5.9` 一节)。

## 验证
- **先红后绿**;全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。

## 版本
`dev_0.5.8` → **`dev_0.5.9`**(**无数据迁移**);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 23:40:31 +08:00

742 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/xuri/excelize/v2"
)
// 分工作表报名模板:**一张工作表 = 一个小项 × 一个组别**,表名即「小项名称@组别名称」。
//
// 现场把 40 个小项 × 2 个组别塞进同一张表时,填表人得自己在「项目编码 / 组别」两列里反复抄,
// 抄错一个字这一行就报「小项 XXX 不存在」。拆成一张表一个组合后,范围由**表名**承担,
// 数据区只剩「谁、哪个单位」这些真正要人填的东西。
//
// 命名与识别是**同一份实现**,生成端(下载模板)与解析端(导入报名表)都走 newEntrySheetPlan:
// 双方各自把当前赛事的 (小项, 组别) 组合算成「工作表名 → 范围」的映射,导入时按表名查这张映射表。
// 好处是解析永远是**精确命中**——没有前缀匹配、没有猜名字的启发式:改过名的小项或组别会让旧模板
// 对不上号,这时宁可报错让现场重新下载,也不能把两张表的数据并到一起。
//
// 表名必须落进 Excel 的限制里(最长 31 个 UTF-16 单元、禁用 : \ / ? * [ ]、同簿内不能重名),
// 所以下面每一处截断与去重都必须是确定性的:同一份基础数据算出来的表名,任何时候都完全一样。
const (
// entrySheetMaxUnits 是 Excel 对工作表名的硬限制(UTF-16 编码单元数,汉字算 1 个)。
entrySheetMaxUnits = 31
// entrySheetMinPartUnits 是截断时一侧至少保留的字符数:再短就认不出是哪一项了。
entrySheetMinPartUnits = 8
// entrySheetSeparator 是小项与组别的分隔符。小项名与组别名里都不准出现它。
entrySheetSeparator = "@"
// entrySheetSuffixLimit 是撞名去重后缀的编号上限,纯粹是防死循环的兜底。
entrySheetSuffixLimit = 9999
)
// atSignNameMessage 是「名字里含 @」的统一理由文案。
//
// 三处拦截(基础数据界面、规则包 YAML、服务端接口)说的必须是同一件事:@ 是分工作表模板里
// 小项与组别的分隔符,写进名字会让「小项@组别」拆不开。只回一句「不能含 @」现场只会换个位置再撞。
func atSignNameMessage(kind, name string) string {
return fmt.Sprintf("%s「%s」里不能有 @(也不能有全角 @):%s 是分工作表报名模板里「小项名称%s组别名称」的分隔符,"+
"写进名字会让工作表名拆不开、模板生成不出来。请换成别的字符(例如 - 或 ·)", kind, name,
entrySheetSeparator, entrySheetSeparator)
}
// nameSeparatorProblem 返回名字里的 @ 问题说明;没问题时返回空串。
//
// 返回字符串而不是 error,是为了让两条调用路径共用同一份判据:接口层要 badRequest(打回请求),
// 基础数据包导入要行级报错(其余行照常导入)。校验层写两遍是历史伤疤的来源——
// 只改一处就会出现「界面上拦住了、导 Excel 却能塞进去」。
func nameSeparatorProblem(kind, name string) string {
// 全角 @ 与半角 @ 在表名里肉眼几乎分不出,一并拦下:现场没有正当理由用它。
idx := strings.IndexAny(name, "@@")
if idx < 0 {
return ""
}
return atSignNameMessage(kind, name)
}
// checkNameSeparator 是接口层的 @ 校验:小项名、组别名都走它。
func checkNameSeparator(kind, name string) error {
if msg := nameSeparatorProblem(kind, name); msg != "" {
return badRequest(msg, nil)
}
return nil
}
// entrySheetScope 是一个 (小项, 组别) 组合,即生成端要建的一张工作表。
type entrySheetScope struct {
EventID int64 `json:"eventId"`
EventCode string `json:"eventCode"`
EventName string `json:"eventName"`
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
}
// entrySheetPlanned 是一张待生成的工作表:名字 + 它代表的范围。
type entrySheetPlanned struct {
Name string
Scope entrySheetScope
}
// entrySheetPlan 是「工作表名 → 范围」的确定性映射,下载与导入共用。
type entrySheetPlan struct {
sheets []entrySheetPlanned
// byKey 的键是 entrySheetKey(工作表名),值是它代表的范围。
byKey map[string]entrySheetScope
// eventByKey 是「小项名归一化键 → 该小项的组别组合」,只在表名对不上时给现场一句能照着改的诊断。
eventByKey map[string][]entrySheetScope
// excluded 是名字里含 @(或全角 @)而**没法**写进工作表名的小项 / 组别。
// 生成端据此拒绝下载并点名让人先改名;解析端不把它们放进映射(这些组合本来就表达不出来)。
excluded []string
}
// loadEntrySheetScopes 读出当前赛事的全部 (小项, 组别) 组合。
//
// 排序即工作表的先后与撞名去重的先后,必须是确定的:现场从上到下就是基础数据里的顺序
// (小项按排序值 + 编码,组别按排序值 + id);换个顺序会让「(2)」后缀落到另一张表上,
// 解析端也就跟着错位。
func (s *server) loadEntrySheetScopes(ctx context.Context) ([]entrySheetScope, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT e.id, COALESCE(e.code, ''), e.name, c.id, c.name
FROM events e
JOIN event_categories c ON c.event_id = e.id
ORDER BY e.sort_order, e.code, c.sort_order, c.id`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]entrySheetScope, 0, 16)
for rows.Next() {
var scope entrySheetScope
if err := rows.Scan(&scope.EventID, &scope.EventCode, &scope.EventName,
&scope.CategoryID, &scope.CategoryName); err != nil {
return nil, internal(err)
}
out = append(out, scope)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return out, nil
}
// newEntrySheetPlan 给每个组合定一个工作表名。scopes 必须按确定顺序传入(见 loadEntrySheetScopes)。
//
// 撞名去重:Excel 一个工作簿里不允许两张同名工作表,重名的后果是**后一张把前一张覆盖掉**,
// 数据无声消失。所以名字重复时在尾部追加 (2)、(3)…,并把名字再截短以塞进 31 字符;
// 去重比的是归一化后的键(entrySheetKey),半角 / 全角、空格、大小写不同不算两张表的区别。
func newEntrySheetPlan(scopes []entrySheetScope) entrySheetPlan {
plan := entrySheetPlan{
sheets: make([]entrySheetPlanned, 0, len(scopes)),
byKey: make(map[string]entrySheetScope, len(scopes)),
eventByKey: make(map[string][]entrySheetScope, len(scopes)),
}
used := make(map[string]bool, len(scopes))
seenPair := make(map[[2]int64]bool, len(scopes))
for _, scope := range scopes {
// 同一对小项 + 组别只出一张表:重复的组合只会多一张内容一样的表,还会挤掉别人的名字。
pair := [2]int64{scope.EventID, scope.CategoryID}
if seenPair[pair] {
continue
}
seenPair[pair] = true
if problem := nameSeparatorProblem("小项名称", scope.EventName); problem != "" {
plan.excluded = append(plan.excluded, problem)
continue
}
if problem := nameSeparatorProblem("组别名称", scope.CategoryName); problem != "" {
plan.excluded = append(plan.excluded, problem)
continue
}
name := entrySheetName(scope.EventName, scope.CategoryName)
key := entrySheetKey(name)
for n := 2; used[key] && n <= entrySheetSuffixLimit; n++ {
// 加后缀会让名字变长,先把尾部截掉凑出空间:后缀甩到 31 字符外,Excel 直接拒绝建表。
suffix := fmt.Sprintf("(%d)", n)
name = headWithinUnits(name, entrySheetMaxUnits-unitsOf(suffix)) + suffix
key = entrySheetKey(name)
}
used[key] = true
plan.sheets = append(plan.sheets, entrySheetPlanned{Name: name, Scope: scope})
plan.byKey[key] = scope
eventKey := entrySheetKey(scope.EventName)
plan.eventByKey[eventKey] = append(plan.eventByKey[eventKey], scope)
}
return plan
}
// entrySheetName 拼一张工作表的名字:<小项名称>@<组别名称>。
//
// 截断策略(确定性,两侧都从**尾部**截,保留开头——人一眼能认出是哪一项):
// 1. 小项名优先保留,但至少给组别名留下 8 个字符(组别名太短就分不出是哪一组);
// 2. 小项名先截到自己那份额度,剩下的全给组别名;
// 3. 预算按 UTF-16 单元算(汉字 1、emoji 2),与 Excel 的计数口径一致。
//
// 截断后名字仍是原名的一段前缀——这是解析端「重新算一遍映射」能对上的前提。
func entrySheetName(eventName, categoryName string) string {
ev := entrySheetPart(eventName)
if ev == "" {
ev = "未命名小项"
}
cat := entrySheetPart(categoryName)
if cat == "" {
cat = "未命名组别"
}
budget := entrySheetMaxUnits - unitsOf(entrySheetSeparator)
// 给组别名留的额度不超过它自己的长度:组别名本来就短时,没必要白白砍掉小项名的字。
reserve := entrySheetMinPartUnits
if catUnits := unitsOf(cat); catUnits < reserve {
reserve = catUnits
}
evKeep := unitsOf(ev)
if room := budget - reserve; evKeep > room {
evKeep = room
}
if evKeep < 1 {
evKeep = 1
}
ev = headWithinUnits(ev, evKeep)
cat = headWithinUnits(cat, budget-unitsOf(ev))
return ev + entrySheetSeparator + cat
}
// entrySheetPart 归一化名字的一侧:去首尾空白,Excel 禁用字符换成同形全角字符。
//
// 换字符而不是删字符:删掉会改变名字的长相(「100 米/200 米」变成「100 米200 米」),
// 现场对着表名找小项就找不到了;换成本表用的全角写法(与 export.sanitizeSheetName 同一张表),
// 肉眼看上去还是原来那个名字。
func entrySheetPart(raw string) string {
name := strings.TrimSpace(raw)
return strings.NewReplacer(
"[", "(", "]", ")", ":", ":", "*", "*",
"?", "?", "/", "/", "\\", "\",
).Replace(name)
}
// entrySheetKey 把工作表名归一化成可比对的键:全角 ASCII 转半角、丢掉 BOM、去掉全部空白、转小写。
//
// 生成端与解析端都要过它,两边算出来的键必然一致;现场手写的表名只要「字对得上」也能命中
// (多打一个空格、输入法打成了全角字母都不算错)。
func entrySheetKey(raw string) string {
var builder strings.Builder
builder.Grow(len(raw))
for _, r := range raw {
switch {
case r == '\uFEFF':
// BOM 粘在表名上是「表名对不上」的经典成因,直接丢掉。
case r == '\u3000':
// 全角空格与半角空格一样,比较时都忽略。
case r >= '\uFF01' && r <= '\uFF5E':
builder.WriteRune(r - 0xFEE0)
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
default:
builder.WriteRune(r)
}
}
return strings.ToLower(builder.String())
}
// unitsOf 按 Excel 的口径数长度:UTF-16 编码单元数(汉字 1、emoji 2)。
func unitsOf(raw string) int {
units := 0
for _, r := range raw {
if r > 0xFFFF {
units += 2
continue
}
units++
}
return units
}
// headWithinUnits 取前若干编码单元,且不切开代理对(emoji 只会整只丢)。
func headWithinUnits(raw string, budget int) string {
if budget <= 0 {
return ""
}
var out []rune
used := 0
for _, r := range raw {
size := 1
if r > 0xFFFF {
size = 2
}
if used+size > budget {
break
}
used += size
out = append(out, r)
}
if out == nil {
return ""
}
return string(out)
}
// ---------- 导入:把上传的工作表定成待导入的表 ----------
// sameImportScopeName 比较表里的组别名与工作表名里的组别名:大小写、全角、首尾空白不算差异
// (与表头识别同一把尺子),差一个字就是不一致。
func sameImportScopeName(cell, scoped string) bool {
return normalizeImportHeader(cell) == normalizeImportHeader(scoped)
}
// withSheetPrefix 给工作表级的报错加上表名。单表口径的表名为空串,原样返回——老文案一字不改。
//
// 复制一份 apiError 而不是重建:status / code / detail / 拦截点都要保住,
// 尤其是 origin——重建会让拦截日志指到本函数,现场拿着它找不到真正的校验代码。
func withSheetPrefix(sheet string, err error) error {
if sheet == "" {
return err
}
var api *apiError
if !errors.As(err, &api) {
return err
}
clone := *api
clone.message = fmt.Sprintf("[工作表 %s] %s", sheet, api.message)
return &clone
}
// prepareImportTables 把上传的工作表定成「待导入的表」:分工作表口径下每张表都要能从工作表名
// 定出 (小项, 组别)。
//
// 口径判定只看一件事:**有没有哪张工作表的名字里带 @**。
// - 有 → 分工作表口径:每张表都必须能定出范围,定不出来的整份拒绝;
// - 没有 → 单表口径:只导第一张可见表,其余工作表给一条提示(与升级前的行为一致,
// 那时 xlsx 只读第一张,只是没说出口)。
//
// 为什么不「定不出来就跳过那张表」:跳过意味着用户填在第 3 张表里的几十行数据无声消失,
// 而结果面板上的「新增 / 更新」看着还是成功的。宁可整份拒绝,让人当场知道要改哪张表。
func (s *server) prepareImportTables(ctx context.Context, sheets []importSheet) ([]importTable, []string, error) {
notices := make([]string, 0, 2)
visible := make([]importSheet, 0, len(sheets))
for _, sheet := range sheets {
if sheet.hidden {
notices = append(notices, fmt.Sprintf("工作表「%s」是隐藏表,按约定不参与导入", sheet.name))
continue
}
visible = append(visible, sheet)
}
if len(visible) == 0 {
return nil, notices, badRequest("报名表里没有可导入的工作表", nil)
}
if !hasEntrySheetName(visible) {
// 单表口径:整份文件就是第一张表,范围由表内的「项目编码 / 组别」两列决定,
// 解析结果与升级前完全一样。
if len(visible) > 1 {
ignored := make([]string, 0, len(visible)-1)
for _, sheet := range visible[1:] {
ignored = append(ignored, sheet.name)
}
notices = append(notices, fmt.Sprintf(
"本工作簿有 %d 张工作表,按单表模板只导入了第一张「%s」,其余(%s)没有读。"+
"如果这是一份「一个小项组别一张表」的模板,工作表名要写成「小项名称%s组别名称」(例如「100 米跑%s男子组」),"+
"否则请把要导入的那张表放到第一张、或单独存成一个文件",
len(visible), visible[0].name, strings.Join(ignored, "、"),
entrySheetSeparator, entrySheetSeparator))
}
if len(visible[0].rows) < 2 {
return nil, notices, badRequest("报名表为空", nil)
}
return []importTable{{rows: visible[0].rows}}, notices, nil
}
scopes, err := s.loadEntrySheetScopes(ctx)
if err != nil {
return nil, notices, err
}
plan := newEntrySheetPlan(scopes)
if len(plan.byKey) == 0 {
return nil, notices, badRequest("这份表看起来是「分工作表模板」(有工作表名里带 "+entrySheetSeparator+"),"+
"但当前赛事还没有任何「小项 + 组别」组合,没法把工作表名对回小项与组别。"+
"请先到「基础数据 → 大项 / 小项」「组别」把基础数据配好再导入;"+
"如果数据本来就都在一张表里,请改用单表模板(表内要有「项目编码」与「组别」两列)", nil)
}
tables := make([]importTable, 0, len(visible))
for _, sheet := range visible {
scope, ok := plan.byKey[entrySheetKey(sheet.name)]
if !ok {
return nil, notices, badRequest(entrySheetMismatchMessage(sheet.name, plan), nil)
}
if len(sheet.rows) < 2 {
// 只有表头(或者整张表是空的)= 这个小项组别这次没人报名:不报错,但要说一句。
notices = append(notices, fmt.Sprintf("工作表「%s」没有数据行,已跳过", sheet.name))
continue
}
tables = append(tables, importTable{sheet: sheet.name, rows: sheet.rows, scope: &scope})
}
if len(tables) == 0 {
return nil, notices, badRequest("报名表为空:几张工作表都只有表头,没有要导入的数据行", nil)
}
return tables, notices, nil
}
// hasEntrySheetName 判断这份上传是不是「分工作表模板」:只要有一张工作表名里带分隔符就算。
func hasEntrySheetName(sheets []importSheet) bool {
for _, sheet := range sheets {
if strings.Contains(sheet.name, entrySheetSeparator) {
return true
}
}
return false
}
// entrySheetMismatchMessage 是「工作表名对不上任何小项 + 组别」时的报错。
//
// 必须说清三件事:哪个表名、为什么对不上、怎么改。现场手上只有 Excel,只说「工作表名非法」
// 等于让他猜——这个名字是自己手写的、还是被 Excel 改过、还是模板从别的赛事下载的,
// 三种情况的改法完全不同。
func entrySheetMismatchMessage(name string, plan entrySheetPlan) string {
head := fmt.Sprintf("工作表「%s」没能定出是哪个小项、哪个组别:", name)
if !strings.Contains(name, entrySheetSeparator) {
return head + "工作表名里没有 " + entrySheetSeparator +
"。分工作表模板的表名必须是「小项名称" + entrySheetSeparator + "组别名称」(例如「100 米跑" +
entrySheetSeparator + "男子组」)。本工作簿里已经有一张表名带 " + entrySheetSeparator +
" 了,整份文件按分工作表模板处理,每张表都要能定出小项与组别:" +
"请把这张表删掉,或把它改名成「小项名称" + entrySheetSeparator + "组别名称」"
}
left, right, _ := strings.Cut(name, entrySheetSeparator)
if _, ok := plan.eventByKey[entrySheetKey(left)]; !ok {
return head + fmt.Sprintf("当前赛事里没有小项「%s」。请核对名字是否一字不差——"+
"小项或组别改过名、名字太长被截断、模板不是从本赛事下载的,都会对不上;最稳的做法是重新下载分工作表模板", left)
}
return head + fmt.Sprintf("小项「%s」下没有组别「%s」。请核对组别名称,或重新下载分工作表模板", left, right)
}
// entryTemplateHeader 是模板的表头:en 用英文规范名,zh 用中文首选别名。
//
// 与 download 的两个按钮、与 importColumns 逐列对应,也和单表 CSV 模板共用同一份列顺序——
// 现场换模板不用重新数格子。改 importColumns 必须同步改这里(本函数就是从它算出来的)。
func entryTemplateHeader(language string) []string {
out := make([]string, 0, len(importColumns))
for _, column := range importColumns {
if language == "en" || len(column.aliases) == 0 {
out = append(out, column.name)
continue
}
out = append(out, column.aliases[0])
}
return out
}
// 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位,
// 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。
//
// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、列名与
// importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
// 口径解析时会对不上号)。所以这里用两处:表头的**单元格批注**(红三角一眼能看见)
// 与这一列的**输入提示**(点中格子就弹出来)。
const (
entryJointHintZH = "单位编码怎么写:\n" +
"· 单个单位参赛:填它的编码,例如 A。\n" +
"· 同一小项组别里两个单位联合参赛(联队):写 A+B,加号左边那个是主单位。\n" +
"  积分在两个单位之间平均分(各 50%,拆出 0.5 分照实记);\n" +
"  奖牌两边各记一枚整牌——联队的金牌,A、B 各记 1 枚。"
entryJointPromptZH = "联队写 A+B(加号左边是主单位):积分平均分,奖牌两边各记一枚"
entryJointTitleZH = "单位编码怎么写"
entryJointHintEN = "How to fill the team code:\n" +
"- One unit: its code, for example A.\n" +
"- Two units entering together as a joint team (same event and category): write A+B, " +
"the left one is the primary unit.\n" +
" Points are split evenly between them (50% each; halves such as 7.5 are kept as they are);\n" +
" each unit is credited one full medal - a joint gold counts as one for A and one for B."
entryJointPromptEN = "Joint team: write A+B (left one is the primary unit). " +
"Points split evenly, each unit gets one full medal"
entryJointTitleEN = "How to fill the team code"
// 「姓名」与「队员名单」这两列决定了这一行是个人报名还是团体报名(服务端用姓名那一格
// 有没有填来判),填错的下场是「队伍 / 队员这一格没写进库里」。模板是现场唯一没被提示
// 打扰过的地方,所以这两列的说明要正面写清「团体怎么填」——现场就是在这里反复试的。
entryNameHintZH = "姓名怎么写:\n" +
"· 填了姓名 = 个人报名:这一行是这位运动员的报名(同一个人在同一组别只有一条)。\n" +
"· 团体报名(一支队)请把姓名留空,队员的号码布填到「队员名单」列。"
entryNamePromptZH = "填了姓名就是个人报名;团体报名请把姓名留空,队员写进「队员名单」列"
entryNameTitleZH = "姓名怎么写"
entryNameHintEN = "How to fill the athlete name:\n" +
"- A name here means an individual entry: this row belongs to that athlete.\n" +
"- For a team entry (one squad), leave the athlete name blank and write the members' bibs in member_names."
entryNamePromptEN = "A name here means an individual entry. For a team entry, leave the name blank"
entryNameTitleEN = "How to fill the athlete name"
entryMemberHintZH = "队员名单怎么写:\n" +
"· 团体报名的队员写在这一格(姓名那一格留空)。\n" +
"· 写号码布(例如 A001)才会关联到人、参与个人算分;只写姓名只当文字。"
entryMemberPromptZH = "团体报名的队员写在这里,写号码布才会关联到人"
entryMemberTitleZH = "队员名单怎么写"
entryMemberHintEN = "How to fill the member list:\n" +
"- Team entries (athlete name left blank): write the members here.\n" +
"- Write their bibs (for example A001) to link them to athletes; names alone stay text only " +
"and score no individual points."
entryMemberPromptEN = "Members of a team entry go here; write bibs to link them to athletes"
entryMemberTitleEN = "How to fill the member list"
// entryTemplateAuthor 是批注作者。写系统名(与导出件的文档属性同一套),
// 绝不写构建期注入的内部署名。
entryTemplateAuthor = "赛事积分榜系统"
// entryTemplateHintRows 是输入提示覆盖的行数:模板是空表,给填表人留够行即可。
entryTemplateHintRows = 500
)
// entryColumnHint 是一列上的填表说明:表头那一格一条批注(红三角),整列一条输入提示。
type entryColumnHint struct {
// Column 是 importColumns 里的规范列名,列号由它现算——改了列序也不会挂错格子。
Column string
// Hint 是批注正文(可以长一点,写清怎么写),Prompt / Title 是输入提示。
Hint string
Prompt string
Title string
}
// entryColumnHints 按表头语言给出要挂说明的列;顺序只决定写进文件里的先后,各挂各的格子。
func entryColumnHints(language string) []entryColumnHint {
if language == "en" {
return []entryColumnHint{
{Column: "team_code", Hint: entryJointHintEN, Prompt: entryJointPromptEN, Title: entryJointTitleEN},
{Column: "athlete_name", Hint: entryNameHintEN, Prompt: entryNamePromptEN, Title: entryNameTitleEN},
{Column: "member_names", Hint: entryMemberHintEN, Prompt: entryMemberPromptEN, Title: entryMemberTitleEN},
}
}
return []entryColumnHint{
{Column: "team_code", Hint: entryJointHintZH, Prompt: entryJointPromptZH, Title: entryJointTitleZH},
{Column: "athlete_name", Hint: entryNameHintZH, Prompt: entryNamePromptZH, Title: entryNameTitleZH},
{Column: "member_names", Hint: entryMemberHintZH, Prompt: entryMemberPromptZH, Title: entryMemberTitleZH},
}
}
// entryColumnIndex 是规范列名在表头里的列号(0 起),没这列返回 -1。
func entryColumnIndex(name string) int {
for i, column := range importColumns {
if column.name == name {
return i
}
}
return -1
}
// entryTemplateFilename 是两种表头语言的下载文件名,前端按同一份口径拼文件名。
func entryTemplateFilename(language string) string {
if language == "en" {
return "报名模板(分工作表·英文表头).xlsx"
}
return "报名模板(分工作表·中文表头).xlsx"
}
// 模板里每列的展示宽度;没列的按 defaultEntryTemplateWidth 走。
var entryTemplateWidths = map[string]float64{
"event_code": 14,
"category": 18,
"team_code": 14,
"athlete_name": 12,
"id_card": 22,
"gender": 8,
"age": 8,
"bib": 10,
"seed": 12,
"heat": 8,
"status": 12,
"member_names": 26,
"squad": 12,
}
const defaultEntryTemplateWidth = 14
// entriesTemplateXLSX 生成「按小项 × 组别分工作表」的报名模板(GET /api/entries/template.xlsx?lang=zh|en)。
//
// 表头语言由 lang 决定(zh 缺省),与单表 CSV 模板的两个下载按钮是同一套口径:中文 / 英文各一份,
// 列数与列顺序完全一样,只有表头语言不同。
func (s *server) entriesTemplateXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
language := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("lang")))
if language == "" {
language = "zh"
}
if language != "zh" && language != "en" {
return nil, badRequest("lang 只能是 zh 或 en(缺省 zh)", language)
}
scopes, err := s.loadEntrySheetScopes(r.Context())
if err != nil {
return nil, err
}
plan := newEntrySheetPlan(scopes)
// 名字里带 @ 的历史数据没法写进工作表名:整次拒绝并点名,让人先改名。
// 静默跳过会让现场拿到一份「少了几张表」的模板而毫不知情——那比报错危险得多。
if len(plan.excluded) > 0 {
return nil, conflict(entrySheetExcludedMessage(plan.excluded))
}
if len(plan.sheets) == 0 {
return nil, badRequest("当前赛事还没有「小项 + 组别」的组合,分工作表模板没有可生成的表。"+
"请先到「基础数据 → 大项 / 小项」建好小项,再到「组别」里给它配上组别", nil)
}
headers := entryTemplateHeader(language)
hints := entryColumnHints(language)
sheets := make([]entriesTemplateSheet, 0, len(plan.sheets))
for _, planned := range plan.sheets {
sheets = append(sheets, entriesTemplateSheet{
Name: planned.Name, Headers: headers, Hints: hints,
})
}
name := entryTemplateFilename(language)
ascii := "entries-template-" + language + ".xlsx"
return s.streamDownload(w, ascii, name, xlsxMIME, func(out io.Writer) error {
return writeEntriesTemplateXLSX(out, sheets)
})
}
// entrySheetExcludedMessage 把「哪些名字挡住了模板生成」拼成能照着做的一句话。
// 最多点 5 个名字,其余的报个数——几十个名字堆在提示里,现场反而看不到重点。
func entrySheetExcludedMessage(problems []string) string {
const show = 5
head := problems
tail := ""
if len(head) > show {
head = head[:show]
tail = fmt.Sprintf("等 %d 处", len(problems))
}
return fmt.Sprintf("当前赛事有 %d 处小项 / 组别的名字里带 @(或全角 @),没法做成「小项名称@组别名称」的工作表名:%s%s。"+
"请先到「基础数据 → 大项 / 小项」「组别」把它们改名(去掉 @),再重新下载模板;"+
"改名不影响任何报名、成绩与积分", len(problems), strings.Join(head, ";"), tail)
}
// entriesTemplateSheet 是一张待写的工作表。
type entriesTemplateSheet struct {
Name string
Headers []string
// Hints 是挂在各列表头上的填表说明(按表头语言取),写进该列的批注与输入提示。
Hints []entryColumnHint
}
// writeEntriesTemplateXLSX 写整份模板工作簿。
//
// 版式与导出件刻意不同:**表头就在第 1 行**,没有标题行 / 副标题行。
// 导入器认第 1 行为表头(见 importColumnIndex 与「表头上方不要加标题行」的现场约定),
// 模板自己先摆两行标题,用户照着填回来的文件就会被判成「缺少列」。宁可朴素,也要能往返。
func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) error {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
headerStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11, Family: "微软雅黑", Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"3482FF"}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
})
if err != nil {
return fmt.Errorf("httpapi: 创建模板表头样式: %w", err)
}
for i, sheet := range sheets {
if i == 0 {
if err := f.SetSheetName("Sheet1", sheet.Name); err != nil {
return fmt.Errorf("httpapi: 重命名模板工作表 %s: %w", sheet.Name, err)
}
} else if _, err := f.NewSheet(sheet.Name); err != nil {
return fmt.Errorf("httpapi: 新建模板工作表 %s: %w", sheet.Name, err)
}
for column, title := range sheet.Headers {
cell, err := excelize.CoordinatesToCellName(column+1, 1)
if err != nil {
return err
}
if err := f.SetCellValue(sheet.Name, cell, title); err != nil {
return err
}
name, err := excelize.ColumnNumberToName(column + 1)
if err != nil {
return err
}
width := float64(defaultEntryTemplateWidth)
if column < len(importColumns) {
if custom, ok := entryTemplateWidths[importColumns[column].name]; ok {
width = custom
}
}
if err := f.SetColWidth(sheet.Name, name, name, width); err != nil {
return err
}
}
if len(sheet.Headers) > 0 {
last, err := excelize.ColumnNumberToName(len(sheet.Headers))
if err != nil {
return err
}
if err := f.SetCellStyle(sheet.Name, "A1", last+"1", headerStyle); err != nil {
return err
}
}
if err := f.SetRowHeight(sheet.Name, 1, 24); err != nil {
return err
}
if err := writeEntryColumnHints(f, sheet); err != nil {
return err
}
// 冻结表头:一张表几十行,往下翻的时候列名得一直看得见。
if err := f.SetPanes(sheet.Name, &excelize.Panes{
Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft",
}); err != nil {
return err
}
}
if err := f.Write(out); err != nil {
return fmt.Errorf("httpapi: 写出模板: %w", err)
}
return nil
}
// writeEntryColumnHints 把各列的填表说明挂上去:表头那一格一条批注(有红三角),
// 整列一条输入提示(点中格子就弹出)。
//
// 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是
// 「第 1 行表头 + 数据行」(列名与 importColumns 一致),批注与输入提示不是数据。
func writeEntryColumnHints(f *excelize.File, sheet entriesTemplateSheet) error {
for _, hint := range sheet.Hints {
column := entryColumnIndex(hint.Column)
if column < 0 || column >= len(sheet.Headers) || hint.Hint == "" {
continue
}
name, err := excelize.ColumnNumberToName(column + 1)
if err != nil {
return err
}
if err := f.AddComment(sheet.Name, excelize.Comment{
Cell: name + "1",
Author: entryTemplateAuthor,
Text: hint.Hint,
Width: 360,
Height: 170,
}); err != nil {
return fmt.Errorf("httpapi: 写模板批注 %s!%s1: %w", sheet.Name, name, err)
}
if hint.Prompt == "" {
continue
}
// 刻意用 type="none":这里只要一条提示,不要任何取值限制——现场的表千奇百怪,
// 模板不该因为一条校验规则把好好的编码挡在门外。
dv := excelize.NewDataValidation(true)
dv.SetSqref(fmt.Sprintf("%s2:%s%d", name, name, entryTemplateHintRows))
dv.Type = "none"
title := hint.Title
if title == "" {
title = hint.Column
}
dv.SetInput(title, hint.Prompt)
if err := f.AddDataValidation(sheet.Name, dv); err != nil {
return fmt.Errorf("httpapi: 写模板输入提示 %s!%s: %w", sheet.Name, name, err)
}
}
return nil
}