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
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user