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