## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
—— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。
## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。
## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。
## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。
## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。
## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。
## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。
## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
700 lines
29 KiB
Go
700 lines
29 KiB
Go
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"
|
||
// entryTemplateAuthor 是批注作者。写系统名(与导出件的文档属性同一套),
|
||
// 绝不写构建期注入的内部署名。
|
||
entryTemplateAuthor = "赛事积分榜系统"
|
||
// entryTemplateHintRows 是输入提示覆盖的行数:模板是空表,给填表人留够行即可。
|
||
entryTemplateHintRows = 500
|
||
)
|
||
|
||
// entryTemplateJointHint 按表头语言取「联队怎么写」的说明(批注正文 + 输入提示的标题与正文)。
|
||
func entryTemplateJointHint(language string) (hint, title, prompt string) {
|
||
if language == "en" {
|
||
return entryJointHintEN, entryJointTitleEN, entryJointPromptEN
|
||
}
|
||
return entryJointHintZH, entryJointTitleZH, entryJointPromptZH
|
||
}
|
||
|
||
// entryTeamCodeColumn 是 team_code 在表头里的列号(0 起)。模板的说明挂在这一列上,
|
||
// 所以它从 importColumns 现算,改了列序也不会挂错格子。
|
||
func entryTeamCodeColumn() int {
|
||
for i, column := range importColumns {
|
||
if column.name == "team_code" {
|
||
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)
|
||
hint, hintTitle, prompt := entryTemplateJointHint(language)
|
||
sheets := make([]entriesTemplateSheet, 0, len(plan.sheets))
|
||
for _, planned := range plan.sheets {
|
||
sheets = append(sheets, entriesTemplateSheet{
|
||
Name: planned.Name, Headers: headers,
|
||
JointHint: hint, JointHintTitle: hintTitle, JointPrompt: prompt,
|
||
})
|
||
}
|
||
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
|
||
// JointHint / JointHintTitle / JointPrompt 是「单位编码写 A+B」的说明(按表头语言取),
|
||
// 挂在该列的批注与输入提示上。
|
||
JointHint string
|
||
JointHintTitle string
|
||
JointPrompt string
|
||
}
|
||
|
||
// 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 := writeEntryJointTeamHint(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
|
||
}
|
||
|
||
// writeEntryJointTeamHint 把「联队怎么写」挂在单位编码列上:表头那一格一条批注(有红三角),
|
||
// 整列一条输入提示(点中格子就弹出)。
|
||
//
|
||
// 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是
|
||
// 「第 1 行表头 + 数据行」(列名与 importColumns 一致),批注与输入提示不是数据。
|
||
func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error {
|
||
column := entryTeamCodeColumn()
|
||
if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" {
|
||
return nil
|
||
}
|
||
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: sheet.JointHint,
|
||
Width: 360,
|
||
Height: 170,
|
||
}); err != nil {
|
||
return fmt.Errorf("httpapi: 写模板批注 %s!%s1: %w", sheet.Name, name, err)
|
||
}
|
||
if sheet.JointPrompt == "" {
|
||
return nil
|
||
}
|
||
// 刻意用 type="none":这里只要一条提示,不要任何取值限制——现场的表千奇百怪,
|
||
// 模板不该因为一条校验规则把好好的编码挡在门外。
|
||
dv := excelize.NewDataValidation(true)
|
||
dv.SetSqref(fmt.Sprintf("%s2:%s%d", name, name, entryTemplateHintRows))
|
||
dv.Type = "none"
|
||
title := sheet.JointHintTitle
|
||
if title == "" {
|
||
title = "单位编码"
|
||
}
|
||
dv.SetInput(title, sheet.JointPrompt)
|
||
if err := f.AddDataValidation(sheet.Name, dv); err != nil {
|
||
return fmt.Errorf("httpapi: 写模板输入提示 %s!%s: %w", sheet.Name, name, err)
|
||
}
|
||
return nil
|
||
}
|