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:
1 parent
9c6d0bd3b3
commit
fc96d17660
42 files changed
+2451
-95
No files matched your search
@@ -18,9 +18,10 @@
|
||||
- **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分)
|
||||
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗
|
||||
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
|
||||
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo),并可在管理端页内一键切换当前赛事
|
||||
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
|
||||
- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出
|
||||
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
|
||||
- **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
+2
-1
@@ -38,6 +38,7 @@ console/
|
||||
├── styles.css
|
||||
├── app.js
|
||||
├── preload.js # 管理窗口的 contextBridge 白名单
|
||||
├── display-preload.js # 大屏窗口的 window.displayShell 白名单(窗口级全屏状态,供页面收放自绘标题栏)
|
||||
└── 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 隐藏 |
|
||||
| 选屏 | 运行时枚举,不写死坐标;`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` 轮转 |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const { BrowserWindow, screen } = require('electron')
|
||||
const { healthHost } = require('./service')
|
||||
|
||||
@@ -133,6 +134,8 @@ class DisplayManager {
|
||||
backgroundColor: this.display.backgroundColor || '#000000',
|
||||
title: `大屏 · ${page}`,
|
||||
webPreferences: {
|
||||
// 页面看不到窗口级全屏,靠这个 preload 转达(见 display-preload.js)
|
||||
preload: path.join(__dirname, '..', 'renderer', 'display-preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
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', () => {
|
||||
if (!win.isDestroyed()) win.show()
|
||||
})
|
||||
|
||||
@@ -569,6 +569,10 @@ function main() {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
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(() => {
|
||||
|
||||
@@ -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
@@ -34,6 +34,7 @@
|
||||
|
||||
- [ ] 「系统设置 → 赛事信息」的**基本信息**:填名称、副标题、主办方,上传**赛事会标**
|
||||
- [ ] 同页的**大屏配置**:上传大屏会标、选大屏背景图,填大屏标题 / 副标题、轮播间隔与主题色(上传的图片随赛事项目包一起导出/导入)
|
||||
- [ ] 同页的**字体**:确认**标题字体**与**正文字体**已选好(默认、四款内置字体,或自己上传的字体);选完点「保存」,并核对大屏上标题与正文显示正常。上传的字体存在赛事项目的 `uploads\fonts\` 里,**导出赛事项目包会一并带走、导入后不用重新上传**(见使用手册 14.3、部署指南 8.5)
|
||||
- [ ] **赛事项目确认 / 导入**:到「导出 → 赛事项目包」核对**赛事项目列表**里带「当前」徽标的是本次要用的赛事;需要换赛事时先「导入 .zip」,再点「切换为当前赛事」(服务端会重启一次,页面显示「正在重启服务端…」,恢复后自动刷新)。账号与会话在数据目录的 `system.db` 里,**不受切换影响**,一般不用重新登录。这一步做完再往下走,否则后面看到的是别的赛事的数据
|
||||
- [ ] **导入规则包**:`%LOCALAPPDATA%\ScoreboardConsole\rules\2026-驭电利刃杯军事运动会.yaml`,走「导出 → 规则包 → 选择 YAML → 预览差异 → 确认导入并全量重算」(规则目录在用户数据目录里,见部署指南 3.5)
|
||||
—— 服务端不会自动加载规则包,必须导入一次
|
||||
@@ -47,7 +48,7 @@
|
||||
- [ ] 确认大屏锁定在「积分榜」页面;需要轮播就在后台开启
|
||||
- [ ] 鼠标移到大屏上应看不到操作按钮;大屏窗口已开防熄屏
|
||||
- [ ] 只有一块屏时是窗口化模式,按 **F11** 可切全屏(用于单屏测试)
|
||||
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」与版本号角标;窗口化窗口(单屏)里显示这两个属正常,按 F11 全屏后「实例 · 屏」隐藏、版本号角标仍在
|
||||
- [ ] **调试标识已隐藏**:多屏 kiosk 全屏画面上不该出现「实例 … · 屏 …」;窗口化窗口(单屏)里显示这一行属正常,按 F11 全屏后它与自绘标题栏一起消失(版本号角标已移除,任何画面都不该出现)
|
||||
- [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常)
|
||||
- [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」
|
||||
|
||||
@@ -55,7 +56,7 @@
|
||||
|
||||
- [ ] 确认数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 下的 `backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟)
|
||||
- [ ] 开幕前手动点一次「立即备份」(「系统设置 → 备份 → 立即备份」)
|
||||
- [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图与单位 logo)拷一份到 U 盘
|
||||
- [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图、单位 logo 与上传的字体)拷一份到 U 盘
|
||||
|
||||
## 7. 现场流程演练(建议至少一次)
|
||||
|
||||
|
||||
+32
-4
@@ -359,6 +359,7 @@
|
||||
4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布);
|
||||
5. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布;
|
||||
6. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
||||
- **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别;
|
||||
7. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
||||
|
||||
**人员信息冲突的处理**
|
||||
@@ -403,7 +404,7 @@
|
||||
- 每行的「切换为当前赛事」用于切换(仅管理员),切换过程见下面「导入后怎样才算生效」;
|
||||
- 列表里出现「缺 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,但它还不是当前赛事」。
|
||||
|
||||
@@ -490,10 +491,32 @@
|
||||
| 主题色 | 大屏主色,如 `#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. 关于
|
||||
|
||||
展示系统名称、版本号、构建时间、运行模式,以及技术栈与开源许可、字体清单、键盘快捷键、部署信息(服务端端口、访问地址、赛事项目名)。
|
||||
展示系统名称、版本号、构建时间、运行模式,以及技术栈与开源许可、字体清单(四款内置字体的用途与授权;给大屏换字体见 14.3)、键盘快捷键、部署信息(服务端端口、访问地址、赛事项目名)。
|
||||
|
||||
---
|
||||
|
||||
@@ -530,6 +553,8 @@
|
||||
|
||||
> 单位 logo 取自「基础数据 → 参赛单位」里上传的图片(见 10.1),按行内文字大小显示,不会撑开行高;没传 logo 的单位不占位,图片临时加载不出来时保留占位、刷新后恢复,队名不会跳动。
|
||||
|
||||
> 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。
|
||||
|
||||
### 17.2 轮播与锁定
|
||||
|
||||
- Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播;
|
||||
@@ -552,7 +577,10 @@
|
||||
|
||||
### 17.5 调试标识
|
||||
|
||||
窗口化(带标题栏的调试窗口)时,右上角会显示「实例 x · 屏 x」与数据版本号 `vN`:前者用来分辨这块屏是哪个实例,后者是**数据版本号**(不是软件版本),和仪表盘「实时连接」里显示的「版本 N」一致,就说明这块屏已经跟上服务端。**kiosk 全屏窗口不显示这些调试标识**,正式上屏的画面是干净的;窗口化窗口按 F11 全屏后,「实例 · 屏」也会一起隐藏。多屏 kiosk 窗口不响应 F11,也不显示任何调试标识与版本号角标。
|
||||
窗口化(带标题栏的调试窗口)**且没有全屏**时,右上角会显示一行「实例 x · 屏 x」,用来分辨这块屏是哪个实例。除此之外没有别的调试标识——**数据版本号 `vN` 角标已经去掉,任何情况下都不再显示**;需要核对某块屏有没有跟上服务端时,用管理端仪表盘「实时连接」里的「版本 N」对照。
|
||||
|
||||
- **kiosk 全屏窗口**(多屏时每屏一个)不显示任何调试标识,正式上屏的画面是干净的;多屏 kiosk 也不响应 F11。
|
||||
- 单屏窗口化窗口按 **F11** 全屏后,自绘标题栏与「实例 · 屏」会一起消失、画面铺满整块屏;再按一次 F11 回到窗口化,标题栏与标识一起回来。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+63
-27
@@ -132,11 +132,10 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
|
||||
没显示就去「基础数据 → 参赛单位 → 编辑 → Logo 路径」上传或填写该单位的 logo(管理端页面本身不显示 logo,只在大屏上显示)。上传的图片存在该赛事的 `data\event_projs\<项目名>\uploads\` 里,会随赛事项目包一起导出 / 导入。
|
||||
|
||||
**Q27:「实例 x · 屏 x」和右上角的版本号角标怎么不见了?**
|
||||
这两个都只是**调试标识**:只在窗口化的调试窗口里显示,正式上屏(多屏 kiosk 全屏,或单屏窗口化后按 `F11` 全屏)都不显示。
|
||||
**Q27:「实例 x · 屏 x」这个标识怎么不见了?**
|
||||
它只是**调试标识**:只在窗口化的调试窗口里、并且**没有全屏**时显示,用来分辨这块屏是哪个实例。正式上屏(多屏 kiosk 全屏,或单屏窗口化后按 `F11` 全屏)都不显示——按 F11 时它会和自绘标题栏一起收起来,再按一次就回来。
|
||||
|
||||
- 「实例 x · 屏 x」用来分辨这块屏是哪个实例,窗口化后按 F11 全屏也会隐藏;
|
||||
- 角标里的 `vN` 是**数据版本号**(不是软件版本):需要现场核对某块屏有没有跟上服务端时,先把该窗口切回窗口化让角标出现,它和仪表盘「实时连接」里显示的「版本 N」一致就说明这块屏已经同步。
|
||||
以前右上角还有一个 `vN` 数据版本号角标,**现在已经去掉,任何情况下都不再显示**。要核对某块屏有没有跟上服务端,看管理端仪表盘「实时连接」里的「版本 N」。
|
||||
|
||||
**Q28:「在线看板」为什么显示未知?**
|
||||
页面上那一块是本地占位节点:系统不按「指令送达」推断大屏是否在线,也不展示每块屏的实际上报状态,所以「在线」列恒为未知,卡片上写着「在线状态未知(占位)」;「最近下发」只是本机下发指令的时间,**不代表大屏在线**。以现场大屏的实际画面为准。
|
||||
@@ -147,35 +146,65 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
**Q30:大屏时间不对怎么办?**
|
||||
系统没有外网时间同步,以主机时间为准。校准 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:备份在哪里?**
|
||||
在数据目录下的 `backups\`,默认即 `%LOCALAPPDATA%\ScoreboardConsole\data\backups\`(数据目录改过就按改后的路径找,见 Q48)。
|
||||
**Q37:备份在哪里?**
|
||||
在数据目录下的 `backups\`,默认即 `%LOCALAPPDATA%\ScoreboardConsole\data\backups\`(数据目录改过就按改后的路径找,见 Q54)。
|
||||
|
||||
文件是数据库快照,命名形如 `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` 已经带好这些路径)。另外每次发布成绩前会自动备一份。
|
||||
|
||||
**Q33:备份会自动删除旧的么?**
|
||||
**Q39:备份会自动删除旧的么?**
|
||||
会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。
|
||||
|
||||
**Q34:怎么恢复备份?**
|
||||
**Q40:怎么恢复备份?**
|
||||
没有一键恢复接口,需停机手工替换数据库文件:在 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` 等):按同样路径放好,或用新机器上的安装包装一遍(安装目录里那几份配置与规则只是模板,拷不拷都行)。
|
||||
|
||||
只拷安装目录会丢数据——成绩、账号、配置、规则包都在用户数据目录里。若数据目录放到了别处,还要同步改 `console\config.json` 的 `server.dataDir`。详见部署指南第 8.4 节。
|
||||
|
||||
**Q36:怎么切换赛事项目?**
|
||||
**Q42:怎么切换赛事项目?**
|
||||
启动时服务端会自动打开**上次打开的赛事**,不用改启动参数。切换在页面里完成:管理面板「导出 → 赛事项目包 → **赛事项目列表**」里找到目标项目(带「当前」徽标的就是正在打开的那一项),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新;最多等 60 秒,超时就打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。
|
||||
|
||||
- 导入赛事包**不会**自动切换当前赛事,导入完成的结果框里直接给了「立即切换并重启服务端」按钮;
|
||||
@@ -189,57 +218,64 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
|
||||
## 导出与导入
|
||||
|
||||
**Q37:能导出哪些文件?**
|
||||
**Q43:能导出哪些文件?**
|
||||
四类 Excel:总表、全部(多 sheet)、分项成绩表、单位积分明细;另外可导出赛事项目包(zip)与规则包(YAML)。
|
||||
|
||||
**Q38:导出文件保存在哪?**
|
||||
**Q44:导出文件保存在哪?**
|
||||
浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。
|
||||
|
||||
**Q39:谁能导入规则包 / 赛事项目包?**
|
||||
**Q45:谁能导入规则包 / 赛事项目包?**
|
||||
只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。
|
||||
|
||||
**Q40:导入赛事包后为什么看不到数据?**
|
||||
**Q46:导入赛事包后为什么看不到数据?**
|
||||
因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。
|
||||
|
||||
处理办法:在「导出 → 赛事项目包」的**赛事项目列表**里找到刚导入的项目(导入完成的结果框里也直接给了「立即切换并重启服务端」按钮),点「切换为当前赛事」。服务端会退出并由服务管理窗口自动拉起,页面显示「正在重启服务端…」,恢复后自动刷新,数据就出来了。超过 60 秒仍未恢复时,请打开服务管理窗口确认服务端状态,待其显示「运行中」后手动刷新本页。
|
||||
|
||||
> 切换会让服务端重启,比赛进行中请避开操作。账号与会话不在赛事库里(见 Q36),一般不用重新登录;万一回到只读访客视图,登录一次即可,若原密码登不上就用 `scoreboard.exe passwd` 重置。
|
||||
> 切换会让服务端重启,比赛进行中请避开操作。账号与会话不在赛事库里(见 Q42),一般不用重新登录;万一回到只读访客视图,登录一次即可,若原密码登不上就用 `scoreboard.exe passwd` 重置。导入的赛事包里如果带了自定义字体,切换过去后大屏直接可用,不用重新上传(见 Q36)。
|
||||
|
||||
**Q41:导入规则包后积分变了,能撤销吗?**
|
||||
**Q47:导入规则包后积分变了,能撤销吗?**
|
||||
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
|
||||
|
||||
**Q42:导入时提示证件号重复怎么办?**
|
||||
**Q48:导入时提示证件号重复怎么办?**
|
||||
证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。
|
||||
|
||||
- **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。
|
||||
- **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
||||
|
||||
**Q43:人员没有身份证,只有号码布,能报名吗?**
|
||||
**Q49:人员没有身份证,只有号码布,能报名吗?**
|
||||
可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。
|
||||
|
||||
**Q44:号码布重复了怎么办?**
|
||||
**Q50:号码布重复了怎么办?**
|
||||
号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。
|
||||
|
||||
- **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。
|
||||
- **表格导入时出现重复**:同一份文件里同一个号码布重复出现,重复的行会报错并跳过,改好后重新上传;号码布在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。详细步骤见 [使用手册 · 报名导入](./使用手册.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 次。稍等一分钟再试。
|
||||
|
||||
**Q48:数据目录在哪?**
|
||||
默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传图片 `uploads\`)、`backups\`、`exports\`。
|
||||
**Q55:数据目录在哪?**
|
||||
默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传资源 `uploads\`,图片与自定义字体都在里面,字体在 `uploads\fonts\`)、`backups\`、`exports\`。
|
||||
|
||||
同一层还有 `config.yaml`(服务端配置)、`console\config.json`(Console 配置)与 `rules\`(规则包目录),也都在安装目录之外,同样不会被覆盖安装或卸载删掉。
|
||||
|
||||
|
||||
+16
-13
@@ -93,7 +93,7 @@ deploy\build.bat
|
||||
|
||||
### 3.4 数据放在哪里(升级/卸载都不会删)
|
||||
|
||||
覆盖安装时安装程序会**先静默运行旧版卸载器**,而卸载器会把整个安装目录删掉,所以数据库、规则、用户、上传的图片都不能放在安装目录里。现在的规则:
|
||||
覆盖安装时安装程序会**先静默运行旧版卸载器**,而卸载器会把整个安装目录删掉,所以数据库、规则、用户、上传的图片与字体都不能放在安装目录里。现在的规则:
|
||||
|
||||
| 场景 | 数据目录 |
|
||||
| --- | --- |
|
||||
@@ -102,6 +102,7 @@ deploy\build.bat
|
||||
| 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** |
|
||||
|
||||
- Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行;
|
||||
- **上传资源随赛事项目走**:赛事会标、大屏背景图、单位 logo,以及「赛事信息 → 字体」里上传的**字体文件**,都在 `<数据目录>\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`)。它们是文件而不是数据库记录,**不在 `backups\` 的 `.db` 快照里**,备份 / 迁移时要连这个目录一起拷(见 8.5、8.6);
|
||||
- **一次性迁移**:如果新数据目录还是空的,而旧的 `<安装目录>\data` 里有数据,Console 会在启动时把旧目录**整体改名**过去(同盘很快),日志里会写「已把旧数据从 X 迁移到 Y」。任何失败(跨盘、权限不足等)都只记日志,旧数据原地保留,不会被删;
|
||||
- 如果两个目录里**都有数据**,Console 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份;
|
||||
- 迁移只在安装包版本(打包后)执行一次,`pnpm start` 之类的开发运行不会碰仓库里的 `data\`;
|
||||
@@ -129,7 +130,8 @@ deploy\build.bat
|
||||
- 规则目录只在首次复制一次,之后你往里面放的规则包不会被模板顶掉;
|
||||
- 某一处建不出来或不可写(权限、杀毒软件拦截等)时**只记日志并回落到安装目录里那份**,绝不因此启动失败;代价是那份改动仍会在覆盖安装时丢失,日志里会明确写出来;
|
||||
- 开发运行(`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 只能把模板复制到用户目录,升级前改在安装目录里的配置需要重做一遍。**从本版本起**(也就是改动落在上表位置之后),配置与规则才会跨升级保留。
|
||||
|
||||
@@ -328,15 +330,15 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
|
||||
### 7.3 单屏:窗口化 + F11
|
||||
|
||||
- 只有一块屏时,大屏以窗口化方式打开(屏幕 80% 大小、居中),并显示自绘标题栏;
|
||||
- 在窗口内按 **F11** 切换全屏 / 窗口;
|
||||
- 在窗口内按 **F11** 切换全屏 / 窗口:全屏时自绘标题栏与「实例 · 屏」标识一起隐藏、画面铺满整块屏;再按一次恢复;
|
||||
- 单屏模式不跟随显示器变化重排;
|
||||
- 窗口化时右上角会显示「实例 … · 屏 …」与数据版本号(`v<n>`)供现场核对;按 F11 全屏后「实例 · 屏」自动隐藏,版本号角标仍在(它只跟「这个窗口是不是窗口化打开的」有关,见 7.7)。
|
||||
- 窗口化且未全屏时右上角显示一行「实例 … · 屏 …」供现场核对(数据版本号角标已移除,见 7.7)。
|
||||
|
||||
### 7.4 多屏:每屏锁一页
|
||||
|
||||
- 除内置屏外的每块屏各开一个 kiosk 全屏窗口;
|
||||
- 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配;
|
||||
- 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识与版本号角标;
|
||||
- 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识;
|
||||
- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。
|
||||
|
||||
### 7.5 在管理端单独控制某块屏
|
||||
@@ -360,7 +362,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
|
||||
|
||||
### 7.7 大屏画面里的调试标识与单位 logo
|
||||
|
||||
- **调试标识**:只有窗口化打开的大屏窗口才会显示「实例 … · 屏 …」与数据版本号 `v<n>`(`v<n>` 是数据版本号,与管理面板顶部的版本号一致就说明这块屏已跟上服务端);**多屏 kiosk 全屏完全不带这些标识**,窗口化窗口按 F11 全屏后「实例 · 屏」也会隐藏、版本号角标仍在。要一个完全干净的展示画面,就用多屏 kiosk;
|
||||
- **调试标识**:只有**窗口化且没有全屏**的大屏窗口才会显示一行「实例 … · 屏 …」;**多屏 kiosk 全屏完全不带调试标识**,单屏窗口化窗口按 F11 全屏后它也会连同自绘标题栏一起隐藏。数据版本号角标 `v<n>` 已移除,任何情况下都不再显示(要核对某块屏是否跟上服务端,看管理端仪表盘「实时连接」里的「版本 N」)。要一个完全干净的展示画面,就用多屏 kiosk;
|
||||
- **单位 logo**:参赛单位的 logo 会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上(个人榜显示在姓名前);没上传 logo 的单位不占位。logo 在「基础数据 → 参赛单位」里上传,随赛事项目包一起导出/导入(见 8.5)。
|
||||
|
||||
### 7.8 大屏窗口的自动行为
|
||||
@@ -452,7 +454,7 @@ http://<主机IP>:19540/display
|
||||
|
||||
- `meta.yaml`:包格式标识、项目名、赛事名称/副标题/主办方、导出时间,以及每个文件的 SHA-256 清单(导入时逐个校验,对不上就拒绝);
|
||||
- `event.db`:赛事库快照(用 `VACUUM INTO` 生成,导出时服务端可以继续写入),成绩、基础数据、积分规则都在里面;
|
||||
- `uploads/`:**全部上传图片**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo 等(在「系统设置 → 赛事信息」与「基础数据 → 参赛单位」里上传的那批文件)。导入时会连图片一起落到新项目目录,切换过去后直接显示,不用重新上传;
|
||||
- `uploads/`:**全部上传资源**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo,以及**上传的字体**(放在 `uploads/fonts/` 下,见使用手册 14.3)。导入时会连文件一起落到新项目目录的 `uploads/`,切换过去后图片直接显示、字体直接可用,不用重新上传;
|
||||
- `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。
|
||||
|
||||
导出界面同一页的「结果导出」标签可以把最终结果导成 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)。
|
||||
|
||||
### 8.6 手工备份 / 迁移整个数据目录
|
||||
|
||||
快照备份之外,数据库文件、上传的图片、备份都可以整体拷走:
|
||||
快照备份之外,数据库文件、上传的图片与字体、备份都可以整体拷走:
|
||||
|
||||
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. 恢复时把整个目录拷回去,然后:
|
||||
- 拷回**原路径**:直接启动 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`,且没有「准备 … 失败」警告(配置与规则也在用户目录里,见 3.5)
|
||||
- [ ] 数据目录的 `backups\` 可写,已生成至少一份备份
|
||||
- [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片)
|
||||
- [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片与字体)
|
||||
- [ ] **当前赛事正确**:「导出 → 赛事项目包 → 赛事项目列表」里带「当前」徽标的是本次要用的那个项目(刚导入的包要切换过去才算生效,见 8.5)
|
||||
- [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常
|
||||
- [ ] **显示器菜单可用**:在服务管理窗口打开的「大屏控制」页里能看到每块屏并「创建全屏窗口」,副屏被误关时能重开(见 7.5)
|
||||
- [ ] **单位 logo 已上传且在榜上显示**(总积分榜 / 团体奖牌榜 / 分项成绩表 / 个人积分榜,见 7.7)
|
||||
- [ ] **大屏字体已选好并核对**:「系统设置 → 赛事信息 → 字体」里选了标题字体与正文字体,大屏上显示正常;上传的字体在数据目录 `event_projs\<项目名>\uploads\fonts\` 里,导出赛事项目包会一并带走(见 8.5)
|
||||
- [ ] **公告可用**:推送一条测试公告,全屏到时后底栏仍能看到正文,点「清除公告」后底栏回到「暂无公告」(见 7.6)
|
||||
- [ ] 管理端各角色账号可登录,权限正确
|
||||
- [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/xuri/excelize/v2 v2.11.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/text v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.58.0
|
||||
)
|
||||
@@ -23,7 +24,6 @@ require (
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/net v0.58.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/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
|
||||
@@ -38,6 +38,10 @@ type EventSettingsDisplay struct {
|
||||
RotateSecond int `json:"rotateSeconds"`
|
||||
// Announce 是当前公告正文,由大屏指令写入;null 表示无公告。
|
||||
Announce *string `json:"announce"`
|
||||
// FontTitle / FontBody 是大屏标题与正文用的字体 id(builtin:* 或 custom:<文件名>);
|
||||
// null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。
|
||||
FontTitle *string `json:"fontTitle"`
|
||||
FontBody *string `json:"fontBody"`
|
||||
}
|
||||
|
||||
// EventSettingsStandings 是榜单口径配置。
|
||||
@@ -103,6 +107,9 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
||||
}
|
||||
out.Display.PrimaryColor = out.Event.PrimaryColor
|
||||
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
|
||||
// 字体按 id 字符串原样下发,前端解析成字体栈;空串与缺键都表示用内置默认字体。
|
||||
out.Display.FontTitle = nilIfEmpty(values["display.font_title"])
|
||||
out.Display.FontBody = nilIfEmpty(values["display.font_body"])
|
||||
// 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。
|
||||
out.Display.Announce = nilIfEmpty(values["display.announce_text"])
|
||||
|
||||
|
||||
@@ -95,6 +95,65 @@ func TestParseEventSettingsAnnounce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 字体 id 是给大屏渲染用的展示字段,非 admin 的快照也必须带上它,
|
||||
// 否则 guest/recorder 打开的大屏只会用默认字体。
|
||||
func TestEventSettingsWithoutAdminFieldsKeepsFonts(t *testing.T) {
|
||||
title := "builtin:noto-serif"
|
||||
body := "custom:思源宋体.ttf"
|
||||
full := EventSettings{Display: EventSettingsDisplay{FontTitle: &title, FontBody: &body}}
|
||||
|
||||
got := full.WithoutAdminFields()
|
||||
if got.Display.FontTitle == nil || *got.Display.FontTitle != title {
|
||||
t.Fatalf("非 admin 快照应保留标题字体,得到 %v", got.Display.FontTitle)
|
||||
}
|
||||
if got.Display.FontBody == nil || *got.Display.FontBody != body {
|
||||
t.Fatalf("非 admin 快照应保留正文字体,得到 %v", got.Display.FontBody)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化: %v", err)
|
||||
}
|
||||
var display map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
|
||||
t.Fatalf("解析 display: %v", err)
|
||||
}
|
||||
if string(display["fontTitle"]) != `"`+title+`"` || string(display["fontBody"]) != `"`+body+`"` {
|
||||
t.Fatalf("字体应随 display 一起下发: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 字体取自 settings 表的 display.font_title / display.font_body;
|
||||
// 空串与缺键都表示用默认字体,必须下发显式 null。
|
||||
func TestParseEventSettingsFonts(t *testing.T) {
|
||||
withFonts := ParseEventSettings(map[string]string{
|
||||
"display.font_title": "builtin:misans",
|
||||
"display.font_body": "custom:my-font.ttf",
|
||||
})
|
||||
if withFonts.Display.FontTitle == nil || *withFonts.Display.FontTitle != "builtin:misans" {
|
||||
t.Fatalf("标题字体应随 settings 下发,得到 %v", withFonts.Display.FontTitle)
|
||||
}
|
||||
if withFonts.Display.FontBody == nil || *withFonts.Display.FontBody != "custom:my-font.ttf" {
|
||||
t.Fatalf("正文字体应随 settings 下发,得到 %v", withFonts.Display.FontBody)
|
||||
}
|
||||
|
||||
empty := ParseEventSettings(map[string]string{"display.font_title": "", "display.font_body": ""})
|
||||
if empty.Display.FontTitle != nil || empty.Display.FontBody != nil {
|
||||
t.Fatalf("空串表示默认字体,应解析为 null,得到 %v / %v", empty.Display.FontTitle, empty.Display.FontBody)
|
||||
}
|
||||
raw, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化: %v", err)
|
||||
}
|
||||
var display map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil {
|
||||
t.Fatalf("解析 display: %v", err)
|
||||
}
|
||||
if string(display["fontTitle"]) != "null" || string(display["fontBody"]) != "null" {
|
||||
t.Fatalf("未配置字体时应显式序列化为 null: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// mustField 取出顶层字段的原始 JSON,供字段级断言使用。
|
||||
func mustField(t *testing.T, raw []byte, key string) []byte {
|
||||
t.Helper()
|
||||
|
||||
@@ -63,8 +63,8 @@ type ProjectExportOptions struct {
|
||||
CreatedBy string
|
||||
AppVersion string
|
||||
UploadsDir string // 可选:背景图 / 会标目录
|
||||
FontsDir string // 可选:字体目录
|
||||
IncludeFonts bool // 是否把字体一并打包
|
||||
FontsDir string // 可选:上传字体目录(运行时的 <项目目录>/uploads/fonts)
|
||||
IncludeFonts bool // 是否把字体一并打包(打进包内 uploads/fonts/)
|
||||
RulesPath string // 可选:规则包 YAML
|
||||
Now time.Time
|
||||
}
|
||||
@@ -121,7 +121,9 @@ func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (Projec
|
||||
}
|
||||
}
|
||||
if opts.IncludeFonts && opts.FontsDir != "" {
|
||||
if err := copyTree(opts.FontsDir, filepath.Join(tmp, "fonts")); err != nil {
|
||||
// 字体在包内的位置就是运行时的位置(<项目目录>/uploads/fonts/),
|
||||
// 否则导入后字体落在没人读的 fonts/ 下,兜底就成了假兜底。
|
||||
if err := copyTree(opts.FontsDir, filepath.Join(uploads, "fonts")); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,36 @@ func TestExportImportProjectZip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 字体必须落在包内 uploads/fonts/:导入后它就在运行时的字体目录下,大屏的 @font-face 才找得到。
|
||||
// 这里只给 FontsDir、不给 UploadsDir,顺带验证兜底路径本身是通的。
|
||||
func TestExportProjectZipIncludesFontsFallback(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
fonts := t.TempDir()
|
||||
writeFile(t, filepath.Join(fonts, "思源宋体.ttf"), "font-bytes")
|
||||
|
||||
zipPath := filepath.Join(t.TempDir(), "赛事包.zip")
|
||||
if _, err := ExportProjectZip(db, zipPath, ProjectExportOptions{
|
||||
Name: "字体赛事",
|
||||
FontsDir: fonts,
|
||||
IncludeFonts: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("导出赛事包: %v", err)
|
||||
}
|
||||
if !zipHas(zipPath, t, "uploads/fonts/思源宋体.ttf") {
|
||||
t.Fatal("包内缺少 uploads/fonts/思源宋体.ttf")
|
||||
}
|
||||
|
||||
res, err := ImportProjectZip(zipPath, t.TempDir(), ImportOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("导入赛事包: %v", err)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(res.Dir, "uploads", "fonts", "思源宋体.ttf")); got != "font-bytes" {
|
||||
t.Errorf("导入后字体内容 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsBadPackages(t *testing.T) {
|
||||
base := ProjectMeta{
|
||||
Format: projectFormat,
|
||||
|
||||
@@ -113,6 +113,14 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
||||
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())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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 白名单不正确")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
@@ -14,8 +15,12 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/middleware"
|
||||
@@ -658,7 +663,11 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
}
|
||||
return rows, nil
|
||||
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.TrimLeadingSpace = true
|
||||
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 是报名表必须包含的表头(大小写不敏感);
|
||||
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
|
||||
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
|
||||
}
|
||||
@@ -91,6 +91,9 @@ type settingsDisplayPatch struct {
|
||||
Subtitle *string `json:"subtitle"`
|
||||
PrimaryColor *string `json:"primaryColor"`
|
||||
RotateSeconds *float64 `json:"rotateSeconds"`
|
||||
// FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。
|
||||
FontTitle *string `json:"fontTitle"`
|
||||
FontBody *string `json:"fontBody"`
|
||||
}
|
||||
|
||||
type settingsStandingsPatch struct {
|
||||
|
||||
@@ -129,6 +129,24 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
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.IndividualToTeamWeight != nil {
|
||||
|
||||
@@ -255,6 +255,14 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
|
||||
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/rules/", s.notWired("规则导入导出"))
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||||
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
||||
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
||||
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"]),
|
||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
|
||||
|
||||
|
||||
@@ -206,7 +206,9 @@ function downloadTemplate(): void {
|
||||
>
|
||||
<MxIcon name="export" :size="26" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { EventSettings } from '@/shared'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { DisplayFont, EventSettings } from '@/shared'
|
||||
import { BUILTIN_FONTS, fontStack, registerCustomFont } from '@/display/fonts'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import FontUploadField from './FontUploadField.vue'
|
||||
import ImageUploadField from './ImageUploadField.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 { useEventStore } from '../../stores/event'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
@@ -26,6 +28,9 @@ const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const usingMock = ref(false)
|
||||
|
||||
const fonts = ref<DisplayFont[]>([])
|
||||
const fontsError = ref<string | null>(null)
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
const dirty = computed(() => {
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 字体
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 是「保持原值」,所以清空必须传空串。
|
||||
* 若这里把空串转成 null,副标题 / 主办方将永远清不掉。
|
||||
* 字体 id 走同一个函数:旧服务端不下发 fontTitle / fontBody,可能是 undefined。
|
||||
*/
|
||||
function optional(value: string | null): string {
|
||||
function optional(value: string | null | undefined): string {
|
||||
return (value ?? '').trim()
|
||||
}
|
||||
|
||||
@@ -88,6 +143,9 @@ async function save(): Promise<void> {
|
||||
logo: optional(next.display.logo),
|
||||
background: optional(next.display.background),
|
||||
rotateSeconds,
|
||||
// 同会标:清空字体必须传空串,传 null 服务端会当成「不改动」。
|
||||
fontTitle: optional(next.display.fontTitle),
|
||||
fontBody: optional(next.display.fontBody),
|
||||
},
|
||||
standings: { ...next.standings },
|
||||
}
|
||||
@@ -237,6 +295,57 @@ async function save(): Promise<void> {
|
||||
</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>
|
||||
@@ -297,6 +406,31 @@ async function save(): Promise<void> {
|
||||
border-radius: var(--mx-r-xs);
|
||||
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 {
|
||||
color: var(--mx-error);
|
||||
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>
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
@@ -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' })
|
||||
},
|
||||
}
|
||||
@@ -38,12 +38,12 @@ const stack: InfoItem[] = [
|
||||
]
|
||||
|
||||
const fonts: InfoItem[] = [
|
||||
{ name: 'Noto Sans 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: 'Noto Sans 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: 'MiSans',
|
||||
usage: '可选界面字体',
|
||||
usage: '无衬线,可用作大屏标题/正文',
|
||||
license: 'MiSans Font License',
|
||||
note: 'MiSans 仅限内部赛事使用,不得对外分发',
|
||||
},
|
||||
@@ -179,6 +179,13 @@ onMounted(async () => {
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">字体清单</span>
|
||||
</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-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
@@ -351,6 +358,11 @@ kbd {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.font-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.signature {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
|
||||
@@ -566,8 +566,9 @@ async function applyRules(): Promise<void> {
|
||||
<div class="mx-card-body">
|
||||
<p class="hint">
|
||||
包内含 <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>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
|
||||
@@ -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
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { mix, parseHexColor, readableForeground, toCss } from './color'
|
||||
import { useShellFullscreen } from './shell'
|
||||
import type { DisplayPageKey } from './types'
|
||||
import { useScoreboard } from './useScoreboard'
|
||||
import { useStageScale } from './useStageScale'
|
||||
@@ -28,13 +29,17 @@ const { bootstrap, dispose, page, settings } = useScoreboard()
|
||||
|
||||
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效。
|
||||
const windowed = isWindowed()
|
||||
// Console 的 F11 由主进程切换全屏:全屏时自绘标题栏必须让位,否则舞台顶部被多扣 50px。
|
||||
const fullscreen = ref(document.fullscreenElement !== null)
|
||||
// Console 的 F11 是窗口级全屏,文档 API 看不到,两个来源的合成判断见 shell.ts。
|
||||
// 全屏时自绘标题栏必须让位,否则舞台顶部被多扣 50px。
|
||||
const fullscreen = useShellFullscreen()
|
||||
const showTitleBar = computed(() => windowed && !fullscreen.value)
|
||||
const { scale, offsetX, offsetY, fit } = useStageScale({
|
||||
topInset: () => (showTitleBar.value ? WINDOWED_TITLEBAR_HEIGHT : 0),
|
||||
})
|
||||
|
||||
// 标题栏出现/消失会改变可用高度,必须重新算缩放(全屏切换不保证带 resize)。
|
||||
watch(fullscreen, () => fit())
|
||||
|
||||
const currentPage = computed<Component>(() => PAGES[page.value])
|
||||
|
||||
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
|
||||
let reloadTimer = 0
|
||||
|
||||
function onFullscreenChange(): void {
|
||||
fullscreen.value = document.fullscreenElement !== null
|
||||
// 标题栏出现/消失会改变可用高度,必须重新算缩放(fullscreenchange 不保证带 resize)。
|
||||
fit()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
void bootstrap()
|
||||
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
window.clearTimeout(reloadTimer)
|
||||
dispose()
|
||||
})
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import { isWindowed } from '../windowed'
|
||||
|
||||
const { booted, status, source, version, authRequired } = useScoreboard()
|
||||
const { booted, status, source, authRequired } = useScoreboard()
|
||||
|
||||
// connecting 是启动瞬间的正常状态,闪「连接中断」会误报;只有真正断开才提示。
|
||||
const offline = computed(() => booted.value && status.value === 'offline')
|
||||
const mock = computed(() => source.value === 'mock')
|
||||
// 数据版本号(change_log 版本)是给现场核对「这块屏有没有跟上服务端」用的调试信息,
|
||||
// 与「实例 · 屏」同规矩:只在窗口化调试时显示,kiosk 全屏不出现。
|
||||
const showVersion = computed(() => isWindowed() && version.value !== null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,7 +14,6 @@ const showVersion = computed(() => isWindowed() && version.value !== null)
|
||||
<span v-if="mock" class="badge mock">示例数据</span>
|
||||
<span v-if="authRequired" class="badge auth">登录已失效 · 请重新登录</span>
|
||||
<span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span>
|
||||
<span v-if="showVersion" class="badge version">v{{ version }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -54,8 +49,4 @@ const showVersion = computed(() => isWindowed() && version.value !== null)
|
||||
border-color: rgba(255, 160, 64, 0.55);
|
||||
color: #ffd0a0;
|
||||
}
|
||||
.version {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { formatClock } from '@/shared'
|
||||
import { useShellFullscreen } from '../shell'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import { isWindowed } from '../windowed'
|
||||
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 clock = computed(() => formatClock(now.value))
|
||||
// 单屏窗口化时用来分辨「这块屏是哪个实例」;现场展示(kiosk,或窗口化后按 F11 全屏)都不该出现。
|
||||
// 监听在 setup 阶段就挂上,onUnmounted 里摘掉,不引入额外 import。
|
||||
const fullscreen = ref(document.fullscreenElement !== null)
|
||||
function onFullscreenChange(): void {
|
||||
fullscreen.value = document.fullscreenElement !== null
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
|
||||
// 窗口级全屏只有 Console 的 preload 看得到,合成判断与清理都在 shell.ts 里。
|
||||
const fullscreen = useShellFullscreen()
|
||||
|
||||
// 实例/屏号都缺失时返回空串,模板的 v-if 顺带挡掉空元素。
|
||||
const identity = computed(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -359,6 +359,9 @@ export function parseSettings(source: unknown): DisplaySettings {
|
||||
// 公告在 settings 表里的键是 display.announce_text,聚合后是 display.announce;
|
||||
// 空白一律归一成 null,调用方只需判断「有没有公告」。
|
||||
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 {
|
||||
event: {
|
||||
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')),
|
||||
rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8),
|
||||
announce: announce === '' ? null : announce,
|
||||
fontTitle: fontTitle === '' ? null : fontTitle,
|
||||
fontBody: fontBody === '' ? null : fontBody,
|
||||
},
|
||||
standings: {
|
||||
individualToTeamWeight: num(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './guards'
|
||||
import type { DisplaySettings } from './guards'
|
||||
import { DEFAULT_ANNOUNCE_SECONDS, createAnnouncement, resolveAnnounceText } from './announce'
|
||||
import { applyDisplayFonts } from './fonts'
|
||||
import { createMockAthletes, createMockSnapshot } from './mock'
|
||||
import { readCachedSnapshot, writeCachedSnapshot } from './storage'
|
||||
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),快照与设置接口都带它。
|
||||
*
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
/* 全局默认字体;各端可通过覆盖 --font-ui 切换(字体清单见 vendor/fonts/manifest.json) */
|
||||
--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 {
|
||||
background: var(--bg);
|
||||
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;
|
||||
}
|
||||
|
||||
/* 标题走标题字体、其余(表格、名单、数值)走正文字体;未配置时两者都等于默认字体。 */
|
||||
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) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -428,6 +428,12 @@ export interface EventSettings {
|
||||
* 旧服务端不下发这个键,所以可选;大屏解析层会归一成 string | null。
|
||||
*/
|
||||
announce?: string | null
|
||||
/**
|
||||
* 大屏标题 / 正文的字体 id(builtin:* 或 custom:<文件名>);null 表示内置默认字体。
|
||||
* 旧服务端不下发时可选,解析层会归一成 string | null。
|
||||
*/
|
||||
fontTitle?: string | null
|
||||
fontBody?: string | null
|
||||
}
|
||||
standings: {
|
||||
individualToTeamWeight: number
|
||||
@@ -471,6 +477,19 @@ export interface BackupResult {
|
||||
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 {
|
||||
version: string
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user