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(), [])
})