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

+2 -1
View File
@@ -18,9 +18,10 @@
- **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分) - **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分)
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗 - **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用 - **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo),并可在管理端页内一键切换当前赛事 - **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出 - **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo - **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
- **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入
## 快速开始 ## 快速开始
+2 -1
View File
@@ -38,6 +38,7 @@ console/
├── styles.css ├── styles.css
├── app.js ├── app.js
├── preload.js # 管理窗口的 contextBridge 白名单 ├── preload.js # 管理窗口的 contextBridge 白名单
├── display-preload.js # 大屏窗口的 window.displayShell 白名单(窗口级全屏状态,供页面收放自绘标题栏)
└── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单(窗口控制 + 显示器菜单) └── shell-preload.js # 服务端管理面板窗口的 window.desktopShell 白名单(窗口控制 + 显示器菜单)
``` ```
@@ -117,7 +118,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
| 场景 | 行为 | | 场景 | 行为 |
| --- | --- | | --- | --- |
| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏 | | 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏 |
| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 | | 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 |
| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` | | 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` |
| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=<id>&instance=<host>-<id>`,页序按 `display.pages` 轮转 | | 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=<id>&instance=<host>-<id>`,页序按 `display.pages` 轮转 |
+14
View File
@@ -1,6 +1,7 @@
'use strict' 'use strict'
const os = require('os') const os = require('os')
const path = require('path')
const { BrowserWindow, screen } = require('electron') const { BrowserWindow, screen } = require('electron')
const { healthHost } = require('./service') const { healthHost } = require('./service')
@@ -133,6 +134,8 @@ class DisplayManager {
backgroundColor: this.display.backgroundColor || '#000000', backgroundColor: this.display.backgroundColor || '#000000',
title: `大屏 · ${page}`, title: `大屏 · ${page}`,
webPreferences: { webPreferences: {
// 页面看不到窗口级全屏,靠这个 preload 转达(见 display-preload.js)
preload: path.join(__dirname, '..', 'renderer', 'display-preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true, sandbox: true,
@@ -156,6 +159,17 @@ class DisplayManager {
}) })
} }
// 窗口级全屏只在主进程可见:页面拿不到 setFullScreen 的结果,标题栏也就不会让位。
// 状态变化下发一次之外,加载完成还要补发一次——重载/渲染进程崩溃恢复后窗口可能
// 已经是全屏,只挂事件会让页面一直画着标题栏。
const notifyFullscreen = () => {
if (win.isDestroyed()) return
win.webContents.send('window:fullscreen-changed', win.isFullScreen())
}
win.on('enter-full-screen', notifyFullscreen)
win.on('leave-full-screen', notifyFullscreen)
win.webContents.on('did-finish-load', notifyFullscreen)
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
if (!win.isDestroyed()) win.show() if (!win.isDestroyed()) win.show()
}) })
+4
View File
@@ -569,6 +569,10 @@ function main() {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win && !win.isDestroyed() && win.isMaximized()) return Boolean(win && !win.isDestroyed() && win.isMaximized())
}) })
ipcMain.handle('window:is-fullscreen', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win && !win.isDestroyed() && win.isFullScreen())
})
} }
app.whenReady().then(() => { app.whenReady().then(() => {
+15
View File
@@ -0,0 +1,15 @@
'use strict'
const { contextBridge, ipcRenderer } = require('electron')
// 大屏窗口的 F11 走主进程的 win.setFullScreen(Electron 窗口级全屏),页面里的
// document.fullscreenElement 永远为 null,自绘标题栏便不知道该让位。这里只放行
// 「查一次当前状态」和「订阅状态变化」两条窄通道,不把 ipcRenderer 交给页面。
contextBridge.exposeInMainWorld('displayShell', {
isFullscreen: () => ipcRenderer.invoke('window:is-fullscreen'),
onFullscreenChange: (callback) => {
const handler = (_event, fullscreen) => callback(fullscreen === true)
ipcRenderer.on('window:fullscreen-changed', handler)
return () => ipcRenderer.removeListener('window:fullscreen-changed', handler)
}
})
+3 -2
View File
@@ -34,6 +34,7 @@
- [ ] 「系统设置 → 赛事信息」的**基本信息**:填名称、副标题、主办方,上传**赛事会标** - [ ] 「系统设置 → 赛事信息」的**基本信息**:填名称、副标题、主办方,上传**赛事会标**
- [ ] 同页的**大屏配置**:上传大屏会标、选大屏背景图,填大屏标题 / 副标题、轮播间隔与主题色(上传的图片随赛事项目包一起导出/导入) - [ ] 同页的**大屏配置**:上传大屏会标、选大屏背景图,填大屏标题 / 副标题、轮播间隔与主题色(上传的图片随赛事项目包一起导出/导入)
- [ ] 同页的**字体**:确认**标题字体**与**正文字体**已选好(默认、四款内置字体,或自己上传的字体);选完点「保存」,并核对大屏上标题与正文显示正常。上传的字体存在赛事项目的 `uploads\fonts\` 里,**导出赛事项目包会一并带走、导入后不用重新上传**(见使用手册 14.3、部署指南 8.5)
- [ ] **赛事项目确认 / 导入**:到「导出 → 赛事项目包」核对**赛事项目列表**里带「当前」徽标的是本次要用的赛事;需要换赛事时先「导入 .zip」,再点「切换为当前赛事」(服务端会重启一次,页面显示「正在重启服务端…」,恢复后自动刷新)。账号与会话在数据目录的 `system.db` 里,**不受切换影响**,一般不用重新登录。这一步做完再往下走,否则后面看到的是别的赛事的数据 - [ ] **赛事项目确认 / 导入**:到「导出 → 赛事项目包」核对**赛事项目列表**里带「当前」徽标的是本次要用的赛事;需要换赛事时先「导入 .zip」,再点「切换为当前赛事」(服务端会重启一次,页面显示「正在重启服务端…」,恢复后自动刷新)。账号与会话在数据目录的 `system.db` 里,**不受切换影响**,一般不用重新登录。这一步做完再往下走,否则后面看到的是别的赛事的数据
- [ ] **导入规则包**:`%LOCALAPPDATA%\ScoreboardConsole\rules\2026-驭电利刃杯军事运动会.yaml`,走「导出 → 规则包 → 选择 YAML → 预览差异 → 确认导入并全量重算」(规则目录在用户数据目录里,见部署指南 3.5) - [ ] **导入规则包**:`%LOCALAPPDATA%\ScoreboardConsole\rules\2026-驭电利刃杯军事运动会.yaml`,走「导出 → 规则包 → 选择 YAML → 预览差异 → 确认导入并全量重算」(规则目录在用户数据目录里,见部署指南 3.5)
—— 服务端不会自动加载规则包,必须导入一次 —— 服务端不会自动加载规则包,必须导入一次
@@ -47,7 +48,7 @@
- [ ] 确认大屏锁定在「积分榜」页面;需要轮播就在后台开启 - [ ] 确认大屏锁定在「积分榜」页面;需要轮播就在后台开启
- [ ] 鼠标移到大屏上应看不到操作按钮;大屏窗口已开防熄屏 - [ ] 鼠标移到大屏上应看不到操作按钮;大屏窗口已开防熄屏
- [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试) - [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试)
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」与版本号角标;窗口化窗口(单屏)里显示这两个属正常,按 F11 全屏后「实例 · 屏」隐藏、版本号角标仍在 - [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现)
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常) - [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
@@ -55,7 +56,7 @@
- [ ] 确认数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 下的 `backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟) - [ ] 确认数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 下的 `backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟)
- [ ] 开幕前手动点一次「立即备份」(「系统设置 → 备份 → 立即备份」) - [ ] 开幕前手动点一次「立即备份」(「系统设置 → 备份 → 立即备份」)
- [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图与单位 logo)拷一份到 U 盘 - [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图、单位 logo 与上传的字体)拷一份到 U 盘
## 7. 现场流程演练(建议至少一次) ## 7. 现场流程演练(建议至少一次)
+32 -4
View File
@@ -359,6 +359,7 @@
4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布); 4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布);
5. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布; 5. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布;
6. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); 6. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
- **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别;
7. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 7. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
**人员信息冲突的处理** **人员信息冲突的处理**
@@ -403,7 +404,7 @@
- 每行的「切换为当前赛事」用于切换(仅管理员),切换过程见下面「导入后怎样才算生效」; - 每行的「切换为当前赛事」用于切换(仅管理员),切换过程见下面「导入后怎样才算生效」;
- 列表里出现「缺 event.db」的项目不可切换,说明该目录不是完整的赛事包。 - 列表里出现「缺 event.db」的项目不可切换,说明该目录不是完整的赛事包。
**导出 zip**:包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`,整包拷走即可在另一台机器复现整场赛事。 **导出 zip**:包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`,整包拷走即可在另一台机器复现整场赛事。`uploads/` 里除了会标、背景图与单位 logo,还包含**上传到赛事信息里的字体**(在 `uploads/fonts/` 下),字体设置也随赛事库一起走,导入后不用重新上传字体。
**导入 zip**(仅管理员):选择 `.zip`,可填「重命名」,勾选「覆盖同名项目」会先二次确认。导入只把数据解到数据目录,**不会自动切换当前赛事**,所以导入完成后页面会提示「已导入为 xxx,但它还不是当前赛事」。 **导入 zip**(仅管理员):选择 `.zip`,可填「重命名」,勾选「覆盖同名项目」会先二次确认。导入只把数据解到数据目录,**不会自动切换当前赛事**,所以导入完成后页面会提示「已导入为 xxx,但它还不是当前赛事」。
@@ -490,10 +491,32 @@
| 主题色 | 大屏主色,如 `#3482FF` | | 主题色 | 大屏主色,如 `#3482FF` |
| 个人积分榜上屏 | 开关;关闭后大屏不展示个人积分榜 | | 个人积分榜上屏 | 开关;关闭后大屏不展示个人积分榜 |
| 个人榜奖牌列 | 开关,默认展示;关闭后个人积分榜不显示金 / 银 / 铜三列 | | 个人榜奖牌列 | 开关,默认展示;关闭后个人积分榜不显示金 / 银 / 铜三列 |
| 标题字体 / 正文字体 | 下拉选择,分别作用于大屏标题与大屏正文;第一项是「默认(跟随系统)」,之后是四款内置字体与已上传的字体。只影响大屏画面,管理端界面字体不变 |
| 字体库 | 「上传字体」按钮与已上传字体列表(每行显示字体名与地址,可「删除」) |
改完点「保存」;未保存时显示「未保存」徽标,点「放弃修改」可还原。 改完点「保存」;未保存时显示「未保存」徽标,点「放弃修改」可还原。
> 修改大屏标题、主题色等会立即影响所有在线大屏,请避开颁奖或播报时段。 **字体区块**
四款内置字体随程序分发,不用上传:`Noto Sans SC(思源黑体同源)`、`Noto Serif SC(思源宋体同源)`、`MiSans`、`Source Han Sans CN(思源黑体)`。区块底部的「预览」会按当前所选字体实时渲染一行中文,先看效果再保存。
上传自己的字体:
1. 在「字体库」点「上传字体」,选择本地字体文件;
2. 只支持 `.ttf` / `.otf` / `.woff` / `.woff2`,**单个文件不超过 20 MiB**;格式不符或超过上限会在上传前直接提示;
3. 上传成功后,新字体立刻出现在两个下拉里(按文件名显示);**文件名重复会被拒绝**,需要先删掉旧文件或改名后再传;
4. 字体文件落在**当前赛事项目目录的 `uploads/fonts/`** 下,列表里会显示它的地址 `/uploads/fonts/<文件名>`;文件名里的中文会保留,其他特殊字符会被换成下划线。
选好字体后点「保存」提交:服务端会校验字体是否真实存在,选了无效字体会被拒绝并给出中文提示。保存后大屏按新字体重新渲染;**字体文件加载失败时大屏自动回落到默认字体**,不会出现方块或空白。
删除已上传的字体:
1. 在「字体库」列表里点该行的「删除」,二次确认后生效;
2. **正在被标题字体或正文字体使用的字体不能删**,会提示「正在被大屏使用,请先在赛事信息里改掉标题/正文字体」:先把下拉改成别的字体并保存,再回来删。
> 字体文件就在赛事的 `uploads/fonts/` 里,所以**导出赛事项目包会连字体一起带走**,导入后直接可用、不用重新上传;字体设置存在赛事库里,也随包走(见 11.2)。
> 修改大屏标题、主题色、字体等会立即影响所有在线大屏,请避开颁奖或播报时段。
--- ---
@@ -510,7 +533,7 @@
## 16. 关于 ## 16. 关于
展示系统名称、版本号、构建时间、运行模式,以及技术栈与开源许可、字体清单、键盘快捷键、部署信息(服务端端口、访问地址、赛事项目名)。 展示系统名称、版本号、构建时间、运行模式,以及技术栈与开源许可、字体清单(四款内置字体的用途与授权;给大屏换字体见 14.3)、键盘快捷键、部署信息(服务端端口、访问地址、赛事项目名)。
--- ---
@@ -530,6 +553,8 @@
> 单位 logo 取自「基础数据 → 参赛单位」里上传的图片(见 10.1),按行内文字大小显示,不会撑开行高;没传 logo 的单位不占位,图片临时加载不出来时保留占位、刷新后恢复,队名不会跳动。 > 单位 logo 取自「基础数据 → 参赛单位」里上传的图片(见 10.1),按行内文字大小显示,不会撑开行高;没传 logo 的单位不占位,图片临时加载不出来时保留占位、刷新后恢复,队名不会跳动。
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
### 17.2 轮播与锁定 ### 17.2 轮播与锁定
- Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播; - Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播;
@@ -552,7 +577,10 @@
### 17.5 调试标识 ### 17.5 调试标识
窗口化(带标题栏的调试窗口)时,右上角会显示「实例 x · 屏 x」与数据版本号 `vN`:前者用来分辨这块屏是哪个实例,后者是**数据版本号**(不是软件版本),和仪表盘「实时连接」里显示的「版本 N」一致,就说明这块屏已经跟上服务端。**kiosk 全屏窗口不显示这些调试标识**,正式上屏的画面是干净的;窗口化窗口按 F11 全屏后,「实例 · 屏」也会一起隐藏。多屏 kiosk 窗口不响应 F11,也不显示任何调试标识与版本号角标。 窗口化(带标题栏的调试窗口)**且没有全屏**时,右上角会显示一行「实例 x · 屏 x」,用来分辨这块屏是哪个实例。除此之外没有别的调试标识——**数据版本号 `vN` 角标已经去掉,任何情况下都不再显示**;需要核对某块屏有没有跟上服务端时,用管理端仪表盘「实时连接」里的「版本 N」对照。
- **kiosk 全屏窗口**(多屏时每屏一个)不显示任何调试标识,正式上屏的画面是干净的;多屏 kiosk 也不响应 F11。
- 单屏窗口化窗口按 **F11** 全屏后,自绘标题栏与「实例 · 屏」会一起消失、画面铺满整块屏;再按一次 F11 回到窗口化,标题栏与标识一起回来。
--- ---
+63 -27
View File
@@ -132,11 +132,10 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
没显示就去「基础数据 → 参赛单位 → 编辑 → Logo 路径」上传或填写该单位的 logo(管理端页面本身不显示 logo,只在大屏上显示)。上传的图片存在该赛事的 `data\event_projs\<项目名>\uploads\` 里,会随赛事项目包一起导出 / 导入。 没显示就去「基础数据 → 参赛单位 → 编辑 → Logo 路径」上传或填写该单位的 logo(管理端页面本身不显示 logo,只在大屏上显示)。上传的图片存在该赛事的 `data\event_projs\<项目名>\uploads\` 里,会随赛事项目包一起导出 / 导入。
**Q27:「实例 x · 屏 x」和右上角的版本号角标怎么不见了?** **Q27:「实例 x · 屏 x」这个标识怎么不见了?**
这两个都只是**调试标识**:只在窗口化的调试窗口里显示,正式上屏(多屏 kiosk 全屏,或单屏窗口化后按 `F11` 全屏)都不显示。 它只是**调试标识**:只在窗口化的调试窗口里、并且**没有全屏**时显示,用来分辨这块屏是哪个实例。正式上屏(多屏 kiosk 全屏,或单屏窗口化后按 `F11` 全屏)都不显示——按 F11 时它会和自绘标题栏一起收起来,再按一次就回来。
- 「实例 x · 屏 x」用来分辨这块屏是哪个实例,窗口化后按 F11 全屏也会隐藏; 以前右上角还有一个 `vN` 数据版本号角标,**现在已经去掉,任何情况下都不再显示**。要核对某块屏有没有跟上服务端,看管理端仪表盘「实时连接」里的「版本 N」。
- 角标里的 `vN` 是**数据版本号**(不是软件版本):需要现场核对某块屏有没有跟上服务端时,先把该窗口切回窗口化让角标出现,它和仪表盘「实时连接」里显示的「版本 N」一致就说明这块屏已经同步。
**Q28:「在线看板」为什么显示未知?** **Q28:「在线看板」为什么显示未知?**
页面上那一块是本地占位节点:系统不按「指令送达」推断大屏是否在线,也不展示每块屏的实际上报状态,所以「在线」列恒为未知,卡片上写着「在线状态未知(占位)」;「最近下发」只是本机下发指令的时间,**不代表大屏在线**。以现场大屏的实际画面为准。 页面上那一块是本地占位节点:系统不按「指令送达」推断大屏是否在线,也不展示每块屏的实际上报状态,所以「在线」列恒为未知,卡片上写着「在线状态未知(占位)」;「最近下发」只是本机下发指令的时间,**不代表大屏在线**。以现场大屏的实际画面为准。
@@ -147,35 +146,65 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
**Q30:大屏时间不对怎么办?** **Q30:大屏时间不对怎么办?**
系统没有外网时间同步,以主机时间为准。校准 Windows 时间与时区即可。 系统没有外网时间同步,以主机时间为准。校准 Windows 时间与时区即可。
**Q31:大屏的字体在哪里改?**
在「系统设置 → 赛事信息 → **字体**」区块(需要管理员):**标题字体**与**正文字体**两个下拉,第一项是「默认(跟随系统)」,后面是四款内置字体与已上传的字体;区块底部的「预览」会按所选字体实时渲染一行中文,选完点页面上的「保存」生效。这两个设置**只影响大屏画面**,管理端界面的字体不变。
四款内置字体随程序分发,不用上传:`Noto Sans SC(思源黑体同源)`、`Noto Serif SC(思源宋体同源)`、`MiSans`、`Source Han Sans CN(思源黑体)`。
**Q32:怎么上传自己的字体?**
1. 到「系统设置 → 赛事信息 → 字体 → 字体库」,点「上传字体」选择本地字体文件;
2. 上传成功后,新字体立刻出现在两个下拉里(按文件名显示,文件名里的中文会保留,其他特殊字符会换成下划线);
3. 在「标题字体」「正文字体」下拉里选上它,再点页面上的「保存」——下拉的改动要保存才生效;
4. 字体文件存在当前赛事项目的 `data\event_projs\<项目名>\uploads\fonts\` 里,列表里会显示它的地址 `/uploads/fonts/<文件名>`。
**Q33:字体支持哪些格式?有大小限制吗?**
只支持 `.ttf` / `.otf` / `.woff` / `.woff2`,**单个文件不超过 20 MiB**;格式不符或超过上限,会在上传前就直接提示,不会传到服务端。同一个文件名再传一次会被拒绝(提示「已存在,请先删除或重命名后再上传」),所以换字体请先删掉旧的、或把文件改成别的名字。上传的字体不做格式解析,损坏的文件传得上去,但大屏加载不了会回落到默认字体(见 Q34)。
**Q34:为什么上传的字体在大屏上没生效?**
按这个顺序排查:
- **保存了吗**:「赛事信息」里的下拉选完要点页面上的「保存」,没保存时页面显示「未保存」徽标;保存时服务端会校验字体是否存在,选了不存在的字体会直接报错;
- **看错地方了**:这两个设置只作用于**大屏**,管理端界面的字体不受影响,别拿管理端的显示效果判断;
- **字体文件加载失败**:大屏是无人值守的,字体只是外观,加载失败时会**静默回落到默认字体**(不会出现方块或空白)。检查 `data\event_projs\<项目名>\uploads\fonts\` 下的文件是否还在(被手工删掉就会出现这种情况),必要时重新上传并保存一次;
- **大屏还没刷新**:保存会立刻下发给在线大屏;仍没变化可以重开大屏窗口,或等大屏自动重载。
**Q35:为什么删不掉某个字体?**
**正在被标题字体或正文字体使用的字体不允许删除**,会提示「正在被大屏使用,请先在赛事信息里改掉标题/正文字体」。先把这两个下拉改成别的字体并**保存**,再回「字体库」点该行的「删除」(会有二次确认,删除前请确认大屏上已不再使用它)。
**Q36:换一台机器还要重新传字体吗?**
不用。字体文件就在当前赛事项目的 `uploads\fonts\` 里,所以**导出赛事项目包(zip)会连字体一起带走**,导入到另一台机器后直接可用、不用重新上传;字体设置存在赛事库里,也随包走(见 Q43)。
只有一个小陷阱:`backups\` 里的 `.db` 快照**不含**字体这些上传文件(见 Q37),如果只拷数据库快照换机器,字体就没了——记得把 `event_projs\<项目名>\uploads\` 一起拷(见 Q54)。
--- ---
## 数据与备份 ## 数据与备份
**Q31:备份在哪里?** **Q37:备份在哪里?**
在数据目录下的 `backups\`,默认即 `%LOCALAPPDATA%\ScoreboardConsole\data\backups\`(数据目录改过就按改后的路径找,见 Q48)。 在数据目录下的 `backups\`,默认即 `%LOCALAPPDATA%\ScoreboardConsole\data\backups\`(数据目录改过就按改后的路径找,见 Q54)。
文件是数据库快照,命名形如 `system-<原因>-<时间戳>.db` 与 `event-<原因>-<时间戳>.db`:`system` 是系统库(账号、会话),`event` 是赛事库(成绩、积分、报名、基础数据),原因是 `auto` / `manual` / `publish`,时间戳是「年月日-时分秒」,例如 `event-publish-20260910-120500.db`。 文件是数据库快照,命名形如 `system-<原因>-<时间戳>.db` 与 `event-<原因>-<时间戳>.db`:`system` 是系统库(账号、会话),`event` 是赛事库(成绩、积分、报名、基础数据),原因是 `auto` / `manual` / `publish`,时间戳是「年月日-时分秒」,例如 `event-publish-20260910-120500.db`。
> 备份里只有数据库,**上传的图片**(赛事会标、大屏背景图、单位 logo)不在其中,它们在 `data\event_projs\<项目名>\uploads\` 里,需要另外归档。 > 备份里只有数据库,**上传的图片与字体**(赛事会标、大屏背景图、单位 logo、自定义字体)不在其中,它们在 `data\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),需要另外归档。
**Q32:多久备份一次?** **Q38:多久备份一次?**
服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"`(部署目录下的 `deploy\backup.bat` 已经带好这些路径)。另外每次发布成绩前会自动备一份。 服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -data "%LOCALAPPDATA%\ScoreboardConsole\data"`(部署目录下的 `deploy\backup.bat` 已经带好这些路径)。另外每次发布成绩前会自动备一份。
**Q33:备份会自动删除旧的么?** **Q39:备份会自动删除旧的么?**
会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。 会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。
**Q34:怎么恢复备份?** **Q40:怎么恢复备份?**
没有一键恢复接口,需停机手工替换数据库文件:在 Console 里停止服务,到 `<数据目录>\event_projs\<项目名>\` 把目标备份复制为 `event.db`(覆盖原文件),删掉同目录的 `event.db-wal` 与 `event.db-shm`,再启动服务,详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。 没有一键恢复接口,需停机手工替换数据库文件:在 Console 里停止服务,到 `<数据目录>\event_projs\<项目名>\` 把目标备份复制为 `event.db`(覆盖原文件),删掉同目录的 `event.db-wal` 与 `event.db-shm`,再启动服务,详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
**Q35:换一台电脑怎么迁移?** **Q41:换一台电脑怎么迁移?**
用户数据目录和安装目录都要带走: 用户数据目录和安装目录都要带走:
- **用户数据目录** `%LOCALAPPDATA%\ScoreboardConsole\`(里面是 `data\` + `config.yaml` + `console\config.json` + `rules\`):整个拷到新机器的同一位置即可,赛事数据、账号、配置、规则包原样接管; - **用户数据目录** `%LOCALAPPDATA%\ScoreboardConsole\`(里面是 `data\` + `config.yaml` + `console\config.json` + `rules\`):整个拷到新机器的同一位置即可,赛事数据、账号、配置、规则包原样接管,`data\` 里自然也包括上传的图片与字体;
- **安装目录**(`Scoreboard_Console.exe`、`scoreboard.exe` 等):按同样路径放好,或用新机器上的安装包装一遍(安装目录里那几份配置与规则只是模板,拷不拷都行)。 - **安装目录**(`Scoreboard_Console.exe`、`scoreboard.exe` 等):按同样路径放好,或用新机器上的安装包装一遍(安装目录里那几份配置与规则只是模板,拷不拷都行)。
只拷安装目录会丢数据——成绩、账号、配置、规则包都在用户数据目录里。若数据目录放到了别处,还要同步改 `console\config.json` 的 `server.dataDir`。详见部署指南第 8.4 节。 只拷安装目录会丢数据——成绩、账号、配置、规则包都在用户数据目录里。若数据目录放到了别处,还要同步改 `console\config.json` 的 `server.dataDir`。详见部署指南第 8.4 节。
**Q36:怎么切换赛事项目?** **Q42:怎么切换赛事项目?**
启动时服务端会自动打开**上次打开的赛事**,不用改启动参数。切换在页面里完成:管理面板「导出 → 赛事项目包 → **赛事项目列表**」里找到目标项目(带「当前」徽标的就是正在打开的那一项),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新;最多等 60 秒,超时就打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。 启动时服务端会自动打开**上次打开的赛事**,不用改启动参数。切换在页面里完成:管理面板「导出 → 赛事项目包 → **赛事项目列表**」里找到目标项目(带「当前」徽标的就是正在打开的那一项),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新;最多等 60 秒,超时就打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。
- 导入赛事包**不会**自动切换当前赛事,导入完成的结果框里直接给了「立即切换并重启服务端」按钮; - 导入赛事包**不会**自动切换当前赛事,导入完成的结果框里直接给了「立即切换并重启服务端」按钮;
@@ -189,57 +218,64 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
## 导出与导入 ## 导出与导入
**Q37:能导出哪些文件?** **Q43:能导出哪些文件?**
四类 Excel:总表、全部(多 sheet)、分项成绩表、单位积分明细;另外可导出赛事项目包(zip)与规则包(YAML)。 四类 Excel:总表、全部(多 sheet)、分项成绩表、单位积分明细;另外可导出赛事项目包(zip)与规则包(YAML)。
**Q38:导出文件保存在哪?** **Q44:导出文件保存在哪?**
浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。 浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。
**Q39:谁能导入规则包 / 赛事项目包?** **Q45:谁能导入规则包 / 赛事项目包?**
只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。 只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。
**Q40:导入赛事包后为什么看不到数据?** **Q46:导入赛事包后为什么看不到数据?**
因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。 因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。
处理办法:在「导出 → 赛事项目包」的**赛事项目列表**里找到刚导入的项目(导入完成的结果框里也直接给了「立即切换并重启服务端」按钮),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新,数据就出来了。超过 60 秒仍未恢复时,请打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。 处理办法:在「导出 → 赛事项目包」的**赛事项目列表**里找到刚导入的项目(导入完成的结果框里也直接给了「立即切换并重启服务端」按钮),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新,数据就出来了。超过 60 秒仍未恢复时,请打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。
> 切换会让服务端重启,比赛进行中请避开操作。账号与会话不在赛事库里(见 Q36),一般不用重新登录;万一回到只读访客视图,登录一次即可,若原密码登不上就用 `scoreboard.exe passwd` 重置。 > 切换会让服务端重启,比赛进行中请避开操作。账号与会话不在赛事库里(见 Q42),一般不用重新登录;万一回到只读访客视图,登录一次即可,若原密码登不上就用 `scoreboard.exe passwd` 重置。导入的赛事包里如果带了自定义字体,切换过去后大屏直接可用,不用重新上传(见 Q36)。
**Q41:导入规则包后积分变了,能撤销吗?** **Q47:导入规则包后积分变了,能撤销吗?**
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。 不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
**Q42:导入时提示证件号重复怎么办?** **Q48:导入时提示证件号重复怎么办?**
证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。 证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。
- **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。 - **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。
- **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 - **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
**Q43:人员没有身份证,只有号码布,能报名吗?** **Q49:人员没有身份证,只有号码布,能报名吗?**
可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。 可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。
**Q44:号码布重复了怎么办?** **Q50:号码布重复了怎么办?**
号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。 号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。
- **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。 - **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。
- **表格导入时出现重复**:同一份文件里同一个号码布重复出现,重复的行会报错并跳过,改好后重新上传;号码布在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 - **表格导入时出现重复**:同一份文件里同一个号码布重复出现,重复的行会报错并跳过,改好后重新上传;号码布在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
**Q51:导入 CSV 时中文变成乱码,或者提示「报名表缺少列 event_code」怎么办?**
**不用转码,直接传原文件就行。** 服务端会自己识别编码:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16(PowerShell `Export-Csv` 的默认编码)都能正常导入。
- 这两种症状以前长这样:GBK 文件的中文单位名、运动员名**变成乱码**;带 BOM 的文件报「**报名表缺少列 event_code**」——看着像表头写错,其实是 BOM 粘在了第一列表头上。现在都已修好,两种文件都直接传。
- 如果还是报「报名表缺少列 xxx」,那就是表头真的不对:第一行必须是英文列名(`event_code,category,team_code,athlete_name,…`),不能是中文标题行,上面也不要有多余的标题行或合并单元格。最稳妥的做法是下载页面上的「CSV 模板」照着填。
- xlsx 不受影响,它自带编码信息。
--- ---
## 其他 ## 其他
**Q45:系统需要联网吗?** **Q52:系统需要联网吗?**
不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。 不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。
**Q46:页面提示「示例数据」是什么意思?** **Q53:页面提示「示例数据」是什么意思?**
表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。
> 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。 > 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。
**Q47:为什么登录 / 写操作偶尔被拒绝?** **Q54:为什么登录 / 写操作偶尔被拒绝?**
系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。
**Q48:数据目录在哪?** **Q55:数据目录在哪?**
默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传图片 `uploads\`)、`backups\`、`exports\`。 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传资源 `uploads\`,图片与自定义字体都在里面,字体在 `uploads\fonts\`)、`backups\`、`exports\`。
同一层还有 `config.yaml`(服务端配置)、`console\config.json`(Console 配置)与 `rules\`(规则包目录),也都在安装目录之外,同样不会被覆盖安装或卸载删掉。 同一层还有 `config.yaml`(服务端配置)、`console\config.json`(Console 配置)与 `rules\`(规则包目录),也都在安装目录之外,同样不会被覆盖安装或卸载删掉。
+16 -13
View File
@@ -93,7 +93,7 @@ deploy\build.bat
### 3.4 数据放在哪里(升级/卸载都不会删) ### 3.4 数据放在哪里(升级/卸载都不会删)
覆盖安装时安装程序会**先静默运行旧版卸载器**,而卸载器会把整个安装目录删掉,所以数据库、规则、用户、上传的图片都不能放在安装目录里。现在的规则: 覆盖安装时安装程序会**先静默运行旧版卸载器**,而卸载器会把整个安装目录删掉,所以数据库、规则、用户、上传的图片与字体都不能放在安装目录里。现在的规则:
| 场景 | 数据目录 | | 场景 | 数据目录 |
| --- | --- | | --- | --- |
@@ -102,6 +102,7 @@ deploy\build.bat
| 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** | | 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** |
- Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行; - Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行;
- **上传资源随赛事项目走**:赛事会标、大屏背景图、单位 logo,以及「赛事信息 → 字体」里上传的**字体文件**,都在 `<数据目录>\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`)。它们是文件而不是数据库记录,**不在 `backups\` 的 `.db` 快照里**,备份 / 迁移时要连这个目录一起拷(见 8.5、8.6);
- **一次性迁移**:如果新数据目录还是空的,而旧的 `<安装目录>\data` 里有数据,Console 会在启动时把旧目录**整体改名**过去(同盘很快),日志里会写「已把旧数据从 X 迁移到 Y」。任何失败(跨盘、权限不足等)都只记日志,旧数据原地保留,不会被删; - **一次性迁移**:如果新数据目录还是空的,而旧的 `<安装目录>\data` 里有数据,Console 会在启动时把旧目录**整体改名**过去(同盘很快),日志里会写「已把旧数据从 X 迁移到 Y」。任何失败(跨盘、权限不足等)都只记日志,旧数据原地保留,不会被删;
- 如果两个目录里**都有数据**,Console 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份; - 如果两个目录里**都有数据**,Console 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份;
- 迁移只在安装包版本(打包后)执行一次,`pnpm start` 之类的开发运行不会碰仓库里的 `data\`; - 迁移只在安装包版本(打包后)执行一次,`pnpm start` 之类的开发运行不会碰仓库里的 `data\`;
@@ -129,7 +130,8 @@ deploy\build.bat
- 规则目录只在首次复制一次,之后你往里面放的规则包不会被模板顶掉; - 规则目录只在首次复制一次,之后你往里面放的规则包不会被模板顶掉;
- 某一处建不出来或不可写(权限、杀毒软件拦截等)时**只记日志并回落到安装目录里那份**,绝不因此启动失败;代价是那份改动仍会在覆盖安装时丢失,日志里会明确写出来; - 某一处建不出来或不可写(权限、杀毒软件拦截等)时**只记日志并回落到安装目录里那份**,绝不因此启动失败;代价是那份改动仍会在覆盖安装时丢失,日志里会明确写出来;
- 开发运行(`pnpm start`)不接管这三项,仍用仓库里的 `console\config.json`、`config.yaml`、`rules\`; - 开发运行(`pnpm start`)不接管这三项,仍用仓库里的 `console\config.json`、`config.yaml`、`rules\`;
- 不用 Console、直接双击 `scoreboard.exe` 时,服务端仍按工作目录下的 `config.yaml` 读配置,**不受这套保护**。 - 不用 Console、直接双击 `scoreboard.exe` 时,服务端仍按工作目录下的 `config.yaml` 读配置,**不受这套保护**;
- 上传的图片与字体不在这三项里,位置见 3.4:它们随赛事项目存放在数据目录的 `event_projs\<项目名>\uploads\`(字体在 `uploads\fonts\`),同样在安装目录之外,覆盖安装不会动它们;**但它们不在 `backups\` 的数据库快照里**,备份 / 迁移时要连这个目录一起拷(见 8.6)。
> 从老版本覆盖升级上来的第一次启动:安装目录里那三份在覆盖安装时**就已经被旧卸载器删过一遍**(新装进去的是新版模板),Console 只能把模板复制到用户目录,升级前改在安装目录里的配置需要重做一遍。**从本版本起**(也就是改动落在上表位置之后),配置与规则才会跨升级保留。 > 从老版本覆盖升级上来的第一次启动:安装目录里那三份在覆盖安装时**就已经被旧卸载器删过一遍**(新装进去的是新版模板),Console 只能把模板复制到用户目录,升级前改在安装目录里的配置需要重做一遍。**从本版本起**(也就是改动落在上表位置之后),配置与规则才会跨升级保留。
@@ -328,15 +330,15 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
### 7.3 单屏:窗口化 + F11 ### 7.3 单屏:窗口化 + F11
- 只有一块屏时,大屏以窗口化方式打开(屏幕 80% 大小、居中),并显示自绘标题栏; - 只有一块屏时,大屏以窗口化方式打开(屏幕 80% 大小、居中),并显示自绘标题栏;
- 在窗口内按 **F11** 切换全屏 / 窗口; - 在窗口内按 **F11** 切换全屏 / 窗口:全屏时自绘标题栏与「实例 · 屏」标识一起隐藏、画面铺满整块屏;再按一次恢复;
- 单屏模式不跟随显示器变化重排; - 单屏模式不跟随显示器变化重排;
- 窗口化时右上角会显示「实例 … · 屏 …」与数据版本号(`v<n>`)供现场核对;按 F11 全屏后「实例 · 屏」自动隐藏,版本号角标仍在(它只跟「这个窗口是不是窗口化打开的」有关,见 7.7)。 - 窗口化且未全屏时右上角显示一行「实例 … · 屏 …」供现场核对(数据版本号角标已移除,见 7.7)。
### 7.4 多屏:每屏锁一页 ### 7.4 多屏:每屏锁一页
- 除内置屏外的每块屏各开一个 kiosk 全屏窗口; - 除内置屏外的每块屏各开一个 kiosk 全屏窗口;
- 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配; - 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配;
- 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识与版本号角标; - 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识;
- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。 - 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。
### 7.5 在管理端单独控制某块屏 ### 7.5 在管理端单独控制某块屏
@@ -360,7 +362,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
### 7.7 大屏画面里的调试标识与单位 logo ### 7.7 大屏画面里的调试标识与单位 logo
- **调试标识**:只有窗口化打开的大屏窗口才会显示「实例 … · 屏 …」与数据版本号 `v<n>`(`v<n>` 是数据版本号,与管理面板顶部的版本号一致就说明这块屏已跟上服务端);**多屏 kiosk 全屏完全不带这些标识**,窗口化窗口按 F11 全屏后「实例 · 屏」也会隐藏、版本号角标仍在。要一个完全干净的展示画面,就用多屏 kiosk; - **调试标识**:只有**窗口化且没有全屏**的大屏窗口才会显示一行「实例 … · 屏 …」;**多屏 kiosk 全屏完全不带调试标识**,单屏窗口化窗口按 F11 全屏后它也会连同自绘标题栏一起隐藏。数据版本号角标 `v<n>` 已移除,任何情况下都不再显示(要核对某块屏是否跟上服务端,看管理端仪表盘「实时连接」里的「版本 N」)。要一个完全干净的展示画面,就用多屏 kiosk;
- **单位 logo**:参赛单位的 logo 会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上(个人榜显示在姓名前);没上传 logo 的单位不占位。logo 在「基础数据 → 参赛单位」里上传,随赛事项目包一起导出/导入(见 8.5)。 - **单位 logo**:参赛单位的 logo 会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上(个人榜显示在姓名前);没上传 logo 的单位不占位。logo 在「基础数据 → 参赛单位」里上传,随赛事项目包一起导出/导入(见 8.5)。
### 7.8 大屏窗口的自动行为 ### 7.8 大屏窗口的自动行为
@@ -452,7 +454,7 @@ http://<主机IP>:19540/display
- `meta.yaml`:包格式标识、项目名、赛事名称/副标题/主办方、导出时间,以及每个文件的 SHA-256 清单(导入时逐个校验,对不上就拒绝); - `meta.yaml`:包格式标识、项目名、赛事名称/副标题/主办方、导出时间,以及每个文件的 SHA-256 清单(导入时逐个校验,对不上就拒绝);
- `event.db`:赛事库快照(用 `VACUUM INTO` 生成,导出时服务端可以继续写入),成绩、基础数据、积分规则都在里面; - `event.db`:赛事库快照(用 `VACUUM INTO` 生成,导出时服务端可以继续写入),成绩、基础数据、积分规则都在里面;
- `uploads/`:**全部上传图片**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo 等(在「系统设置 → 赛事信息」与「基础数据 → 参赛单位」里上传的那批文件)。导入时会连图片一起落到新项目目录,切换过去后直接显示,不用重新上传; - `uploads/`:**全部上传资源**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo,以及**上传的字体**(放在 `uploads/fonts/` 下,见使用手册 14.3)。导入时会连文件一起落到新项目目录的 `uploads/`,切换过去后图片直接显示、字体直接可用,不用重新上传;
- `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。 - `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。
导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**分项成绩表**(按小项)、**单位积分明细**(按单位)四项各自可单独导出打印。 导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**分项成绩表**(按小项)、**单位积分明细**(按单位)四项各自可单独导出打印。
@@ -466,21 +468,21 @@ http://<主机IP>:19540/display
- 不能覆盖**正在运行**的赛事项目:对当前赛事的同名包,导入会被直接拒绝(先切到别的赛事再导); - 不能覆盖**正在运行**的赛事项目:对当前赛事的同名包,导入会被直接拒绝(先切到别的赛事再导);
- 勾选「覆盖同名项目」时,旧目录会先改名保留、新项目落盘成功后才删除它; - 勾选「覆盖同名项目」时,旧目录会先改名保留、新项目落盘成功后才删除它;
- 参赛单位 logo、会标、背景图都在 `uploads\` 里,所以**备份 / 迁移时要连 `event_projs\<项目名>\uploads\` 一起拷**(见 8.6); - 参赛单位 logo、会标、背景图与上传的字体都在 `uploads\` 里,所以**备份 / 迁移时要连 `event_projs\<项目名>\uploads\` 一起拷**(见 8.6);
- **不要用 `-project` 启动参数来切赛事**:显式传 `-project` 时,服务端会顺手把它写回「上次打开的赛事」记忆文件,于是以后每次启动都优先打开这个项目——在页面里切完、重启后又被拉回去。只有在「刻意把一台机器固定成只开某一个项目」时才这么用:在 Console 的用户配置 `config.json` 里给 `server.args` 加 `"-project","<项目名>"` 后重启 Console,或直接 `scoreboard.exe -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -project <项目名> -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 启动(见 6.2)。 - **不要用 `-project` 启动参数来切赛事**:显式传 `-project` 时,服务端会顺手把它写回「上次打开的赛事」记忆文件,于是以后每次启动都优先打开这个项目——在页面里切完、重启后又被拉回去。只有在「刻意把一台机器固定成只开某一个项目」时才这么用:在 Console 的用户配置 `config.json` 里给 `server.args` 加 `"-project","<项目名>"` 后重启 Console,或直接 `scoreboard.exe -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -project <项目名> -data "%LOCALAPPDATA%\ScoreboardConsole\data"` 启动(见 6.2)。
### 8.6 手工备份 / 迁移整个数据目录 ### 8.6 手工备份 / 迁移整个数据目录
快照备份之外,数据库文件、上传的图片、备份都可以整体拷走: 快照备份之外,数据库文件、上传的图片与字体、备份都可以整体拷走:
1. 在 Console 里**停止服务**(托盘 → 停止服务),避免拷贝过程中继续写入; 1. 在 Console 里**停止服务**(托盘 → 停止服务),避免拷贝过程中继续写入;
2. 把整个数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 复制到 U 盘或其他盘(里面含 `system.db`、`event_projs\`(数据库 + `uploads\` 图片)、`backups\`); 2. 把整个数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 复制到 U 盘或其他盘(里面含 `system.db`、`event_projs\`(数据库 + `uploads\` 图片与字体)、`backups\`);
3. 恢复时把整个目录拷回去,然后: 3. 恢复时把整个目录拷回去,然后:
- 拷回**原路径**:直接启动 Console 即可; - 拷回**原路径**:直接启动 Console 即可;
- 拷到**别的路径**:先建好目标目录并放入数据,再在用户配置 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console; - 拷到**别的路径**:先建好目标目录并放入数据,再在用户配置 `console\config.json` 里把 `server.dataDir` 指向它,重启 Console;
4. 恢复完成后在管理端核对赛事、账号、上传图片是否齐全。 4. 恢复完成后在管理端核对赛事、账号、上传图片与字体是否齐全。
> 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标在 `event_projs\<项目名>\uploads\` 里,不拷这个目录会丢图片。 > 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标/单位 logo **与自定义字体**在 `event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),不拷这个目录会丢这些文件,大屏上表现为图片不显示、字体回落成默认字体。
--- ---
@@ -581,11 +583,12 @@ http://<主机IP>:19540/display
- [ ] 「运行日志」里的 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(不是安装目录),且没有迁移警告 - [ ] 「运行日志」里的 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(不是安装目录),且没有迁移警告
- [ ] 「运行日志」里的 `用户数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole`,且没有「准备 … 失败」警告(配置与规则也在用户目录里,见 3.5) - [ ] 「运行日志」里的 `用户数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole`,且没有「准备 … 失败」警告(配置与规则也在用户目录里,见 3.5)
- [ ] 数据目录的 `backups\` 可写,已生成至少一份备份 - [ ] 数据目录的 `backups\` 可写,已生成至少一份备份
- [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片) - [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片与字体)
- [ ] **当前赛事正确**:「导出 → 赛事项目包 → 赛事项目列表」里带「当前」徽标的是本次要用的那个项目(刚导入的包要切换过去才算生效,见 8.5) - [ ] **当前赛事正确**:「导出 → 赛事项目包 → 赛事项目列表」里带「当前」徽标的是本次要用的那个项目(刚导入的包要切换过去才算生效,见 8.5)
- [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常 - [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常
- [ ] **显示器菜单可用**:在服务管理窗口打开的「大屏控制」页里能看到每块屏并「创建全屏窗口」,副屏被误关时能重开(见 7.5) - [ ] **显示器菜单可用**:在服务管理窗口打开的「大屏控制」页里能看到每块屏并「创建全屏窗口」,副屏被误关时能重开(见 7.5)
- [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 团体奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7) - [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 团体奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7)
- [ ] **大屏字体已选好并核对**:「系统设置 → 赛事信息 → 字体」里选了标题字体与正文字体,大屏上显示正常;上传的字体在数据目录 `event_projs\<项目名>\uploads\fonts\` 里,导出赛事项目包会一并带走(见 8.5)
- [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6) - [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6)
- [ ] 管理端各角色账号可登录,权限正确 - [ ] 管理端各角色账号可登录,权限正确
- [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台 - [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
github.com/xuri/excelize/v2 v2.11.0 github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/text v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.58.0 modernc.org/sqlite v1.58.0
) )
@@ -23,7 +24,6 @@ require (
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/net v0.58.0 // indirect golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
modernc.org/libc v1.75.6 // indirect modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect modernc.org/memory v1.12.1 // indirect
+7
View File
@@ -38,6 +38,10 @@ type EventSettingsDisplay struct {
RotateSecond int `json:"rotateSeconds"` RotateSecond int `json:"rotateSeconds"`
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。 // Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
Announce *string `json:"announce"` Announce *string `json:"announce"`
// FontTitle / FontBody 是大屏标题与正文用的字体 id(builtin:* 或 custom:<文件名>);
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
FontTitle *string `json:"fontTitle"`
FontBody *string `json:"fontBody"`
} }
// EventSettingsStandings 是榜单口径配置。 // EventSettingsStandings 是榜单口径配置。
@@ -103,6 +107,9 @@ func ParseEventSettings(values map[string]string) EventSettings {
} }
out.Display.PrimaryColor = out.Event.PrimaryColor out.Display.PrimaryColor = out.Event.PrimaryColor
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15) 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 也能推),空串表示无公告。 // 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
out.Display.Announce = nilIfEmpty(values["display.announce_text"]) 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,供字段级断言使用。 // mustField 取出顶层字段的原始 JSON,供字段级断言使用。
func mustField(t *testing.T, raw []byte, key string) []byte { func mustField(t *testing.T, raw []byte, key string) []byte {
t.Helper() t.Helper()
+5 -3
View File
@@ -63,8 +63,8 @@ type ProjectExportOptions struct {
CreatedBy string CreatedBy string
AppVersion string AppVersion string
UploadsDir string // 可选:背景图 / 会标目录 UploadsDir string // 可选:背景图 / 会标目录
FontsDir string // 可选:字体目录 FontsDir string // 可选:上传字体目录(运行时的 <项目目录>/uploads/fonts)
IncludeFonts bool // 是否把字体一并打包 IncludeFonts bool // 是否把字体一并打包(打进包内 uploads/fonts/)
RulesPath string // 可选:规则包 YAML RulesPath string // 可选:规则包 YAML
Now time.Time Now time.Time
} }
@@ -121,7 +121,9 @@ func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (Projec
} }
} }
if opts.IncludeFonts && opts.FontsDir != "" { 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 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) { func TestImportRejectsBadPackages(t *testing.T) {
base := ProjectMeta{ base := ProjectMeta{
Format: projectFormat, Format: projectFormat,
@@ -113,6 +113,14 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
opts.UploadsDir = uploads opts.UploadsDir = uploads
} }
} }
// 上传字体单独再接一次:它就在 uploads 目录里,整目录打包本来就会带上,
// 但字体是大屏渲染的硬依赖,这里显式再兜一层,避免以后 uploads 打包条件变动时静默漏掉。
if fonts, err := s.fontsDir(); err == nil {
if info, err := os.Stat(fonts); err == nil && info.IsDir() {
opts.FontsDir = fonts
opts.IncludeFonts = true
}
}
rulesPath, cleanup, err := s.writeRulesTempFile(r.Context()) rulesPath, cleanup, err := s.writeRulesTempFile(r.Context())
if err != nil { if err != nil {
return nil, err return nil, err
+316
View File
@@ -0,0 +1,316 @@
package httpapi
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
)
// ---------- 字体清单 ----------
// fontDTO 是 GET /api/fonts 的一项,也是上传成功后返回的对象。
// 内置字体没有文件,URL 为 null;上传字体带 /uploads/fonts/<文件名> 下的静态地址。
type fontDTO struct {
ID string `json:"id"`
Label string `json:"label"`
Builtin bool `json:"builtin"`
URL *string `json:"url"`
}
// builtinFonts 是随前端一起分发的内置字体(@font-face 见 web/src/shared/styles/fonts.css)。
// id 与 CSS 家族名的对应关系是固定契约,前端 web/src/display/fonts.ts 必须与这里一致。
var builtinFonts = []fontDTO{
{ID: "builtin:noto-sans", Label: "Noto Sans SC(思源黑体同源)", Builtin: true},
{ID: "builtin:noto-serif", Label: "Noto Serif SC(思源宋体同源)", Builtin: true},
{ID: "builtin:misans", Label: "MiSans", Builtin: true},
{ID: "builtin:source-han-sans", Label: "Source Han Sans CN(思源黑体)", Builtin: true},
}
// 上传字体的限制:扩展名白名单与单文件上限。
const (
maxFontBytes = 20 << 20
maxFontFormSize = maxFontBytes + 1<<20
customFontID = "custom:"
)
// allowedFontTypes 是允许上传的字体扩展名与对应 Content-Type。
var allowedFontTypes = map[string]string{
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
}
func isBuiltinFontID(id string) bool {
for _, f := range builtinFonts {
if f.ID == id {
return true
}
}
return false
}
// fontsDir 返回当前赛事项目的字体目录 <项目目录>/uploads/fonts。
// 放在 uploads 下是为了随赛事包一起走:导出整包就带上了字体。
func (s *server) fontsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法管理字体"}
}
return filepath.Join(dir, "uploads", "fonts"), nil
}
// safeFontName 把客户端文件名收敛成安全落盘名。
//
// 先取 Base 去掉目录部分,再只保留 ASCII 字母数字、点、下划线、连字符与汉字,
// 其余字符替换为下划线:字体是外部资源,名字会进 URL 与磁盘路径,必须挡住路径穿越;
// 保留汉字是为了让「思源宋体.ttf」这类中文名还能认出是什么字体。
// 返回 false 表示收敛后为空或只剩扩展名,调用方按 400 处理。
func safeFontName(raw string) (string, bool) {
// Windows 客户端可能传 "C:\Fonts\思源.ttf",反斜杠在 Linux 上不会被 Base 拆开。
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(raw), `\`, "/"))
var out strings.Builder
for _, r := range base {
switch {
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-'):
out.WriteRune(r)
case unicode.Is(unicode.Han, r):
out.WriteRune(r)
default:
out.WriteRune('_')
}
}
name := out.String()
ext := strings.ToLower(filepath.Ext(name))
if name == "" || name == ext {
return "", false
}
if _, ok := allowedFontTypes[ext]; !ok {
return "", false
}
return name, true
}
// fontContentType 校验上传字体的文件名并给出 Content-Type。
// 只接受「单层文件名 + 白名单扩展名」,且必须与 safeFontName 收敛结果一致。
func fontContentType(name string) (string, bool) {
if name == "" || strings.ContainsAny(name, `/\`) {
return "", false
}
safe, ok := safeFontName(name)
if !ok || safe != name {
return "", false
}
return allowedFontTypes[strings.ToLower(filepath.Ext(name))], true
}
// customFontName 从 `custom:<文件名>` 形式的 id 里取出文件名;不是上传字体时返回 false。
func customFontName(id string) (string, bool) {
if !strings.HasPrefix(id, customFontID) {
return "", false
}
name := strings.TrimPrefix(id, customFontID)
if _, ok := fontContentType(name); !ok {
return "", false
}
return name, true
}
// checkDisplayFont 校验 display.font_* 的取值:空串表示用默认字体,
// 其余必须是内置 id,或确实存在于 uploads/fonts 下的上传字体。
func (s *server) checkDisplayFont(key, value string) error {
id := strings.TrimSpace(value)
if id == "" {
return nil
}
if isBuiltinFontID(id) {
return nil
}
name, ok := customFontName(id)
if !ok {
return badRequest(key+" 不是已知字体,请从字体列表中选择", value)
}
dir, err := s.fontsDir()
if err != nil {
return err
}
if info, err := os.Stat(filepath.Join(dir, name)); err != nil || info.IsDir() {
return badRequest(key+" 引用的字体 "+name+" 不存在,请先上传", value)
}
return nil
}
// ---------- 接口 ----------
// listFontsHandler 返回内置字体(固定顺序在前)与已上传字体(按文件名排序)。
func (s *server) listFontsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
items := make([]fontDTO, len(builtinFonts))
copy(items, builtinFonts)
// 没有字体目录说明还没上传过,属于正常状态而不是错误。
if dir, err := s.fontsDir(); err == nil {
entries, err := os.ReadDir(dir)
if err == nil {
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
if _, ok := fontContentType(e.Name()); ok {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
items = append(items, customFontDTO(name))
}
}
}
return map[string]any{"fonts": items}, nil
}
func customFontDTO(name string) fontDTO {
url := "/uploads/fonts/" + name
return fontDTO{ID: customFontID + name, Label: name, Builtin: false, URL: &url}
}
// uploadFontHandler 保存 multipart file 字段里的字体文件,成功返回 201 与字体对象。
func (s *server) uploadFontHandler(w http.ResponseWriter, r *http.Request) (any, error) {
dir, err := s.fontsDir()
if err != nil {
return nil, err
}
r.Body = http.MaxBytesReader(w, r.Body, maxFontFormSize)
if err := r.ParseMultipartForm(maxFontFormSize); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return nil, fontTooLarge()
}
return nil, badRequest("上传内容解析失败", err.Error())
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", nil)
}
defer func() { _ = file.Close() }()
name, ok := safeFontName(header.Filename)
if !ok {
return nil, badRequest("字体文件名非法,只支持 ttf/otf/woff/woff2 且需带文件名", header.Filename)
}
// header.Size 由客户端声明,不可信,按真实读到的字节数判限。
body, err := io.ReadAll(io.LimitReader(file, maxFontBytes+1))
if err != nil {
return nil, badRequest("读取上传内容失败", err.Error())
}
if len(body) > maxFontBytes {
return nil, fontTooLarge()
}
if len(body) == 0 {
return nil, badRequest("上传内容为空", nil)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
// O_EXCL:并发上传同名文件时只有一个成功,其余按重名拒绝。
out, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if errors.Is(err, os.ErrExist) {
return nil, conflict("字体 " + name + " 已存在,请先删除或重命名后再上传")
}
if err != nil {
return nil, internal(err)
}
if _, err := out.Write(body); err != nil {
_ = out.Close()
_ = os.Remove(filepath.Join(dir, name))
return nil, internal(err)
}
if err := out.Close(); err != nil {
_ = os.Remove(filepath.Join(dir, name))
return nil, internal(err)
}
s.audit(r.Context(), "font.upload", "font", 0, nil, map[string]any{"file": name, "size": len(body)})
writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": customFontDTO(name)})
return nil, errHandled
}
// fontTooLarge 返回 413:字体超过上限时必须给出状态码,前端才知道是「太大」而不是「格式不对」。
func fontTooLarge() *apiError {
return &apiError{status: http.StatusRequestEntityTooLarge, code: "VALIDATION_FAILED",
message: "字体文件超过 20 MiB 上限"}
}
// deleteFontHandler 删除上传字体;仍被 display.font_title / display.font_body 引用时拒绝。
func (s *server) deleteFontHandler(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
if _, ok := fontContentType(name); !ok {
return notFound("字体不存在")
}
dir, err := s.fontsDir()
if err != nil {
return err
}
if err := s.ensureFontUnused(r, name); err != nil {
return err
}
if err := os.Remove(filepath.Join(dir, name)); err != nil {
if os.IsNotExist(err) {
return notFound("字体不存在")
}
return internal(err)
}
s.audit(r.Context(), "font.delete", "font", 0, map[string]any{"file": name}, nil)
return nil
}
// ensureFontUnused 挡住「删掉正在被大屏使用的字体」:删了之后大屏只剩回落字体,
// 现场看到的是「字体突然变了」,比直接拒绝更难排查。
func (s *server) ensureFontUnused(r *http.Request, name string) error {
settings, err := s.getSettings(r.Context())
if err != nil {
return err
}
id := customFontID + name
for _, ref := range []*string{settings.Display.FontTitle, settings.Display.FontBody} {
if ref != nil && *ref == id {
return conflict("字体 " + name + " 正在被大屏使用,请先在赛事信息里改掉标题/正文字体")
}
}
return nil
}
// serveFont 匿名提供上传字体:大屏的 @font-face 不带会话,不能要求登录。
func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := fontContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
dir, err := s.fontsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("字体不存在"))
return
}
w.Header().Set("Content-Type", ctype)
// 字体名是用户起的、可以同名重传,不能像随机名图片那样长缓存,否则换了字体现场看不出来。
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, file)
}
@@ -0,0 +1,71 @@
package httpapi
import "testing"
// 文件名收敛规则:取 Base、只保留 [A-Za-z0-9._-] 与汉字、其余换成下划线,
// 结果为空或只剩扩展名时拒绝。它同时守住落盘路径与 URL,不允许有例外。
func TestSafeFontName(t *testing.T) {
ok := map[string]string{
"my-font.ttf": "my-font.ttf",
"思源宋体.otf": "思源宋体.otf",
"Noto Sans SC.woff2": "Noto_Sans_SC.woff2",
`C:\Fonts\思源 宋体.TTF`: "思源_宋体.TTF",
"../../etc/passwd.ttf": "passwd.ttf",
"/abs/path/font.woff": "font.woff",
"字体 v2 (正式).woff2": "字体_v2__正式_.woff2",
"a/b/c/d/..name...ttf": "..name...ttf",
`sub\dir\zh-cn-bold.ttf`: "zh-cn-bold.ttf",
}
for raw, want := range ok {
got, valid := safeFontName(raw)
if !valid || got != want {
t.Errorf("safeFontName(%q) = (%q, %v),期望 (%q, true)", raw, got, valid, want)
}
}
// 只有扩展名、空名、非白名单扩展名一律拒绝。
for _, raw := range []string{"", " ", ".ttf", ".woff2", "字体.xlsx", "font", `C:\Fonts\`, "..", "../"} {
if got, valid := safeFontName(raw); valid {
t.Errorf("safeFontName(%q) 应拒绝,得到 %q", raw, got)
}
}
}
// 静态访问只认「上传时收敛过的名字」,避免用别名读到别人的文件或目录。
func TestFontContentType(t *testing.T) {
want := map[string]string{
"a.ttf": "font/ttf",
"a.OTF": "font/otf",
"a.woff": "font/woff",
"a.woff2": "font/woff2",
"思源宋体.ttf": "font/ttf",
}
for name, ctype := range want {
got, ok := fontContentType(name)
if !ok || got != ctype {
t.Errorf("fontContentType(%q) = (%q, %v),期望 %q", name, got, ok, ctype)
}
}
for _, name := range []string{"", "a.png", "..", "../a.ttf", "sub/a.ttf", `sub\a.ttf`, "a b.ttf", "没有扩展名"} {
if _, ok := fontContentType(name); ok {
t.Errorf("fontContentType(%q) 应拒绝", name)
}
}
}
// 字体 id 的解析与生成必须对称,否则删除接口会找不到文件。
func TestCustomFontName(t *testing.T) {
name, ok := customFontName("custom:思源宋体.ttf")
if !ok || name != "思源宋体.ttf" {
t.Fatalf("customFontName = (%q, %v)", name, ok)
}
for _, id := range []string{"builtin:noto-sans", "custom:", "custom:../x.ttf", "custom:x.png", "思源宋体.ttf"} {
if _, ok := customFontName(id); ok {
t.Errorf("customFontName(%q) 应拒绝", id)
}
}
if !isBuiltinFontID("builtin:misans") || isBuiltinFontID("builtin:nope") {
t.Error("builtin 字体 id 白名单不正确")
}
}
+419
View File
@@ -0,0 +1,419 @@
package httpapi_test
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// ---------- 字体 ----------
// fontEnv 建好带赛事项目目录的测试环境,并给出字体落盘目录。
func fontEnv(t *testing.T) (*testEnv, string) {
t.Helper()
projectDir := t.TempDir()
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
return env, filepath.Join(projectDir, "uploads", "fonts")
}
// writeFontFile 直接在字体目录里放一个文件,模拟「已经上传过」。
func writeFontFile(t *testing.T, dir, name string, content []byte) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("创建字体目录: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), content, 0o644); err != nil {
t.Fatalf("写入字体文件: %v", err)
}
}
// postFont 以 multipart 字段 file 上传到 /api/fonts。
func (e *testEnv) postFont(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("构造 multipart: %v", err)
}
if _, err := fw.Write(content); err != nil {
t.Fatalf("写入上传内容: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/fonts", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if token := e.csrfToken(client); token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传字体: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析上传响应: %v (%s)", err, raw)
}
}
return resp.StatusCode, env
}
type fontBody struct {
ID string `json:"id"`
Label string `json:"label"`
Builtin bool `json:"builtin"`
URL *string `json:"url"`
}
type fontListBody struct {
Fonts []fontBody `json:"fonts"`
}
// 内置字体固定排在前面且 URL 为 null,上传字体按文件名排序跟在后面。
func TestListFontsBuiltinThenUploaded(t *testing.T) {
env, fontsDir := fontEnv(t)
writeFontFile(t, fontsDir, "b.ttf", []byte("bbbb"))
writeFontFile(t, fontsDir, "a.ttf", []byte("aaaa"))
// 不是字体扩展名的残留文件不能出现在清单里。
writeFontFile(t, fontsDir, "notes.txt", []byte("x"))
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, got := env.do(client, http.MethodGet, "/api/fonts", nil, false)
if status != http.StatusOK {
t.Fatalf("读取字体清单应 200,得到 %d %+v", status, got.Error)
}
list := decodeData[fontListBody](t, got)
wantIDs := []string{
"builtin:noto-sans", "builtin:noto-serif", "builtin:misans", "builtin:source-han-sans",
"custom:a.ttf", "custom:b.ttf",
}
if len(list.Fonts) != len(wantIDs) {
t.Fatalf("字体数量 = %d,期望 %d:%+v", len(list.Fonts), len(wantIDs), list.Fonts)
}
for i, want := range wantIDs {
if list.Fonts[i].ID != want {
t.Fatalf("第 %d 项 = %q,期望 %q", i, list.Fonts[i].ID, want)
}
}
if list.Fonts[0].Label != "Noto Sans SC(思源黑体同源)" {
t.Errorf("内置字体应带中文显示名,得到 %q", list.Fonts[0].Label)
}
for _, f := range list.Fonts[:4] {
if !f.Builtin || f.URL != nil {
t.Errorf("内置字体 %s 应为 builtin=true 且 url=null:%+v", f.ID, f)
}
}
last := list.Fonts[5]
if last.Builtin || last.URL == nil || *last.URL != "/uploads/fonts/b.ttf" || last.Label != "b.ttf" {
t.Errorf("上传字体对象不符:%+v", last)
}
}
// 字体清单是系统配置,guest 读不到;admin 与 recorder 都能读。
func TestListFontsRequiresRecorder(t *testing.T) {
env, _ := fontEnv(t)
env.createUser("admin", "admin-pass", "admin")
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/fonts", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读字体清单应 403,得到 %d", status)
}
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
if status, _ := env.do(admin, http.MethodGet, "/api/fonts", nil, false); status != http.StatusOK {
t.Fatalf("admin 读字体清单应 200,得到 %d", status)
}
}
// 上传成功要真落盘、返回 201 与字体对象,并且匿名 GET 能取到同一份内容。
func TestUploadFontThenServeAnonymously(t *testing.T) {
env, fontsDir := fontEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 文件名里的空格与斜杠都要被收敛:服务端只允许安全字符落盘。
status, got := env.postFont(t, client, `C:\Fonts\思源 宋体.ttf`, []byte("fake-ttf"))
if status != http.StatusCreated {
t.Fatalf("上传字体应 201,得到 %d %+v", status, got.Error)
}
font := decodeData[fontBody](t, got)
if font.ID != "custom:思源_宋体.ttf" || font.Builtin || font.URL == nil {
t.Fatalf("上传返回的字体对象不符:%+v", font)
}
if *font.URL != "/uploads/fonts/思源_宋体.ttf" {
t.Fatalf("字体 URL = %q", *font.URL)
}
if body, err := os.ReadFile(filepath.Join(fontsDir, "思源_宋体.ttf")); err != nil || string(body) != "fake-ttf" {
t.Fatalf("字体未正确落盘: %v %q", err, body)
}
// 大屏的 @font-face 不带会话,静态访问必须匿名放行。
anon, _ := env.client()
resp, err := anon.Get(env.server.URL + *font.URL)
if err != nil {
t.Fatalf("匿名取字体: %v", err)
}
defer func() { _ = resp.Body.Close() }()
served, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || string(served) != "fake-ttf" {
t.Fatalf("匿名取字体 status=%d body=%q", resp.StatusCode, served)
}
if ct := resp.Header.Get("Content-Type"); ct != "font/ttf" {
t.Errorf("Content-Type = %q,期望 font/ttf", ct)
}
// 字体会被同名重传覆盖,不能长缓存。
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Errorf("Cache-Control = %q,期望 no-cache", cc)
}
// 目录里没有的文件、非白名单扩展名一律 404。
for _, path := range []string{"/uploads/fonts/nope.ttf", "/uploads/fonts/notes.txt"} {
resp, err := anon.Get(env.server.URL + path)
if err != nil {
t.Fatalf("取 %s: %v", path, err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("取 %s 应 404,得到 %d", path, resp.StatusCode)
}
}
}
// 类型不符 400、超限 413、重名 409,错误消息必须能直接给操作员看。
func TestUploadFontRejectsBadInput(t *testing.T) {
env, fontsDir := fontEnv(t)
env.createUser("admin", "admin-pass", "admin")
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, got := env.postFont(t, client, "表格.xlsx", []byte("nope"))
if status != http.StatusBadRequest {
t.Fatalf("非字体文件应 400,得到 %d", status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "字体文件名非法") {
t.Fatalf("错误消息应说明文件名非法:%+v", got.Error)
}
// 只有扩展名、没有文件名的情况同样拒绝。
if status, _ := env.postFont(t, client, ".ttf", []byte("nope")); status != http.StatusBadRequest {
t.Fatalf("只有扩展名的文件名应 400,得到 %d", status)
}
status, got = env.postFont(t, client, "big.ttf", make([]byte, 20<<20+1))
if status != http.StatusRequestEntityTooLarge {
t.Fatalf("超限字体应 413,得到 %d %+v", status, got.Error)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "20 MiB") {
t.Fatalf("413 消息应说明上限:%+v", got.Error)
}
if status, _ := env.postFont(t, client, "dup.ttf", []byte("one")); status != http.StatusCreated {
t.Fatalf("首次上传应 201,得到 %d", status)
}
status, got = env.postFont(t, client, "dup.ttf", []byte("two"))
if status != http.StatusConflict {
t.Fatalf("重名应 409,得到 %d", status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "已存在") {
t.Fatalf("重名消息应说明已存在:%+v", got.Error)
}
if body, err := os.ReadFile(filepath.Join(fontsDir, "dup.ttf")); err != nil || string(body) != "one" {
t.Fatalf("重名被拒后原文件不能被改写: %v %q", err, body)
}
// 上传是系统级资源管理,只有 admin 能做。
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.postFont(t, recorder, "r.ttf", []byte("x")); status != http.StatusForbidden {
t.Fatalf("recorder 上传字体应 403,得到 %d", status)
}
guest, _ := env.client()
if status, _ := env.postFont(t, guest, "g.ttf", []byte("x")); status != http.StatusUnauthorized {
t.Fatalf("guest 上传字体应 401,得到 %d", status)
}
}
// 写入 settings 时必须挡住不存在的字体 id:否则大屏只会静默回落成默认字体。
func TestSettingsFontValidation(t *testing.T) {
env, fontsDir := fontEnv(t)
writeFontFile(t, fontsDir, "my-font.ttf", []byte("fake"))
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
seq := 0
put := func(title *string, body *string) (int, envelope) {
seq++
display := map[string]any{}
if title != nil {
display["fontTitle"] = *title
}
if body != nil {
display["fontBody"] = *body
}
return env.do(client, http.MethodPut, "/api/settings",
map[string]any{"display": display, "requestId": fmt.Sprintf("req-font-%d", seq)}, true)
}
for _, bad := range []string{"builtin:comic-sans", "custom:nope.ttf", "custom:../evil.ttf", "随便写的"} {
status, got := put(&bad, nil)
if status != http.StatusBadRequest {
t.Fatalf("非法字体 id %q 应 400,得到 %d", bad, status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "fontTitle") {
t.Fatalf("错误消息应指明字段:%+v", got.Error)
}
}
title := "builtin:noto-serif"
body := "custom:my-font.ttf"
if status, got := put(&title, &body); status != http.StatusOK {
t.Fatalf("合法字体应 200,得到 %d %+v", status, got.Error)
}
settings := decodeData[map[string]any](t, mustGetSettings(t, env, client))
display := settings["display"].(map[string]any)
if display["fontTitle"] != "builtin:noto-serif" || display["fontBody"] != "custom:my-font.ttf" {
t.Fatalf("字体未落库:%+v", display)
}
// 空串表示回到默认字体,必须下发显式 null。
empty := ""
if status, got := put(nil, &empty); status != http.StatusOK {
t.Fatalf("清空字体应 200,得到 %d %+v", status, got.Error)
}
settings = decodeData[map[string]any](t, mustGetSettings(t, env, client))
display = settings["display"].(map[string]any)
if display["fontBody"] != nil {
t.Fatalf("清空后 fontBody 应为 null:%+v", display)
}
if display["fontTitle"] != "builtin:noto-serif" {
t.Fatalf("只改 fontBody 时不应动到 fontTitle:%+v", display)
}
}
// 删除字体:不存在 404、被设置引用 409(提示先改配置)、改完配置后可以删掉。
func TestDeleteFont(t *testing.T) {
env, fontsDir := fontEnv(t)
writeFontFile(t, fontsDir, "used.ttf", []byte("used"))
writeFontFile(t, fontsDir, "free.ttf", []byte("free"))
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
if status, _ := env.do(client, http.MethodDelete, "/api/fonts/nope.ttf", nil, true); status != http.StatusNotFound {
t.Fatalf("删除不存在的字体应 404,得到 %d", status)
}
used := "custom:used.ttf"
if status, got := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"display": map[string]any{"fontTitle": used}, "requestId": "req-del-1"}, true); status != http.StatusOK {
t.Fatalf("设置字体应 200,得到 %d %+v", status, got.Error)
}
status, got := env.do(client, http.MethodDelete, "/api/fonts/used.ttf", nil, true)
if status != http.StatusConflict {
t.Fatalf("被引用的字体应 409,得到 %d", status)
}
if got.Error == nil || !strings.Contains(got.Error.Message, "正在被大屏使用") {
t.Fatalf("409 消息应提示先改配置:%+v", got.Error)
}
if _, err := os.Stat(filepath.Join(fontsDir, "used.ttf")); err != nil {
t.Fatalf("被引用的字体不能被删除: %v", err)
}
if status, got := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"display": map[string]any{"fontTitle": ""}, "requestId": "req-del-2"}, true); status != http.StatusOK {
t.Fatalf("清空字体应 200,得到 %d %+v", status, got.Error)
}
if status, got := env.do(client, http.MethodDelete, "/api/fonts/used.ttf", nil, true); status != http.StatusOK {
t.Fatalf("不再被引用的字体应可删除,得到 %d %+v", status, got.Error)
}
if _, err := os.Stat(filepath.Join(fontsDir, "used.ttf")); !os.IsNotExist(err) {
t.Fatalf("删除后文件应消失: %v", err)
}
// 删除是系统级资源管理,recorder 不能做。
env.createUser("recorder", "recorder-pass", "recorder")
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodDelete, "/api/fonts/free.ttf", nil, true); status != http.StatusForbidden {
t.Fatalf("recorder 删除字体应 403,得到 %d", status)
}
}
// 赛事包必须带上上传字体:字体是 uploads/ 下的资源,整包拷走才能在大屏上复现。
func TestExportProjectZipIncludesFonts(t *testing.T) {
env, _ := fontEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
if status, got := env.postFont(t, client, "思源宋体.ttf", []byte("fake-font")); status != http.StatusCreated {
t.Fatalf("上传字体应 201,得到 %d %+v", status, got.Error)
}
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("读取 zip: %v", err)
}
found := false
for _, f := range zr.File {
if f.Name == "uploads/fonts/思源宋体.ttf" {
found = true
}
}
if !found {
names := make([]string, 0, len(zr.File))
for _, f := range zr.File {
names = append(names, f.Name)
}
t.Fatalf("赛事包缺少 uploads/fonts/思源宋体.ttf,实际含 %v", names)
}
}
// mustGetSettings 读一次 GET /api/settings,返回统一响应体。
func mustGetSettings(t *testing.T, env *testEnv, client *http.Client) envelope {
t.Helper()
status, got := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK {
t.Fatalf("读取设置应 200,得到 %d %+v", status, got.Error)
}
return got
}
+60 -1
View File
@@ -1,6 +1,7 @@
package httpapi package httpapi
import ( import (
"bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/csv" "encoding/csv"
@@ -14,8 +15,12 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
"github.com/xuri/excelize/v2" "github.com/xuri/excelize/v2"
"golang.org/x/text/encoding/simplifiedchinese"
textunicode "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
@@ -658,7 +663,11 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
} }
return rows, nil return rows, nil
default: default:
reader := csv.NewReader(file) text, err := decodeTableText(file)
if err != nil {
return nil, err
}
reader := csv.NewReader(strings.NewReader(text))
reader.FieldsPerRecord = -1 reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true reader.TrimLeadingSpace = true
rows, err := reader.ReadAll() rows, err := reader.ReadAll()
@@ -669,6 +678,56 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
} }
} }
// 报名表文本可能带的字节序标记。
var (
tableUTF8BOM = []byte{0xEF, 0xBB, 0xBF}
tableUTF16LEBOM = []byte{0xFF, 0xFE}
tableUTF16BEBOM = []byte{0xFE, 0xFF}
)
// decodeTableText 把报名表的字节流解码成 UTF-8 文本。
//
// 现场拿到的 CSV 基本都是直接从 Excel 存的,而 Excel 默认不写 UTF-8:中文 Windows 上
// 「另存为 CSV」出的是 GB18030,「CSV UTF-8」会带 BOM,PowerShell 的 Export-Csv 出 UTF-16LE。
// 这三种原样丢给 csv.Reader 都会炸,而且症状很误导——
// 中文单位名、运动员名变乱码;BOM 会粘在第一列表头上("\ufeffevent_code"),
// 于是报「报名表缺少列 event_code」,让人以为表头写错了。
func decodeTableText(file io.Reader) (string, error) {
// 上游 readImportForm 已用 MaxBytesReader 限住 body,整读不会失控。
data, err := io.ReadAll(file)
if err != nil {
return "", badRequest("读取报名表失败", err.Error())
}
switch {
case bytes.HasPrefix(data, tableUTF8BOM):
data = data[len(tableUTF8BOM):]
case bytes.HasPrefix(data, tableUTF16LEBOM):
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian)
case bytes.HasPrefix(data, tableUTF16BEBOM):
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian)
}
if utf8.Valid(data) {
return string(data), nil
}
// GBK 是 GB18030 的子集,按 GB18030 解能一并覆盖。
text, _, err := transform.String(simplifiedchinese.GB18030.NewDecoder(), string(data))
if err != nil {
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 或 GB18030 编码的 CSV 后重试", err.Error())
}
return text, nil
}
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。
func decodeUTF16Table(data []byte, order textunicode.Endianness) (string, error) {
text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data))
if err != nil {
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试", err.Error())
}
return text, nil
}
// importColumns 是报名表必须包含的表头(大小写不敏感); // importColumns 是报名表必须包含的表头(大小写不敏感);
// id_card、gender、age 为可选列,缺列不报错,按空值处理。 // id_card、gender、age 为可选列,缺列不报错,按空值处理。
var importColumns = []string{ var importColumns = []string{
@@ -0,0 +1,94 @@
package httpapi_test
import (
"net/http"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
textunicode "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// 报名表是现场从 Excel 存出来的,绝大多数不是 UTF-8:
// 中文 Windows 上「另存为 CSV」出 GB18030,「CSV UTF-8」会带 BOM,PowerShell 的
// Export-Csv 出 UTF-16LE。这三种都要能正常导入,否则现场看到的是乱码,
// 或者更误导的「报名表缺少列 event_code」。
// importBody 是一行合法的个人报名(11 列,与 ImportSection 模板一致)。
const importBody = "M100,男子,A,张三,110101199001011234,男,,,,registered,\n"
// athleteName 取库中该证件号对应的人员姓名,用来确认中文没有变成乱码。
func athleteName(t *testing.T, env *testEnv, idCard string) string {
t.Helper()
var name string
if err := env.ev.QueryRow(`SELECT name FROM athletes WHERE id_card = ?`, idCard).Scan(&name); err != nil {
t.Fatalf("查询人员 %s: %v", idCard, err)
}
return name
}
// assertImportedAsZhangSan 断言这次导入真的落库了「张三」。
func assertImportedAsZhangSan(t *testing.T, env *testEnv, status int, env2 envelope) {
t.Helper()
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) {
t.Fatalf("应新建 1 条报名: %+v", result)
}
if name := athleteName(t, env, "110101199001011234"); name != "张三" {
t.Fatalf("姓名应为「张三」,得到 %q", name)
}
}
// 模板自带 BOM,用户「下载模板 → 填 → 原样传回来」必须走得通:
// BOM 不能粘在第一列表头上("\ufeffevent_code")导致误报缺列。
func TestImportAcceptsUTF8BOM(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := "\uFEFF" + importHeader + "\n" + importBody
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
// Excel 默认另存出来的 GB18030。
func TestImportDecodesGB18030(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := encodeGB18030(t, importHeader+"\n"+importBody)
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
// PowerShell 的 Export-Csv 默认出 UTF-16LE(带 BOM)。
func TestImportDecodesUTF16LE(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := encodeUTF16LE(t, importHeader+"\n"+importBody)
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
func encodeGB18030(t *testing.T, text string) string {
t.Helper()
out, _, err := transform.String(simplifiedchinese.GB18030.NewEncoder(), text)
if err != nil {
t.Fatalf("编码 GB18030: %v", err)
}
return out
}
func encodeUTF16LE(t *testing.T, text string) string {
t.Helper()
// UseBOM 让编码器写出 FF FE,解码侧才有 BOM 可认。
out, _, err := transform.String(textunicode.UTF16(textunicode.LittleEndian, textunicode.UseBOM).NewEncoder(), text)
if err != nil {
t.Fatalf("编码 UTF-16LE: %v", err)
}
return out
}
+3
View File
@@ -91,6 +91,9 @@ type settingsDisplayPatch struct {
Subtitle *string `json:"subtitle"` Subtitle *string `json:"subtitle"`
PrimaryColor *string `json:"primaryColor"` PrimaryColor *string `json:"primaryColor"`
RotateSeconds *float64 `json:"rotateSeconds"` RotateSeconds *float64 `json:"rotateSeconds"`
// FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。
FontTitle *string `json:"fontTitle"`
FontBody *string `json:"fontBody"`
} }
type settingsStandingsPatch struct { type settingsStandingsPatch struct {
+18
View File
@@ -129,6 +129,24 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
return dto.EventSettings{}, err return dto.EventSettings{}, err
} }
} }
// 字体 id 必须在写入时就校验:引用一个不存在的字体,大屏只会静默回落成黑体,
// 现场看到的「配置没生效」比直接报错难排查得多。空串表示用默认字体。
if p.FontTitle != nil {
if err := s.checkDisplayFont("display.fontTitle", *p.FontTitle); err != nil {
return dto.EventSettings{}, err
}
if err := set("display.font_title", strings.TrimSpace(*p.FontTitle)); err != nil {
return dto.EventSettings{}, err
}
}
if p.FontBody != nil {
if err := s.checkDisplayFont("display.fontBody", *p.FontBody); err != nil {
return dto.EventSettings{}, err
}
if err := set("display.font_body", strings.TrimSpace(*p.FontBody)); err != nil {
return dto.EventSettings{}, err
}
}
} }
if p := patch.Standings; p != nil { if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil { if p.IndividualToTeamWeight != nil {
+8
View File
@@ -255,6 +255,14 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler)) mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
mux.HandleFunc("GET /uploads/{name}", s.serveUpload) mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
// 字体:4 款内置字体(前端 @font-face)由 GET /api/fonts 列出,上传的落
// <项目目录>/uploads/fonts/,随赛事包导出;静态访问同样是匿名放行——大屏的
// @font-face 不带会话。字体目录比图片多一层,静态路由必须单独注册。
mux.HandleFunc("GET /api/fonts", s.read(auth.RoleRecorder, s.listFontsHandler))
mux.HandleFunc("POST /api/fonts", s.write(auth.RoleAdmin, s.uploadFontHandler))
mux.HandleFunc("DELETE /api/fonts/{name}", s.writeVoid(auth.RoleAdmin, s.deleteFontHandler))
mux.HandleFunc("GET /uploads/fonts/{name}", s.serveFont)
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。 // 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出")) mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出")) mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
+1 -1
View File
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
assertKeys(t, "settings.event", asMap(t, settings["event"]), assertKeys(t, "settings.event", asMap(t, settings["event"]),
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"}) []string{"name", "subtitle", "host", "logoPath", "primaryColor"})
assertKeys(t, "settings.display", asMap(t, settings["display"]), assertKeys(t, "settings.display", asMap(t, settings["display"]),
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce"}) []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody"})
assertKeys(t, "settings.standings", asMap(t, settings["standings"]), assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"}) []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
@@ -206,7 +206,9 @@ function downloadTemplate(): void {
> >
<MxIcon name="export" :size="26" /> <MxIcon name="export" :size="26" />
<div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div> <div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div>
<div class="bd-note">单个文件不超过 16MB;xlsx 由服务端解析第一个工作表</div> <div class="bd-note">
单个文件不超过 16MB;xlsx 由服务端解析第一个工作表;CSV 直接用 Excel「另存为 CSV」即可(UTF-8、带 BOM 的 UTF-8、GBK/GB18030、UTF-16 都能读,不用特意转码)
</div>
<input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" /> <input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" />
</div> </div>
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import type { EventSettings } from '@/shared' import type { DisplayFont, EventSettings } from '@/shared'
import { BUILTIN_FONTS, fontStack, registerCustomFont } from '@/display/fonts'
import MxIcon from '../MxIcon.vue' import MxIcon from '../MxIcon.vue'
import FontUploadField from './FontUploadField.vue'
import ImageUploadField from './ImageUploadField.vue' import ImageUploadField from './ImageUploadField.vue'
import MockBadge from './MockBadge.vue' import MockBadge from './MockBadge.vue'
import { describeError, settingsApi, type EventSettingsPatch } from './systemApi' import { describeError, fontsApi, settingsApi, type EventSettingsPatch } from './systemApi'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useEventStore } from '../../stores/event' import { useEventStore } from '../../stores/event'
import { useToastStore } from '../../stores/toast' import { useToastStore } from '../../stores/toast'
@@ -26,6 +28,9 @@ const saving = ref(false)
const saveError = ref<string | null>(null) const saveError = ref<string | null>(null)
const usingMock = ref(false) const usingMock = ref(false)
const fonts = ref<DisplayFont[]>([])
const fontsError = ref<string | null>(null)
const isAdmin = computed(() => auth.user?.role === 'admin') const isAdmin = computed(() => auth.user?.role === 'admin')
const dirty = computed(() => { const dirty = computed(() => {
if (form.value === null || data.settings === null) return false if (form.value === null || data.settings === null) return false
@@ -45,11 +50,61 @@ function clone(value: EventSettings): EventSettings {
return JSON.parse(JSON.stringify(value)) as EventSettings return JSON.parse(JSON.stringify(value)) as EventSettings
} }
// ---------------------------------------------------------------------------
// 字体
// ---------------------------------------------------------------------------
onMounted(() => {
void loadFonts()
})
async function loadFonts(): Promise<void> {
try {
fonts.value = await fontsApi.list()
fontsError.value = null
} catch (caught) {
fontsError.value = describeError(caught)
// 清单读不到时至少列出四款内置字体:字体已经配错的赛事还能改回来。
fonts.value = BUILTIN_FONTS.map((font) => ({ ...font, builtin: true, url: null }))
}
}
/** 设置里引用的字体不在清单里(例如文件被手工删掉)时也要能显示出来,否则下拉会是空白。 */
function missingFont(id: string | null | undefined): boolean {
if (id === null || id === undefined || id === '') return false
return !fonts.value.some((font) => font.id === id)
}
/** 预览用的字体栈:与前端大屏同一个解析函数,所见即所得。 */
function previewStyle(id: string | null | undefined): Record<string, string> {
const stack = fontStack(id)
return stack === null ? {} : { fontFamily: stack }
}
const titleFontStyle = computed(() => previewStyle(form.value?.display.fontTitle))
const bodyFontStyle = computed(() => previewStyle(form.value?.display.fontBody))
// 上传字体要先注册 @font-face,预览才会真的换字体(浏览器对未注册的家族会静默回落)。
watch(
() => `${form.value?.display.fontTitle ?? ''}|${form.value?.display.fontBody ?? ''}`,
() => {
void registerCustomFont(form.value?.display.fontTitle)
void registerCustomFont(form.value?.display.fontBody)
},
{ immediate: true },
)
/** 字体库变动(上传/删除)后刷新清单,下拉里立刻能看到新字体或去掉已删的。 */
function onFontsChanged(): void {
void loadFonts()
}
/** /**
* 可选文本:服务端对 `*string` 的 nil 是「保持原值」,所以清空必须传空串。 * 可选文本:服务端对 `*string` 的 nil 是「保持原值」,所以清空必须传空串。
* 若这里把空串转成 null,副标题 / 主办方将永远清不掉。 * 若这里把空串转成 null,副标题 / 主办方将永远清不掉。
* 字体 id 走同一个函数:旧服务端不下发 fontTitle / fontBody,可能是 undefined。
*/ */
function optional(value: string | null): string { function optional(value: string | null | undefined): string {
return (value ?? '').trim() return (value ?? '').trim()
} }
@@ -88,6 +143,9 @@ async function save(): Promise<void> {
logo: optional(next.display.logo), logo: optional(next.display.logo),
background: optional(next.display.background), background: optional(next.display.background),
rotateSeconds, rotateSeconds,
// 同会标:清空字体必须传空串,传 null 服务端会当成「不改动」。
fontTitle: optional(next.display.fontTitle),
fontBody: optional(next.display.fontBody),
}, },
standings: { ...next.standings }, standings: { ...next.standings },
} }
@@ -237,6 +295,57 @@ async function save(): Promise<void> {
</div> </div>
</div> </div>
<h3 class="group-title">字体</h3>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="display-font-title">标题字体</label>
<div class="mx-input-wrap">
<select id="display-font-title" v-model="form.display.fontTitle" class="mx-input" :disabled="!isAdmin">
<option :value="null">默认(跟随系统)</option>
<option
v-if="missingFont(form.display.fontTitle)"
:value="form.display.fontTitle"
>
{{ form.display.fontTitle }}(字体清单里没有)
</option>
<option v-for="font in fonts" :key="font.id" :value="font.id">{{ font.label }}</option>
</select>
</div>
</div>
<div class="field">
<label class="mx-label" for="display-font-body">正文字体</label>
<div class="mx-input-wrap">
<select id="display-font-body" v-model="form.display.fontBody" class="mx-input" :disabled="!isAdmin">
<option :value="null">默认(跟随系统)</option>
<option v-if="missingFont(form.display.fontBody)" :value="form.display.fontBody">
{{ form.display.fontBody }}(字体清单里没有)
</option>
<option v-for="font in fonts" :key="font.id" :value="font.id">{{ font.label }}</option>
</select>
</div>
</div>
<div class="field wide">
<span class="mx-label">字体库</span>
<FontUploadField
:fonts="fonts"
:disabled="!isAdmin"
hint="上传的字体存放在服务端 <赛事项目>/uploads/fonts/ 下,会随赛事包一起导出,换一台机器导入后仍可用。"
@uploaded="onFontsChanged"
@removed="onFontsChanged"
/>
<span v-if="fontsError !== null" class="font-error">字体清单读取失败:{{ fontsError }}</span>
</div>
<div class="field wide">
<span class="mx-label">预览</span>
<div class="font-preview">
<p class="preview-title" :style="titleFontStyle">{{ form?.display.title?.trim() || '赛事标题' }}</p>
<p class="preview-body" :style="bodyFontStyle">
第一中学 128.5 | 第二中学 96 | 第三中学 74.5
</p>
</div>
</div>
</div>
<p class="notice"> <p class="notice">
修改大屏标题、轮播间隔或主题色会立即影响所有在线大屏,保存前请确认现场没有正在进行的颁奖或播报。 修改大屏标题、轮播间隔或主题色会立即影响所有在线大屏,保存前请确认现场没有正在进行的颁奖或播报。
</p> </p>
@@ -297,6 +406,31 @@ async function save(): Promise<void> {
border-radius: var(--mx-r-xs); border-radius: var(--mx-r-xs);
background: var(--mx-s3); background: var(--mx-s3);
} }
/* 预览块用浅底深字:大屏是深色底,这里的目的是让管理员看清字形差异。 */
.font-preview {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--mx-border);
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
overflow: hidden;
}
.preview-title {
font-size: 20px;
font-weight: 700;
color: var(--mx-t1);
}
.preview-body {
font-size: 13px;
color: var(--mx-t2);
}
.font-error {
margin-top: 4px;
font-size: 12px;
color: var(--mx-error);
}
.notice.error { .notice.error {
color: var(--mx-error); color: var(--mx-error);
background: rgba(248, 113, 113, 0.1); background: rgba(248, 113, 113, 0.1);
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { DisplayFont } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useToastStore } from '../../stores/toast'
import { describeError, fontsApi } from './systemApi'
/**
* 字体库字段:上传按钮 + 已上传字体列表(带删除)。
*
* 上传走 POST /api/fonts,文件落在服务端的 `<赛事项目>/uploads/fonts/` 下,
* 随赛事包一起导出;下拉里选中的字体 id 由父组件写进 display.fontTitle / fontBody。
* 删除交给服务端兜底:仍被大屏引用的字体会被 409 拒绝,这里直接把原因显示出来。
*/
/** 与服务端一致的单文件上限;本地先挡一道,省掉一次白跑的上传。 */
const MAX_FONT_BYTES = 20 * 1024 * 1024
const EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']
const props = withDefaults(
defineProps<{
fonts: DisplayFont[]
disabled?: boolean
hint?: string
}>(),
{ disabled: false, hint: '' },
)
const emit = defineEmits<{ uploaded: [font: DisplayFont]; removed: [name: string] }>()
const toast = useToastStore()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const removing = ref(false)
const target = ref<DisplayFont | null>(null)
/** 内置字体随前端分发,不能删;列表里只列上传的那部分。 */
const customFonts = computed(() => props.fonts.filter((font) => !font.builtin))
function choose(): void {
if (props.disabled || uploading.value) return
fileInput.value?.click()
}
async function onPick(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
// 不清空 value 时连续选同一个文件不会再触发 change。
input.value = ''
if (file === null) return
const lower = file.name.toLowerCase()
if (!EXTENSIONS.some((ext) => lower.endsWith(ext))) {
toast.error('字体格式不支持', '只支持 ttf / otf / woff / woff2 四种格式')
return
}
if (file.size > MAX_FONT_BYTES) {
toast.error('字体过大', `单个字体不能超过 ${MAX_FONT_BYTES / 1024 / 1024} MiB`)
return
}
uploading.value = true
try {
const font = await fontsApi.upload(file)
toast.success('字体已上传', `${font.label} · 已存入赛事包的 uploads/fonts/`)
emit('uploaded', font)
} catch (caught) {
toast.error('字体上传失败', describeError(caught))
} finally {
uploading.value = false
}
}
async function remove(): Promise<void> {
const font = target.value
if (font === null) return
// 服务端只认文件名,id 前缀要去掉。
const name = font.id.replace(/^custom:/, '')
removing.value = true
try {
await fontsApi.remove(name)
toast.success('字体已删除', name)
target.value = null
emit('removed', name)
} catch (caught) {
toast.error('字体删除失败', describeError(caught))
target.value = null
} finally {
removing.value = false
}
}
</script>
<template>
<div class="font-field">
<div class="font-row">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="disabled || uploading" @click="choose">
<MxIcon name="push" :size="15" /> {{ uploading ? '上传中…' : '上传字体' }}
</button>
<span class="font-note">支持 ttf / otf / woff / woff2,单个不超过 20 MiB,文件名重复会被拒绝。</span>
</div>
<input ref="fileInput" class="file-input" type="file" accept=".ttf,.otf,.woff,.woff2" @change="onPick" />
<ul v-if="customFonts.length > 0" class="font-list">
<li v-for="font in customFonts" :key="font.id" class="font-item">
<span class="font-name">{{ font.label }}</span>
<code class="font-url mono">{{ font.url }}</code>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="disabled" @click="target = font">
删除
</button>
</li>
</ul>
<span v-else class="font-note">还没有上传字体:先用系统自带的四款字体,或点上面的按钮上传。</span>
<span v-if="hint !== ''" class="font-note">{{ hint }}</span>
<ConfirmDialog
:open="target !== null"
tone="warning"
title="删除该字体?"
:message="`删除后 ${target?.label ?? ''} 会从赛事包里移除。若它正被大屏的标题/正文字体使用,服务端会拒绝删除,请先在「赛事信息」里改掉再用。`"
confirm-text="删除字体"
:pending="removing"
@confirm="remove"
@cancel="target = null"
/>
</div>
</template>
<style scoped>
.font-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.font-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.file-input {
display: none;
}
.font-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.font-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.font-name {
color: var(--mx-t2);
word-break: break-all;
}
.font-url {
flex: 1;
min-width: 0;
color: var(--mx-t3);
word-break: break-all;
}
.font-note {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
word-break: break-all;
}
</style>
+48 -1
View File
@@ -1,4 +1,4 @@
import { ApiError, type ApiErrorCode, type EventSettings } from '@/shared' import { ApiError, type ApiErrorCode, type DisplayFont, type EventSettings } from '@/shared'
import { mockMode, mockReason } from '../../api/client' import { mockMode, mockReason } from '../../api/client'
/** /**
@@ -875,3 +875,50 @@ export const settingsApi = {
) )
}, },
} }
// ---------------------------------------------------------------------------
// 字体(内置字体 + 上传字体)
// ---------------------------------------------------------------------------
function toFont(value: unknown): DisplayFont | null {
if (!isRecord(value)) return null
const id = readString(value, 'id')
const label = readString(value, 'label')
if (id === null || label === null) return null
return { id, label, builtin: value.builtin === true, url: readString(value, 'url') }
}
/**
* 字体清单 / 上传 / 删除。
*
* 一律不降级示例数据:字体是文件级资源,假的清单会让管理员以为字体已经随包分发,
* 而真正的上传与删除在示例模式下根本无从谈起。
*/
export const fontsApi = {
async list(): Promise<DisplayFont[]> {
const body = await requestJson<unknown>('/fonts')
// 服务端的 data 是 {fonts:[...]},不是裸数组。
const raw = isRecord(body) ? body.fonts : null
if (!Array.isArray(raw)) throw new ApiError('PARSE_FAILED', '字体清单响应格式不符', 0, body)
return raw.map(toFont).filter((font): font is DisplayFont => font !== null)
},
async upload(file: File): Promise<DisplayFont> {
const form = new FormData()
form.append('file', file)
// 字体上限 20 MiB,内网弱网下也要留足上传时间。
const body = await requestJson<unknown>('/fonts', {
method: 'POST',
body: form,
timeoutMs: UPLOAD_TIMEOUT_MS,
})
const font = toFont(body)
if (font === null) throw new ApiError('PARSE_FAILED', '字体上传响应格式不符', 0, body)
return font
},
async remove(name: string): Promise<void> {
// 文件名可能含汉字,必须转义后再进 URL。
await requestJson<unknown>(`/fonts/${encodeURIComponent(name)}`, { method: 'DELETE' })
},
}
+16 -4
View File
@@ -38,12 +38,12 @@ const stack: InfoItem[] = [
] ]
const fonts: InfoItem[] = [ const fonts: InfoItem[] = [
{ name: 'Noto Sans SC', usage: '默认界面字体', license: 'OFL-1.1' }, { name: 'Noto Sans SC', usage: '默认字体(大屏与界面)', license: 'OFL-1.1' },
{ name: 'Noto Serif SC', usage: '标题衬线字体', license: 'OFL-1.1' }, { name: 'Noto Serif SC', usage: '衬线,可用作大屏标题', license: 'OFL-1.1' },
{ name: 'Source Han Sans CN(思源黑体)', usage: '备用无衬线字体', license: 'OFL-1.1' }, { name: 'Source Han Sans CN(思源黑体)', usage: '无衬线,可用作大屏标题/正文', license: 'OFL-1.1' },
{ {
name: 'MiSans', name: 'MiSans',
usage: '可选界面字体', usage: '无衬线,可用作大屏标题/正文',
license: 'MiSans Font License', license: 'MiSans Font License',
note: 'MiSans 仅限内部赛事使用,不得对外分发', note: 'MiSans 仅限内部赛事使用,不得对外分发',
}, },
@@ -179,6 +179,13 @@ onMounted(async () => {
<div class="mx-card-head"> <div class="mx-card-head">
<span class="mx-card-title">字体清单</span> <span class="mx-card-title">字体清单</span>
</div> </div>
<div class="mx-card-body">
<p class="font-hint">
以下四款内置字体(各 400/700 两档)随程序分发,可在「系统设置 → 赛事信息 → 字体」里选用;
另有需要的字体可在同一处上传(ttf / otf / woff / woff2,单个不超过 20 MiB),
上传的字体存在赛事项目的 <code class="mono">uploads/fonts/</code> 下并随赛事包导出。
</p>
</div>
<div class="mx-card-body no-pad"> <div class="mx-card-body no-pad">
<div class="mx-table-wrap"> <div class="mx-table-wrap">
<table class="mx-table mx-table-compact"> <table class="mx-table mx-table-compact">
@@ -351,6 +358,11 @@ kbd {
font-size: 12px; font-size: 12px;
color: var(--mx-t3); color: var(--mx-t3);
} }
.font-hint {
font-size: 12px;
line-height: 1.7;
color: var(--mx-t3);
}
.signature { .signature {
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
+3 -2
View File
@@ -566,8 +566,9 @@ async function applyRules(): Promise<void> {
<div class="mx-card-body"> <div class="mx-card-body">
<p class="hint"> <p class="hint">
包内含 <code class="mono">meta.yaml</code> + <code class="mono">event.db</code> + 包内含 <code class="mono">meta.yaml</code> + <code class="mono">event.db</code> +
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>(字体可选)。 <code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>。会标、背景图与上传的字体
整包拷走即可在另一台机器复现整场赛事。 都在 <code class="mono">uploads/</code> 里(字体在 <code class="mono">uploads/fonts/</code>),
连同字体设置一并随包带走,整包拷到另一台机器导入后即可复现整场赛事。
</p> </p>
<button <button
class="mx-btn mx-btn-primary full" class="mx-btn mx-btn-primary full"
+85
View File
@@ -0,0 +1,85 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fontsApi } from '../components/system/systemApi'
/**
* 字体接口的前端契约:路径 / 字段名 / 响应形状。
*
* 字段名写错时现场看到的是「上传成功但列表里没有」这类静默故障,
* 所以这里用 stub fetch 钉住真正发出去的请求。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function stubFetch(reply: (captured: Captured) => Response): Captured {
const captured: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
captured.url = String(input)
captured.method = init?.method ?? 'GET'
captured.body = init?.body
return reply(captured)
}) as typeof fetch
// systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
return captured
}
const BUILTIN = { id: 'builtin:noto-sans', label: 'Noto Sans SC(思源黑体同源)', builtin: true, url: null }
const CUSTOM = { id: 'custom:思源宋体.ttf', label: '思源宋体.ttf', builtin: false, url: '/uploads/fonts/思源宋体.ttf' }
test('fonts.list 读 GET /api/fonts 并拆出 fonts 数组', async () => {
const captured = stubFetch(() => jsonResponse({ ok: true, data: { fonts: [BUILTIN, CUSTOM] } }))
const fonts = await fontsApi.list()
assert.equal(captured.url, '/api/fonts')
assert.equal(captured.method, 'GET')
assert.deepEqual(fonts, [BUILTIN, CUSTOM])
})
// 服务端把 data 包成 {fonts:[...]};形状不对时必须报解析失败,
// 而不是把 undefined 当成「没有字体」让下拉变成空的。
test('fonts.list 对非法响应报解析失败', async () => {
stubFetch(() => jsonResponse({ ok: true, data: [BUILTIN] }))
await assert.rejects(() => fontsApi.list(), (error: Error) => error.message.includes('字体清单'))
})
test('fonts.upload 以 multipart 字段 file POST /api/fonts', async () => {
const captured = stubFetch(() => jsonResponse({ ok: true, data: CUSTOM }, 201))
const file = new File(['x'], '思源宋体.ttf')
const font = await fontsApi.upload(file)
assert.equal(captured.url, '/api/fonts')
assert.equal(captured.method, 'POST')
assert.equal((captured.body as FormData).get('file'), file)
assert.deepEqual(font, CUSTOM)
})
test('fonts.remove 删除时转义文件名并原样抛出服务端错误', async () => {
const captured = stubFetch(() =>
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '字体 思源宋体.ttf 正在被大屏使用,请先在赛事信息里改掉标题/正文字体' } }, 409),
)
await assert.rejects(
() => fontsApi.remove('思源宋体.ttf'),
(error: Error) => error.message.includes('正在被大屏使用'),
)
assert.equal(captured.url, `/api/fonts/${encodeURIComponent('思源宋体.ttf')}`)
assert.equal(captured.method, 'DELETE')
})
+8 -11
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, watch } from 'vue'
import type { Component } from 'vue' import type { Component } from 'vue'
import { mix, parseHexColor, readableForeground, toCss } from './color' import { mix, parseHexColor, readableForeground, toCss } from './color'
import { useShellFullscreen } from './shell'
import type { DisplayPageKey } from './types' import type { DisplayPageKey } from './types'
import { useScoreboard } from './useScoreboard' import { useScoreboard } from './useScoreboard'
import { useStageScale } from './useStageScale' import { useStageScale } from './useStageScale'
@@ -28,13 +29,17 @@ const { bootstrap, dispose, page, settings } = useScoreboard()
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效。 // 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效。
const windowed = isWindowed() const windowed = isWindowed()
// Console 的 F11 由主进程切换全屏:全屏时自绘标题栏必须让位,否则舞台顶部被多扣 50px。 // Console 的 F11 是窗口级全屏,文档 API 看不到,两个来源的合成判断见 shell.ts。
const fullscreen = ref(document.fullscreenElement !== null) // 全屏时自绘标题栏必须让位,否则舞台顶部被多扣 50px。
const fullscreen = useShellFullscreen()
const showTitleBar = computed(() => windowed && !fullscreen.value) const showTitleBar = computed(() => windowed && !fullscreen.value)
const { scale, offsetX, offsetY, fit } = useStageScale({ const { scale, offsetX, offsetY, fit } = useStageScale({
topInset: () => (showTitleBar.value ? WINDOWED_TITLEBAR_HEIGHT : 0), topInset: () => (showTitleBar.value ? WINDOWED_TITLEBAR_HEIGHT : 0),
}) })
// 标题栏出现/消失会改变可用高度,必须重新算缩放(全屏切换不保证带 resize)。
watch(fullscreen, () => fit())
const currentPage = computed<Component>(() => PAGES[page.value]) const currentPage = computed<Component>(() => PAGES[page.value])
const rootStyle = { '--display-titlebar-h': `${WINDOWED_TITLEBAR_HEIGHT}px` } const rootStyle = { '--display-titlebar-h': `${WINDOWED_TITLEBAR_HEIGHT}px` }
@@ -74,20 +79,12 @@ const themeVars = computed<Record<string, string>>(() => {
const RELOAD_MS = 24 * 60 * 60 * 1000 const RELOAD_MS = 24 * 60 * 60 * 1000
let reloadTimer = 0 let reloadTimer = 0
function onFullscreenChange(): void {
fullscreen.value = document.fullscreenElement !== null
// 标题栏出现/消失会改变可用高度,必须重新算缩放(fullscreenchange 不保证带 resize)。
fit()
}
onMounted(() => { onMounted(() => {
document.addEventListener('fullscreenchange', onFullscreenChange)
void bootstrap() void bootstrap()
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS) reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
window.clearTimeout(reloadTimer) window.clearTimeout(reloadTimer)
dispose() dispose()
}) })
+1 -10
View File
@@ -1,16 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useScoreboard } from '../useScoreboard' import { useScoreboard } from '../useScoreboard'
import { isWindowed } from '../windowed'
const { booted, status, source, version, authRequired } = useScoreboard() const { booted, status, source, authRequired } = useScoreboard()
// connecting 是启动瞬间的正常状态,闪「连接中断」会误报;只有真正断开才提示。 // connecting 是启动瞬间的正常状态,闪「连接中断」会误报;只有真正断开才提示。
const offline = computed(() => booted.value && status.value === 'offline') const offline = computed(() => booted.value && status.value === 'offline')
const mock = computed(() => source.value === 'mock') const mock = computed(() => source.value === 'mock')
// 数据版本号(change_log 版本)是给现场核对「这块屏有没有跟上服务端」用的调试信息,
// 与「实例 · 屏」同规矩:只在窗口化调试时显示,kiosk 全屏不出现。
const showVersion = computed(() => isWindowed() && version.value !== null)
</script> </script>
<template> <template>
@@ -18,7 +14,6 @@ const showVersion = computed(() => isWindowed() && version.value !== null)
<span v-if="mock" class="badge mock">示例数据</span> <span v-if="mock" class="badge mock">示例数据</span>
<span v-if="authRequired" class="badge auth">登录已失效 · 请重新登录</span> <span v-if="authRequired" class="badge auth">登录已失效 · 请重新登录</span>
<span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span> <span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span>
<span v-if="showVersion" class="badge version">v{{ version }}</span>
</div> </div>
</template> </template>
@@ -54,8 +49,4 @@ const showVersion = computed(() => isWindowed() && version.value !== null)
border-color: rgba(255, 160, 64, 0.55); border-color: rgba(255, 160, 64, 0.55);
color: #ffd0a0; color: #ffd0a0;
} }
.version {
background: rgba(255, 255, 255, 0.06);
color: var(--text-dim);
}
</style> </style>
+3 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { formatClock } from '@/shared' import { formatClock } from '@/shared'
import { useShellFullscreen } from '../shell'
import { useScoreboard } from '../useScoreboard' import { useScoreboard } from '../useScoreboard'
import { isWindowed } from '../windowed' import { isWindowed } from '../windowed'
import OfflineBadge from './OfflineBadge.vue' import OfflineBadge from './OfflineBadge.vue'
@@ -21,13 +22,8 @@ const host = computed(() => settings.value?.event.host?.trim() || '主办方待
const logo = computed(() => display.value?.logo?.trim() || '') const logo = computed(() => display.value?.logo?.trim() || '')
const clock = computed(() => formatClock(now.value)) const clock = computed(() => formatClock(now.value))
// 单屏窗口化时用来分辨「这块屏是哪个实例」;现场展示(kiosk,或窗口化后按 F11 全屏)都不该出现。 // 单屏窗口化时用来分辨「这块屏是哪个实例」;现场展示(kiosk,或窗口化后按 F11 全屏)都不该出现。
// 监听在 setup 阶段就挂上,onUnmounted 里摘掉,不引入额外 import。 // 窗口级全屏只有 Console 的 preload 看得到,合成判断与清理都在 shell.ts 里。
const fullscreen = ref(document.fullscreenElement !== null) const fullscreen = useShellFullscreen()
function onFullscreenChange(): void {
fullscreen.value = document.fullscreenElement !== null
}
document.addEventListener('fullscreenchange', onFullscreenChange)
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
// 实例/屏号都缺失时返回空串,模板的 v-if 顺带挡掉空元素。 // 实例/屏号都缺失时返回空串,模板的 v-if 顺带挡掉空元素。
const identity = computed(() => { const identity = computed(() => {
+164
View File
@@ -0,0 +1,164 @@
import type { EventSettings } from '@/shared'
/**
* 大屏字体:把 settings 里的字体 id 解析成可用的 CSS 字体栈。
*
* 内置字体在 `@/shared/styles/fonts.css` 里已有 @font-face 与 `--font-*` 令牌,直接用令牌;
* 上传字体要从 `<项目目录>/uploads/fonts/` 现取,这里动态注册 @font-face,
* 家族名统一加 `scoreboard-custom-` 前缀,避免与内置家族撞名。
*
* 大屏是无人值守的:字体只是外观,任何一步失败都必须静默回落到内置默认字体,
* 绝不能让整块屏空着或显示方块。
*/
/** 内置字体 id → CSS 令牌(与 `fonts.css` 的 `--font-*` 一一对应)。 */
export const BUILTIN_FONT_VARS: Readonly<Record<string, string>> = {
'builtin:noto-sans': 'var(--font-noto-sans)',
'builtin:noto-serif': 'var(--font-noto-serif)',
'builtin:misans': 'var(--font-misans)',
'builtin:source-han-sans': 'var(--font-source-han-sans)',
}
/** 内置字体的下拉显示名(与服务端 GET /api/fonts 的 label 一致)。 */
export const BUILTIN_FONTS: ReadonlyArray<{ id: string; label: string }> = [
{ id: 'builtin:noto-sans', label: 'Noto Sans SC(思源黑体同源)' },
{ id: 'builtin:noto-serif', label: 'Noto Serif SC(思源宋体同源)' },
{ id: 'builtin:misans', label: 'MiSans' },
{ id: 'builtin:source-han-sans', label: 'Source Han Sans CN(思源黑体)' },
]
export const CUSTOM_FONT_PREFIX = 'custom:'
export const CUSTOM_FONT_DIR = '/uploads/fonts/'
export const CUSTOM_FAMILY_PREFIX = 'scoreboard-custom-'
const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']
/** 上传字体的回落链:与内置默认令牌一致,注册失败时大屏仍是正常可读的黑体。 */
const FALLBACK_STACK = "var(--font-noto-sans, 'Noto Sans SC', system-ui, sans-serif)"
/** 从 `custom:<文件名>` 取文件名;不是上传字体或扩展名不受支持时返回 null。 */
export function customFontName(id: string | null | undefined): string | null {
const value = (id ?? '').trim()
if (!value.startsWith(CUSTOM_FONT_PREFIX)) return null
const name = value.slice(CUSTOM_FONT_PREFIX.length)
if (name === '') return null
const lower = name.toLowerCase()
return FONT_EXTENSIONS.some((ext) => lower.endsWith(ext)) ? name : null
}
/** 上传字体的静态地址;文件名可能含汉字与空格,必须转义。 */
export function customFontUrl(name: string): string {
return CUSTOM_FONT_DIR + encodeURIComponent(name)
}
/**
* 上传字体的 @font-face 家族名。
*
* 只保留字母数字与汉字:家族名会被拼进 CSS,引号/分号之类的字符必须挡在外面。
*/
export function customFontFamily(id: string | null | undefined): string | null {
const name = customFontName(id)
if (name === null) return null
const safe = name
.replace(/[^0-9A-Za-z\u3400-\u9fff\uf900-\ufaff]/g, '-')
.replace(/^-+|-+$/g, '')
return CUSTOM_FAMILY_PREFIX + (safe === '' ? 'font' : safe)
}
/**
* 字体 id → CSS 字体栈;空值或未知 id 返回 null(调用方保持现状,即内置默认字体)。
*
* 这是纯函数:上传字体即便还没注册成功,也返回带回落链的完整栈——
* 浏览器会先用回落字体渲染,注册完成后自动切换,不会出现空白。
*/
export function fontStack(id: string | null | undefined): string | null {
const value = (id ?? '').trim()
if (value === '') return null
const builtin = BUILTIN_FONT_VARS[value]
if (builtin !== undefined) return builtin
const family = customFontFamily(value)
if (family === null) return null
return `'${family}', ${FALLBACK_STACK}`
}
/** 已注册(或正在注册)的上传字体,避免设置每次下发都重复加载同一份文件。 */
const registered = new Map<string, Promise<string | null>>()
/**
* 注册上传字体的 @font-face,成功返回家族名,失败返回 null。
*
* 加载失败不缓存:现场可能是服务端刚重启导致文件一时取不到,
* 下次设置下发时应当重试,而不是把这块屏永久钉在默认字体上。
*/
export function registerCustomFont(id: string | null | undefined): Promise<string | null> {
const value = (id ?? '').trim()
const cached = registered.get(value)
if (cached !== undefined) return cached
const task = loadCustomFont(value).then((family) => {
if (family === null) registered.delete(value)
return family
})
registered.set(value, task)
return task
}
async function loadCustomFont(id: string): Promise<string | null> {
const name = customFontName(id)
const family = customFontFamily(id)
if (name === null || family === null) return null
// 没有 FontFace API(老内核 / 非浏览器环境)时只能回落,不能让大屏报错。
if (typeof FontFace === 'undefined' || typeof document === 'undefined' || document.fonts === undefined) {
return null
}
try {
const face = new FontFace(family, `url("${customFontUrl(name)}")`)
await face.load()
document.fonts.add(face)
return family
} catch (error) {
console.warn('[大屏字体] 上传字体加载失败,回落到默认字体:', id, error)
return null
}
}
let applySeq = 0
/**
* 把 settings 里的字体挂到 `document.documentElement` 的 CSS 变量上,
* 由 `@/shared/styles/base.css` 消费(标题用 `--display-font-title`、正文用 `--display-font-body`)。
*
* 未配置或加载失败时删掉内联变量,让 base.css 的 `:root` 默认值生效——
* 没配字体的赛事视觉必须与之前完全一致。
*/
export async function applyDisplayFonts(settings: EventSettings | null): Promise<void> {
const seq = ++applySeq
const display = settings?.display
const [title, body] = await Promise.all([
resolveStack(display?.fontTitle),
resolveStack(display?.fontBody),
])
// 加载期间可能又来了一份设置:丢弃过期结果,避免慢的旧字体盖掉新选择。
if (seq !== applySeq) return
setRootFontVar('--display-font-title', title)
setRootFontVar('--display-font-body', body)
}
async function resolveStack(id: string | null | undefined): Promise<string | null> {
const value = (id ?? '').trim()
if (value === '' || BUILTIN_FONT_VARS[value] !== undefined) return fontStack(value)
// 上传字体必须先确认能加载:加载不了就用默认字体,不留一个不存在的家族名在栈里。
const family = await registerCustomFont(value)
return family === null ? null : fontStack(value)
}
function setRootFontVar(name: string, value: string | null): void {
if (typeof document === 'undefined') return
if (value === null) {
document.documentElement.style.removeProperty(name)
return
}
document.documentElement.style.setProperty(name, value)
}
+5
View File
@@ -359,6 +359,9 @@ export function parseSettings(source: unknown): DisplaySettings {
// 公告在 settings 表里的键是 display.announce_text,聚合后是 display.announce; // 公告在 settings 表里的键是 display.announce_text,聚合后是 display.announce;
// 空白一律归一成 null,调用方只需判断「有没有公告」。 // 空白一律归一成 null,调用方只需判断「有没有公告」。
const announce = str(settingValue(rec, 'display.announce', 'display.announce_text')).trim() const announce = str(settingValue(rec, 'display.announce', 'display.announce_text')).trim()
// 字体 id 同理:空白归一成 null,调用方按「用默认字体」处理。
const fontTitle = str(settingValue(rec, 'display.fontTitle', 'display.font_title')).trim()
const fontBody = str(settingValue(rec, 'display.fontBody', 'display.font_body')).trim()
return { return {
event: { event: {
name: str(settingValue(rec, 'event.name', 'event.name')), name: str(settingValue(rec, 'event.name', 'event.name')),
@@ -375,6 +378,8 @@ export function parseSettings(source: unknown): DisplaySettings {
primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')), primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')),
rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8), rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8),
announce: announce === '' ? null : announce, announce: announce === '' ? null : announce,
fontTitle: fontTitle === '' ? null : fontTitle,
fontBody: fontBody === '' ? null : fontBody,
}, },
standings: { standings: {
individualToTeamWeight: num( individualToTeamWeight: num(
+134
View File
@@ -0,0 +1,134 @@
import { onUnmounted, ref, type Ref } from 'vue'
/**
* 大屏「是否全屏」的合成判断:两套互不相干的语义取或。
*
* - 窗口级:Console 里的 F11 走主进程 `win.setFullScreen()`,渲染进程的
* `document.fullscreenElement` 始终是 null,只有 preload 转达的这条能看到;
* - 文档级:浏览器里直接开 `/display` 时的 HTML 全屏,`document.fullscreenElement` 才是判据。
*
* 任一路判定全屏,页面就按全屏排布:窗口化标题栏让位,舞台按全屏高度重新缩放。
*/
/** Console 的 display-preload.js 注入的能力;浏览器直接访问 `/display` 时不存在。 */
export interface DisplayShellApi {
/** 当前窗口是否处于窗口级全屏。 */
isFullscreen: () => Promise<boolean>
/** 订阅窗口级全屏变化,返回取消订阅函数。 */
onFullscreenChange: (callback: (fullscreen: boolean) => void) => () => void
}
declare global {
interface Window {
displayShell?: DisplayShellApi
}
}
/** 只用到全屏相关的两个成员,抽成窄接口便于测试注入替身。 */
export interface FullscreenDocument {
fullscreenElement: Element | null
addEventListener(type: 'fullscreenchange', listener: () => void): void
removeEventListener(type: 'fullscreenchange', listener: () => void): void
}
export interface FullscreenDeps {
/** 窗口级来源,默认取全局 `window.displayShell`。 */
shell?: DisplayShellApi | null
/** 文档级来源,默认取全局 `document`。 */
doc?: FullscreenDocument | null
}
export interface FullscreenTracker {
get: () => boolean
/** 订阅变化(与上次同值不重复通知),返回取消订阅函数。 */
subscribe: (listener: (fullscreen: boolean) => void) => () => void
/** 摘掉所有监听。 */
dispose: () => void
}
function resolveShell(): DisplayShellApi | null {
if (typeof window === 'undefined') return null
return window.displayShell ?? null
}
function resolveDocument(): FullscreenDocument | null {
if (typeof document === 'undefined') return null
return document
}
export function createFullscreenTracker(deps: FullscreenDeps = {}): FullscreenTracker {
const shell = deps.shell === undefined ? resolveShell() : deps.shell
const doc = deps.doc === undefined ? resolveDocument() : deps.doc
const listeners = new Set<(fullscreen: boolean) => void>()
let windowFullscreen = false
// 窗口事件可能先于 isFullscreen() 的应答到达,落了标记才不会让慢半拍的应答把新状态盖回去
let windowEventSeen = false
let disposed = false
let last = false
const current = (): boolean => windowFullscreen || (doc !== null && doc.fullscreenElement !== null)
const emit = (): void => {
const next = current()
if (next === last) return
last = next
for (const listener of [...listeners]) listener(next)
}
const onDocumentChange = (): void => emit()
if (doc) doc.addEventListener('fullscreenchange', onDocumentChange)
let unsubscribeShell: (() => void) | null = null
if (shell) {
// 先订阅再查询:两步之间发生的切换不会被漏掉
unsubscribeShell = shell.onFullscreenChange((value) => {
windowEventSeen = true
windowFullscreen = value === true
emit()
})
// 页面加载时窗口可能已经是全屏(重载、渲染进程崩溃后重建),主动问一次
void Promise.resolve(shell.isFullscreen()).then(
(value) => {
if (disposed || windowEventSeen) return
windowFullscreen = value === true
emit()
},
// 主进程没应答时保持文档级判断,不影响标题栏
() => {}
)
}
last = current()
return {
get: () => current(),
subscribe: (listener) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
dispose: () => {
disposed = true
listeners.clear()
if (doc) doc.removeEventListener('fullscreenchange', onDocumentChange)
if (unsubscribeShell) unsubscribeShell()
unsubscribeShell = null
},
}
}
/** 组件用的响应式版本:状态变化刷新 Ref,卸载时摘掉监听。 */
export function useShellFullscreen(): Ref<boolean> {
const tracker = createFullscreenTracker()
const fullscreen = ref(tracker.get())
const unsubscribe = tracker.subscribe((value) => {
fullscreen.value = value
})
onUnmounted(() => {
unsubscribe()
tracker.dispose()
})
return fullscreen
}
+192
View File
@@ -0,0 +1,192 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
BUILTIN_FONT_VARS,
applyDisplayFonts,
customFontFamily,
customFontName,
customFontUrl,
fontStack,
registerCustomFont,
} from '../fonts'
import type { EventSettings } from '@/shared'
// 内置字体直接映射到 fonts.css 的令牌,回落链由令牌本身保证,前端不再抄一份。
test('fontStack 把内置 id 映射到 CSS 令牌', () => {
assert.equal(fontStack('builtin:noto-sans'), 'var(--font-noto-sans)')
assert.equal(fontStack('builtin:noto-serif'), 'var(--font-noto-serif)')
assert.equal(fontStack('builtin:misans'), 'var(--font-misans)')
assert.equal(fontStack('builtin:source-han-sans'), 'var(--font-source-han-sans)')
})
// 空值 / 未知 id 一律返回 null:调用方据此保持默认字体,绝不能塞一个坏家族名进去。
test('fontStack 对空值与未知 id 返回 null', () => {
for (const value of [null, undefined, '', ' ', 'builtin:nope', 'custom:font.xyz', '随便写的']) {
assert.equal(fontStack(value), null, `fontStack(${String(value)}) 应为 null`)
}
})
// 上传字体的家族名统一带前缀,且只保留字母数字与汉字(家族名会拼进 CSS,不能被注入)。
test('customFontFamily 生成安全且稳定的家族名', () => {
assert.equal(customFontFamily('custom:my-font.ttf'), 'scoreboard-custom-my-font-ttf')
assert.equal(customFontFamily('custom:思源宋体.otf'), 'scoreboard-custom-思源宋体-otf')
assert.equal(customFontFamily('custom:Noto Sans SC.woff2'), 'scoreboard-custom-Noto-Sans-SC-woff2')
assert.equal(customFontFamily('custom:"><script>.ttf'), 'scoreboard-custom-script--ttf')
assert.equal(customFontFamily('builtin:misans'), null)
})
// 上传字体的栈必须带回落链:文件取不到时浏览器直接落到内置默认字体,不会出现空白或方块。
test('fontStack 给上传字体带上内置回落链', () => {
const stack = fontStack('custom:思源宋体.ttf')
assert.ok(stack !== null)
assert.ok(stack.startsWith("'scoreboard-custom-思源宋体-ttf'"))
assert.ok(stack.includes('var(--font-noto-sans'))
})
test('customFontName 只认白名单扩展名', () => {
assert.equal(customFontName('custom:思源宋体.ttf'), '思源宋体.ttf')
assert.equal(customFontName('custom:a.WOFF2'), 'a.WOFF2')
assert.equal(customFontName('custom:'), null)
assert.equal(customFontName('custom:a.ttc'), null)
assert.equal(customFontName('builtin:misans'), null)
})
test('customFontUrl 转义汉字与空格', () => {
assert.equal(customFontUrl('思源宋体.ttf'), '/uploads/fonts/%E6%80%9D%E6%BA%90%E5%AE%8B%E4%BD%93.ttf')
assert.equal(customFontUrl('a b.ttf'), '/uploads/fonts/a%20b.ttf')
})
// ---------------------------------------------------------------------------
// applyDisplayFonts:挂到 documentElement 的 CSS 变量上
// ---------------------------------------------------------------------------
interface FakeVar {
style: { setProperty(name: string, value: string): void; removeProperty(name: string): void }
vars: Record<string, string>
}
/** node 里没有 DOM,这里造一个只记变量读写的最小实现,验证变量确实挂到了 html 上。 */
function installFakeDocument(): FakeVar {
const vars: Record<string, string> = {}
const fake: FakeVar = {
vars,
style: {
setProperty(name, value) {
vars[name] = value
},
removeProperty(name) {
delete vars[name]
},
},
}
;(globalThis as { document?: unknown }).document = { documentElement: fake }
return fake
}
function settingsWith(fontTitle: string | null, fontBody: string | null): EventSettings {
return { display: { fontTitle, fontBody } } as unknown as EventSettings
}
function uninstallDocument(): void {
delete (globalThis as { document?: unknown }).document
delete (globalThis as { FontFace?: unknown }).FontFace
}
test('applyDisplayFonts 把选中的字体挂到 html 的 CSS 变量上', async () => {
const doc = installFakeDocument()
try {
await applyDisplayFonts(settingsWith('builtin:noto-serif', 'custom:no-such-file.ttf'))
assert.equal(doc.vars['--display-font-title'], 'var(--font-noto-serif)')
// node 里没有 FontFace,上传字体注册必然失败,必须回落到默认(不设变量)。
assert.equal(doc.vars['--display-font-body'], undefined)
} finally {
uninstallDocument()
}
})
test('applyDisplayFonts 未配置字体时清掉变量,回落到 base.css 的默认令牌', async () => {
const doc = installFakeDocument()
try {
await applyDisplayFonts(settingsWith('builtin:misans', 'builtin:misans'))
assert.equal(doc.vars['--display-font-title'], 'var(--font-misans)')
await applyDisplayFonts(settingsWith(null, null))
assert.deepEqual(doc.vars, {})
await applyDisplayFonts(null)
assert.deepEqual(doc.vars, {})
} finally {
uninstallDocument()
}
})
// 字体文件取不到(解码失败 / 服务端刚重启)时注册返回 null,绝不能抛出去把大屏搞崩。
test('上传字体加载失败时静默回落,不抛异常', async () => {
const doc = installFakeDocument()
;(globalThis as { FontFace?: unknown }).FontFace = class {
async load(): Promise<never> {
throw new Error('decode failed')
}
}
try {
const family = await registerCustomFont('custom:broken.ttf')
assert.equal(family, null)
await applyDisplayFonts(settingsWith('custom:broken.ttf', 'custom:broken.ttf'))
// 失败不写变量:大屏仍是内置默认字体。
assert.deepEqual(doc.vars, {})
} finally {
uninstallDocument()
}
})
// 注册成功后要带上真实的家族名,浏览器才会用上传字体渲染。
test('上传字体注册成功后使用 scoreboard-custom- 家族名', async () => {
const vars: Record<string, string> = {}
;(globalThis as { document?: unknown }).document = {
documentElement: {
style: {
setProperty: (name: string, value: string) => {
vars[name] = value
},
removeProperty: (name: string) => {
delete vars[name]
},
},
},
fonts: { add: () => undefined },
}
const added: unknown[] = []
;(globalThis as { document: { fonts: { add(face: unknown): void } } }).document.fonts.add = (face) =>
added.push(face)
;(globalThis as { FontFace?: unknown }).FontFace = class {
family: string
constructor(family: string) {
this.family = family
}
async load(): Promise<this> {
return this
}
}
try {
const family = await registerCustomFont('custom:ok-font.ttf')
assert.equal(family, 'scoreboard-custom-ok-font-ttf')
assert.equal(added.length, 1)
await applyDisplayFonts(settingsWith('custom:ok-font.ttf', null))
assert.equal(
vars['--display-font-title'],
"'scoreboard-custom-ok-font-ttf', var(--font-noto-sans, 'Noto Sans SC', system-ui, sans-serif)",
)
} finally {
uninstallDocument()
}
})
// 内置字体的令牌表必须与服务端 GET /api/fonts 的 4 个 id 一一对应。
test('BUILTIN_FONT_VARS 覆盖四款内置字体', () => {
assert.deepEqual(Object.keys(BUILTIN_FONT_VARS).sort(), [
'builtin:misans',
'builtin:noto-sans',
'builtin:noto-serif',
'builtin:source-han-sans',
])
})
+176
View File
@@ -0,0 +1,176 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createFullscreenTracker, type DisplayShellApi, type FullscreenDocument } from '../shell'
// 大屏「是否全屏」有两个来源:Console 里 F11 是窗口级全屏(页面看不到 document.fullscreenElement),
// 浏览器直接开 /display 时才是文档级全屏。这里覆盖两路各自生效、取或合成,以及没有 preload 的回落。
const tick = () => new Promise<void>((resolve) => setImmediate(resolve))
/** document 替身:只实现全屏判断用到的三个成员。 */
class FakeDocument implements FullscreenDocument {
fullscreenElement: Element | null = null
private listeners = new Set<() => void>()
addEventListener(_type: 'fullscreenchange', listener: () => void): void {
this.listeners.add(listener)
}
removeEventListener(_type: 'fullscreenchange', listener: () => void): void {
this.listeners.delete(listener)
}
/** 浏览器 HTML 全屏进出:改状态并派发 fullscreenchange。 */
setFullscreen(value: boolean): void {
this.fullscreenElement = value ? ({} as Element) : null
for (const listener of [...this.listeners]) listener()
}
get listenerCount(): number {
return this.listeners.size
}
}
/** displayShell 替身:窗口级全屏,语义与主进程 win.setFullScreen 一致。 */
class FakeShell implements DisplayShellApi {
fullscreen = false
private listeners = new Set<(fullscreen: boolean) => void>()
async isFullscreen(): Promise<boolean> {
return this.fullscreen
}
onFullscreenChange(callback: (fullscreen: boolean) => void): () => void {
this.listeners.add(callback)
return () => {
this.listeners.delete(callback)
}
}
async setFullscreen(value: boolean): Promise<void> {
this.fullscreen = value
for (const listener of [...this.listeners]) listener(value)
await tick()
}
get listenerCount(): number {
return this.listeners.size
}
}
test('窗口级全屏(F11)切换会通知订阅者', async () => {
const shell = new FakeShell()
const tracker = createFullscreenTracker({ shell, doc: new FakeDocument() })
const seen: boolean[] = []
tracker.subscribe((value) => seen.push(value))
assert.equal(tracker.get(), false)
await shell.setFullscreen(true)
assert.equal(tracker.get(), true)
await shell.setFullscreen(false)
assert.equal(tracker.get(), false)
assert.deepEqual(seen, [true, false])
})
test('页面加载时窗口已经是全屏,主动查询能补上初始状态', async () => {
const shell = new FakeShell()
shell.fullscreen = true
const tracker = createFullscreenTracker({ shell, doc: new FakeDocument() })
const seen: boolean[] = []
tracker.subscribe((value) => seen.push(value))
await tick()
assert.equal(tracker.get(), true)
assert.deepEqual(seen, [true])
})
test('文档级全屏(浏览器 HTML 全屏)只看 document.fullscreenElement', () => {
const doc = new FakeDocument()
const tracker = createFullscreenTracker({ shell: null, doc })
const seen: boolean[] = []
tracker.subscribe((value) => seen.push(value))
doc.setFullscreen(true)
assert.equal(tracker.get(), true)
doc.setFullscreen(false)
assert.equal(tracker.get(), false)
assert.deepEqual(seen, [true, false])
})
test('两个来源取或:任一路全屏就算全屏', async () => {
const shell = new FakeShell()
const doc = new FakeDocument()
const tracker = createFullscreenTracker({ shell, doc })
doc.setFullscreen(true)
// 文档级还全屏时退出窗口级,合成结果必须仍是全屏
await shell.setFullscreen(true)
await shell.setFullscreen(false)
assert.equal(tracker.get(), true)
doc.setFullscreen(false)
assert.equal(tracker.get(), false)
})
test('没有 displayShell 时优雅回落,不报错也不碰窗口级接口', () => {
const doc = new FakeDocument()
// Node 里没有全局 window,等价于浏览器直接访问 /display:默认取不到 displayShell
const tracker = createFullscreenTracker({ doc })
doc.setFullscreen(true)
assert.equal(tracker.get(), true)
doc.setFullscreen(false)
assert.equal(tracker.get(), false)
})
test('窗口事件先于查询应答到达时,不会被过期应答盖回去', async () => {
const doc = new FakeDocument()
let release: (value: boolean) => void = () => {}
const shell: DisplayShellApi = {
isFullscreen: () => new Promise<boolean>((resolve) => {
release = resolve
}),
onFullscreenChange: (callback) => {
// 订阅时就立刻回调一次,模拟事件抢在 invoke 应答之前到达
callback(true)
return () => {}
},
}
const tracker = createFullscreenTracker({ shell, doc })
assert.equal(tracker.get(), true)
release(false)
await tick()
assert.equal(tracker.get(), true)
})
test('同值不重复通知,退订后不再收到', () => {
const doc = new FakeDocument()
const tracker = createFullscreenTracker({ shell: null, doc })
const seen: boolean[] = []
const unsubscribe = tracker.subscribe((value) => seen.push(value))
doc.setFullscreen(false)
assert.deepEqual(seen, [])
doc.setFullscreen(true)
unsubscribe()
doc.setFullscreen(false)
assert.deepEqual(seen, [true])
})
test('dispose 摘掉文档级与窗口级两路监听', () => {
const shell = new FakeShell()
const doc = new FakeDocument()
const tracker = createFullscreenTracker({ shell, doc })
assert.equal(doc.listenerCount, 1)
assert.equal(shell.listenerCount, 1)
tracker.dispose()
assert.equal(doc.listenerCount, 0)
assert.equal(shell.listenerCount, 0)
})
+10
View File
@@ -18,6 +18,7 @@ import {
} from './guards' } from './guards'
import type { DisplaySettings } from './guards' import type { DisplaySettings } from './guards'
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce' import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
import { applyDisplayFonts } from './fonts'
import { createMockAthletes, createMockSnapshot } from './mock' import { createMockAthletes, createMockSnapshot } from './mock'
import { readCachedSnapshot, writeCachedSnapshot } from './storage' import { readCachedSnapshot, writeCachedSnapshot } from './storage'
import { DISPLAY_PAGES, normalizePage } from './types' import { DISPLAY_PAGES, normalizePage } from './types'
@@ -395,6 +396,15 @@ function handleCommand(command: DisplayCommand): void {
} }
} }
// 大屏字体随设置变化:快照、缓存、示例数据任何一条路径改动 settings 都要重新挂字体。
watch(
settings,
(next) => {
void applyDisplayFonts(next)
},
{ immediate: true },
)
/** /**
* 服务端把当前公告持久化在设置里(display.announce),快照与设置接口都带它。 * 服务端把当前公告持久化在设置里(display.announce),快照与设置接口都带它。
* *
+16 -1
View File
@@ -23,6 +23,11 @@
/* 全局默认字体;各端可通过覆盖 --font-ui 切换(字体清单见 vendor/fonts/manifest.json) */ /* 全局默认字体;各端可通过覆盖 --font-ui 切换(字体清单见 vendor/fonts/manifest.json) */
--font-ui: var(--font-noto-sans); --font-ui: var(--font-noto-sans);
/* 赛事包字体:大屏端 useScoreboard 按赛事配置覆盖这两项(见 web/src/display/fonts.ts)。
未配置或字体加载失败时删掉覆盖值,回落到上面的全局默认,视觉与以前完全一致。 */
--display-font-title: var(--font-ui);
--display-font-body: var(--font-ui);
} }
* { * {
@@ -41,10 +46,20 @@ body,
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: var(--font-ui, 'Noto Sans SC', 'Source Han Sans SC', system-ui, sans-serif); font-family: var(--display-font-body, var(--font-ui, 'Noto Sans SC', 'Source Han Sans SC', system-ui, sans-serif));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* 标题走标题字体、其余(表格、名单、数值)走正文字体;未配置时两者都等于默认字体。 */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--display-font-title, var(--font-ui, 'Noto Sans SC', 'Source Han Sans SC', system-ui, sans-serif));
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+19
View File
@@ -428,6 +428,12 @@ export interface EventSettings {
* 旧服务端不下发这个键,所以可选;大屏解析层会归一成 string | null。 * 旧服务端不下发这个键,所以可选;大屏解析层会归一成 string | null。
*/ */
announce?: string | null announce?: string | null
/**
* 大屏标题 / 正文的字体 id(builtin:* 或 custom:<文件名>);null 表示内置默认字体。
* 旧服务端不下发时可选,解析层会归一成 string | null。
*/
fontTitle?: string | null
fontBody?: string | null
} }
standings: { standings: {
individualToTeamWeight: number individualToTeamWeight: number
@@ -471,6 +477,19 @@ export interface BackupResult {
createdAt: string createdAt: string
} }
/**
* 一项可选字体(GET /api/fonts)。
*
* `builtin` 为 true 的是随前端分发的内置字体,没有文件,`url` 为 null;
* 上传字体的 id 是 `custom:<文件名>`,`url` 指向 /uploads/fonts/ 下的静态地址。
*/
export interface DisplayFont {
id: string
label: string
builtin: boolean
url: string | null
}
export interface HealthInfo { export interface HealthInfo {
version: string version: string
} }