Files
Scoreboard/web/src/admin/pages/StandingsPage.vue
T
AskaEth bc8b1ee6c0 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
2026-09-09 19:02:24 +08:00

466 lines
14 KiB
Vue

<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>