三块改动,均为现场反馈:
一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 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/ 同步更新。
165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
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)
|
||
}
|