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:
1 parent
cded54e440
commit
b5f40642ff
24 files changed
+1073
-67
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { EventSettings } from '@/shared'
|
||||
|
||||
/**
|
||||
* 榜单口径(standings.individual_to_team_weight)的保存契约。
|
||||
*
|
||||
* adminApi 的 http 实例在模块初始化时就绑定了 `globalThis.fetch`,所以这里先替换 fetch,
|
||||
* 再用动态 import 拿到 store 与控制器,才能观察到真实请求的 URL / 方法与载荷。
|
||||
*/
|
||||
|
||||
const SETTINGS: EventSettings = {
|
||||
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
|
||||
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
}
|
||||
|
||||
interface Captured {
|
||||
url: string
|
||||
method: string
|
||||
body: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function stubFetch(weight: number): Captured {
|
||||
const captured: Captured = { url: '', method: '', body: null }
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method ?? 'GET'
|
||||
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
|
||||
const data: EventSettings = { ...SETTINGS, standings: { ...SETTINGS.standings, individualToTeamWeight: weight } }
|
||||
return new Response(JSON.stringify({ ok: true, data }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof fetch
|
||||
return captured
|
||||
}
|
||||
|
||||
test('保存榜单口径 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
|
||||
const captured = stubFetch(0.5)
|
||||
const { useEventStore } = await import('../stores/event')
|
||||
const { useBaseData } = await import('../components/baseData/useBaseData')
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const event = useEventStore()
|
||||
event.settings = { ...SETTINGS }
|
||||
const data = useBaseData()
|
||||
|
||||
const saved = await data.saveIndividualToTeamWeight(0.5)
|
||||
|
||||
assert.equal(captured.url, '/api/settings')
|
||||
assert.equal(captured.method, 'PUT')
|
||||
const standings = captured.body?.standings as Record<string, unknown>
|
||||
assert.equal(standings.individualToTeamWeight, 0.5)
|
||||
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
|
||||
assert.equal(standings.rankingMode, 'points')
|
||||
assert.equal(standings.showIndividualBoard, true)
|
||||
assert.equal(standings.showIndividualMedals, true)
|
||||
|
||||
assert.equal(saved.standings.individualToTeamWeight, 0.5)
|
||||
// 成绩录入页与积分预览直接读 event store 的设置,不回写就等于「保存成功但预览没变」。
|
||||
assert.equal(event.settings?.standings.individualToTeamWeight, 0.5)
|
||||
assert.equal(data.settings.value?.standings.individualToTeamWeight, 0.5)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { CurrentUser } from '@/shared'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useBaseData } from '../components/baseData/useBaseData'
|
||||
import { useBaseData, parseIndividualToTeamWeight } from '../components/baseData/useBaseData'
|
||||
|
||||
// 写权限只看角色:实时连接断开(服务端重启、网络瞬断)不得让整页变只读。
|
||||
// 曾经 canWrite 依赖 realtimeStatus === 'online',WS 一断连「保存全部」都是灰的。
|
||||
@@ -25,3 +25,30 @@ test('实时连接离线时仍可写,只读访客不可写', () => {
|
||||
auth.user = null
|
||||
assert.equal(data.canWrite.value, false, '退出后回到只读访客')
|
||||
})
|
||||
|
||||
// 榜单口径输入框是 type="number",清空时 v-model 给空串;Number('') 是 0,
|
||||
// 不拦住就会把「清空」当成「系数 0」提交给服务端。
|
||||
test('榜单系数解析:空值 / 越界 / 非数字一律 null,0–100 原样保留', () => {
|
||||
assert.equal(parseIndividualToTeamWeight(''), null)
|
||||
assert.equal(parseIndividualToTeamWeight(' '), null)
|
||||
assert.equal(parseIndividualToTeamWeight(undefined), null)
|
||||
assert.equal(parseIndividualToTeamWeight(null), null)
|
||||
assert.equal(parseIndividualToTeamWeight('abc'), null)
|
||||
assert.equal(parseIndividualToTeamWeight(Number.NaN), null)
|
||||
assert.equal(parseIndividualToTeamWeight(-1), null)
|
||||
assert.equal(parseIndividualToTeamWeight(100.5), null)
|
||||
assert.equal(parseIndividualToTeamWeight(0), 0)
|
||||
assert.equal(parseIndividualToTeamWeight('0.5'), 0.5)
|
||||
assert.equal(parseIndividualToTeamWeight(1), 1)
|
||||
assert.equal(parseIndividualToTeamWeight('100'), 100)
|
||||
})
|
||||
|
||||
test('赛事设置未加载时保存榜单口径直接报错,不发请求', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const event = useEventStore()
|
||||
const data = useBaseData()
|
||||
|
||||
assert.equal(data.settings.value, null, '尚未 bootstrap 时设置应为 null')
|
||||
await assert.rejects(() => data.saveIndividualToTeamWeight(2), /赛事设置未加载/)
|
||||
assert.equal(event.settings, null, '失败不应写入 store')
|
||||
})
|
||||
@@ -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({}), '')
|
||||
})
|
||||
@@ -228,3 +228,45 @@ test('项目与组别权重相乘', () => {
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 60)
|
||||
})
|
||||
|
||||
// 团体行的 athleteId 是 null(服务端 entries.athlete_id 为空,0 队员的「只报单位」报名也是这种行),
|
||||
// 团体通道的排名主体是单位,不能因为没有运动员就把行丢掉。
|
||||
test('团体行(athleteId 为 null)按单位通道正常拿名次与积分', () => {
|
||||
const profile = makeProfile({
|
||||
rules: [
|
||||
{ rank: 1, points: 9 },
|
||||
{ rank: 2, points: 6 },
|
||||
],
|
||||
})
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'manual' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
assert.equal(result.ranks.get(1), 1)
|
||||
assert.equal(result.ranks.get(2), 2)
|
||||
close(result.rowPoints.get(1) ?? 0, 9)
|
||||
close(result.rowTeamPoints.get(1) ?? 0, 9)
|
||||
close(result.teamPoints.get(100) ?? 0, 9)
|
||||
close(result.teamPoints.get(101) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||||
]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }) }),
|
||||
)
|
||||
assert.equal(result.ranks.get(1), 2)
|
||||
assert.equal(result.ranks.get(2), 1)
|
||||
})
|
||||
Reference in new issue
Block a user