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

@@ -5,6 +5,8 @@ import {
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
entryRowLabel,
selectionAllowsSubmit,
toCsv,
} from '../components/baseData/entrySelection'
@@ -91,3 +93,33 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
})
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', 'registered', ''])
})
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [],
})
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', 'registered', ''])
})
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
assert.equal(selectionAllowsSubmit('team', 0), true, '团体项目允许只报单位')
assert.equal(selectionAllowsSubmit('team', 3), true)
assert.equal(selectionAllowsSubmit('individual', 0), false, '个人项目必须至少勾一人')
assert.equal(selectionAllowsSubmit('individual', 1), true)
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
assert.equal(entryRowLabel({ athleteName: '张伟', memberNames: null, teamShort: '一队' }), '张伟')
assert.equal(entryRowLabel({ athleteName: '', memberNames: ['王芳', '刘洋'], teamShort: '一队' }), '王芳')
assert.equal(entryRowLabel({ athleteName: null, memberNames: [], teamShort: '一队' }), '一队')
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
assert.equal(entryRowLabel({}), '')
})