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,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)),
|
||||
|
||||
|
||||
@@ -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] }
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in new issue
Block a user