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

+258
View File
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { formatPoints, type RecordEntry } 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'
const data = useEventStore()
const auth = useAuthStore()
const records = ref<RecordEntry[]>([])
const publishedCount = computed(() => data.events.filter((event) => event.status === 'published').length)
const pendingEvents = computed(() => data.events.filter((event) => event.status === 'pending'))
const topTeams = computed(() => data.standings.slice(0, 3))
const leader = computed(() => data.standings[0] ?? null)
const connectionText = computed(() => {
if (data.isMock) return '示例数据'
if (data.realtimeStatus === 'online') return '实时在线'
if (data.realtimeStatus === 'connecting') return '连接中'
return '连接中断'
})
onMounted(async () => {
try {
records.value = await adminApi.records()
} catch {
records.value = []
}
})
</script>
<template>
<div>
<PageHeader title="仪表盘" :subtitle="data.settings?.event.name ?? '赛事概览'">
<template #actions>
<RouterLink class="mx-btn mx-btn-primary" :to="{ name: 'entry' }">
<MxIcon name="entry" :size="16" /> 继续录入
</RouterLink>
</template>
</PageHeader>
<section class="mx-stat-grid mx-section">
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="standings" :size="14" /></span>领先单位</div>
<div class="mx-stat-value">{{ leader?.shortName ?? leader?.teamName ?? '—' }}</div>
<span class="mx-stat-delta up">{{ leader ? `${formatPoints(leader.totalPoints)} 分` : '暂无成绩' }}</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="entry" :size="14" /></span>已发布项目</div>
<div class="mx-stat-value">{{ publishedCount }}/{{ data.events.length }}</div>
<span class="mx-stat-delta" :class="pendingEvents.length > 0 ? 'down' : 'up'">
{{ pendingEvents.length }} 项待录入
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="record" :size="14" /></span>纪录条目</div>
<div class="mx-stat-value">{{ records.length }}</div>
<span class="mx-stat-delta up">赛会纪录库</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="display" :size="14" /></span>实时连接</div>
<div class="mx-stat-value conn">{{ connectionText }}</div>
<span class="mx-stat-delta up">
{{ data.serverVersion === null ? '版本未知' : `版本 ${data.serverVersion}` }}
</span>
</div>
</section>
<div class="dash-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">当前赛程</span>
<span class="mx-badge mx-badge-neutral">{{ data.events.length }} 个小项</span>
</div>
<div class="mx-card-body no-pad">
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>项目</th>
<th>组别</th>
<th>状态</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="event in data.events.slice(0, 8)" :key="event.id">
<td>{{ event.name }}</td>
<td class="mono">{{ event.code }}</td>
<td>
<span
class="mx-badge"
:class="event.status === 'published' ? 'mx-badge-success' : 'mx-badge-warning'"
>
{{ event.status === 'published' ? '已发布' : '待录入' }}
</span>
</td>
<td class="right">
<RouterLink class="mx-btn mx-btn-ghost mx-btn-sm" :to="{ name: 'entry', query: { eventId: event.id } }">
录入
</RouterLink>
</td>
</tr>
<tr v-if="data.events.length === 0">
<td colspan="4">
<div class="mx-empty">
<div class="mx-empty-title">还没有项目</div>
<div class="mx-empty-desc">先在「基础数据」里创建小项</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">实时积分 Top 3</span>
<RouterLink class="mx-btn mx-btn-ghost mx-btn-sm" :to="{ name: 'standings' }">查看全部</RouterLink>
</div>
<div class="mx-card-body">
<ol class="top-list">
<li v-for="row in topTeams" :key="row.teamId" class="top-item">
<span class="top-rank">{{ row.rank }}</span>
<span class="top-name">
<strong>{{ row.teamName }}</strong>
<small>金 {{ row.gold }} · 银 {{ row.silver }} · 铜 {{ row.bronze }}</small>
</span>
<span class="top-points mono">{{ formatPoints(row.totalPoints) }}</span>
</li>
<li v-if="topTeams.length === 0" class="top-empty">暂无已发布成绩</li>
</ol>
<div v-if="pendingEvents.length > 0" class="todo">
<div class="todo-title"><MxIcon name="warning" :size="14" /> 待办</div>
<p>
还有 {{ pendingEvents.length }} 个项目未录入:
{{ pendingEvents.map((event) => event.name).join('、') }}
</p>
<RouterLink class="mx-btn mx-btn-primary mx-btn-sm" :to="{ name: 'entry' }">去录入</RouterLink>
</div>
</div>
</section>
</div>
<p v-if="!auth.readOnly" class="foot-hint">
提示:录入页支持 <kbd>Tab</kbd> / <kbd>Enter</kbd> 在格子间移动,<kbd>Ctrl</kbd>+<kbd>Enter</kbd> 存草稿,
<kbd>Alt</kbd>+<kbd>Enter</kbd> 发布。
</p>
</div>
</template>
<style scoped>
.conn {
font-size: 20px;
}
.no-pad {
padding: 0;
}
.right {
text-align: right;
}
.dash-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
}
.top-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.top-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.top-rank {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
background: var(--mx-primary-glow);
color: var(--mx-primary);
flex-shrink: 0;
}
.top-name {
flex: 1;
display: flex;
flex-direction: column;
line-height: 1.3;
}
.top-name small {
font-size: 11px;
color: var(--mx-t3);
}
.top-points {
font-size: 18px;
font-weight: 800;
color: var(--mx-primary);
}
.top-empty {
color: var(--mx-t3);
font-size: 13px;
padding: 12px 0;
}
.todo {
margin-top: 16px;
padding: 12px 14px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.08);
font-size: 13px;
color: var(--mx-t2);
}
.todo-title {
display: flex;
align-items: center;
gap: 6px;
color: var(--mx-amber);
font-weight: 700;
margin-bottom: 6px;
}
.todo p {
margin-bottom: 10px;
line-height: 1.6;
}
.foot-hint {
margin-top: 20px;
font-size: 12px;
color: var(--mx-t3);
}
kbd {
padding: 1px 6px;
border: 1px solid var(--mx-border);
border-radius: 5px;
background: var(--mx-s3);
font-family: var(--mx-mono);
font-size: 11px;
}
@media (max-width: 1023px) {
.dash-grid {
grid-template-columns: 1fr;
}
}
</style>
+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>
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import MxIcon from '../components/MxIcon.vue'
import { mockMode, mockReason } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
const auth = useAuthStore()
const data = useEventStore()
const toast = useToastStore()
const router = useRouter()
const username = ref('')
const password = ref('')
const canSubmit = computed(() => username.value.trim() !== '' && password.value !== '' && !auth.loading)
async function submit(): Promise<void> {
if (!canSubmit.value) return
const ok = await auth.login(username.value.trim(), password.value)
if (!ok) {
toast.error(auth.error ?? '登录失败')
return
}
toast.success(`欢迎回来,${auth.displayName}`)
await data.load()
void router.push({ name: 'entry' })
}
function asGuest(): void {
toast.info('已进入只读访客视图')
void router.push({ name: 'standings' })
}
</script>
<template>
<div class="login-page">
<div class="mx-card login-card">
<div class="mx-card-body">
<div class="login-brand">
<span class="mx-logo-icon"><MxIcon name="standings" :size="20" /></span>
<div>
<h1>积分榜管理面板</h1>
<p>2026 职工运动会 · 成绩录入与实时积分</p>
</div>
</div>
<form class="login-form" @submit.prevent="submit">
<label class="mx-label" for="username">账号</label>
<div class="mx-input-wrap">
<span class="mx-input-icon"><MxIcon name="user" :size="16" /></span>
<input id="username" v-model="username" class="mx-input" autocomplete="username" placeholder="录入员 / 裁判长" />
</div>
<label class="mx-label" for="password">密码</label>
<div class="mx-input-wrap">
<span class="mx-input-icon"><MxIcon name="lock" :size="16" /></span>
<input
id="password"
v-model="password"
class="mx-input"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
/>
</div>
<button class="mx-btn mx-btn-primary mx-btn-lg login-submit" type="submit" :disabled="!canSubmit">
<MxIcon name="check" :size="16" />
{{ auth.loading ? '登录中…' : '登录' }}
</button>
</form>
<div class="login-divider"><span>或</span></div>
<button class="mx-btn mx-btn-secondary login-submit" type="button" @click="asGuest">
以访客身份只读浏览
</button>
<p v-if="mockMode" class="mx-badge mx-badge-warning login-mock">
示例数据:{{ mockReason ?? '服务端未就绪' }},登录后操作不会写入真实赛事
</p>
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(1200px 600px at 80% -10%, var(--mx-primary-glow), transparent 60%),
var(--mx-bg);
}
.login-card {
width: 100%;
max-width: 420px;
}
.login-brand {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 24px;
}
.login-brand h1 {
font-size: 19px;
font-weight: 800;
}
.login-brand p {
font-size: 12px;
color: var(--mx-t3);
margin-top: 3px;
}
.login-form {
display: flex;
flex-direction: column;
}
.login-form .mx-label:not(:first-child) {
margin-top: 16px;
}
.login-submit {
width: 100%;
margin-top: 20px;
}
.login-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 18px 0;
color: var(--mx-t3);
font-size: 12px;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--mx-divider);
}
.login-mock {
margin-top: 18px;
width: 100%;
justify-content: center;
text-align: center;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import type { IconName } from '../components/icons'
/**
* 未实现页面的统一骨架。
* M4 只要求占位路由,这里把「这页将来做什么」写清楚,避免点进来是一片空白。
*/
const route = useRoute()
const TITLE_ICONS: Record<string, IconName> = {
review: 'review',
records: 'record',
adjustments: 'adjust',
'base-data': 'database',
export: 'export',
history: 'history',
logs: 'logs',
system: 'system',
}
const title = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '页面'))
const note = computed(() => (typeof route.meta.note === 'string' ? route.meta.note : ''))
const icon = computed<IconName>(() => TITLE_ICONS[String(route.name)] ?? 'system')
const PLANNED: Record<string, string[]> = {
review: ['草稿列表(按项目 / 时间筛选)', '并排对比历史版本', '批量发布 / 退回,留审计'],
records: ['疑似破纪录自动提示', '裁判长确认后生成加分流水', '本期破纪录加分关闭,功能保留'],
adjustments: ['人工加减分(分值可正可负)', '类别:处罚 / 补分 / 更正', '理由必填、可作废、全程审计'],
'base-data': ['单位 / 大项 / 小项 / 组别', '积分模板与计分通道', '运动员与报名表 xlsx 导入'],
export: ['总表 / 分项成绩表 / 单位明细', '按小项、组别单独导出', '赛事项目包 zip 导入导出'],
history: ['仿 Git Graph 的时间轴 + 分支线', '节点详情:操作人 / 动作 / 分数增减', '只读,不做一键回滚'],
logs: ['审计日志:谁在什么时候改了什么', '按实体类型 / 用户 / 时间筛选', '可关联 requestId 追查重试'],
system: ['用户与角色管理', '备份与恢复', '赛事信息与项目切换'],
}
</script>
<template>
<div>
<PageHeader :title="title" :subtitle="note">
<template #actions>
<span class="mx-badge mx-badge-neutral">规划中</span>
</template>
</PageHeader>
<div class="mx-card">
<div class="mx-card-body">
<div class="skeleton-head">
<span class="skeleton-icon"><MxIcon :name="icon" :size="22" /></span>
<div>
<h2>{{ title }}页面将在后续里程碑实现</h2>
<p>M4 只交付占位路由与骨架,核心链路(成绩录入 → 积分榜 → 大屏)已可用。</p>
</div>
</div>
<div class="planned">
<div class="planned-title">计划能力</div>
<ul>
<li v-for="item in PLANNED[String(route.name)] ?? []" :key="item">
<MxIcon name="check" :size="14" />
<span>{{ item }}</span>
</li>
</ul>
</div>
<div class="mx-stat-grid skeleton-grid">
<div v-for="index in 3" :key="index" class="mx-stat skeleton-stat">
<div class="mx-skeleton line short"></div>
<div class="mx-skeleton line tall"></div>
<div class="mx-skeleton line"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.skeleton-head {
display: flex;
gap: 14px;
align-items: flex-start;
margin-bottom: 20px;
}
.skeleton-icon {
width: 44px;
height: 44px;
border-radius: var(--mx-r-sm);
background: var(--mx-primary-glow);
color: var(--mx-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.skeleton-head h2 {
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.skeleton-head p {
font-size: 13px;
color: var(--mx-t3);
line-height: 1.6;
}
.planned {
padding: 14px 16px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
margin-bottom: 20px;
}
.planned-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mx-t3);
margin-bottom: 10px;
}
.planned ul {
display: flex;
flex-direction: column;
gap: 8px;
}
.planned li {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t2);
}
.planned li svg {
color: var(--mx-green);
flex-shrink: 0;
}
.skeleton-grid {
opacity: 0.6;
}
.skeleton-stat {
cursor: default;
display: flex;
flex-direction: column;
gap: 10px;
}
.line {
height: 12px;
border-radius: 6px;
}
.line.short {
width: 40%;
}
.line.tall {
height: 24px;
width: 60%;
}
</style>
+465
View File
@@ -0,0 +1,465 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatDateTime, formatPoints, formatRankChange, type TeamBreakdown } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
type TabKey = 'points' | 'medals' | 'athletes'
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'points', label: '单位总分榜' },
{ key: 'medals', label: '团体奖牌榜' },
{ key: 'athletes', label: '个人积分榜' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const activeTab = ref<TabKey>('points')
const expandedTeamId = ref<number | null>(null)
const breakdowns = ref<Record<number, TeamBreakdown>>({})
const loadingBreakdown = ref(false)
const pushing = ref(false)
const closeDialog = ref(false)
const search = ref('')
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const filteredStandings = computed(() => {
const keyword = search.value.trim()
if (keyword === '') return data.standings
return data.standings.filter(
(row) => row.teamName.includes(keyword) || (row.shortName ?? '').includes(keyword) || row.teamCode.includes(keyword),
)
})
const filteredAthletes = computed(() => {
const keyword = search.value.trim()
if (keyword === '') return data.athleteStandings
return data.athleteStandings.filter(
(row) => row.athleteName.includes(keyword) || row.teamName.includes(keyword),
)
})
const activeBreakdown = computed<TeamBreakdown | null>(() =>
expandedTeamId.value === null ? null : (breakdowns.value[expandedTeamId.value] ?? null),
)
async function toggleTeam(teamId: number): Promise<void> {
if (expandedTeamId.value === teamId) {
expandedTeamId.value = null
return
}
expandedTeamId.value = teamId
if (breakdowns.value[teamId] !== undefined) return
loadingBreakdown.value = true
try {
breakdowns.value = { ...breakdowns.value, [teamId]: await adminApi.breakdown(teamId) }
} catch (caught) {
toast.error('加载积分明细失败', caught instanceof Error ? caught.message : null)
} finally {
loadingBreakdown.value = false
}
}
async function pushToDisplay(): Promise<void> {
const detail = activeBreakdown.value
if (detail === null) return
if (!canWrite.value) {
toast.error('当前不可推送', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
pushing.value = true
try {
await adminApi.displayCommand({
action: 'show-detail',
payload: {
teamId: detail.teamId,
title: `${detail.teamName} 积分明细`,
detail,
autoCloseSeconds: 60,
},
})
toast.success('已推送至大屏', '60 秒后自动关闭')
} catch (caught) {
toast.error('推送失败', caught instanceof Error ? caught.message : null)
} finally {
pushing.value = false
}
}
async function closeDisplayDetail(): Promise<void> {
pushing.value = true
try {
await adminApi.displayCommand({ action: 'hide-detail', payload: {} })
closeDialog.value = false
toast.info('已关闭大屏明细覆盖层')
} catch (caught) {
toast.error('关闭失败', caught instanceof Error ? caught.message : null)
} finally {
pushing.value = false
}
}
async function refresh(): Promise<void> {
await data.refreshStandings()
toast.success('积分榜已刷新')
}
</script>
<template>
<div>
<PageHeader title="积分榜" subtitle="实时排序,不做固定顺序;点击单位行可下钻明细">
<template #actions>
<button class="mx-btn mx-btn-secondary" type="button" @click="refresh">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
<button class="mx-btn mx-btn-ghost" type="button" @click="closeDialog = true">
<MxIcon name="close" :size="15" /> 关闭大屏明细
</button>
</template>
</PageHeader>
<div class="standings-toolbar">
<div class="mx-tabs">
<button
v-for="tab in TABS"
:key="tab.key"
class="mx-tab"
:class="{ active: activeTab === tab.key }"
type="button"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<div class="mx-input-wrap search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索单位 / 运动员" />
</div>
</div>
<div class="standings-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">{{ TABS.find((tab) => tab.key === activeTab)?.label }}</span>
<span class="mx-badge mx-badge-info">
更新于 {{ formatDateTime(data.lastRefreshAt) }}
</span>
</div>
<div class="mx-table-wrap">
<!-- 单位总分榜 -->
<table v-if="activeTab === 'points'" class="mx-table">
<thead>
<tr>
<th>名次</th>
<th>单位</th>
<th class="right">总分</th>
<th class="center">金</th>
<th class="center">银</th>
<th class="center">铜</th>
<th class="center">变化</th>
<th class="right">明细</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredStandings" :key="row.teamId" :class="{ expanded: expandedTeamId === row.teamId }">
<td>
<span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span>
</td>
<td>
<span class="team-name">{{ row.teamName }}</span>
<small class="team-code mono">{{ row.teamCode }}</small>
</td>
<td class="right points mono">{{ formatPoints(row.totalPoints) }}</td>
<td class="center mono gold">{{ row.gold }}</td>
<td class="center mono silver">{{ row.silver }}</td>
<td class="center mono bronze">{{ row.bronze }}</td>
<td class="center change">{{ formatRankChange(row.rankChange) || '—' }}</td>
<td class="right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="toggleTeam(row.teamId)">
{{ expandedTeamId === row.teamId ? '收起' : '展开' }}
</button>
</td>
</tr>
<tr v-if="filteredStandings.length === 0">
<td colspan="8">
<div class="mx-empty">
<div class="mx-empty-title">暂无积分数据</div>
<div class="mx-empty-desc">发布成绩后这里会实时更新</div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 团体奖牌榜 -->
<table v-else-if="activeTab === 'medals'" class="mx-table">
<thead>
<tr>
<th>名次</th>
<th>单位</th>
<th class="center">金</th>
<th class="center">银</th>
<th class="center">铜</th>
<th class="right">合计</th>
<th class="right">明细</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredStandings" :key="row.teamId">
<td><span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span></td>
<td>{{ row.teamName }}</td>
<td class="center mono gold">{{ row.gold }}</td>
<td class="center mono silver">{{ row.silver }}</td>
<td class="center mono bronze">{{ row.bronze }}</td>
<td class="right mono">{{ row.medalTotal }}</td>
<td class="right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="toggleTeam(row.teamId)">
{{ expandedTeamId === row.teamId ? '收起' : '展开' }}
</button>
</td>
</tr>
</tbody>
</table>
<!-- 个人积分榜 -->
<table v-else class="mx-table">
<thead>
<tr>
<th>名次</th>
<th>运动员</th>
<th>单位</th>
<th class="right">个人总分</th>
<th class="center">金</th>
<th class="center">银</th>
<th class="center">铜</th>
</tr>
</thead>
<tbody>
<tr v-for="row in filteredAthletes" :key="`${row.athleteId}-${row.teamId}`">
<td><span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span></td>
<td>{{ row.athleteName }}</td>
<td>{{ row.teamName }}</td>
<td class="right points mono">{{ formatPoints(row.totalPoints) }}</td>
<td class="center mono gold">{{ row.gold }}</td>
<td class="center mono silver">{{ row.silver }}</td>
<td class="center mono bronze">{{ row.bronze }}</td>
</tr>
<tr v-if="filteredAthletes.length === 0">
<td colspan="7">
<div class="mx-empty">
<div class="mx-empty-title">暂无个人积分</div>
<div class="mx-empty-desc">个人项目发布后自动汇总</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<aside class="mx-card detail-card">
<div class="mx-card-head">
<span class="mx-card-title">
{{ activeBreakdown === null ? '单位积分明细' : `${activeBreakdown.teamName} · 明细` }}
</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="activeBreakdown === null || !canWrite || pushing"
@click="pushToDisplay"
>
<MxIcon name="push" :size="14" /> 推送到大屏
</button>
</div>
<div class="mx-card-body">
<div v-if="activeBreakdown === null" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="standings" :size="26" /></div>
<div class="mx-empty-title">选择左侧单位查看明细</div>
<div class="mx-empty-desc">每一分都可溯源到具体项目与名次</div>
</div>
<template v-else>
<div class="detail-total">
<span>合计</span>
<strong class="mono">{{ formatPoints(activeBreakdown.totalPoints) }}</strong>
</div>
<ul class="detail-list">
<li v-for="item in activeBreakdown.items" :key="item.ledgerId" class="detail-item">
<span class="detail-main">
<strong>{{ item.eventName ?? '—' }}</strong>
<small>{{ item.categoryName ?? '' }} · {{ item.athleteName ?? '团体' }}</small>
</span>
<span class="detail-result mono">{{ item.resultText ?? '—' }}</span>
<span class="detail-rank mono">第 {{ item.rank ?? '—' }} 名</span>
<span class="detail-points mono">+{{ formatPoints(item.points) }}</span>
</li>
<li v-if="activeBreakdown.items.length === 0" class="detail-empty">暂无已发布的得分记录</li>
</ul>
</template>
</div>
</aside>
</div>
<ConfirmDialog
:open="closeDialog"
tone="warning"
title="关闭大屏积分明细?"
message="大屏将关闭当前的半屏明细覆盖层,并恢复自动轮播。"
confirm-text="关闭明细"
:pending="pushing"
@confirm="closeDisplayDetail"
@cancel="closeDialog = false"
/>
</div>
</template>
<style scoped>
.standings-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.search {
width: 260px;
}
.standings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: start;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--mx-s3);
font-weight: 800;
font-size: 13px;
}
.rank-badge.top {
background: var(--mx-primary-glow);
color: var(--mx-primary);
}
.team-name {
font-weight: 600;
}
.team-code {
margin-left: 8px;
font-size: 11px;
color: var(--mx-t3);
}
.points {
font-size: 15px;
font-weight: 800;
color: var(--mx-primary);
}
.gold {
color: #ffd75e;
}
.silver {
color: #cfd6e4;
}
.bronze {
color: #d98b4a;
}
.change {
font-size: 12px;
color: var(--mx-t3);
}
tr.expanded {
background: var(--mx-primary-glow);
}
.detail-card {
position: sticky;
top: 0;
}
.detail-total {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
margin-bottom: 12px;
font-size: 13px;
color: var(--mx-t3);
}
.detail-total strong {
font-size: 22px;
color: var(--mx-primary);
}
.detail-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 52dvh;
overflow-y: auto;
}
.detail-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s2);
font-size: 12px;
}
.detail-main {
display: flex;
flex-direction: column;
line-height: 1.3;
overflow: hidden;
}
.detail-main strong {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-main small {
color: var(--mx-t3);
}
.detail-result,
.detail-rank {
color: var(--mx-t2);
font-size: 11px;
}
.detail-points {
color: var(--mx-green);
font-weight: 800;
}
.detail-empty {
color: var(--mx-t3);
font-size: 12px;
padding: 10px 0;
}
@media (max-width: 1279px) {
.standings-grid {
grid-template-columns: 1fr;
}
.detail-card {
position: static;
}
}
</style>