Files
Scoreboard/server/internal/export/style.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

403 lines
12 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。
//
// 只被本包那 9 个 Export*XLSX 包装函数调用,而它们**只有测试在调**:生产导出(HTTP 层)
// 一律走 Load* + Write*(w io.Writer, wb),把工作簿直接写进响应体,不落临时盘。
// 所以整条「写文件」路径在生产不可达(代码卫生项 C28)。作用是让单测真的落盘、再用
// openXLSX 读回来逐格校验,比只比对内存结构更接近现场拿到的那份文件。
// 要把它接进生产,先想清楚多一份临时文件与一次磁盘写的代价,别在 handler 里随手用。
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
}