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

@@ -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')
})