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:
AskaEth committed 2026-09-10 12:02:00 +08:00
1 parent 592e47aef7
commit 6bd7bd1627
47 files changed
+1770 -153

No files matched your search

+22
View File
@@ -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,
}
}
+1
View File
@@ -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)
})