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
@@ -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) => {
|
||||
|
||||
@@ -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 节)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user