- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
466 lines
14 KiB
Vue
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>
|