diff --git a/README.md b/README.md index 1ec6f41..fc09649 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,10 @@ - **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分) - **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗 - **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用 -- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo),并可在管理端页内一键切换当前赛事 +- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事 - **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出 - **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo +- **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入 ## 快速开始 diff --git a/console/README.md b/console/README.md index e49797e..6ed2696 100644 --- a/console/README.md +++ b/console/README.md @@ -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=&instance=-`,页序按 `display.pages` 轮转 | diff --git a/console/main/displays.js b/console/main/displays.js index b80100d..7ba361a 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -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() }) diff --git a/console/main/index.js b/console/main/index.js index c387307..29dd760 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -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(() => { diff --git a/console/renderer/display-preload.js b/console/renderer/display-preload.js new file mode 100644 index 0000000..6e18cfc --- /dev/null +++ b/console/renderer/display-preload.js @@ -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) + } +}) diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 6062000..824522a 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -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. 现场流程演练(建议至少一次) diff --git a/docs/使用手册.md b/docs/使用手册.md index 6f2bfd4..2815b81 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -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 回到窗口化,标题栏与标识一起回来。 --- diff --git a/docs/常见问题.md b/docs/常见问题.md index 08d408f..6fe2c61 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -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\`(规则包目录),也都在安装目录之外,同样不会被覆盖安装或卸载删掉。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 0cbc495..7cb4618 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -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`)供现场核对;按 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`(`v` 是数据版本号,与管理面板顶部的版本号一致就说明这块屏已跟上服务端);**多屏 kiosk 全屏完全不带这些标识**,窗口化窗口按 F11 全屏后「实例 · 屏」也会隐藏、版本号角标仍在。要一个完全干净的展示画面,就用多屏 kiosk; +- **调试标识**:只有**窗口化且没有全屏**的大屏窗口才会显示一行「实例 … · 屏 …」;**多屏 kiosk 全屏完全不带调试标识**,单屏窗口化窗口按 F11 全屏后它也会连同自绘标题栏一起隐藏。数据版本号角标 `v` 已移除,任何情况下都不再显示(要核对某块屏是否跟上服务端,看管理端仪表盘「实时连接」里的「版本 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 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台 diff --git a/server/go.mod b/server/go.mod index 39cce81..7a7e0ca 100644 --- a/server/go.mod +++ b/server/go.mod @@ -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 diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go index 54971c5..74cae93 100644 --- a/server/internal/dto/settings.go +++ b/server/internal/dto/settings.go @@ -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"]) diff --git a/server/internal/dto/settings_test.go b/server/internal/dto/settings_test.go index 4963026..7da0f08 100644 --- a/server/internal/dto/settings_test.go +++ b/server/internal/dto/settings_test.go @@ -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() diff --git a/server/internal/export/project.go b/server/internal/export/project.go index 21fa781..bd14dd7 100644 --- a/server/internal/export/project.go +++ b/server/internal/export/project.go @@ -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 } } diff --git a/server/internal/export/project_test.go b/server/internal/export/project_test.go index 152f2f7..88f1574 100644 --- a/server/internal/export/project_test.go +++ b/server/internal/export/project_test.go @@ -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, diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index c6faebf..35207e7 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -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 diff --git a/server/internal/httpapi/fonts.go b/server/internal/httpapi/fonts.go new file mode 100644 index 0000000..79c8fec --- /dev/null +++ b/server/internal/httpapi/fonts.go @@ -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) +} diff --git a/server/internal/httpapi/fonts_internal_test.go b/server/internal/httpapi/fonts_internal_test.go new file mode 100644 index 0000000..bbe46eb --- /dev/null +++ b/server/internal/httpapi/fonts_internal_test.go @@ -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 白名单不正确") + } +} diff --git a/server/internal/httpapi/fonts_test.go b/server/internal/httpapi/fonts_test.go new file mode 100644 index 0000000..ea3cead --- /dev/null +++ b/server/internal/httpapi/fonts_test.go @@ -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 +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 6dc801e..2de1752 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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{ diff --git a/server/internal/httpapi/handlers_import_encoding_test.go b/server/internal/httpapi/handlers_import_encoding_test.go new file mode 100644 index 0000000..4d89359 --- /dev/null +++ b/server/internal/httpapi/handlers_import_encoding_test.go @@ -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 +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index f046fac..11ac343 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -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 { diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 64d4827..82071e6 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -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 { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index eab9860..4827356 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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("规则导入导出")) diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index af14cce..f854c50 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -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"}) diff --git a/web/src/admin/components/baseData/ImportSection.vue b/web/src/admin/components/baseData/ImportSection.vue index c5f13e8..35f4ea5 100644 --- a/web/src/admin/components/baseData/ImportSection.vue +++ b/web/src/admin/components/baseData/ImportSection.vue @@ -206,7 +206,9 @@ function downloadTemplate(): void { >
点击选择,或把 CSV / XLSX 拖到这里
-
单个文件不超过 16MB;xlsx 由服务端解析第一个工作表
+
+ 单个文件不超过 16MB;xlsx 由服务端解析第一个工作表;CSV 直接用 Excel「另存为 CSV」即可(UTF-8、带 BOM 的 UTF-8、GBK/GB18030、UTF-16 都能读,不用特意转码) +
diff --git a/web/src/admin/components/system/EventInfoPanel.vue b/web/src/admin/components/system/EventInfoPanel.vue index 612051e..acbb9f0 100644 --- a/web/src/admin/components/system/EventInfoPanel.vue +++ b/web/src/admin/components/system/EventInfoPanel.vue @@ -1,10 +1,12 @@ + + + + diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts index 4dae899..23d600d 100644 --- a/web/src/admin/components/system/systemApi.ts +++ b/web/src/admin/components/system/systemApi.ts @@ -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 { + const body = await requestJson('/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 { + const form = new FormData() + form.append('file', file) + // 字体上限 20 MiB,内网弱网下也要留足上传时间。 + const body = await requestJson('/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 { + // 文件名可能含汉字,必须转义后再进 URL。 + await requestJson(`/fonts/${encodeURIComponent(name)}`, { method: 'DELETE' }) + }, +} diff --git a/web/src/admin/pages/AboutPage.vue b/web/src/admin/pages/AboutPage.vue index 10654a2..6962b00 100644 --- a/web/src/admin/pages/AboutPage.vue +++ b/web/src/admin/pages/AboutPage.vue @@ -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 () => {
字体清单
+
+

+ 以下四款内置字体(各 400/700 两档)随程序分发,可在「系统设置 → 赛事信息 → 字体」里选用; + 另有需要的字体可在同一处上传(ttf / otf / woff / woff2,单个不超过 20 MiB), + 上传的字体存在赛事项目的 uploads/fonts/ 下并随赛事包导出。 +

+
@@ -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; diff --git a/web/src/admin/pages/ExportPage.vue b/web/src/admin/pages/ExportPage.vue index b2e0e7a..a390790 100644 --- a/web/src/admin/pages/ExportPage.vue +++ b/web/src/admin/pages/ExportPage.vue @@ -566,8 +566,9 @@ async function applyRules(): Promise {

包内含 meta.yaml + event.db + - uploads/ + rules.yaml(字体可选)。 - 整包拷走即可在另一台机器复现整场赛事。 + uploads/ + rules.yaml。会标、背景图与上传的字体 + 都在 uploads/ 里(字体在 uploads/fonts/), + 连同字体设置一并随包带走,整包拷到另一台机器导入后即可复现整场赛事。