Files
Scoreboard/web/src/admin/components/system/FontUploadField.vue
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

180 lines
5.3 KiB
Vue
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.
<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>