feat: 大屏控制可看局域网设备并逐台切页 + 两种新导出(单页成绩 / 单位积分总明细)

## 大屏控制(用户:管理面板只能看到本机显示器,看不到局域网设备;也不能给某块屏单独切页)
- 服务端:`POST /api/display/command` 新增可选 `targets []string` —— 空 = 广播(公告语义不变),
  非空 = 只发给点名的实例并返回命中数;命中 0 个返回 404 +「目标大屏已离线,请刷新在线列表」
  (不落审计、公告也不落库)。`DisplaySender` 签名随之扩展,新增
  `Hub.SendCommandToInstances`(与按房间的 SendCommand 并存)。
- 服务端:WS `hello` 新增可选 `screen`,`realtime.Instance` 新增 `Screen`(不带 omitempty,
  局域网浏览器恒为空串),这样管理端能把「本机显示器」与「在线实例」精确对上。
- 管理端「大屏控制」:写死的「在线看板」占位换成真实列表(5 秒轮询、页面隐藏停表),
  列出实例 id / 在线离线 / 落后 / 屏号或「局域网浏览器」/ 当前页 / 来源 / 心跳;
  每行可单独切页;本机显示器卡片补「当前页」与逐屏切页;离线时控件置灰并说明原因。
  页面下拉改为共用大屏那份页名清单(管理端两处标签随之变为「个人积分奖牌榜」「分项成绩」)。

## 导出(用户:保留多页工作簿的同时,再要一种全平铺单页;积分榜明细也要能导出)
- 新增 `GET /api/export/all-flat.xlsx` → `赛事成绩总表(单页).xlsx`:一个工作簿一张表,
  列 = 大项/小项/组别/名次/号码/单位/运动员-队/成绩/积分/状态/备注;前三列取
  `EventSheet` 的结构化字段(不解析标题),后 8 列就是 `eventColumns` 本身(append 拼出,
  两处不可能漂移)。**既有「全部」(all.xlsx,多 sheet)一字未动。**
- 新增 `GET /api/export/teams.xlsx` → `单位积分总明细.xlsx`:一张带「单位」列的总表 +
  每个有明细行的单位一张分表;**无明细行的单位不出分表**(空表没信息量,且会把假删除的
  隐藏单位名字重新印进导出件);已隐藏但有流水的单位照常出表(它在榜单上)。
- 积分榜页:页头「导出总明细」、单位明细面板「导出该单位明细」;导出页新增「全部(单页)」。

测试:服务端 12 包全绿 / 347 个测试函数;前端 vue-tsc 通过、184 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(含新增 Q65–Q70 与两轮进度记录)。
This commit is contained in:
AskaEth committed 2026-09-11 00:44:03 +08:00
1 parent 793d2ac064
commit 782a38f82f
33 files changed
+2473 -119

No files matched your search

+13 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { ApiError, createApiClient } from '@/shared/api'
import type { AthletePurgeResult } from '@/shared/api'
import type { AthletePurgeResult, DisplayInstance } from '@/shared/api'
import type {
AthleteStanding,
AuditLog,
@@ -186,6 +186,18 @@ export const adminApi = {
displayCommand: (command: DisplayCommand): Promise<void> =>
callWrite(() => client.display.command(command)),
/**
* 在线大屏实例(含 TTL 内保留的离线实例)。
*
* 后端连不上时给空列表而不是报错:这是每几秒轮询一次的看板,弹一屏错误提示只会盖住
* 真正有用的信息,页面自己会标出「示例数据 / 未就绪」。
*/
displayInstances: (): Promise<DisplayInstance[]> =>
call(
() => client.display.instances(),
() => [],
),
login: (username: string, password: string): Promise<CurrentUser> =>
callWrite(() => client.auth.login(username, password)),
+6 -1
View File
@@ -516,7 +516,7 @@ export const backupsApi = {
// 结果导出(xlsx)
// ---------------------------------------------------------------------------
export type ResultExportKind = 'standings' | 'all' | 'event' | 'team'
export type ResultExportKind = 'standings' | 'all' | 'allFlat' | 'event' | 'team' | 'teamAll'
export interface ResultExportRequest {
kind: ResultExportKind
@@ -532,8 +532,10 @@ export interface DownloadFile {
const RESULT_EXPORT_FILENAME: Record<ResultExportKind, string> = {
standings: '总积分榜.xlsx',
all: '赛事成绩总表.xlsx',
allFlat: '赛事成绩总表(单页).xlsx',
event: '分项成绩表.xlsx',
team: '单位积分明细.xlsx',
teamAll: '单位积分总明细.xlsx',
}
function mockDownload(request: ResultExportRequest, context: string): DownloadFile {
@@ -558,6 +560,7 @@ export const resultsExportApi = {
async () => {
let path = '/export/standings.xlsx'
if (request.kind === 'all') path = '/export/all.xlsx'
if (request.kind === 'allFlat') path = '/export/all-flat.xlsx'
if (request.kind === 'event') {
if (request.eventId === undefined) {
throw new ApiError('VALIDATION_FAILED', '请先选择要导出的小项', 0)
@@ -570,6 +573,8 @@ export const resultsExportApi = {
}
path = `/export/teams/${request.teamId}`
}
// 总明细:服务端把「单位」列加在最前,再给每个有明细行的单位附一张分表。
if (request.kind === 'teamAll') path = '/export/teams.xlsx'
const blob = await requestBlob(path, { timeoutMs: 60_000 })
return { blob, filename: RESULT_EXPORT_FILENAME[request.kind] }
},
+243 -69
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import type { DisplayInstance } from '@/shared/api'
import { DISPLAY_PAGES, type DisplayPageKey } from '@/display/types'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
@@ -8,24 +10,11 @@ import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 大屏页面表里的规范值;写错会被大屏静默丢弃,所以用字面量联合锁死。 */
type DisplayPage = 'standings' | 'team-medals' | 'athlete-standings' | 'event-results'
interface ScreenNode {
key: string
name: string
/** null = 未知;后端没有实例上报接口,占位节点恒为未知。 */
online: boolean | null
/** 本机最后一次下发指令的时间,不是大屏心跳。 */
lastCommandAt: string | null
}
const PAGES: Array<{ key: DisplayPage; label: string }> = [
{ key: 'standings', label: '总积分榜' },
{ key: 'team-medals', label: '团体奖牌榜' },
{ key: 'athlete-standings', label: '个人积分榜' },
{ key: 'event-results', label: '最新成绩' },
]
/**
* 页面选项直接取大屏那份清单(`@/display/types`):规范页名与中文标签只维护一处,
* 这里再抄一份迟早会和大屏对不上。写错页名大屏会静默丢弃,所以不给自定义。
*/
type DisplayPage = DisplayPageKey
const data = useEventStore()
const auth = useAuthStore()
@@ -161,27 +150,163 @@ async function relayoutDisplays(): Promise<void> {
})
}
// ── 在线大屏实例(含局域网浏览器) ────────────────────────────────────────────
const instances = ref<DisplayInstance[]>([])
const instancesLoading = ref(false)
const instancesError = ref<string | null>(null)
const instancesUpdatedAt = ref<string | null>(null)
/** 轮询间隔:与现场「点完切页看大屏反应」的节奏对得上,也不至于把服务端打满。 */
const INSTANCE_POLL_MS = 5000
let pollTimer: ReturnType<typeof setInterval> | null = null
/** 在线优先,其余按实例 id:离线实例会在注册表里按 TTL 留一阵,排在后面不挡视线。 */
function sortInstances(list: DisplayInstance[]): DisplayInstance[] {
return [...list].sort((a, b) => Number(b.online) - Number(a.online) || a.id.localeCompare(b.id))
}
async function refreshInstances(): Promise<void> {
// 轮询与手动刷新撞在一起时只留一个在飞,避免旧响应盖掉新结果。
if (instancesLoading.value) return
instancesLoading.value = true
try {
instances.value = sortInstances(await adminApi.displayInstances())
instancesError.value = null
instancesUpdatedAt.value = new Date().toISOString()
} catch (caught) {
// 列表读不到不该清空上一次的结果:现场还得靠它判断谁在线。
instancesError.value = caught instanceof Error ? caught.message : '读取在线大屏失败'
} finally {
instancesLoading.value = false
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
function startPolling(): void {
stopPolling()
pollTimer = setInterval(() => {
// 面板切到后台就不再请求;切回来时 visibilitychange 立刻补一次。
if (document.visibilityState === 'hidden') return
void refreshInstances()
}, INSTANCE_POLL_MS)
}
function onVisibilityChange(): void {
if (document.visibilityState === 'visible') void refreshInstances()
}
onMounted(() => {
void loadDisplays()
void refreshAnnounce()
void refreshInstances()
startPolling()
document.addEventListener('visibilitychange', onVisibilityChange)
})
/**
* 在线看板(占位)。
*
* devdocs/05 §7 未定义「大屏在线列表」接口,管理端拿不到每块屏的心跳,
* 所以这里只登记本地已知的屏,在线状态恒为「未知」——指令送达不等于大屏在线。
* 后端补上实例上报接口后,把 screens 换成接口数据、hasInstanceData 才可能为真。
*/
const screens = ref<ScreenNode[]>([{ key: 'main', name: '主屏(本地登记)', online: null, lastCommandAt: null }])
onBeforeUnmount(() => {
stopPolling()
document.removeEventListener('visibilitychange', onVisibilityChange)
})
const canWrite = computed(() => !auth.readOnly && data.canWrite)
/** 只有真实实例上报(online 非 null)才算有在线数据,占位节点一律不谎报在线。 */
const hasInstanceData = computed(() => screens.value.some((screen) => screen.online !== null))
const onlineCount = computed(() => screens.value.filter((screen) => screen.online === true).length)
const onlineCount = computed(() => instances.value.filter((instance) => instance.online).length)
function recordDispatch(): void {
screens.value = screens.value.map((screen) => ({ ...screen, lastCommandAt: new Date().toISOString() }))
/**
* 实例的显示名:有屏号用屏号,局域网浏览器没有屏号,退回来源 IP,都用不上才退回实例 id。
* 只用于提示语,表格里始终显示完整 id。
*/
function instanceLabel(instance: DisplayInstance): string {
if (instance.screen !== '') return `屏 ${instance.screen}`
if (instance.remoteAddr !== '') return instance.remoteAddr
return instance.id
}
function pageLabel(page: string): string {
return DISPLAY_PAGES.find((item) => item.key === page)?.title ?? page
}
/** 本机显示器对应的在线实例:屏号是 Console 的 display.id,注册表里以字符串存。 */
function onlineInstanceOfScreen(item: DesktopDisplayInfo): DisplayInstance | null {
const screen = String(item.id)
return instances.value.find((instance) => instance.online && instance.screen === screen) ?? null
}
/** 该屏没有可用实例的原因,用来解释切页按钮为什么是灰的。 */
function screenMissingNote(item: DesktopDisplayInfo): string | null {
if (onlineInstanceOfScreen(item) !== null) return null
const known = instances.value.some((instance) => instance.screen === String(item.id))
return known ? '该屏窗口已关闭(实例离线),先开窗或等它重连' : '该屏窗口未开,或大屏没上报屏号'
}
function screenCurrentPage(item: DesktopDisplayInfo): string {
const instance = onlineInstanceOfScreen(item)
if (instance === null) return '窗口未开'
return instance.page === '' ? '未上报' : pageLabel(instance.page)
}
// ── 定向切页 ────────────────────────────────────────────────────────────────
/** 每行待下发的页名,键是 `instance:<id>` / `screen:<id>`,各行互不影响。 */
const draftPage = ref<Record<string, DisplayPageKey>>({})
/** 正在下发的行;同时挡住重复点击与并发指令。 */
const targetPending = ref<string | null>(null)
/** 没手动选过时默认停在该实例当前所在页;认不出的页名退回总积分榜。 */
function draftFor(key: string, current: string): DisplayPageKey {
const picked = draftPage.value[key]
if (picked !== undefined) return picked
return DISPLAY_PAGES.some((page) => page.key === current) ? (current as DisplayPageKey) : 'standings'
}
/** 只接受页面表里的值:页名写错大屏会静默丢弃,这里先拦住。 */
function pickPage(key: string, event: Event): void {
const value = (event.target as HTMLSelectElement).value
if (!DISPLAY_PAGES.some((page) => page.key === value)) return
draftPage.value = { ...draftPage.value, [key]: value as DisplayPageKey }
}
/** 只发给点名的实例(targets);不带 targets 是广播,公告那类全屏指令才用。 */
async function sendGoto(key: string, targets: string[], page: DisplayPageKey, label: string): Promise<void> {
if (!canWrite.value) {
toast.error('当前不可下发指令', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (data.isMock) {
toast.warning('示例数据模式未真正下发', '后端未就绪,指令不会到达大屏')
return
}
if (targetPending.value !== null) return
targetPending.value = key
try {
await adminApi.displayCommand({ action: 'goto', payload: { page }, targets })
toast.success(`已把「${label}」切到「${pageLabel(page)}」`)
// 服务端会同步更新注册表里的当前页,刷一次让列表跟上。
await refreshInstances()
} catch (caught) {
// 「目标大屏已离线,请刷新在线列表」这类服务端原文必须让操作员看见。
toast.error('切页失败', caught instanceof Error ? caught.message : null)
} finally {
targetPending.value = null
}
}
function gotoInstance(instance: DisplayInstance): void {
const key = `instance:${instance.id}`
void sendGoto(key, [instance.id], draftFor(key, instance.page), instanceLabel(instance))
}
function gotoScreen(item: DesktopDisplayInfo): void {
const instance = onlineInstanceOfScreen(item)
if (instance === null) return
const key = `screen:${item.id}`
void sendGoto(key, [instance.id], draftFor(key, instance.page), item.label)
}
async function send(
@@ -200,12 +325,13 @@ async function send(
try {
if (action === 'goto') {
// 页面值不在大屏别名表内会被静默丢弃,这里先拦下并明确报错,避免谎报成功。
if (!PAGES.some((page) => page.key === currentPage.value)) {
if (!DISPLAY_PAGES.some((page) => page.key === currentPage.value)) {
toast.error('大屏不认识该页面', `页面值「${currentPage.value}」不在大屏页面表内`)
return
}
// 不带 targets:这里是「所有大屏一起切」,逐台切页在下面的在线列表里。
await adminApi.displayCommand({ action, payload: { page: currentPage.value } })
toast.success(`已切换大屏到「${PAGES.find((page) => page.key === currentPage.value)?.label ?? currentPage.value}」`)
toast.success(`已切换全部大屏到「${pageLabel(currentPage.value)}」`)
} else if (action === 'lock' || action === 'unlock') {
await adminApi.displayCommand({ action, payload: {} })
locked.value = action === 'lock'
@@ -230,7 +356,6 @@ async function send(
toast.success('公告已推送')
await refreshAnnounce()
}
recordDispatch()
} catch (caught) {
toast.error('指令下发失败', caught instanceof Error ? caught.message : null)
} finally {
@@ -241,7 +366,7 @@ async function send(
<template>
<div>
<PageHeader title="大屏控制" subtitle="切页 / 锁定 / 刷新 / 公告 / 在线看板">
<PageHeader title="大屏控制" subtitle="切页 / 锁定 / 刷新 / 公告 / 在线大屏">
<template #actions>
<span class="mx-badge" :class="locked ? 'mx-badge-warning' : 'mx-badge-success'">
{{ locked ? '已锁定' : '轮播中' }}
@@ -256,7 +381,7 @@ async function send(
<div class="mx-card-body">
<div class="page-grid">
<button
v-for="page in PAGES"
v-for="page in DISPLAY_PAGES"
:key="page.key"
class="page-btn"
:class="{ active: currentPage === page.key }"
@@ -264,7 +389,7 @@ async function send(
@click="currentPage = page.key"
>
<MxIcon name="display" :size="16" />
{{ page.label }}
{{ page.title }}
</button>
</div>
<button
@@ -376,7 +501,7 @@ async function send(
<span class="display-pick-label">窗口页面</span>
<span class="mx-input-wrap display-pick-select">
<select v-model="windowPage" class="mx-input">
<option v-for="page in PAGES" :key="page.key" :value="page.key">{{ page.label }}</option>
<option v-for="page in DISPLAY_PAGES" :key="page.key" :value="page.key">{{ page.title }}</option>
</select>
</span>
</label>
@@ -394,8 +519,26 @@ async function send(
{{ item.open ? `已开窗口(${item.mode === 'kiosk' ? '全屏' : '窗口化'})` : '未开窗口' }}
</div>
<div v-if="!item.open && !canFullscreen(item)" class="display-sub">单显示器 / 主显示器只能窗口化</div>
<div class="display-sub">当前页:{{ screenCurrentPage(item) }}</div>
<div v-if="screenMissingNote(item) !== null" class="display-sub">{{ screenMissingNote(item) }}</div>
</div>
<div class="display-actions">
<select
class="mx-cell-input screen-page-select"
:value="draftFor(`screen:${item.id}`, onlineInstanceOfScreen(item)?.page ?? '')"
:disabled="!canWrite || onlineInstanceOfScreen(item) === null || targetPending !== null"
@change="pickPage(`screen:${item.id}`, $event)"
>
<option v-for="page in DISPLAY_PAGES" :key="page.key" :value="page.key">{{ page.title }}</option>
</select>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite || onlineInstanceOfScreen(item) === null || targetPending !== null"
@click="gotoScreen(item)"
>
{{ targetPending === `screen:${item.id}` ? '下发中…' : '切到此页' }}
</button>
<button
v-if="item.open"
class="mx-btn mx-btn-secondary"
@@ -448,44 +591,63 @@ async function send(
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">在线看板</span>
<span class="mx-badge" :class="hasInstanceData ? (onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-error') : 'mx-badge-neutral'">
{{ hasInstanceData ? `${onlineCount} / ${screens.length} 在线` : '在线状态未知(占位)' }}
<span class="mx-card-title">在线大屏</span>
<span class="mx-badge" :class="onlineCount > 0 ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ onlineCount }} / {{ instances.length }} 在线
</span>
</div>
<div class="mx-card-body">
<div class="board-note">
<div v-if="instancesError !== null" class="board-note">
<MxIcon name="warning" :size="14" />
<span>后端未提供大屏实例上报接口(devdocs/05 §7 未定义),下表是本地占位节点:「在线」无法判定,也不按指令送达推断;「最近下发」只是本机下发时间,不代表大屏在线。</span>
<span>读取在线大屏失败:{{ instancesError }}</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.lastCommandAt === null ? '—' : formatDateTime(screen.lastCommandAt) }}</td>
</tr>
</tbody>
</table>
<p v-if="instances.length === 0" class="display-note">
还没有大屏上报。本机用 Console 打开的大屏窗口、以及局域网设备打开 /display 后,都会在这里出现(每 5 秒刷新一次)。
</p>
<div v-for="instance in instances" :key="instance.id" class="display-row">
<div class="display-meta">
<div class="display-name">
<span class="instance-id">{{ instance.id }}</span>
<span class="mx-badge" :class="instance.online ? 'mx-badge-success' : 'mx-badge-error'">
{{ instance.online ? '在线' : '离线' }}
</span>
<span v-if="instance.slow" class="mx-badge mx-badge-warning">落后</span>
<span class="mx-badge mx-badge-neutral">
{{ instance.screen === '' ? '局域网浏览器' : `屏 ${instance.screen}` }}
</span>
</div>
<div class="display-sub">
当前页:{{ instance.page === '' ? '未上报' : pageLabel(instance.page) }} · 来源:{{ instance.remoteAddr === '' ? '未知' : instance.remoteAddr }}
</div>
<div class="display-sub">最后心跳:{{ formatDateTime(instance.lastHeartbeat) }}</div>
<div v-if="!instance.online" class="display-sub">已离线:指令发不到它,等大屏重新连上再切页。</div>
</div>
<div class="display-actions">
<select
class="mx-cell-input instance-page-select"
:value="draftFor(`instance:${instance.id}`, instance.page)"
:disabled="!canWrite || !instance.online || targetPending !== null"
@change="pickPage(`instance:${instance.id}`, $event)"
>
<option v-for="page in DISPLAY_PAGES" :key="page.key" :value="page.key">{{ page.title }}</option>
</select>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite || !instance.online || targetPending !== null"
@click="gotoInstance(instance)"
>
{{ targetPending === `instance:${instance.id}` ? '下发中…' : '切到此页' }}
</button>
</div>
</div>
<div class="board-hint">
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 页面 {{ PAGES.find((page) => page.key === currentPage)?.label ?? currentPage }}</p>
<p>当前状态:{{ locked ? '已锁定' : '自动轮播' }} · 全部大屏页面 {{ pageLabel(currentPage) }}</p>
<p v-if="instancesUpdatedAt !== null" class="muted">最近一次刷新:{{ formatDateTime(instancesUpdatedAt) }}</p>
<p class="muted">「落后」= 该实例确认的版本落后(大屏可能在卡或网络差);离线实例会在服务端保留一段时间,便于排查。</p>
<p v-if="data.isMock" class="mock">示例数据模式下指令不会真正下发到大屏。</p>
</div>
</div>
@@ -651,9 +813,21 @@ async function send(
display: flex;
align-items: center;
gap: 8px;
/* 实例 id + 状态 + 屏号几个徽标在窄栏里要能换行,不能把卡片撑破。 */
flex-wrap: wrap;
font-size: 13px;
font-weight: 600;
}
.instance-id {
font-family: var(--mx-mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.instance-page-select,
.screen-page-select {
width: 132px;
flex: none;
}
.display-sub {
font-size: 12px;
color: var(--mx-t3);
+7 -1
View File
@@ -55,6 +55,12 @@ const EXPORT_OPTIONS: ReadonlyArray<ExportOption> = [
icon: 'standings',
},
{ key: 'all', title: '全部', desc: '总表 + 全部分项,多 sheet 工作簿,适合归档打印', icon: 'database' },
{
key: 'allFlat',
title: '全部(单页)',
desc: '所有单位、所有项目、所有成绩平铺在一张表里,适合筛选与二次统计',
icon: 'entry',
},
{ key: 'event', title: '分项成绩表', desc: '按小项导出成绩与积分,可单独给项目裁判', icon: 'export' },
{ key: 'team', title: '单位积分明细', desc: '按单位逐项列出得分来源,可解释每一分', icon: 'user' },
]
@@ -433,7 +439,7 @@ async function applyRules(): Promise<void> {
</div>
<div class="mx-card-body">
<p class="hint">
表头、合并单元格与字体都已按打印模板预设,导出即可直接打印。总表与全部导出由服务端合成一个多 sheet 工作簿。
表头、合并单元格与字体都已按打印模板预设,导出即可直接打印。「总表」「全部」由服务端合成一个多 sheet 工作簿;「全部(单页)」把所有单位所有项目的成绩平铺成一张表,便于筛选与二次统计。
</p>
<div class="export-grid">
+73 -8
View File
@@ -4,6 +4,7 @@ import { formatDateTime, formatPoints, formatRankChange, type TeamBreakdown } fr
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { describeError, resultsExportApi } from '../components/system/systemApi'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -26,6 +27,8 @@ const expandedTeamId = ref<number | null>(null)
const breakdowns = ref<Record<number, TeamBreakdown>>({})
const loadingBreakdown = ref(false)
const pushing = ref(false)
/** 正在进行的导出任务;非空时两个导出按钮都禁用,避免重复触发下载。 */
const exporting = ref<'team' | 'teamAll' | null>(null)
const closeDialog = ref(false)
const search = ref('')
@@ -94,6 +97,40 @@ async function pushToDisplay(): Promise<void> {
}
}
/**
* 导出明细是只读下载,服务端对 guest 也开放(与结果导出页一致),
* 因此不像「推送到大屏」那样要求 canWrite,只在请求进行中禁用,避免重复下载。
*/
async function exportTeamDetail(): Promise<void> {
const detail = activeBreakdown.value
if (detail === null || exporting.value !== null) return
exporting.value = 'team'
try {
const result = await resultsExportApi.download({ kind: 'team', teamId: detail.teamId })
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
} catch (caught) {
// 服务端的原文(无权限 / 单位不存在 / 连不上)原样带出,现场不用去翻日志。
toast.error('导出该单位明细失败', describeError(caught))
} finally {
exporting.value = null
}
}
async function exportAllTeams(): Promise<void> {
if (exporting.value !== null) return
exporting.value = 'teamAll'
try {
const result = await resultsExportApi.download({ kind: 'teamAll' })
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
} catch (caught) {
toast.error('导出总明细失败', describeError(caught))
} finally {
exporting.value = null
}
}
async function closeDisplayDetail(): Promise<void> {
pushing.value = true
try {
@@ -120,6 +157,15 @@ async function refresh(): Promise<void> {
<button class="mx-btn mx-btn-secondary" type="button" @click="refresh">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="exporting !== null"
@click="exportAllTeams"
>
<MxIcon name="export" :size="15" />
{{ exporting === 'teamAll' ? '导出中…' : '导出总明细' }}
</button>
<button class="mx-btn mx-btn-ghost" type="button" @click="closeDialog = true">
<MxIcon name="close" :size="15" /> 关闭大屏明细
</button>
@@ -271,14 +317,25 @@ async function refresh(): Promise<void> {
<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>
<span class="detail-actions">
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="activeBreakdown === null || exporting !== null"
@click="exportTeamDetail"
>
<MxIcon name="export" :size="14" />
{{ exporting === 'team' ? '导出中…' : '导出该单位明细' }}
</button>
<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>
</span>
</div>
<div class="mx-card-body">
<div v-if="activeBreakdown === null" class="mx-empty">
@@ -393,6 +450,14 @@ tr.expanded {
position: sticky;
top: 0;
}
/* 明细卡头部要同时放「导出」与「推送」两个按钮,超出时换行而不是撑破卡片。 */
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.detail-total {
display: flex;
align-items: baseline;
@@ -0,0 +1,135 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 大屏控制的接口契约:在线实例列表的字段、定向 goto 的请求形状,以及 404 原文的传递。
*
* 现场靠这份列表判断「局域网设备到底连上没有」,字段一错就会把在线设备显示成未知;
* 定向切页点名的实例全离线时服务端回 404,那句「目标大屏已离线」必须原样让操作员看到。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例,
// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const CONSOLE_WINDOW = {
id: 'LAPTOP-1-2528732444',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-11T10:00:00Z',
connectedAt: '2026-09-11T09:00:00Z',
remoteAddr: '127.0.0.1:52341',
ackVersion: 42,
slow: false,
screen: '2',
}
/** 局域网浏览器:没屏号,且 page / remoteAddr 因服务端 omitempty 可能整个字段都不在。 */
const LAN_BROWSER = {
id: 'display-9f3c11ab',
online: true,
lastHeartbeat: '2026-09-11T10:00:02Z',
connectedAt: '2026-09-11T09:58:00Z',
ackVersion: 7,
slow: true,
screen: '',
}
test('实例列表解析出屏号、来源 IP 与当前页', async () => {
respondWith(() => jsonResponse({ ok: true, data: [CONSOLE_WINDOW, LAN_BROWSER] }))
const list = await adminApi.displayInstances()
assert.equal(lastRequest.url, '/api/display/instances')
assert.equal(lastRequest.method, 'GET')
assert.equal(list.length, 2)
assert.deepEqual(list[0], CONSOLE_WINDOW)
// 局域网浏览器没有屏号,缺字段的 page / remoteAddr 归一成空串而不是 undefined。
assert.equal(list[1]?.screen, '')
assert.equal(list[1]?.remoteAddr, '')
assert.equal(list[1]?.page, '')
assert.equal(list[1]?.slow, true)
})
test('实例列表不是数组时报解析失败,不当成空列表', async () => {
respondWith(() => jsonResponse({ ok: true, data: { instances: [] } }))
await assert.rejects(() => adminApi.displayInstances(), (error: Error) =>
error.message.includes('大屏实例列表'),
)
})
test('定向 goto 带 targets,广播指令不带', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.displayCommand({ action: 'goto', payload: { page: 'standings' }, targets: ['display-1'] })
assert.equal(lastRequest.url, '/api/display/command')
assert.equal(lastRequest.method, 'POST')
const targeted = JSON.parse(String(lastRequest.body)) as {
action: string
payload: { page: string }
targets?: string[]
}
assert.equal(targeted.action, 'goto')
assert.equal(targeted.payload.page, 'standings')
assert.deepEqual(targeted.targets, ['display-1'])
// 公告那类全屏指令必须继续走广播:带上 targets 就会只发给一台。
await adminApi.displayCommand({ action: 'announce', payload: { text: '集合', seconds: 10 } })
const broadcast = JSON.parse(String(lastRequest.body)) as Record<string, unknown>
assert.equal('targets' in broadcast, false)
})
// 点名的实例全离线时服务端回 404;吞成「操作失败」现场就不知道该刷新列表了。
test('目标大屏离线时 404 原文原样抛出', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '目标大屏已离线,请刷新在线列表' } }, 404),
)
await assert.rejects(
() => adminApi.displayCommand({ action: 'goto', payload: { page: 'team-medals' }, targets: ['gone'] }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '目标大屏已离线,请刷新在线列表')
return true
},
)
})
// 看板每 5 秒轮询一次,后端没起来时给空列表就够了——这里弹错误只会盖住真正有用的信息。
test('后端连不上时实例列表给空数组,不抛错', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
assert.deepEqual(await adminApi.displayInstances(), [])
})
@@ -0,0 +1,146 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiError } from '@/shared'
import { mockReason } from '../api/client'
import { resultsExportApi } from '../components/system/systemApi'
/**
* 积分榜明细导出的前端契约:kind → /api/export/... 路径 + 下载文件名。
*
* 路径或文件名写错时现场拿到的是 404 或一份名字不对的表,所以这里用 stub fetch
* 钉住真正发出去的请求;同时确认服务端报错时那句原文没被吞成笼统的「导出失败」。
*/
interface Captured {
url?: string
method?: string
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** xlsx 下载是二进制响应,这里用一小段字节冒充,只关心 URL 与文件名。 */
function xlsxResponse(): Response {
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
status: 200,
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
})
}
let reply: () => Response = () => xlsxResponse()
let lastRequest: Captured = {}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET' }
return reply()
}) as typeof fetch
// systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。
;(globalThis as { window?: unknown }).window = {
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
}
function respondWith(next: () => Response): void {
lastRequest = {}
reply = next
}
test('总明细导出走 GET /api/export/teams.xlsx,文件名是「单位积分总明细.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await resultsExportApi.download({ kind: 'teamAll' })
assert.equal(lastRequest.url, '/api/export/teams.xlsx')
assert.equal(lastRequest.method, 'GET')
assert.equal(result.data.filename, '单位积分总明细.xlsx')
assert.equal(result.mock, false, '正常响应不应被当成示例数据')
assert.equal(result.data.blob.size, 4)
})
test('指定单位明细导出走 GET /api/export/teams/{id},文件名是「单位积分明细.xlsx」', async () => {
respondWith(() => xlsxResponse())
const result = await resultsExportApi.download({ kind: 'team', teamId: 7 })
assert.equal(lastRequest.url, '/api/export/teams/7')
assert.equal(lastRequest.method, 'GET')
assert.equal(result.data.filename, '单位积分明细.xlsx')
})
// teamAll 是「总明细」,不能误落进 team 分支读出单位 ID 而变成 /export/teams/undefined。
test('teamAll 不会被 team 分支截胡(不要求 teamId)', async () => {
respondWith(() => xlsxResponse())
await resultsExportApi.download({ kind: 'teamAll' })
assert.equal(lastRequest.url, '/api/export/teams.xlsx')
})
test('未选单位时不发请求,直接报「请先选择要导出的单位」', async () => {
respondWith(() => xlsxResponse())
await assert.rejects(
() => resultsExportApi.download({ kind: 'team' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /请先选择要导出的单位/)
return true
},
)
assert.equal(lastRequest.url, undefined, '校验失败不应白跑一次请求')
})
test('服务端报错时原样抛出,页面能拿到那句原文而不是笼统的失败', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'INTERNAL', message: '读取积分流水失败:数据库已锁定' } }, 500),
)
await assert.rejects(
() => resultsExportApi.download({ kind: 'teamAll' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 500)
assert.equal(error.code, 'INTERNAL')
assert.equal(error.message, '读取积分流水失败:数据库已锁定')
return true
},
)
})
// 单位不存在是服务端的真实回答,必须抛出来,不能悄悄降级成示例文件假装导出了。
test('单位不存在时 404 原样抛出,不降级成示例数据', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '单位不存在' } }, 404),
)
await assert.rejects(
() => resultsExportApi.download({ kind: 'team', teamId: 9999 }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '单位不存在')
return true
},
)
})
// 连不上服务端时按 systemApi 的既有约定降级为占位文件,但网络错误要留在 mockReason 里,
// 页面据此提示「示例数据」,不会把占位文本当成真正的 xlsx。
test('连不上服务端时降级为占位文件,网络错误留在 mockReason', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await resultsExportApi.download({ kind: 'teamAll' })
assert.equal(result.mock, true)
assert.equal(result.data.filename, '单位积分总明细(示例数据).txt')
assert.equal(mockReason.value, '无法连接服务端')
assert.equal(lastRequest.url, '/api/export/teams.xlsx')
})