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

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