三块改动,均为现场反馈:
一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 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/ 同步更新。
200 lines
7.6 KiB
Go
200 lines
7.6 KiB
Go
package dto
|
||
|
||
import (
|
||
"encoding/json"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
)
|
||
|
||
// 非 admin 会话只能看到大屏渲染所需字段:admin-only 字段必须清零,
|
||
// 且序列化后仍是显式的 null / 0,不能变成字段缺失。
|
||
func TestEventSettingsWithoutAdminFields(t *testing.T) {
|
||
profileID := int64(3)
|
||
full := EventSettings{
|
||
Event: EventSettingsEvent{Name: "运动会"},
|
||
Display: EventSettingsDisplay{Title: "大屏", RotateSecond: 15},
|
||
Standings: EventSettingsStandings{IndividualToTeamWeight: 0.5, RankingMode: "points", ShowIndividualBoard: true, ShowIndividualMedals: false},
|
||
DefaultProfileID: &profileID,
|
||
RequireApproval: true,
|
||
}
|
||
|
||
got := full.WithoutAdminFields()
|
||
if got.DefaultProfileID != nil {
|
||
t.Fatalf("defaultProfileId 应置空,得到 %v", got.DefaultProfileID)
|
||
}
|
||
if got.RequireApproval {
|
||
t.Fatalf("requireApproval 应清零")
|
||
}
|
||
if got.Standings.IndividualToTeamWeight != 0 {
|
||
t.Fatalf("individualToTeamWeight 应清零,得到 %v", got.Standings.IndividualToTeamWeight)
|
||
}
|
||
if got.Event.Name != "运动会" || got.Display.Title != "大屏" || got.Display.RotateSecond != 15 {
|
||
t.Fatalf("event/display 不应被裁剪: %+v", got)
|
||
}
|
||
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || got.Standings.ShowIndividualMedals {
|
||
t.Fatalf("榜单开关不应被裁剪: %+v", got.Standings)
|
||
}
|
||
|
||
raw, err := json.Marshal(got)
|
||
if err != nil {
|
||
t.Fatalf("序列化: %v", err)
|
||
}
|
||
var top map[string]json.RawMessage
|
||
if err := json.Unmarshal(raw, &top); err != nil {
|
||
t.Fatalf("解析: %v", err)
|
||
}
|
||
if string(top["defaultProfileId"]) != "null" {
|
||
t.Fatalf("defaultProfileId 应显式序列化为 null: %s", raw)
|
||
}
|
||
if string(top["requireApproval"]) != "false" {
|
||
t.Fatalf("requireApproval 应显式序列化为 false: %s", raw)
|
||
}
|
||
var standings map[string]json.RawMessage
|
||
if err := json.Unmarshal(top["standings"], &standings); err != nil {
|
||
t.Fatalf("解析 standings: %v", err)
|
||
}
|
||
if string(standings["individualToTeamWeight"]) != "0" {
|
||
t.Fatalf("individualToTeamWeight 应显式序列化为 0: %s", raw)
|
||
}
|
||
}
|
||
|
||
// 公告是给大屏底栏用的展示字段,非 admin 的快照也必须带上它。
|
||
func TestEventSettingsWithoutAdminFieldsKeepsAnnounce(t *testing.T) {
|
||
announce := "决赛推迟到 15:00"
|
||
full := EventSettings{Display: EventSettingsDisplay{Announce: &announce}}
|
||
|
||
got := full.WithoutAdminFields()
|
||
if got.Display.Announce == nil || *got.Display.Announce != announce {
|
||
t.Fatalf("非 admin 快照应保留公告,得到 %v", got.Display.Announce)
|
||
}
|
||
}
|
||
|
||
// 公告正文取自 settings 表的 display.announce_text(由大屏指令写入);
|
||
// 空串表示无公告,必须下发显式 null,底栏才能回落到「暂无公告」。
|
||
func TestParseEventSettingsAnnounce(t *testing.T) {
|
||
withText := ParseEventSettings(map[string]string{"display.announce_text": "决赛推迟到 15:00"})
|
||
if withText.Display.Announce == nil || *withText.Display.Announce != "决赛推迟到 15:00" {
|
||
t.Fatalf("公告应随 settings 下发,得到 %v", withText.Display.Announce)
|
||
}
|
||
|
||
empty := ParseEventSettings(map[string]string{"display.announce_text": ""})
|
||
if empty.Display.Announce != nil {
|
||
t.Fatalf("空串表示无公告,应解析为 null,得到 %q", *empty.Display.Announce)
|
||
}
|
||
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["announce"]) != "null" {
|
||
t.Fatalf("无公告时应显式序列化为 null: %s", raw)
|
||
}
|
||
}
|
||
|
||
// 字体 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()
|
||
var top map[string]json.RawMessage
|
||
if err := json.Unmarshal(raw, &top); err != nil {
|
||
t.Fatalf("解析 %s: %v (%s)", key, err, raw)
|
||
}
|
||
return top[key]
|
||
}
|
||
|
||
// 单位/小项的 DTO 必须把可空列写成显式 null,不能沿用 domain 的 omitempty。
|
||
func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
|
||
teamRaw, err := json.Marshal(TeamFromDomain(domain.Team{ID: 1, Code: "T1", Name: "第一单位"}))
|
||
if err != nil {
|
||
t.Fatalf("序列化单位: %v", err)
|
||
}
|
||
var team map[string]json.RawMessage
|
||
if err := json.Unmarshal(teamRaw, &team); err != nil {
|
||
t.Fatalf("解析单位: %v", err)
|
||
}
|
||
for _, key := range []string{"shortName", "logoPath", "color"} {
|
||
if got, ok := team[key]; !ok || string(got) != "null" {
|
||
t.Fatalf("单位字段 %s 应为显式 null: %s", key, teamRaw)
|
||
}
|
||
}
|
||
|
||
eventRaw, err := json.Marshal(EventFromDomain(domain.Event{ID: 1, Code: "E1", Name: "100米"}))
|
||
if err != nil {
|
||
t.Fatalf("序列化小项: %v", err)
|
||
}
|
||
var event map[string]json.RawMessage
|
||
if err := json.Unmarshal(eventRaw, &event); err != nil {
|
||
t.Fatalf("解析小项: %v", err)
|
||
}
|
||
for _, key := range []string{"scheduledAt", "venue", "remark"} {
|
||
if got, ok := event[key]; !ok || string(got) != "null" {
|
||
t.Fatalf("小项字段 %s 应为显式 null: %s", key, eventRaw)
|
||
}
|
||
}
|
||
if string(event["sortOrder"]) != "0" {
|
||
t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
|
||
}
|
||
}
|