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
@@ -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