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,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 ?? []
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user