feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容

三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
This commit is contained in:
AskaEth committed 2026-09-10 17:45:11 +08:00
1 parent 9c6d0bd3b3
commit fc96d17660
42 files changed
+2451 -95

No files matched your search

+7
View File
@@ -38,6 +38,10 @@ type EventSettingsDisplay struct {
RotateSecond int `json:"rotateSeconds"`
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
Announce *string `json:"announce"`
// FontTitle / FontBody 是大屏标题与正文用的字体 id(builtin:* 或 custom:<文件名>);
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
FontTitle *string `json:"fontTitle"`
FontBody *string `json:"fontBody"`
}
// EventSettingsStandings 是榜单口径配置。
@@ -103,6 +107,9 @@ func ParseEventSettings(values map[string]string) EventSettings {
}
out.Display.PrimaryColor = out.Event.PrimaryColor
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
// 字体按 id 字符串原样下发,前端解析成字体栈;空串与缺键都表示用内置默认字体。
out.Display.FontTitle = nilIfEmpty(values["display.font_title"])
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
+59
View File
@@ -95,6 +95,65 @@ func TestParseEventSettingsAnnounce(t *testing.T) {
}
}
// 字体 id 是给大屏渲染用的展示字段,非 admin 的快照也必须带上它,
// 否则 guest/recorder 打开的大屏只会用默认字体。
func TestEventSettingsWithoutAdminFieldsKeepsFonts(t *testing.T) {
title := "builtin:noto-serif"
body := "custom:思源宋体.ttf"
full := EventSettings{Display: EventSettingsDisplay{FontTitle: &title, FontBody: &body}}
got := full.WithoutAdminFields()
if got.Display.FontTitle == nil || *got.Display.FontTitle != title {
t.Fatalf("非 admin 快照应保留标题字体,得到 %v", got.Display.FontTitle)
}
if got.Display.FontBody == nil || *got.Display.FontBody != body {
t.Fatalf("非 admin 快照应保留正文字体,得到 %v", got.Display.FontBody)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("序列化: %v", err)
}
var display map[string]json.RawMessage
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
t.Fatalf("解析 display: %v", err)
}
if string(display["fontTitle"]) != `"`+title+`"` || string(display["fontBody"]) != `"`+body+`"` {
t.Fatalf("字体应随 display 一起下发: %s", raw)
}
}
// 字体取自 settings 表的 display.font_title / display.font_body;
// 空串与缺键都表示用默认字体,必须下发显式 null。
func TestParseEventSettingsFonts(t *testing.T) {
withFonts := ParseEventSettings(map[string]string{
"display.font_title": "builtin:misans",
"display.font_body": "custom:my-font.ttf",
})
if withFonts.Display.FontTitle == nil || *withFonts.Display.FontTitle != "builtin:misans" {
t.Fatalf("标题字体应随 settings 下发,得到 %v", withFonts.Display.FontTitle)
}
if withFonts.Display.FontBody == nil || *withFonts.Display.FontBody != "custom:my-font.ttf" {
t.Fatalf("正文字体应随 settings 下发,得到 %v", withFonts.Display.FontBody)
}
empty := ParseEventSettings(map[string]string{"display.font_title": "", "display.font_body": ""})
if empty.Display.FontTitle != nil || empty.Display.FontBody != nil {
t.Fatalf("空串表示默认字体,应解析为 null,得到 %v / %v", empty.Display.FontTitle, empty.Display.FontBody)
}
raw, err := json.Marshal(empty)
if err != nil {
t.Fatalf("序列化: %v", err)
}
var display map[string]json.RawMessage
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
t.Fatalf("解析 display: %v", err)
}
if string(display["fontTitle"]) != "null" || string(display["fontBody"]) != "null" {
t.Fatalf("未配置字体时应显式序列化为 null: %s", raw)
}
}
// mustField 取出顶层字段的原始 JSON,供字段级断言使用。
func mustField(t *testing.T, raw []byte, key string) []byte {
t.Helper()