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

+5 -3
View File
@@ -63,8 +63,8 @@ type ProjectExportOptions struct {
CreatedBy string
AppVersion string
UploadsDir string // 可选:背景图 / 会标目录
FontsDir string // 可选:字体目录
IncludeFonts bool // 是否把字体一并打包
FontsDir string // 可选:上传字体目录(运行时的 <项目目录>/uploads/fonts)
IncludeFonts bool // 是否把字体一并打包(打进包内 uploads/fonts/)
RulesPath string // 可选:规则包 YAML
Now time.Time
}
@@ -121,7 +121,9 @@ func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (Projec
}
}
if opts.IncludeFonts && opts.FontsDir != "" {
if err := copyTree(opts.FontsDir, filepath.Join(tmp, "fonts")); err != nil {
// 字体在包内的位置就是运行时的位置(<项目目录>/uploads/fonts/),
// 否则导入后字体落在没人读的 fonts/ 下,兜底就成了假兜底。
if err := copyTree(opts.FontsDir, filepath.Join(uploads, "fonts")); err != nil {
return ProjectMeta{}, err
}
}
+30
View File
@@ -89,6 +89,36 @@ func TestExportImportProjectZip(t *testing.T) {
}
}
// 字体必须落在包内 uploads/fonts/:导入后它就在运行时的字体目录下,大屏的 @font-face 才找得到。
// 这里只给 FontsDir、不给 UploadsDir,顺带验证兜底路径本身是通的。
func TestExportProjectZipIncludesFontsFallback(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
fonts := t.TempDir()
writeFile(t, filepath.Join(fonts, "思源宋体.ttf"), "font-bytes")
zipPath := filepath.Join(t.TempDir(), "赛事包.zip")
if _, err := ExportProjectZip(db, zipPath, ProjectExportOptions{
Name: "字体赛事",
FontsDir: fonts,
IncludeFonts: true,
}); err != nil {
t.Fatalf("导出赛事包: %v", err)
}
if !zipHas(zipPath, t, "uploads/fonts/思源宋体.ttf") {
t.Fatal("包内缺少 uploads/fonts/思源宋体.ttf")
}
res, err := ImportProjectZip(zipPath, t.TempDir(), ImportOptions{})
if err != nil {
t.Fatalf("导入赛事包: %v", err)
}
if got := readFile(t, filepath.Join(res.Dir, "uploads", "fonts", "思源宋体.ttf")); got != "font-bytes" {
t.Errorf("导入后字体内容 = %q", got)
}
}
func TestImportRejectsBadPackages(t *testing.T) {
base := ProjectMeta{
Format: projectFormat,