feat: M3-M7 全模块落地 + 集成接线

- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+138 -144
View File
@@ -1,69 +1,105 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import type { Component } from 'vue'
import { mix, parseHexColor, readableForeground, toCss } from './color'
import type { DisplayPageKey } from './types'
import { useScoreboard } from './useScoreboard'
import { useStageScale } from './useStageScale'
import DetailOverlay from './components/DetailOverlay.vue'
import TickerBar from './components/TickerBar.vue'
import TopBar from './components/TopBar.vue'
import AnnouncementPage from './components/pages/AnnouncementPage.vue'
import AthleteStandingsPage from './components/pages/AthleteStandingsPage.vue'
import EventResultsPage from './components/pages/EventResultsPage.vue'
import StandingsPage from './components/pages/StandingsPage.vue'
import TeamMedalsPage from './components/pages/TeamMedalsPage.vue'
// 占位数据:M1/M3 接入 /api 后替换为实时数据
const teams = ref([
{ rank: 1, name: '第一分公司', points: 186 },
{ rank: 2, name: '机关本部', points: 172 },
{ rank: 3, name: '第二分公司', points: 158 },
{ rank: 4, name: '第三分公司', points: 141 },
{ rank: 5, name: '第四分公司', points: 127 },
{ rank: 6, name: '第五分公司', points: 103 },
{ rank: 7, name: '第六分公司', points: 95 },
])
const scale = ref(1)
const stage = ref<HTMLElement | null>(null)
function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
scale.value = s
if (stage.value) {
stage.value.style.left = `${(window.innerWidth - 1920 * s) / 2}px`
stage.value.style.top = `${(window.innerHeight - 1080 * s) / 2}px`
}
const PAGES: Record<DisplayPageKey, Component> = {
standings: StandingsPage,
'team-medals': TeamMedalsPage,
'athlete-standings': AthleteStandingsPage,
'event-results': EventResultsPage,
announcement: AnnouncementPage,
}
const { bootstrap, dispose, page, settings } = useScoreboard()
const { scale, offsetX, offsetY } = useStageScale()
const currentPage = computed<Component>(() => PAGES[page.value])
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(() => {
fit()
window.addEventListener('resize', fit)
void bootstrap()
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
})
onUnmounted(() => {
window.clearTimeout(reloadTimer)
dispose()
})
onUnmounted(() => window.removeEventListener('resize', fit))
</script>
<template>
<div ref="stage" class="stage" :style="{ transform: `scale(${scale})` }">
<header class="head">
<div class="logo">会标</div>
<h1>赛事积分榜</h1>
<div class="spacer" />
</header>
<div class="screen">
<div class="stage" :style="[stageStyle, themeVars]">
<img v-if="background" class="bg" :src="background" alt="" />
<div class="bg-fallback" />
<section class="board">
<h2>总积分榜</h2>
<table>
<thead>
<tr>
<th class="rk">名次</th>
<th>单位</th>
<th class="pt">总分</th>
</tr>
</thead>
<tbody>
<tr v-for="t in teams" :key="t.name">
<td class="rk">{{ t.rank }}</td>
<td class="nm">{{ t.name }}</td>
<td class="pt">{{ t.points }}</td>
</tr>
</tbody>
</table>
</section>
<div class="content">
<TopBar />
<main class="main">
<Transition name="page" mode="out-in">
<component :is="currentPage" :key="page" />
</Transition>
</main>
<TickerBar />
</div>
<footer class="ticker">底栏信息条(轮播占位)</footer>
<DetailOverlay />
</div>
</div>
</template>
<style scoped>
.screen {
position: fixed;
inset: 0;
overflow: hidden;
background: #000;
cursor: none;
}
.stage {
position: absolute;
top: 0;
@@ -72,109 +108,67 @@ onUnmounted(() => window.removeEventListener('resize', fit))
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: 24px;
background:
radial-gradient(ellipse at 20% 0%, rgba(79, 216, 255, 0.12), transparent 55%),
radial-gradient(ellipse at 85% 100%, rgba(255, 215, 94, 0.08), transparent 50%), var(--bg);
cursor: none;
gap: 20px;
}
.head {
display: flex;
align-items: center;
gap: 28px;
}
.logo {
width: 96px;
height: 96px;
border-radius: 50%;
border: 3px solid var(--gold);
display: grid;
place-items: center;
font-size: 22px;
color: var(--text-dim);
}
h1 {
font-size: 64px;
letter-spacing: 8px;
background: linear-gradient(180deg, #fff 30%, var(--gold) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.spacer {
flex: 1;
}
.board {
.main {
flex: 1;
min-height: 0;
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 16px;
padding: 20px 28px 24px;
display: flex;
flex-direction: column;
}
h2 {
font-size: 34px;
letter-spacing: 4px;
color: var(--cyan);
padding-bottom: 10px;
border-bottom: 2px solid rgba(79, 216, 255, 0.35);
margin-bottom: 8px;
.main > * {
flex: 1;
min-width: 0;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
.page-enter-active,
.page-leave-active {
transition:
opacity 0.35s ease,
transform 0.35s ease;
}
th,
td {
font-size: 30px;
padding: 12px 8px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.page-enter-from {
opacity: 0;
transform: translateY(24px);
}
th {
font-size: 20px;
color: var(--text-dim);
font-weight: 400;
letter-spacing: 2px;
.page-leave-to {
opacity: 0;
transform: translateY(-24px);
}
td.rk {
width: 140px;
font-weight: 800;
font-size: 34px;
}
tbody tr:nth-child(1) td.rk {
color: var(--gold);
}
tbody tr:nth-child(2) td.rk {
color: #d8e2ee;
}
tbody tr:nth-child(3) td.rk {
color: #e0995e;
}
td.nm {
text-align: left;
padding-left: 32px;
}
td.pt {
width: 220px;
color: var(--gold);
font-weight: 800;
font-size: 36px;
}
.ticker {
height: 88px;
border-radius: 16px;
background: var(--panel);
border: 2px solid var(--panel-border);
display: grid;
place-items: center;
font-size: 26px;
color: var(--text-dim);
letter-spacing: 2px;
:global(body) {
cursor: none;
overflow: hidden;
}
</style>