feat: 公告持久化(底栏常驻)+ 团体可单独报名 + 榜单系数开关

公告:
- 服务端把当前公告落库到 settings(display.announce_text / announce_seconds),
  作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留)
- announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计
- realtime 协议登记 announce_clear(补上,使清除能即时推到大屏)
- 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏,
  直到被替换或清除;新增公告状态机(可测)与底栏取值优先级
  推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态

团体报名:
- EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致
  「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了)
- 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过),
  抽出共享的 entryRowLabel 供两处复用

榜单口径:
- 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight
  数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」,
  保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段,
  故做成独立卡片并标注全局,不塞进模板表单

自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
This commit is contained in:
AskaEth committed 2026-09-10 11:06:58 +08:00
1 parent cded54e440
commit b5f40642ff
24 files changed
+1073 -67

No files matched your search

@@ -1,5 +1,5 @@
/**
* 「从已有名单勾选报名」的纯函数部分,以及报名导入冲突的差异 / 决策计算。
* 报名相关的纯函数:勾选转 CSV、报名行文案,以及报名导入冲突的差异 / 决策计算。
*
* 之所以复用导入通道的表头与 CSV 格式:服务端只认 /entries/import 这一个报名入口,
* 勾选结果转成同格式 CSV 后走同一个接口,字段校验、按 (组别, 单位, 运动员) upsert
@@ -39,6 +39,26 @@ export const MANDATORY_COLUMNS: readonly string[] = REQUIRED_COLUMNS.filter(
export type SelectionMode = 'individual' | 'team'
/**
* 「加入报名」是否放行。
*
* 个人项目一人一条报名,必须勾人;团体 / 对抗项目一条报名只挂单位(服务端 athlete_id 为 NULL
* 的报名合法),队员名单可以留空后补,所以 0 勾选也要放行。
*/
export function selectionAllowsSubmit(mode: SelectionMode, chosenCount: number): boolean {
return mode === 'team' || chosenCount > 0
}
/** 报名行的展示文案:运动员姓名 → 团体队员首人 → 单位简称 / 全称。 */
export function entryRowLabel(row: {
athleteName?: string | null
memberNames?: string[] | null
teamShort?: string | null
teamName?: string | null
}): string {
return row.athleteName || row.memberNames?.[0] || row.teamShort || row.teamName || ''
}
export interface SelectionInput {
eventCode: string
categoryName: string
@@ -54,7 +74,7 @@ export interface SelectionInput {
/**
* 生成与导入表头一致的二维数组。
*
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names。
* 个人项目一人一行;团体 / 对抗项目整体一行,队员姓名进 member_names(可以为空,即只报单位)。
* 服务端按 (组别, 单位, 运动员) upsert,团体行运动员为空,因此同组别 + 单位只留一条报名。
*/
export function buildSelectionRows(input: SelectionInput): string[][] {