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

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { normalizeCountToTeam } from '@/shared'
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
@@ -19,7 +20,7 @@ const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [
const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
{ value: 'result', label: '按成绩自动排名' },
{ value: 'manual', label: '手动录名次' },
{ value: 'aggregate', label: '按团体汇总(预留)' },
{ value: 'aggregate', label: '按队内平均' },
]
interface ChannelRow {
@@ -28,6 +29,7 @@ interface ChannelRow {
basis: ChannelBasis
profileId: string
sortOrder: string
countToTeam: boolean
}
interface RowError {
@@ -78,6 +80,7 @@ function toRow(channel: ScoreChannel): ChannelRow {
basis: channel.basis,
profileId: String(channel.profileId),
sortOrder: String(channel.sortOrder),
countToTeam: normalizeCountToTeam(channel.countToTeam),
}
}
@@ -90,6 +93,7 @@ function addRow(): void {
basis: 'result',
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
sortOrder: String(rows.value.length + 1),
countToTeam: true,
},
]
}
@@ -131,6 +135,7 @@ function buildInputs(): ScoreChannelInput[] {
basis: row.basis,
profileId: Number(row.profileId),
sortOrder: Number(row.sortOrder) || index + 1,
countToTeam: row.countToTeam,
}))
}
@@ -205,6 +210,10 @@ async function save(): Promise<void> {
该小项标记为「不计分」,通道配置会保存但不参与积分计算。
</div>
<div class="bd-note count-to-team-note">
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
@@ -212,6 +221,7 @@ async function save(): Promise<void> {
<th class="bd-select">名次范围</th>
<th class="bd-select">名次来源</th>
<th>积分模板</th>
<th class="bd-select">计入单位总分</th>
<th class="bd-cell-mini">排序</th>
<th class="right">操作</th>
</tr>
@@ -227,6 +237,9 @@ async function save(): Promise<void> {
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
团体成绩 = 该单位成员成绩的平均值
</span>
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
</td>
<td>
@@ -237,6 +250,12 @@ async function save(): Promise<void> {
<span class="profile-summary bd-note">{{ profileSummary(row.profileId) }}</span>
<span v-if="errors[row.key]?.profile" class="row-error">{{ errors[row.key]?.profile }}</span>
</td>
<td>
<label class="count-to-team" :title="row.countToTeam ? '计入单位总分' : '不计入单位总分'">
<input v-model="row.countToTeam" type="checkbox" :disabled="!data.canWrite.value" />
<span>{{ row.countToTeam ? '计入' : '不计入' }}</span>
</label>
</td>
<td>
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
</td>
@@ -249,7 +268,7 @@ async function save(): Promise<void> {
</td>
</tr>
<tr v-if="rows.length === 0">
<td colspan="5">
<td colspan="6">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">该小项没有计分通道</div>
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
@@ -297,4 +316,15 @@ async function save(): Promise<void> {
color: var(--mx-amber);
margin-bottom: 10px;
}
.count-to-team-note {
margin-bottom: 10px;
}
.count-to-team {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--mx-t2);
cursor: pointer;
}
</style>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import type {
AvgMode,
CompareMode,
Event,
EventInput,
@@ -101,9 +103,10 @@ const channelSummary = computed(() =>
return {
id: channel.id,
scope: channel.scope === 'athlete' ? '个人名次' : '团体名次',
basis: channel.basis === 'result' ? '按成绩排名' : channel.basis === 'manual' ? '手动名次' : '团体汇总',
basis: channel.basis === 'result' ? '按成绩排名' : channel.basis === 'manual' ? '手动名次' : '按队内平均',
profile: profile?.name ?? `模板 #${channel.profileId}`,
award: profile?.awardRanks ?? null,
countToTeam: normalizeCountToTeam(channel.countToTeam),
}
}),
)
@@ -246,6 +249,8 @@ interface EventForm {
venue: string
sortOrder: string
remark: string
/** 平均值口径只在录入页改;这里保留原值,避免编辑小项时被重置成默认。 */
avgMode: AvgMode
}
type EventField = keyof EventForm
@@ -284,6 +289,7 @@ function emptyEventForm(): EventForm {
venue: '',
sortOrder: '0',
remark: '',
avgMode: 'valid',
}
}
@@ -330,6 +336,7 @@ function openEditEvent(event: Event): void {
venue: event.venue ?? '',
sortOrder: String(event.sortOrder),
remark: event.remark ?? '',
avgMode: normalizeAvgMode(event.avgMode),
}
eventErrors.value = {}
eventFormError.value = null
@@ -374,6 +381,7 @@ async function submitEvent(): Promise<void> {
venue: eventForm.value.venue.trim() === '' ? null : eventForm.value.venue.trim(),
sortOrder: Number(eventForm.value.sortOrder),
remark: eventForm.value.remark.trim() === '' ? null : eventForm.value.remark.trim(),
avgMode: eventForm.value.avgMode,
}
try {
const saved = await data.saveEvent(input, eventEditingId.value)
@@ -568,6 +576,7 @@ async function confirmDeleteEvent(): Promise<void> {
<span>{{ channel.basis }}</span>
<span class="bd-mono">{{ channel.profile }}</span>
<span class="bd-note">录取 {{ channel.award === null ? '—' : `前 ${channel.award} 名` }}</span>
<span v-if="!channel.countToTeam" class="mx-badge mx-badge-warning">不计入单位总分</span>
</li>
</ul>
</div>
@@ -1,3 +1,4 @@
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
import type {
Athlete,
AthleteInput,
@@ -94,11 +95,16 @@ const SEED_PROFILES: ScoringProfile[] = [
]
interface EventSeed {
event: Omit<Event, 'id' | 'categories' | 'channels'>
/** avgMode 统一由构造函数补默认值,示例数据里不必逐个写。 */
event: Omit<Event, 'id' | 'categories' | 'channels' | 'avgMode'>
category: string
scope: ScoreChannel['scope']
basis: ScoreChannel['basis']
profileId: number
/** 通道是否计入单位总分;缺省 true。 */
countToTeam?: boolean
/** 团体成绩的平均口径;缺省 valid。 */
avgMode?: Event['avgMode']
/** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */
entryCount: number
}
@@ -206,7 +212,7 @@ export class MockBaseData {
constructor() {
SEED_EVENTS.forEach((seed, index) => {
const id = 101 + index
this.events.push({ ...seed.event, id })
this.events.push({ ...seed.event, id, avgMode: normalizeAvgMode(seed.avgMode) })
this.entryCounts.set(id, seed.entryCount)
this.categories.push({
id: (this.nextCategoryId += 1),
@@ -225,6 +231,7 @@ export class MockBaseData {
basis: seed.basis,
profileId: seed.profileId,
sortOrder: 1,
countToTeam: seed.countToTeam ?? true,
})
})
@@ -316,7 +323,7 @@ export class MockBaseData {
createEvent(input: EventInput): Event {
if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`)
const id = (this.nextEventId += 1)
const event: Event = { ...input, id }
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
this.events = [...this.events, event]
this.entryCounts.set(id, 0)
return { ...event }
@@ -328,7 +335,7 @@ export class MockBaseData {
if (this.events.some((event) => event.id !== id && event.code === input.code)) {
throw new Error(`小项代码 ${input.code} 已存在`)
}
const event: Event = { ...input, id }
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
this.events[index] = event
return { ...event }
}
@@ -370,7 +377,12 @@ export class MockBaseData {
}
this.channels = this.channels.filter((item) => item.eventId !== eventId)
for (const input of inputs) {
this.channels.push({ ...input, id: (this.nextChannelId += 1), eventId })
this.channels.push({
...input,
id: (this.nextChannelId += 1),
eventId,
countToTeam: normalizeCountToTeam(input.countToTeam),
})
}
return this.getEvent(eventId).channels ?? []
}