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:
AskaEth committed 2026-09-10 09:47:06 +08:00
1 parent 005b51b2ec
commit 2d207fd654
14 files changed
+1363 -24

No files matched your search

@@ -641,6 +641,54 @@ export const projectApi = {
},
}
// ---------------------------------------------------------------------------
// 切换赛事后的重启等待
// ---------------------------------------------------------------------------
export interface ServerRestartWaitOptions {
/** 健康检查;不抛错即视为服务端已回来。 */
probe: () => Promise<unknown>
/** 两次探测的间隔,默认 1.5 秒。 */
intervalMs?: number
/** 总等待上限,默认 60 秒。 */
timeoutMs?: number
/**
* true 表示这次切换会让服务端重启。服务端先优雅退出、再由 Console 拉起,
* 旧进程在退出前仍能满足健康检查,所以必须等到探测到一次失败才认为重启真的开始,
* 否则会在旧进程上误判「已经回来了」。
*/
waitForDown?: boolean
/** 便于测试注入;默认用 window.setTimeout。 */
sleep?: (ms: number) => Promise<void>
now?: () => number
}
/** 轮询健康检查等服务端回来;返回 false 表示超时,调用方需提示人工检查服务状态。 */
export async function waitForServerOnline(options: ServerRestartWaitOptions): Promise<boolean> {
const intervalMs = options.intervalMs ?? 1500
const timeoutMs = options.timeoutMs ?? 60_000
const now = options.now ?? ((): number => Date.now())
const sleep =
options.sleep ??
((ms: number): Promise<void> =>
new Promise((resolve) => {
window.setTimeout(resolve, ms)
}))
const deadline = now() + timeoutMs
let downSeen = false
for (;;) {
try {
await options.probe()
if (options.waitForDown !== true || downSeen) return true
} catch {
// 重启窗口内连不上是预期结果,继续等。
downSeen = true
}
if (now() >= deadline) return false
await sleep(intervalMs)
}
}
// ---------------------------------------------------------------------------
// 规则包(YAML)
// ---------------------------------------------------------------------------
+216 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatDateTime } from '@/shared'
import { computed, ref, watch } from 'vue'
import { formatDateTime, type ProjectInfo } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
@@ -11,6 +11,7 @@ import {
projectApi,
resultsExportApi,
rulesApi,
waitForServerOnline,
type ProjectImportResult,
type ResultExportKind,
type RuleApplyResult,
@@ -26,6 +27,9 @@ import { useToastStore } from '../stores/toast'
*
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
*
* 赛事包导入只是把目录解到数据目录里,服务端只打开「当前赛事」那一项,
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
*/
type TabKey = 'results' | 'project' | 'rules'
@@ -86,6 +90,16 @@ const confirmProjectImport = ref(false)
const projectResult = ref<ProjectImportResult | null>(null)
const projectError = ref<string | null>(null)
// 服务端一次只打开一个赛事项目(其余目录只是躺在数据目录里的数据),
// 导入不会改变「当前赛事」,所以这里列出全部项目供切换。
const projects = ref<ProjectInfo[]>([])
const projectsLoading = ref(false)
const projectsError = ref<string | null>(null)
const switchTarget = ref<string | null>(null)
const switchError = ref<string | null>(null)
/** 已触发重启、正在等服务端回来;期间所有操作都要挡住。 */
const restarting = ref(false)
// ── 规则包 ──────────────────────────────────────────────────────────────────
const rulesFileInput = ref<HTMLInputElement | null>(null)
const rulesFileName = ref('')
@@ -101,6 +115,18 @@ const confirmRulesApply = ref(false)
const isAdmin = computed(() => auth.user?.role === 'admin')
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
/**
* 刚导入的赛事还不是当前赛事时,页面上的数据仍旧来自原来的当前赛事,需要就地给出切换入口。
* 列表读不到时也按「还不是当前」处理:宁可多提示一次,也不要谎报已生效。
*/
const importedNotActive = computed(() => {
const imported = projectResult.value
if (imported === null) return null
return activeProjectName.value === imported.name ? null : imported
})
const CHANGE_LABEL: Record<string, string> = {
added: '新增',
removed: '删除',
@@ -206,6 +232,8 @@ async function runProjectImport(): Promise<void> {
toast.success(`已导入「${result.data.name}」`, result.mock ? '示例数据:未真正解压落盘' : null)
projectFile.value = null
if (projectFileInput.value !== null) projectFileInput.value.value = ''
// 新导入的项目要出现在列表里,用户才能就地切成当前赛事。
void loadProjects()
} catch (caught) {
projectError.value = describeError(caught)
} finally {
@@ -213,6 +241,81 @@ async function runProjectImport(): Promise<void> {
}
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
async function loadProjects(): Promise<void> {
if (!isAdmin.value) return
projectsLoading.value = true
projectsError.value = null
try {
projects.value = await adminApi.client.projects.list()
} catch (caught) {
projects.value = []
projectsError.value = describeError(caught)
} finally {
projectsLoading.value = false
}
}
// 页面挂载时 AdminLayout 还在探测会话,isAdmin 之后才变 true,所以要跟着它再拉一次列表。
watch(
isAdmin,
(value) => {
if (value) void loadProjects()
},
{ immediate: true },
)
/**
* 切换当前赛事。
*
* 服务端会把项目名写进记忆文件后优雅退出,由 Console 自动重启服务端并打开新赛事;
* 这段时间页面必须轮询健康检查等它回来,不能像普通写操作那样立刻认为已经生效。
*/
async function activateProject(name: string): Promise<void> {
if (!isAdmin.value) {
toast.warning('只有管理员可以切换当前赛事')
return
}
if (restarting.value || switchTarget.value !== null) return
switchTarget.value = name
switchError.value = null
try {
const result = await adminApi.client.projects.activate(name)
if (!result.restarting) {
// 本来就是当前赛事,服务端不会退出,直接刷新拿最新数据。
toast.success(`「${result.name}」已是当前赛事`)
window.location.reload()
return
}
restarting.value = true
toast.info('正在重启服务端…', `正在切换到「${result.name}」,请勿关闭服务管理窗口`)
const online = await waitForServerOnline({
probe: () => adminApi.client.health(),
waitForDown: true,
})
if (online) {
toast.success('服务端已恢复,正在刷新页面')
window.location.reload()
return
}
restarting.value = false
switchError.value =
'等待服务端重启超时(60 秒)。请打开服务管理窗口确认服务端状态,待其「运行中」后手动刷新本页。'
toast.error('服务端重启未在 60 秒内完成', '请在服务管理窗口查看服务端状态')
void loadProjects()
} catch (caught) {
switchError.value = describeError(caught)
} finally {
switchTarget.value = null
}
}
async function exportRules(): Promise<void> {
rulesExporting.value = true
rulesError.value = null
@@ -377,6 +480,84 @@ async function applyRules(): Promise<void> {
<!-- 赛事项目包 -->
<div v-else-if="activeTab === 'project'" class="project-grid">
<section class="mx-card wide">
<div class="mx-card-head">
<span class="mx-card-title">赛事项目列表</span>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!isAdmin || projectsLoading || restarting"
@click="loadProjects"
>
<MxIcon name="refresh" :size="14" /> {{ projectsLoading ? '读取中…' : '刷新' }}
</button>
</div>
<div class="mx-card-body">
<p class="hint">
服务端一次只打开一个赛事项目,页面上看到的数据全部来自标记为「当前」的那一项。
导入项目包只是把数据解到数据目录,必须切换为当前赛事才会生效。
</p>
<p v-if="!isAdmin" class="notice">切换当前赛事会让服务端重启,只有管理员可操作。</p>
<p v-if="restarting" class="notice warn">
正在重启服务端…切换赛事需要服务端退出后由服务管理窗口自动拉起,请勿关闭该窗口(最多等待 60 秒)。
</p>
<p v-if="switchError !== null" class="notice error">{{ switchError }}</p>
<p v-if="projectsError !== null" class="notice error">读取赛事项目列表失败:{{ projectsError }}</p>
<div v-if="projects.length === 0 && projectsError === null" class="mx-empty">
<div class="mx-empty-title">
{{
!isAdmin
? '赛事项目列表仅管理员可见'
: projectsLoading
? '正在读取赛事项目…'
: '没有可切换的赛事项目'
}}
</div>
<div v-if="isAdmin && !projectsLoading" class="mx-empty-desc">
导入一个赛事项目包后,它会出现在这个列表里
</div>
</div>
<div v-else-if="projects.length > 0" class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>项目名</th>
<th>状态</th>
<th>更新时间</th>
<th>大小</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in projects" :key="item.name">
<td class="mono">{{ item.name }}</td>
<td>
<span v-if="item.active" class="mx-badge mx-badge-success">当前</span>
<span v-else-if="!item.hasEvent" class="mx-badge mx-badge-error">缺 event.db</span>
<span v-else class="mx-badge mx-badge-neutral">未打开</span>
</td>
<td>{{ formatDateTime(item.updatedAt) }}</td>
<td>{{ formatBytes(item.sizeBytes) }}</td>
<td>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!isAdmin || item.active || !item.hasEvent || restarting || switchTarget !== null"
@click="activateProject(item.name)"
>
{{ switchTarget === item.name ? '切换中…' : '切换为当前赛事' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出项目包</span>
@@ -388,7 +569,12 @@ async function applyRules(): Promise<void> {
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>(字体可选)。
整包拷走即可在另一台机器复现整场赛事。
</p>
<button class="mx-btn mx-btn-primary full" type="button" :disabled="projectExporting" @click="exportProject">
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="projectExporting || restarting"
@click="exportProject"
>
<MxIcon name="export" :size="15" /> {{ projectExporting ? '打包中…' : '导出 zip' }}
</button>
</div>
@@ -442,7 +628,7 @@ async function applyRules(): Promise<void> {
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!isAdmin || projectImporting || projectFile === null"
:disabled="!isAdmin || projectImporting || projectFile === null || restarting"
@click="requestProjectImport"
>
{{ projectImporting ? '导入中…' : '导入' }}
@@ -455,7 +641,28 @@ async function applyRules(): Promise<void> {
<div><dt>落盘目录</dt><dd class="mono">{{ projectResult.dir }}</dd></div>
<div><dt>数据库</dt><dd class="mono">{{ projectResult.eventDB }}</dd></div>
</dl>
<p class="hint">如需立即切换到这个赛事,请到「系统 · 赛事信息」确认配置后重启服务端。</p>
<p v-if="importedNotActive === null" class="hint">
「{{ projectResult.name }}」已经是当前赛事,直接刷新即可看到数据。
</p>
<template v-else>
<p class="notice warn">
已导入为「{{ projectResult.name }}」,但它还不是当前赛事:服务端当前打开的是「{{
activeProjectName ?? 'default'
}}」,页面上看到的是那一项的数据。
</p>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!isAdmin || restarting || switchTarget !== null"
@click="activateProject(projectResult.name)"
>
<MxIcon name="refresh" :size="15" />
{{ switchTarget === projectResult.name ? '切换中…' : '立即切换并重启服务端' }}
</button>
<p class="hint">切换会让服务端退出并由服务管理窗口自动拉起,期间页面会短暂连不上。</p>
</template>
<p v-if="!isAdmin" class="notice">只有管理员可以切换当前赛事。</p>
</div>
</div>
</section>
@@ -665,6 +872,10 @@ async function applyRules(): Promise<void> {
gap: 16px;
align-items: start;
}
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
.project-grid > .wide {
grid-column: 1 / -1;
}
.file-row {
display: flex;
align-items: center;
+204
View File
@@ -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)
})
+59
View File
@@ -23,6 +23,8 @@ import type {
Page,
PageQuery,
PointAdjustment,
ProjectActivateResult,
ProjectInfo,
PublishPayload,
RecordBreak,
RecordBreakInput,
@@ -212,6 +214,19 @@ export interface SystemApi {
backup(): Promise<BackupResult>
}
/**
* 赛事项目切换。
*
* 服务端一次只打开一个赛事项目(启动参数 -project → 记忆文件 → default),
* 其余项目在数据目录里只是「躺着的数据」:导入赛事包并不会让它变成当前赛事,必须显式切换。
*/
export interface ProjectsApi {
/** GET /api/projects:列出数据目录下的赛事项目及当前打开的那个。 */
list(): Promise<ProjectInfo[]>
/** POST /api/projects/activate:切换当前赛事;服务端会退出并由 Console 自动拉起。 */
activate(name: string): Promise<ProjectActivateResult>
}
/** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */
export interface SystemAbout {
internalSignature?: string
@@ -308,6 +323,48 @@ function createRulesApi(http: Http): RulesApi {
}
}
function readProjectInfo(value: unknown): ProjectInfo | null {
if (!isRecord(value)) return null
const name = readString(value, 'name')
if (name === null) return null
return {
name,
active: value.active === true,
// 缺字段时按「有库」处理:真正缺库时激活接口会给出中文报错,不该在列表里被静默禁掉。
hasEvent: value.hasEvent !== false,
sizeBytes:
typeof value.sizeBytes === 'number' && Number.isFinite(value.sizeBytes) ? value.sizeBytes : 0,
updatedAt: readString(value, 'updatedAt'),
}
}
function createProjectsApi(http: Http): ProjectsApi {
return {
list: async () => {
const body = await http.request<unknown>('/projects')
// 服务端回 { projects: [...] };裸数组也接受,省得为了外层字段名再改一次前端。
const raw = Array.isArray(body)
? body
: isRecord(body) && Array.isArray(body.projects)
? body.projects
: null
if (raw === null) throw new ApiError('PARSE_FAILED', '赛事项目列表响应格式不符', 0, body)
return raw.map(readProjectInfo).filter((row): row is ProjectInfo => row !== null)
},
activate: async (name) => {
const body = await http.request<unknown>('/projects/activate', {
method: 'POST',
body: { name },
})
// 字段缺失时按「不需要重启」处理:宁可让页面直接刷新,也不要卡在一个不会来的重启上。
return {
name: isRecord(body) ? (readString(body, 'name') ?? name) : name,
restarting: isRecord(body) && body.restarting === true,
}
},
}
}
function readCsrfCookie(): string | null {
if (typeof document === 'undefined') return null
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
@@ -341,6 +398,7 @@ export class ApiClient {
readonly auditLogs: AuditApi
readonly rules: RulesApi
readonly system: SystemApi
readonly projects: ProjectsApi
readonly uploads: UploadsApi
private readonly baseUrl: string
@@ -447,6 +505,7 @@ export class ApiClient {
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
}
this.projects = createProjectsApi(http)
this.uploads = {
upload: (file) => {
+21
View File
@@ -458,6 +458,27 @@ export interface HealthInfo {
version: string
}
/** 数据目录 `event_projs/` 下的一个赛事项目(GET /api/projects,admin)。 */
export interface ProjectInfo {
name: string
/** 是否为服务端当前打开的赛事(启动参数 -project,或记忆文件里记下的名字)。 */
active: boolean
/** 目录里是否存在 event.db;缺库的项目不可切换。 */
hasEvent: boolean
sizeBytes: number
updatedAt: string | null
}
/** POST /api/projects/activate 的结果。 */
export interface ProjectActivateResult {
name: string
/**
* true:服务端已把项目名写入记忆文件并优雅退出,等待 Console 自动拉起;
* false:本来就是当前赛事,服务端不会退出,直接刷新页面即可。
*/
restarting: boolean
}
// ---------------------------------------------------------------------------
// 规则包(见 02-积分与排名规则 第 7 节)
// ---------------------------------------------------------------------------