feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认): - 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数) - 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分 - 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、 没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存 服务端: - 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team; events 加 avg_mode(默认 valid) - 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team → 默认 athlete(result, 不计入单位) + team(aggregate 队内平均) - 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名 (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与) 前端: - 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」 - 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存 - 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致) - 大屏/管理端团体成绩只显示数值,不出现「平均」字样 自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
This commit is contained in:
1 parent
592e47aef7
commit
6bd7bd1627
47 files changed
+1770
-153
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
BreakdownItem,
|
||||
ChannelBasis,
|
||||
ChannelScope,
|
||||
CompareMode,
|
||||
Event,
|
||||
EventResultGroup,
|
||||
@@ -16,6 +19,7 @@ import type {
|
||||
ResultMode,
|
||||
ResultStatus,
|
||||
ResultType,
|
||||
ScoreChannel,
|
||||
SnapshotMessage,
|
||||
StandingRow,
|
||||
Team,
|
||||
@@ -78,6 +82,8 @@ const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
|
||||
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
|
||||
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
|
||||
const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[]
|
||||
const CHANNEL_SCOPES = ['athlete', 'team'] as const satisfies readonly ChannelScope[]
|
||||
const CHANNEL_BASES = ['result', 'manual', 'aggregate'] as const satisfies readonly ChannelBasis[]
|
||||
const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[]
|
||||
const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
|
||||
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
|
||||
@@ -124,6 +130,19 @@ export function parseTeam(source: unknown): Team {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseScoreChannel(source: unknown): ScoreChannel {
|
||||
const rec = record(source)
|
||||
return {
|
||||
id: num(rec.id),
|
||||
eventId: num(rec.eventId),
|
||||
scope: oneOf(rec.scope, CHANNEL_SCOPES, 'athlete'),
|
||||
basis: oneOf(rec.basis, CHANNEL_BASES, 'result'),
|
||||
profileId: num(rec.profileId),
|
||||
sortOrder: num(rec.sortOrder),
|
||||
countToTeam: normalizeCountToTeam(rec.countToTeam),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEvent(source: unknown): Event {
|
||||
const rec = record(source)
|
||||
return {
|
||||
@@ -143,6 +162,9 @@ export function parseEvent(source: unknown): Event {
|
||||
venue: strOrNull(rec.venue),
|
||||
sortOrder: num(rec.sortOrder),
|
||||
remark: strOrNull(rec.remark),
|
||||
// 旧服务端不下发 avgMode,按默认「只算有效成绩」处理。
|
||||
avgMode: normalizeAvgMode(rec.avgMode),
|
||||
channels: Array.isArray(rec.channels) ? rec.channels.map(parseScoreChannel) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ function buildEvents(): Event[] {
|
||||
venue: null,
|
||||
sortOrder: index,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseEvent, parseScoreChannel } from '../guards'
|
||||
|
||||
// 大屏快照里的 avgMode / countToTeam 是后加的字段:旧服务端不下发时必须有默认值,
|
||||
// 而 basis='aggregate'(新口径「按队内平均」)不能被当成未知取值回落到 result。
|
||||
test('parseEvent 缺 avgMode 时按 valid 处理', () => {
|
||||
assert.equal(parseEvent({ id: 1, code: 'TPULL', eventType: 'individual_team' }).avgMode, 'valid')
|
||||
assert.equal(parseEvent({ id: 1, avgMode: 'all' }).avgMode, 'all')
|
||||
assert.equal(parseEvent({ id: 1, avgMode: 'AVG' }).avgMode, 'valid')
|
||||
})
|
||||
|
||||
test('parseScoreChannel 保留 aggregate 并按 true 补 countToTeam', () => {
|
||||
const channel = parseScoreChannel({ id: 3, eventId: 1, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 1 })
|
||||
assert.equal(channel.basis, 'aggregate')
|
||||
assert.equal(channel.countToTeam, true)
|
||||
|
||||
const personal = parseScoreChannel({ id: 4, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false })
|
||||
assert.equal(personal.countToTeam, false)
|
||||
// 未知 basis 仍回落到 result,避免渲染出不存在的口径。
|
||||
assert.equal(parseScoreChannel({ basis: 'sum' }).basis, 'result')
|
||||
})
|
||||
|
||||
test('parseEvent 同时解析详情接口带出的通道', () => {
|
||||
const event = parseEvent({
|
||||
id: 1,
|
||||
channels: [{ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }],
|
||||
})
|
||||
assert.equal(event.channels?.length, 1)
|
||||
assert.equal(event.channels?.[0]?.countToTeam, false)
|
||||
// 列表接口不下发 channels 时保持 undefined,不能凭空造出空数组。
|
||||
assert.equal(parseEvent({ id: 1 }).channels, undefined)
|
||||
})
|
||||
Reference in new issue
Block a user