Files
Scoreboard/server/internal/export/style.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

368 lines
10 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()
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
}
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
}