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:
1 parent
793d2ac064
commit
782a38f82f
33 files changed
+2473
-119
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user