## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发) - 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、 手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。 - 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错 (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。 ## 实时赛况(新大屏页 + 定向接口) - 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩 (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。 设置键 display.live_events / display.live_source。 - 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面 (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。 - 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only), 故对非管理员整组置灰 + 提示条(不新增角色)。 ## 显式批次层(组别之下) - 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果 完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 + 一键均分(按 N 人切片,已有批次号接着往后排)。 ## 成绩输入框实时悬浮提示 - 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻; 合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文; 与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。 ## 窄屏 / 手机端布局 - 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针, 四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框 塌成 22px 这类真问题。 ## 版本号单一真相 - 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json 的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。 测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
210 lines
6.2 KiB
Vue
210 lines
6.2 KiB
Vue
<script setup lang="ts">
|
||
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'
|
||
import { WINDOWED_TITLEBAR_HEIGHT, isWindowed } from './windowed'
|
||
import DetailOverlay from './components/DetailOverlay.vue'
|
||
import TickerBar from './components/TickerBar.vue'
|
||
import TopBar from './components/TopBar.vue'
|
||
import WindowedTitleBar from './components/WindowedTitleBar.vue'
|
||
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
|
||
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
|
||
import EventResultsPage from './components/pages/EventResultsPage.vue'
|
||
import LivePage from './components/pages/LivePage.vue'
|
||
import StandingsPage from './components/pages/StandingsPage.vue'
|
||
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
|
||
|
||
const PAGES: Record<DisplayPageKey, Component> = {
|
||
standings: StandingsPage,
|
||
'team-medals': TeamMedalsPage,
|
||
'athlete-standings': AthleteStandingsPage,
|
||
'event-results': EventResultsPage,
|
||
live: LivePage,
|
||
announcement: AnnouncementPage,
|
||
}
|
||
|
||
const { bootstrap, dispose, page, settings } = useScoreboard()
|
||
|
||
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效。
|
||
const windowed = isWindowed()
|
||
// 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` }
|
||
const barTitle = computed(() => settings.value?.display?.title?.trim() || '赛事标题待配置')
|
||
const barLogo = computed(() => settings.value?.display?.logo?.trim() ?? '')
|
||
|
||
const stageStyle = computed(() => ({
|
||
transform: `scale(${scale.value})`,
|
||
left: `${offsetX.value}px`,
|
||
top: `${offsetY.value}px`,
|
||
}))
|
||
|
||
const background = computed(() => {
|
||
const value = settings.value?.display?.background?.trim()
|
||
return value !== undefined && value !== '' ? value : null
|
||
})
|
||
|
||
/** 主色由配置驱动,亮/暗变体与前景色在这里一次性算好挂成 CSS 变量。 */
|
||
const themeVars = computed<Record<string, string>>(() => {
|
||
const vars: Record<string, string> = {}
|
||
const primary = parseHexColor(settings.value?.display?.primaryColor ?? null)
|
||
if (primary === null) return vars
|
||
|
||
const light = mix(primary, { r: 255, g: 255, b: 255 }, 0.45)
|
||
const dark = mix(primary, { r: 0, g: 0, b: 0 }, 0.45)
|
||
vars['--primary'] = toCss(primary)
|
||
vars['--primary-light'] = toCss(light)
|
||
vars['--primary-dark'] = toCss(dark)
|
||
vars['--primary-fg'] = readableForeground(primary)
|
||
vars['--primary-text'] = toCss(light)
|
||
vars['--primary-line'] = `rgba(${primary.r}, ${primary.g}, ${primary.b}, 0.38)`
|
||
vars['--primary-flash'] = `rgba(${primary.r}, ${primary.g}, ${primary.b}, 0.14)`
|
||
return vars
|
||
})
|
||
|
||
// 无人值守兜底:每 24h 刷新一次,清掉长跑累积的内存与渲染状态(06 第 8 节)。
|
||
const RELOAD_MS = 24 * 60 * 60 * 1000
|
||
let reloadTimer = 0
|
||
|
||
onMounted(() => {
|
||
void bootstrap()
|
||
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.clearTimeout(reloadTimer)
|
||
dispose()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="root" :style="rootStyle">
|
||
<WindowedTitleBar v-if="showTitleBar" :title="barTitle" :logo="barLogo" />
|
||
|
||
<div class="screen">
|
||
<div class="stage" :style="[stageStyle, themeVars]">
|
||
<!-- 兜底渐变与背景图互斥:两者都是 absolute inset:0 同层,同时存在时后画的渐变会整块盖住图片 -->
|
||
<img v-if="background" class="bg" :src="background" alt="" />
|
||
<div v-else class="bg-fallback" />
|
||
|
||
<div class="content">
|
||
<TopBar />
|
||
<main class="main">
|
||
<Transition name="page" mode="out-in">
|
||
<component :is="currentPage" :key="page" />
|
||
</Transition>
|
||
</main>
|
||
<TickerBar />
|
||
</div>
|
||
|
||
<DetailOverlay />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.root {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background: #000;
|
||
}
|
||
/* kiosk 下 root 里只有 .screen,flex:1 等价于原来的铺满视口 */
|
||
.screen {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
background: #000;
|
||
cursor: none;
|
||
}
|
||
.stage {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 1920px;
|
||
height: 1080px;
|
||
transform-origin: top left;
|
||
padding: 40px 48px;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
|
||
--primary: #4fd8ff;
|
||
--primary-light: #8fe6ff;
|
||
--primary-dark: #1b7fa8;
|
||
--primary-fg: #04121c;
|
||
--primary-text: #8fe6ff;
|
||
--primary-line: rgba(79, 216, 255, 0.38);
|
||
--primary-flash: rgba(79, 216, 255, 0.14);
|
||
}
|
||
.bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
opacity: 0.55;
|
||
}
|
||
.bg-fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(ellipse at 18% 0%, rgba(79, 216, 255, 0.16), transparent 55%),
|
||
radial-gradient(ellipse at 88% 100%, rgba(255, 215, 94, 0.1), transparent 50%),
|
||
linear-gradient(160deg, #0b1e3a 0%, #06152b 100%);
|
||
}
|
||
.content {
|
||
position: relative;
|
||
z-index: 1;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
.main {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
}
|
||
.main > * {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.page-enter-active,
|
||
.page-leave-active {
|
||
transition:
|
||
opacity 0.35s ease,
|
||
transform 0.35s ease;
|
||
}
|
||
.page-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(24px);
|
||
}
|
||
.page-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-24px);
|
||
}
|
||
|
||
:global(body) {
|
||
cursor: none;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|