Files
Scoreboard/server/internal/httpapi/entries_sheet_template.go
T
AskaEth 7a3e4ad365 feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `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`。
2026-09-14 11:45:32 +08:00

700 lines
29 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"
// 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
}