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:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
+138
-144
@@ -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>
|
||||
Reference in new issue
Block a user