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

+358
View File
@@ -0,0 +1,358 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
interface ScreenNode {
key: string
name: string
online: boolean | null
lastSeen: string | null
}
const PAGES: Array<{ key: string; label: string }> = [
{ key: 'standings', label: '总积分榜' },
{ key: 'medals', label: '团体奖牌榜' },
{ key: 'athletes', label: '个人积分榜' },
{ key: 'latest', label: '最新成绩' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const currentPage = ref('standings')
const locked = ref(false)
const announceText = ref('')
const announceSeconds = ref(15)
const pending = ref<string | null>(null)
/**
* 在线看板。
*
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
* 因此这里只登记本地已知的屏,并用「最近一次命令是否送达」推断在线状态。
* 后端补上接口后,把 screens 换成接口数据即可。
*/
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastSeen: null }])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
function markSeen(): void {
screens.value = screens.value.map((screen) => ({
...screen,
online: true,
lastSeen: new Date().toISOString(),
}))
}
async function send(action: 'goto' | 'lock' | 'unlock' | 'reload' | 'announce'): Promise<void> {
if (!canWrite.value) {
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
pending.value = action
try {
if (action === 'goto') {
await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
} else if (action === 'lock' || action === 'unlock') {
await adminApi.displayCommand({ action, payload: {} })
locked.value = action === 'lock'
toast.success(action === 'lock' ? '大屏已锁定,停止自动轮播' : '大屏已解锁,恢复轮播')
} else if (action === 'reload') {
await adminApi.displayCommand({ action, payload: {} })
toast.success('已请求所有大屏重新拉取快照')
} else {
const text = announceText.value.trim()
if (text === '') {
toast.error('公告内容不能为空')
return
}
await adminApi.displayCommand({ action, payload: { text, seconds: announceSeconds.value } })
announceText.value = ''
toast.success('公告已推送')
}
markSeen()
} catch (caught) {
toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
} finally {
pending.value = null
}
}
</script>
<template>
<div>
<PageHeader title="大屏控制" subtitle="切页 / 锁定 / 刷新 / 公告 / 在线看板">
<template #actions>
<span class="mx-badge" :class="locked ? 'mx-badge-warning' : 'mx-badge-success'">
{{ locked ? '已锁定' : '轮播中' }}
</span>
</template>
</PageHeader>
<div class="display-grid">
<div class="col">
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">切页</span></div>
<div class="mx-card-body">
<div class="page-grid">
<button
v-for="page in PAGES"
:key="page.key"
class="page-btn"
:class="{ active: currentPage === page.key }"
type="button"
@click="currentPage = page.key"
>
<MxIcon name="display" :size="16" />
{{ page.label }}
</button>
</div>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('goto')"
>
<MxIcon name="push" :size="15" />
{{ pending === 'goto' ? '下发中…' : '立即跳转' }}
</button>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">锁定与刷新</span></div>
<div class="mx-card-body">
<div class="action-row">
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null || locked"
@click="send('lock')"
>
<MxIcon name="lock" :size="15" /> 锁定
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null || !locked"
@click="send('unlock')"
>
<MxIcon name="unlock" :size="15" /> 解锁
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('reload')"
>
<MxIcon name="refresh" :size="15" /> 强制刷新
</button>
</div>
<p class="hint">锁定后停止自动轮播并停在当前页;强制刷新让所有大屏重新拉取快照,用于排障。</p>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">公告</span></div>
<div class="mx-card-body">
<div class="mx-input-wrap textarea-wrap">
<textarea
v-model="announceText"
class="mx-input announce"
rows="3"
placeholder="输入要在大屏底部滚动显示的公告"
></textarea>
</div>
<div class="announce-foot">
<label class="seconds">
滚动时长
<input v-model.number="announceSeconds" class="mx-cell-input seconds-input" type="number" min="5" max="120" />
秒
</label>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || pending !== null"
@click="send('announce')"
>
<MxIcon name="bell" :size="15" />
{{ pending === 'announce' ? '推送中…' : '推送公告' }}
</button>
</div>
</div>
</section>
</div>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">在线看板</span>
<span class="mx-badge" :class="onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ onlineCount }} / {{ screens.length }} 在线
</span>
</div>
<div class="mx-card-body">
<div class="board-note">
<MxIcon name="warning" :size="14" />
<span>后端未提供大屏在线接口(devdocs/05 §7 未定义),下表为本地登记节点,在线状态按最近一次指令送达推断。</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>大屏</th>
<th>状态</th>
<th>最后心跳</th>
</tr>
</thead>
<tbody>
<tr v-for="screen in screens" :key="screen.key">
<td>{{ screen.name }}</td>
<td>
<span
class="mx-badge"
:class="screen.online === true ? 'mx-badge-success' : screen.online === false ? 'mx-badge-error' : 'mx-badge-neutral'"
>
{{ screen.online === true ? '在线' : screen.online === false ? '掉线' : '未知' }}
</span>
</td>
<td class="mono muted">{{ screen.lastSeen === null ? '—' : formatDateTime(screen.lastSeen) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="board-hint">
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ currentPage }}</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.display-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.col {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.page-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
border: 1px solid transparent;
color: var(--mx-t2);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all var(--mx-d2) var(--mx-ease);
}
.page-btn:hover {
color: var(--mx-text);
border-color: var(--mx-border);
}
.page-btn.active {
background: var(--mx-primary-glow);
border-color: var(--mx-primary);
color: var(--mx-primary);
}
.full {
width: 100%;
}
.action-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.hint,
.board-note,
.board-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.hint {
margin-top: 12px;
}
.textarea-wrap {
height: auto;
align-items: flex-start;
padding: 10px 14px;
}
.announce {
resize: vertical;
line-height: 1.6;
padding: 4px 0;
}
.announce-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.seconds {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--mx-t3);
}
.seconds-input {
width: 68px;
text-align: center;
}
.board-note {
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.08);
color: var(--mx-amber);
margin-bottom: 12px;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.board-hint {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 4px;
}
.board-hint .mock {
color: var(--mx-amber);
}
@media (max-width: 1023px) {
.display-grid {
grid-template-columns: 1fr;
}
}
</style>