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