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
@@ -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