Files
Scoreboard/web/src/display/fonts.ts
T
AskaEth fc96d17660 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/ 同步更新。
2026-09-10 17:45:11 +08:00

165 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}