feat: 赛事项目切换 + 记住上次打开的赛事(修「导入赛事包后没数据」)
根因:服务端启动只认 -project(缺省 default),Console 从不传;导入只是把包解到
event_projs/<名字>,没有任何切换入口 → 导入的赛事永远打不开,页面看到的是空的 default。
- 服务端 -project 缺省改为空串;解析顺序:显式 flag(并写回记忆)> <dataDir>/active_project.txt
> default;backup/passwd 口径一致;启动日志打印生效项目与来源
- GET /api/projects(admin)列出各赛事(是否当前/是否有 event.db/大小/更新时间)
- POST /api/projects/activate(admin):校验后原子写记忆文件 → 审计 → 返回
{ok,name,restarting:true} → 触发优雅关闭;Console 对非用户发起的退出会自动拉起,
重启后即打开新赛事(无需改 Console)。本来就是当前赛事则 restarting:false 不重启
- 管理端「导出」页新增赛事项目列表与「切换为当前赛事」;导入成功后提示「已导入为 X,
但还不是当前赛事」并给「立即切换并重启服务端」;切换后轮询 /api/health 等它回来再刷新
- 文档:使用手册(导入≠切换、生效四步)、常见问题(导入后看不到数据)
自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 66/66;console 14 文件通过。
This commit is contained in:
1 parent
005b51b2ec
commit
2d207fd654
14 files changed
+1363
-24
No files matched your search
@@ -0,0 +1,204 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ApiError, createApiClient } from '@/shared/api'
|
||||
import { waitForServerOnline } from '../components/system/systemApi'
|
||||
|
||||
/**
|
||||
* 赛事项目切换是本路新增能力:导入赛事包后不切换,服务端就一直看着原来那个项目,
|
||||
* 页面上永远是空数据。这里钉住前端这一侧的契约(路径 / 字段名 / 降级口径)。
|
||||
*/
|
||||
|
||||
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' },
|
||||
})
|
||||
}
|
||||
|
||||
function captureClient(reply: (captured: Captured) => Response): {
|
||||
client: ReturnType<typeof createApiClient>
|
||||
captured: Captured
|
||||
} {
|
||||
const captured: Captured = {}
|
||||
const client = createApiClient({
|
||||
fetchImpl: async (input, init) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method
|
||||
captured.body = init?.body
|
||||
return reply(captured)
|
||||
},
|
||||
})
|
||||
return { client, captured }
|
||||
}
|
||||
|
||||
test('projects.list 读 GET /api/projects 并归一化每条项目', async () => {
|
||||
const { client, captured } = captureClient(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
projects: [
|
||||
{
|
||||
name: 'default',
|
||||
active: true,
|
||||
hasEvent: true,
|
||||
sizeBytes: 2048,
|
||||
updatedAt: '2026-09-10 08:00:00',
|
||||
},
|
||||
{ name: '市运会2026', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const rows = await client.projects.list()
|
||||
|
||||
assert.equal(captured.url, '/api/projects')
|
||||
assert.equal(captured.method, 'GET')
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
name: 'default',
|
||||
active: true,
|
||||
hasEvent: true,
|
||||
sizeBytes: 2048,
|
||||
updatedAt: '2026-09-10 08:00:00',
|
||||
},
|
||||
{ name: '市运会2026', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null },
|
||||
])
|
||||
})
|
||||
|
||||
// 缺字段时按「有库、未激活」呈现:真正缺库激活时服务端会给出中文 400,
|
||||
// 不该在列表里被静默禁掉,那样用户只会看到一排点不动的按钮。
|
||||
test('projects.list 补全缺失字段,并跳过没有名字的条目', async () => {
|
||||
const { client } = captureClient(() =>
|
||||
jsonResponse({ ok: true, data: [{ name: '甲' }, { active: true }, 'x'] }),
|
||||
)
|
||||
|
||||
const rows = await client.projects.list()
|
||||
|
||||
assert.deepEqual(rows, [{ name: '甲', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null }])
|
||||
})
|
||||
|
||||
test('projects.list 响应格式不符时报 PARSE_FAILED,不伪装成空列表', async () => {
|
||||
const { client } = captureClient(() => jsonResponse({ ok: true, data: { nope: 1 } }))
|
||||
|
||||
await assert.rejects(
|
||||
() => client.projects.list(),
|
||||
(error: unknown) => error instanceof ApiError && error.code === 'PARSE_FAILED',
|
||||
)
|
||||
})
|
||||
|
||||
test('projects.activate 以 POST /api/projects/activate 提交项目名', async () => {
|
||||
const { client, captured } = captureClient(() =>
|
||||
jsonResponse({ ok: true, data: { ok: true, name: '市运会2026', restarting: true } }),
|
||||
)
|
||||
|
||||
const result = await client.projects.activate('市运会2026')
|
||||
|
||||
assert.equal(captured.url, '/api/projects/activate')
|
||||
assert.equal(captured.method, 'POST')
|
||||
const body = JSON.parse(String(captured.body)) as Record<string, unknown>
|
||||
assert.equal(body.name, '市运会2026')
|
||||
assert.equal(result.restarting, true)
|
||||
assert.equal(result.name, '市运会2026')
|
||||
})
|
||||
|
||||
// restarting 缺失按 false 处理:这是「本来就是当前赛事」的回答,
|
||||
// 误判成 true 会让页面干等一个永远不会发生的重启,60 秒后误报失败。
|
||||
test('projects.activate 缺 restarting 时按「不重启」处理,失败文案原样抛出', async () => {
|
||||
const ok = captureClient(() => jsonResponse({ ok: true, data: { name: 'default' } }))
|
||||
assert.deepEqual(await ok.client.projects.activate('default'), { name: 'default', restarting: false })
|
||||
|
||||
const failed = captureClient(() =>
|
||||
jsonResponse(
|
||||
{ ok: false, error: { code: 'VALIDATION_FAILED', message: '赛事项目不存在:丙', detail: '丙' } },
|
||||
400,
|
||||
),
|
||||
)
|
||||
await assert.rejects(
|
||||
() => failed.client.projects.activate('丙'),
|
||||
(error: unknown) =>
|
||||
error instanceof ApiError && error.status === 400 && error.message === '赛事项目不存在:丙',
|
||||
)
|
||||
})
|
||||
|
||||
// 可控时钟:sleep 只推进 now,不真的等待,超时分支才能瞬间跑完。
|
||||
function fakeClock(): ServerRestartClock {
|
||||
let time = 0
|
||||
return {
|
||||
now: () => time,
|
||||
sleep: async (ms: number) => {
|
||||
time += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerRestartClock {
|
||||
now: () => number
|
||||
sleep: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
test('waitForServerOnline 在服务端重新可达时返回 true', async () => {
|
||||
const clock = fakeClock()
|
||||
const replies = [false, false, true]
|
||||
let probes = 0
|
||||
|
||||
const online = await waitForServerOnline({
|
||||
...clock,
|
||||
intervalMs: 1500,
|
||||
timeoutMs: 60_000,
|
||||
probe: async () => {
|
||||
const up = replies[probes] ?? true
|
||||
probes += 1
|
||||
if (!up) throw new ApiError('NETWORK', '无法连接服务端', 0)
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(online, true)
|
||||
assert.equal(probes, 3)
|
||||
})
|
||||
|
||||
// 优雅退出需要时间:旧进程在退出前仍能满足健康检查,
|
||||
// 不等首次失败就下结论会在旧进程上误判「已经回来了」。
|
||||
test('waitForServerOnline 要求先探到一次失败,再等恢复', async () => {
|
||||
const clock = fakeClock()
|
||||
let probes = 0
|
||||
|
||||
const online = await waitForServerOnline({
|
||||
...clock,
|
||||
intervalMs: 1500,
|
||||
timeoutMs: 60_000,
|
||||
waitForDown: true,
|
||||
probe: async () => {
|
||||
probes += 1
|
||||
// 前两次仍是旧进程(健康检查照常通过),第三、四次进入重启窗口,第五次新进程已起来。
|
||||
if (probes === 3 || probes === 4) throw new ApiError('NETWORK', '无法连接服务端', 0)
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(online, true)
|
||||
assert.equal(probes, 5)
|
||||
})
|
||||
|
||||
test('waitForServerOnline 一直连不上时超时返回 false', async () => {
|
||||
const clock = fakeClock()
|
||||
let probes = 0
|
||||
|
||||
const online = await waitForServerOnline({
|
||||
...clock,
|
||||
intervalMs: 1500,
|
||||
timeoutMs: 6000,
|
||||
probe: async () => {
|
||||
probes += 1
|
||||
throw new ApiError('NETWORK', '无法连接服务端', 0)
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(online, false)
|
||||
assert.equal(probes, 5)
|
||||
})
|
||||
Reference in new issue
Block a user