Files
Scoreboard/server/internal/export/style.go
T
AskaEth 8dd599bd97 fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:

导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向

导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)

错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)

大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
  字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
  scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)

文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)

自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
2026-09-11 15:35:42 +08:00

397 lines
11 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 export
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/xuri/excelize/v2"
)
// 配色跟随管理端 MIUIx 令牌,导出件与界面观感一致;字体用 Windows 自带的微软雅黑,
// 现场机器不需要额外装字体就能正确显示。
const (
colorPrimary = "3482FF"
colorHeaderText = "FFFFFF"
colorBorder = "D0D7E5"
colorBand = "F5F8FF"
colorMuted = "6B7280"
fontFamily = "微软雅黑"
)
// 每张表的固定版式:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起为数据。
const (
rowTitle = 1
rowSubtitle = 2
rowHeader = 4
)
// maxSheetName 是 Excel 对工作表名的硬限制。
const maxSheetName = 31
// cellKind 决定单元格的对齐方式与数字格式。
type cellKind int
const (
kindText cellKind = iota
kindCenter
kindNumber
kindInt
)
// column 是一列的展示定义;宽度按内容预留,导出即可打印,不用再手工调列宽。
type column struct {
title string
width float64
kind cellKind
}
type dataKey struct {
kind cellKind
band bool
}
// styleSet 是一份工作簿内复用的样式集合;excelize 的样式 id 与工作簿绑定,
// 因此每打开一份新工作簿都要重建一次。
type styleSet struct {
title int
subtitle int
header int
data map[dataKey]int
}
func newStyleSet(f *excelize.File) (*styleSet, error) {
border := []excelize.Border{
{Type: "left", Color: colorBorder, Style: 1},
{Type: "right", Color: colorBorder, Style: 1},
{Type: "top", Color: colorBorder, Style: 1},
{Type: "bottom", Color: colorBorder, Style: 1},
}
st := &styleSet{data: make(map[dataKey]int, 8)}
var err error
if st.title, err = f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 16, Family: fontFamily},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}); err != nil {
return nil, fmt.Errorf("export: 创建标题样式: %w", err)
}
if st.subtitle, err = f.NewStyle(&excelize.Style{
Font: &excelize.Font{Size: 10, Family: fontFamily, Color: colorMuted},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}); err != nil {
return nil, fmt.Errorf("export: 创建副标题样式: %w", err)
}
if st.header, err = f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11, Family: fontFamily, Color: colorHeaderText},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorPrimary}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
Border: border,
}); err != nil {
return nil, fmt.Errorf("export: 创建表头样式: %w", err)
}
for _, kind := range []cellKind{kindText, kindCenter, kindNumber, kindInt} {
for _, band := range []bool{false, true} {
id, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Size: 11, Family: fontFamily},
Alignment: &excelize.Alignment{Horizontal: horizontal(kind), Vertical: "center", WrapText: kind == kindText},
Border: border,
Fill: fill(band),
CustomNumFmt: numFmt(kind),
})
if err != nil {
return nil, fmt.Errorf("export: 创建数据样式: %w", err)
}
st.data[dataKey{kind: kind, band: band}] = id
}
}
return st, nil
}
func horizontal(kind cellKind) string {
if kind == kindText {
return "left"
}
return "center"
}
func fill(band bool) excelize.Fill {
if !band {
return excelize.Fill{}
}
return excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorBand}}
}
// numFmt 让积分最多保留两位小数、不显示多余的 0;整数列固定不带小数。
func numFmt(kind cellKind) *string {
switch kind {
case kindNumber:
return strPtr("0.##")
case kindInt:
return strPtr("0")
default:
return nil
}
}
func (s *styleSet) cell(kind cellKind, band bool) int {
return s.data[dataKey{kind: kind, band: band}]
}
// buildWorkbook 建好工作簿与样式后交给 build 填充。
// build 若不新建任何工作表,就把默认工作表改名为 defaultSheet,避免出现空白的 "Sheet1"。
func buildWorkbook(defaultSheet string, build func(f *excelize.File, st *styleSet) error) (*excelize.File, error) {
f := excelize.NewFile()
if err := setDocProps(f); err != nil {
return nil, err
}
st, err := newStyleSet(f)
if err != nil {
return nil, err
}
if err := build(f, st); err != nil {
return nil, err
}
sheets := f.GetSheetList()
if len(sheets) == 1 && sheets[0] == "Sheet1" {
if err := f.SetSheetName("Sheet1", sanitizeSheetName(defaultSheet)); err != nil {
return nil, fmt.Errorf("export: 重命名工作表: %w", err)
}
return f, nil
}
if idx, err := f.GetSheetIndex("Sheet1"); err == nil && idx >= 0 {
_ = f.DeleteSheet("Sheet1")
}
return f, nil
}
// 文档属性里的作者与应用名。这属于对外可见的元数据(Excel「文件 → 信息」),
// 用系统名即可;绝不能写内部署名——那是构建期注入、只应出现在管理端「关于」页的东西。
const (
docAuthor = "赛事积分榜系统"
docApplication = "赛事积分榜系统"
)
// setDocProps 覆盖 excelize 的默认文档属性(作者 xuri、创建/修改时间 2006-09-16):
// 领导在「文件 → 信息」里看到的是这份导出件的元数据,不该带第三方库作者名和一个假日期。
// 时间取导出当下——它表示这份文件的生成时间,与二进制何时构建无关。
func setDocProps(f *excelize.File) error {
now := time.Now().Format(time.RFC3339)
if err := f.SetDocProps(&excelize.DocProperties{
Creator: docAuthor,
LastModifiedBy: docAuthor,
Created: now,
Modified: now,
}); err != nil {
return fmt.Errorf("export: 设置文档属性: %w", err)
}
if err := f.SetAppProps(&excelize.AppProperties{Application: docApplication}); err != nil {
return fmt.Errorf("export: 设置应用属性: %w", err)
}
return nil
}
func addSheet(f *excelize.File, name string) error {
if _, err := f.NewSheet(name); err != nil {
return fmt.Errorf("export: 新建工作表 %s: %w", name, err)
}
return nil
}
// writeSheet 按统一版式写一张表:合并标题、副标题、带底色的表头、斑马纹数据行、
// 冻结表头,并预设 A4 横向、按宽度缩放、每页重复表头,导出后可直接打印。
func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title string, cols []column, rows [][]any) error {
if len(cols) == 0 {
return fmt.Errorf("export: 工作表 %s 没有列定义", sheet)
}
lastCol := colName(len(cols))
if err := f.SetCellValue(sheet, "A1", title); err != nil {
return err
}
if err := f.MergeCell(sheet, "A1", lastCol+"1"); err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A1", lastCol+"1", st.title); err != nil {
return err
}
if err := f.SetRowHeight(sheet, rowTitle, 32); err != nil {
return err
}
if sub := metaSubtitle(meta); sub != "" {
if err := f.SetCellValue(sheet, "A2", sub); err != nil {
return err
}
if err := f.MergeCell(sheet, "A2", lastCol+"2"); err != nil {
return err
}
if err := f.SetCellStyle(sheet, "A2", lastCol+"2", st.subtitle); err != nil {
return err
}
}
for i, c := range cols {
name := colName(i + 1)
if err := f.SetCellValue(sheet, name+strconv.Itoa(rowHeader), c.title); err != nil {
return err
}
if err := f.SetColWidth(sheet, name, name, c.width); err != nil {
return err
}
}
if err := f.SetCellStyle(sheet, "A"+strconv.Itoa(rowHeader), lastCol+strconv.Itoa(rowHeader), st.header); err != nil {
return err
}
if err := f.SetRowHeight(sheet, rowHeader, 26); err != nil {
return err
}
for r, row := range rows {
excelRow := rowHeader + 1 + r
for i, c := range cols {
var v any = ""
if i < len(row) && row[i] != nil {
v = row[i]
}
cell := colName(i+1) + strconv.Itoa(excelRow)
if err := f.SetCellValue(sheet, cell, v); err != nil {
return err
}
if err := f.SetCellStyle(sheet, cell, cell, st.cell(c.kind, r%2 == 1)); err != nil {
return err
}
}
}
if err := f.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: rowHeader,
TopLeftCell: "A" + strconv.Itoa(rowHeader+1),
ActivePane: "bottomLeft",
}); err != nil {
return err
}
if err := f.SetPageLayout(sheet, &excelize.PageLayoutOptions{
Size: intPtr(9), // 9 = A4
Orientation: strPtr("landscape"),
FitToWidth: intPtr(1),
FitToHeight: intPtr(0),
}); err != nil {
return err
}
// 打印标题与页码是「锦上添花」,失败不影响文件可用性,故忽略错误。
_ = f.SetDefinedName(&excelize.DefinedName{
Name: "_xlnm.Print_Titles",
RefersTo: fmt.Sprintf("'%s'!$1:$%d", sheet, rowHeader),
Scope: sheet,
})
_ = f.SetHeaderFooter(sheet, &excelize.HeaderFooterOptions{
OddHeader: "&C" + title,
OddFooter: "&C第 &P 页 / 共 &N 页",
})
return nil
}
// saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。
func saveWorkbook(f *excelize.File, dst string) error {
if strings.TrimSpace(dst) == "" {
return errors.New("export: 导出路径为空")
}
if dir := filepath.Dir(dst); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
}
}
if err := f.SaveAs(dst); err != nil {
return fmt.Errorf("export: 保存 xlsx %s: %w", dst, err)
}
return nil
}
// sheetTitle 把赛事名拼在表名前面,打印出来一眼能看出是哪场赛事。
func sheetTitle(meta Meta, name string) string {
if meta.EventName == "" {
return name
}
return meta.EventName + " · " + name
}
func metaSubtitle(m Meta) string {
parts := make([]string, 0, 4)
if m.Organizer != "" {
parts = append(parts, m.Organizer)
}
if m.Subtitle != "" {
parts = append(parts, m.Subtitle)
}
if !m.GeneratedAt.IsZero() {
parts = append(parts, "导出时间 "+m.GeneratedAt.Format("2006-01-02 15:04"))
}
if m.GeneratedBy != "" {
parts = append(parts, "导出人 "+m.GeneratedBy)
}
return strings.Join(parts, " · ")
}
// sanitizeSheetName 替换 Excel 不允许的字符并截断到 31 字符。
func sanitizeSheetName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "成绩"
}
name = strings.NewReplacer(
"[", "(", "]", ")", ":", ":", "*", "*",
"?", "?", "/", "/", "\\", "\",
).Replace(name)
if rs := []rune(name); len(rs) > maxSheetName {
name = string(rs[:maxSheetName])
}
return name
}
// sheetNamer 保证同一工作簿内的工作表名唯一(Excel 不允许重名)。
type sheetNamer struct {
used map[string]bool
}
func (n *sheetNamer) add(base string) string {
base = sanitizeSheetName(base)
if n.used == nil {
n.used = make(map[string]bool)
}
name := base
for i := 2; n.used[name]; i++ {
suffix := fmt.Sprintf("(%d)", i)
rs := []rune(base)
if keep := maxSheetName - len([]rune(suffix)); len(rs) > keep {
rs = rs[:keep]
}
name = string(rs) + suffix
}
n.used[name] = true
return name
}
func colName(n int) string {
name, _ := excelize.ColumnNumberToName(n)
return name
}
func intPtr(v int) *int { return &v }
func strPtr(v string) *string { return &v }
// nowOr 让测试可以注入固定时间;零值取当前时间。
func nowOr(t time.Time) time.Time {
if t.IsZero() {
return time.Now()
}
return t
}