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