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

+54 -19
View File
@@ -1,4 +1,6 @@
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import type {
AvgMode,
ChannelBasis,
ChannelScope,
CompareMode,
@@ -15,7 +17,7 @@ import type {
*
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
* 个人通道分再按 standings.individual_to_team_weight 折算进单位总分。
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
*/
@@ -50,6 +52,8 @@ interface Candidate {
rankIn: number
/** 聚合通道的排名主体(单位 id 或运动员 id)。 */
subject: number
/** 该候选计入单位总分时的归属单位。 */
teamId: number
}
interface RankedRow {
@@ -124,11 +128,11 @@ function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): C
if (row.status !== 'ok') continue
if (basis === 'manual') {
if (row.rank === null || row.rank <= 0) continue
out.push({ row, value: 0, rankIn: row.rank, subject: 0 })
out.push({ row, value: 0, rankIn: row.rank, subject: 0, teamId: row.teamId })
continue
}
if (row.resultValue === null) continue
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0 })
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0, teamId: row.teamId })
}
return out
}
@@ -178,26 +182,51 @@ function sortManual(cands: Candidate[]): void {
}
/**
* 聚合通道:先把同一主体的成绩求和,再按值排名(scoring.go scoreByAggregate)。
* 运动员维度缺 athleteId 的行按服务端口径直接跳过(subject == 0)。
* 聚合通道:先把同一主体的成绩取平均,再按平均值排名(scoring.go scoreByAggregate)。
*
* 分母由 avgMode 决定:valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,
* 没成绩的按 0 计入。scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
*/
function aggregateCandidates(rows: readonly ScorableRow[], channel: ScoreChannel): Candidate[] {
function aggregateCandidates(
rows: readonly ScorableRow[],
channel: ScoreChannel,
avgMode: AvgMode,
): Candidate[] {
const order: number[] = []
const buckets = new Map<number, { row: ScorableRow; total: number }>()
for (const candidate of collectCandidates(rows, 'result')) {
const subject = channel.scope === 'team' ? candidate.row.teamId : (candidate.row.athleteId ?? 0)
const buckets = new Map<number, { row: ScorableRow; total: number; valid: number; registered: number }>()
for (const row of rows) {
const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0)
if (subject === 0) continue
const bucket = buckets.get(subject)
const value = row.status === 'ok' ? row.resultValue : null
let bucket = buckets.get(subject)
if (bucket === undefined) {
buckets.set(subject, { row: candidate.row, total: candidate.value })
// valid 口径下没有任何有效成绩的主体不参与排名。
if (value === null && avgMode !== 'all') continue
bucket = { row, total: 0, valid: 0, registered: 0 }
buckets.set(subject, bucket)
order.push(subject)
} else {
bucket.total += candidate.value
}
bucket.registered += 1
if (value !== null) {
bucket.total += value
bucket.valid += 1
}
}
return order.map((subject) => {
const bucket = buckets.get(subject) as { row: ScorableRow; total: number }
return { row: bucket.row, value: bucket.total, rankIn: 0, subject }
const bucket = buckets.get(subject) as {
row: ScorableRow
total: number
valid: number
registered: number
}
const count = avgMode === 'all' ? bucket.registered : bucket.valid
return {
row: bucket.row,
value: count > 0 ? bucket.total / count : 0,
rankIn: 0,
subject,
teamId: bucket.row.teamId,
}
})
}
@@ -216,7 +245,7 @@ function scoreChannel(
let groups: TieGroup[]
if (basis === 'aggregate') {
const aggregated = aggregateCandidates(rows, channel)
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode))
sortByValue(aggregated, event.compare)
groups = groupByValue(aggregated)
} else if (basis === 'manual') {
@@ -242,7 +271,7 @@ function scoreChannel(
for (const member of group.members) {
out.push({
entryId: basis === 'aggregate' ? 0 : member.row.entryId,
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.row.teamId,
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.teamId,
scope: channel.scope,
rank: group.startRank,
points,
@@ -262,6 +291,7 @@ function rankOnlyChannel(event: Event): ScoreChannel {
basis: event.resultMode === 'manual' ? 'manual' : 'result',
profileId: 0,
sortOrder: 0,
countToTeam: true,
}
}
@@ -308,11 +338,15 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
const ranked = scoreChannel(rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null)
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
const countToTeam = normalizeCountToTeam(channel.countToTeam)
for (const row of ranked) {
if (row.entryId > 0) {
rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
if (countToTeam) {
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
}
// 名次优先取个人通道;同一优先级下先处理的通道胜出。
const current = rankScope.get(row.entryId)
if (current === undefined || (current === 'team' && row.scope === 'athlete')) {
@@ -320,6 +354,7 @@ export function computeEventScore(rows: readonly ScorableRow[], context: ScoreCo
rankScope.set(row.entryId, row.scope)
}
}
if (!countToTeam) continue
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
teamPoints.set(row.teamId, (teamPoints.get(row.teamId) ?? 0) + teamContribution)
}