先把「实机复测」能在这台 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。
158 lines
4.2 KiB
Go
158 lines
4.2 KiB
Go
package export
|
|
|
|
import (
|
|
"database/sql"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/xuri/excelize/v2"
|
|
)
|
|
|
|
// EventResultRow 是分项成绩表的一行。
|
|
// Rank 用字符串是为了承载「1(并列)」这种展示值。
|
|
type EventResultRow struct {
|
|
Rank string
|
|
Bib string
|
|
TeamName string
|
|
Participant string
|
|
ResultText string
|
|
Unit string
|
|
Points float64
|
|
Status string
|
|
Remark string
|
|
}
|
|
|
|
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
|
|
type EventSheet struct {
|
|
SportName string
|
|
EventName string
|
|
Category string
|
|
Rows []EventResultRow
|
|
}
|
|
|
|
// EventsWorkbook 是「分项成绩表」的内容:一个小项可含多个组别 sheet,
|
|
// 也可承载全部小项(ExportAllXLSX 用)。
|
|
type EventsWorkbook struct {
|
|
Meta Meta
|
|
Sheets []EventSheet
|
|
}
|
|
|
|
// eventColumns 的列宽按「最长实际内容」预留:名次并列时写成「3(并列)」,
|
|
// 列宽 8 且这一类列不折行,右边又紧挨非空的「号码」,在 Excel 里会被硬裁;取 11 才能连
|
|
// 「10(并列)」这种两位数并列一起放下。运动员/队 是接力队名单(「王磊、赵雷、陈刚2、郑凯」
|
|
// 约 23 个显示宽度),18 会折成两行,24 够放 4~6 人名单。加宽只影响 fitToWidth=1 的打印
|
|
// 缩放比例(整表总宽 105→114,A4 横向仍是一页),不会把表推到第二页;再宽就得重新权衡。
|
|
var eventColumns = []column{
|
|
{"名次", 11, kindCenter},
|
|
{"号码", 9, kindCenter},
|
|
{"单位", 22, kindText},
|
|
{"运动员/队", 24, kindText},
|
|
{"成绩", 12, kindCenter},
|
|
{"积分", 8, kindNumber},
|
|
{"状态", 8, kindCenter},
|
|
{"备注", 20, kindText},
|
|
}
|
|
|
|
// ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。
|
|
func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error {
|
|
wb, err := LoadEventWorkbook(db, eventID, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := eventFile(wb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return saveWorkbook(f, dst)
|
|
}
|
|
|
|
// WriteEventXLSX 把分项成绩表写成 xlsx 流。
|
|
func WriteEventXLSX(w io.Writer, wb EventsWorkbook) error {
|
|
f, err := eventFile(wb)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.Write(w)
|
|
}
|
|
|
|
func eventFile(wb EventsWorkbook) (*excelize.File, error) {
|
|
return buildWorkbook("成绩表", func(f *excelize.File, st *styleSet) error {
|
|
return writeEventSheets(f, st, wb.Meta, wb.Sheets, &sheetNamer{})
|
|
})
|
|
}
|
|
|
|
func writeEventSheets(f *excelize.File, st *styleSet, meta Meta, sheets []EventSheet, namer *sheetNamer) error {
|
|
meta = meta.normalized()
|
|
for _, sh := range sheets {
|
|
name := namer.add(eventSheetBase(sh))
|
|
if err := addSheet(f, name); err != nil {
|
|
return err
|
|
}
|
|
if err := writeSheet(f, st, name, meta, eventSheetTitle(meta, sh), eventColumns, eventRows(sh.Rows)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// eventSheetBase 是工作表名:大项·小项·组别,够短且能区分。
|
|
func eventSheetBase(sh EventSheet) string {
|
|
parts := make([]string, 0, 3)
|
|
if sh.SportName != "" {
|
|
parts = append(parts, sh.SportName)
|
|
}
|
|
parts = append(parts, sh.EventName)
|
|
if sh.Category != "" {
|
|
parts = append(parts, sh.Category)
|
|
}
|
|
return strings.Join(parts, "·")
|
|
}
|
|
|
|
// eventSheetTitle 是表内大标题,带赛事名。
|
|
func eventSheetTitle(meta Meta, sh EventSheet) string {
|
|
base := eventSheetBase(sh)
|
|
if base == "" {
|
|
base = "成绩表"
|
|
}
|
|
return sheetTitle(meta, base)
|
|
}
|
|
|
|
func eventRows(rows []EventResultRow) [][]any {
|
|
out := make([][]any, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, []any{
|
|
r.Rank, r.Bib, r.TeamName, r.Participant,
|
|
displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// displayResult 把成绩与单位拼成「11.58 秒」;单位已含在文本里时不重复拼接。
|
|
func displayResult(text, unit string) string {
|
|
text = strings.TrimSpace(text)
|
|
unit = strings.TrimSpace(unit)
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
if unit == "" || strings.HasSuffix(text, unit) {
|
|
return text
|
|
}
|
|
return text + " " + unitLabel(unit)
|
|
}
|
|
|
|
func unitLabel(unit string) string {
|
|
switch unit {
|
|
case "s":
|
|
return "秒"
|
|
case "m":
|
|
return "米"
|
|
case "pts":
|
|
return "分"
|
|
case "次":
|
|
return "次"
|
|
default:
|
|
return unit
|
|
}
|
|
}
|