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,8 @@
|
||||
import { normalizeAvgMode } from '@/shared'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
AvgMode,
|
||||
CurrentUser,
|
||||
DraftResultInput,
|
||||
Entry,
|
||||
@@ -109,6 +111,8 @@ interface EventSeed {
|
||||
compare: Event['compare']
|
||||
scored: boolean
|
||||
categoryName: string
|
||||
/** 「个人+团体」的团体成绩平均口径;缺省按 valid。 */
|
||||
avgMode?: AvgMode
|
||||
/** 已发布的成绩:单位序号(从 1 开始)+ 成绩文本 + 手动名次(团体项目用)。 */
|
||||
published?: Array<[number, string] | [number, string, number]>
|
||||
}
|
||||
@@ -314,6 +318,7 @@ class MockDb {
|
||||
venue: seed.sportId === 1 ? '田径场' : '体育馆',
|
||||
sortOrder: index + 1,
|
||||
remark: null,
|
||||
avgMode: normalizeAvgMode(seed.avgMode),
|
||||
}))
|
||||
|
||||
this.categories = EVENT_SEEDS.map((seed) => ({
|
||||
@@ -327,12 +332,12 @@ class MockDb {
|
||||
sortOrder: 1,
|
||||
}))
|
||||
|
||||
// 「个人+团体」同时配个人通道与团体通道,其余类型单通道。
|
||||
// 「个人+团体」按规则包默认模板配两条通道:个人通道只记个人分,团体通道取队内平均。
|
||||
this.channels = EVENT_SEEDS.flatMap((seed): ScoreChannel[] => {
|
||||
if (seed.eventType === 'individual_team') {
|
||||
return [
|
||||
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1 },
|
||||
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 2 },
|
||||
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false },
|
||||
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true },
|
||||
]
|
||||
}
|
||||
return [{
|
||||
@@ -342,6 +347,7 @@ class MockDb {
|
||||
basis: seed.resultMode === 'manual' ? 'manual' : 'result',
|
||||
profileId: seed.eventType === 'individual' ? 1 : 2,
|
||||
sortOrder: 1,
|
||||
countToTeam: true,
|
||||
}]
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ?? []
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
eventInputOf,
|
||||
formatPoints,
|
||||
hasAggregateChannel,
|
||||
parseResultValue,
|
||||
type AvgMode,
|
||||
type DraftResultInput,
|
||||
type EntryWithResult,
|
||||
type Event,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||
@@ -98,6 +102,9 @@ const categories = computed(() => detail.value?.categories ?? [])
|
||||
const channels = computed(() => detail.value?.channels ?? [])
|
||||
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
|
||||
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
|
||||
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
|
||||
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
|
||||
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
|
||||
|
||||
function unitOf(type: Event['resultType']): string {
|
||||
if (type === 'time') return 's'
|
||||
@@ -320,6 +327,7 @@ async function selectEvent(id: number): Promise<void> {
|
||||
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
|
||||
if (seq !== requestSeq) return
|
||||
detail.value = event
|
||||
avgMode.value = loadAvgMode(id, event.avgMode)
|
||||
selectedCategoryId.value = event.categories?.[0]?.id ?? null
|
||||
allRows.value = entries.map(toRow)
|
||||
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
|
||||
@@ -332,6 +340,43 @@ async function selectEvent(id: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换平均值口径:立即整体回写该小项(PUT /api/events/{id}),成功后 toast。
|
||||
*
|
||||
* 与成绩草稿的保存完全分离——这里既不改录入行,也不走 saveDraft;
|
||||
* 失败时把选择退回原值,避免页面显示的口径与服务端实际生效的口径不一致。
|
||||
*/
|
||||
async function pickAvgMode(mode: AvgMode): Promise<void> {
|
||||
const event = detail.value
|
||||
if (event === null || !canWrite.value) return
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报;与「发布」一致直接停用。
|
||||
toast.error('示例数据模式不能保存口径', '后端未就绪,改动不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
const next = selectAvgMode(avgMode.value, mode)
|
||||
avgMode.value = next.state
|
||||
if (!next.shouldSave) return
|
||||
try {
|
||||
const saved = await adminApi.client.events.update(event.id, eventInputOf(event, { avgMode: mode }))
|
||||
avgMode.value = settleAvgMode(avgMode.value, true)
|
||||
// 服务端返回的是小项本身,组别 / 通道仍以当前页加载的详情为准。
|
||||
detail.value = {
|
||||
...saved,
|
||||
categories: saved.categories ?? event.categories,
|
||||
channels: saved.channels ?? event.channels,
|
||||
}
|
||||
await data.refreshEvents()
|
||||
toast.success(
|
||||
'平均值口径已保存',
|
||||
mode === 'all' ? '没成绩的按 0 计入平均' : '只算有有效成绩的人',
|
||||
)
|
||||
} catch (caught) {
|
||||
avgMode.value = settleAvgMode(avgMode.value, false)
|
||||
toast.error('保存平均值口径失败', caught instanceof Error ? caught.message : null)
|
||||
}
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (selectedEventId.value === null) return
|
||||
const id = selectedEventId.value
|
||||
@@ -676,6 +721,29 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAvgMode" class="avg-mode-bar">
|
||||
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
||||
<label
|
||||
v-for="option in AVG_MODE_OPTIONS"
|
||||
:key="option.value"
|
||||
class="avg-mode-option"
|
||||
:class="{ active: avgMode.mode === option.value }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="avg-mode"
|
||||
:value="option.value"
|
||||
:checked="avgMode.mode === option.value"
|
||||
:disabled="!canWrite || avgMode.saving"
|
||||
@change="pickAvgMode(option.value)"
|
||||
/>
|
||||
<span class="avg-mode-text">{{ option.label }}</span>
|
||||
<span class="avg-mode-hint">{{ option.hint }}</span>
|
||||
</label>
|
||||
<span v-if="avgMode.saving" class="avg-mode-hint">保存中…</span>
|
||||
<span class="avg-mode-note">团体成绩 = 该单位成员成绩的平均值</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isPublished" class="published-note">
|
||||
<MxIcon name="lock" :size="15" />
|
||||
该组已发布,成绩与名次已锁定上屏:如需修改,请先「撤销发布」。草稿态不会上屏。
|
||||
@@ -945,6 +1013,45 @@ watch(
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.avg-mode-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
border: 1px solid var(--mx-primary);
|
||||
background: var(--mx-primary-glow);
|
||||
}
|
||||
.avg-mode-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mx-primary);
|
||||
}
|
||||
.avg-mode-option {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.avg-mode-option.active .avg-mode-text {
|
||||
color: var(--mx-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.avg-mode-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.avg-mode-note {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.published-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { normalizeAvgMode } from '@/shared'
|
||||
import type { AvgMode } from '@/shared'
|
||||
|
||||
/**
|
||||
* 成绩录入页「平均值口径」选择的状态机。
|
||||
*
|
||||
* 口径是随项目存的(PUT /api/events/{id}),点一下就立即保存,不参与成绩草稿的保存流程,
|
||||
* 所以状态单独放在这里,页面只负责发请求与提示。
|
||||
*/
|
||||
|
||||
export interface AvgModeOption {
|
||||
value: AvgMode
|
||||
label: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export const AVG_MODE_OPTIONS: readonly AvgModeOption[] = [
|
||||
{ value: 'valid', label: '只算有效成绩', hint: '没有有效成绩的人不进分母' },
|
||||
{ value: 'all', label: '全部报名人员都计入', hint: '没成绩的按 0 计入平均' },
|
||||
]
|
||||
|
||||
export interface AvgModeState {
|
||||
eventId: number | null
|
||||
/** 已保存到服务端的口径。 */
|
||||
saved: AvgMode
|
||||
/** 当前选择;保存中先按新值显示。 */
|
||||
mode: AvgMode
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
/** 切换 / 首次载入项目时重置,避免把上一个小项的口径带过来。 */
|
||||
export function loadAvgMode(eventId: number | null, mode: unknown): AvgModeState {
|
||||
const value = normalizeAvgMode(mode)
|
||||
return { eventId, saved: value, mode: value, saving: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户点选口径:与当前值相同、或上一次保存还没回来时不发请求
|
||||
* (同一时间只允许一个在途 PUT,避免两次响应互相覆盖)。
|
||||
*/
|
||||
export function selectAvgMode(
|
||||
state: AvgModeState,
|
||||
mode: AvgMode,
|
||||
): { state: AvgModeState; shouldSave: boolean } {
|
||||
if (mode === state.mode || state.saving) return { state, shouldSave: false }
|
||||
return { state: { ...state, mode, saving: true }, shouldSave: true }
|
||||
}
|
||||
|
||||
/** 保存结果回落:成功记住新值,失败退回上一次已保存的值。 */
|
||||
export function settleAvgMode(state: AvgModeState, ok: boolean): AvgModeState {
|
||||
if (!state.saving) return state
|
||||
const value = ok ? state.mode : state.saved
|
||||
return { ...state, saving: false, saved: value, mode: value }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode'
|
||||
|
||||
// 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('载入口径时归一化并清掉上一次的保存状态', () => {
|
||||
const state = loadAvgMode(108, 'all')
|
||||
assert.deepEqual(state, { eventId: 108, saved: 'all', mode: 'all', saving: false })
|
||||
// 旧数据 / 非法值一律按 valid。
|
||||
assert.equal(loadAvgMode(108, undefined).mode, 'valid')
|
||||
assert.equal(loadAvgMode(null, 'avg').mode, 'valid')
|
||||
})
|
||||
|
||||
test('选同一个口径或上一次保存未返回时不发请求', () => {
|
||||
const state = loadAvgMode(108, 'valid')
|
||||
|
||||
const same = selectAvgMode(state, 'valid')
|
||||
assert.equal(same.shouldSave, false)
|
||||
assert.equal(same.state, state)
|
||||
|
||||
const changed = selectAvgMode(state, 'all')
|
||||
assert.equal(changed.shouldSave, true)
|
||||
assert.equal(changed.state.mode, 'all')
|
||||
assert.equal(changed.state.saving, true)
|
||||
assert.equal(changed.state.saved, 'valid')
|
||||
|
||||
// 在途请求只允许一个:保存中再点另一个口径被忽略。
|
||||
const again = selectAvgMode(changed.state, 'valid')
|
||||
assert.equal(again.shouldSave, false)
|
||||
assert.equal(again.state.mode, 'all')
|
||||
})
|
||||
|
||||
test('保存成功记住新口径,失败退回原口径', () => {
|
||||
const pending = selectAvgMode(loadAvgMode(108, 'valid'), 'all').state
|
||||
|
||||
const ok = settleAvgMode(pending, true)
|
||||
assert.deepEqual(ok, { eventId: 108, saved: 'all', mode: 'all', saving: false })
|
||||
|
||||
const failed = settleAvgMode(pending, false)
|
||||
assert.deepEqual(failed, { eventId: 108, saved: 'valid', mode: 'valid', saving: false })
|
||||
|
||||
// 没有在途请求时回落是空操作。
|
||||
const idle = loadAvgMode(108, 'valid')
|
||||
assert.equal(settleAvgMode(idle, true), idle)
|
||||
})
|
||||
|
||||
test('口径选项覆盖两种分母口径与「按 0 计入」提示', () => {
|
||||
assert.deepEqual(
|
||||
AVG_MODE_OPTIONS.map((option) => option.value),
|
||||
['valid', 'all'],
|
||||
)
|
||||
assert.ok(AVG_MODE_OPTIONS.some((option) => option.label === '只算有效成绩'))
|
||||
assert.ok(AVG_MODE_OPTIONS.some((option) => option.label === '全部报名人员都计入' && option.hint === '没成绩的按 0 计入平均'))
|
||||
})
|
||||
|
||||
test('录入页只在存在 aggregate 通道时显示口径选择,并整体回写小项', () => {
|
||||
assert.match(ENTRY_SOURCE, /hasAggregateChannel\(channels\.value\)/)
|
||||
assert.match(ENTRY_SOURCE, /平均值口径/)
|
||||
assert.match(ENTRY_SOURCE, /v-if="showAvgMode"/)
|
||||
// 口径改动走 PUT /api/events/{id},不能混进成绩草稿的保存流程。
|
||||
assert.match(ENTRY_SOURCE, /client\.events\.update\(event\.id, eventInputOf\(event, \{ avgMode: mode \}\)\)/)
|
||||
})
|
||||
|
||||
test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => {
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/)
|
||||
assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/)
|
||||
assert.match(CHANNELS_SOURCE, /计入单位总分/)
|
||||
assert.match(CHANNELS_SOURCE, /只记在个人头上/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeam: row\.countToTeam/)
|
||||
})
|
||||
@@ -24,6 +24,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -33,7 +34,7 @@ function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
|
||||
}
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, ...overrides }
|
||||
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow {
|
||||
@@ -258,6 +259,83 @@ test('团体行(athleteId 为 null)按单位通道正常拿名次与积分',
|
||||
close(result.teamPoints.get(101) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('countToTeam=false 的通道只算个人分,不进单位总分', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
|
||||
const result = computeEventScore(
|
||||
[makeRow(1, 10, 100)],
|
||||
makeContext({
|
||||
channels: [makeChannel({ basis: 'result', countToTeam: false })],
|
||||
profiles: [profile],
|
||||
weight: 2,
|
||||
}),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 10)
|
||||
close(result.rowTeamPoints.get(1) ?? 0, 0)
|
||||
close(result.teamPoints.get(100) ?? 0, 0)
|
||||
// 名次照算,只是分不落到单位头上。
|
||||
assert.equal(result.ranks.get(1), 1)
|
||||
})
|
||||
|
||||
test('「个人+团体」:个人通道不进单位总分,团体通道按队内平均记分', () => {
|
||||
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }, { rank: 2, points: 8 }] })
|
||||
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 15 }, { rank: 2, points: 8 }] })
|
||||
const rows = [makeRow(1, 18, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)]
|
||||
const result = computeEventScore(
|
||||
rows,
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'individual_team', resultType: 'count', compare: 'desc' }),
|
||||
channels: [
|
||||
makeChannel({ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }),
|
||||
makeChannel({ id: 2, scope: 'team', basis: 'aggregate', profileId: 2, countToTeam: true }),
|
||||
],
|
||||
profiles: [athleteProfile, teamProfile],
|
||||
}),
|
||||
)
|
||||
// 个人分照给:20 排第 1、18 排第 2、16 排第 3(录取前 2 名)。
|
||||
close(result.rowPoints.get(2) ?? 0, 10)
|
||||
close(result.rowPoints.get(1) ?? 0, 8)
|
||||
// 单位总分只来自团体通道:100 队平均 19 排第 1,101 队平均 16 排第 2。
|
||||
// 个人通道若也计入,100 队会多出个人第 1 名的 10 分。
|
||||
close(result.teamPoints.get(100) ?? 0, 15)
|
||||
close(result.teamPoints.get(101) ?? 0, 8)
|
||||
})
|
||||
|
||||
test('aggregate 通道按队内平均排名(valid 只算有效成绩)', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||||
const result = computeEventScore(
|
||||
[makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)],
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc' }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||||
profiles: [profile],
|
||||
}),
|
||||
)
|
||||
// 100 队平均 15 < 101 队平均 16,desc 下 101 队第一。
|
||||
close(result.teamPoints.get(101) ?? 0, 10)
|
||||
close(result.teamPoints.get(100) ?? 0, 5)
|
||||
})
|
||||
|
||||
test('avgMode=all 把没成绩的报名项按 0 计入平均分母', () => {
|
||||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||||
const makeRows = (): ScorableRow[] => [makeRow(1, 20, 100), makeRow(2, 20, 100), makeRow(3, null, 100), makeRow(4, 16, 101)]
|
||||
const context = (avgMode: Event['avgMode']): ScoreContext =>
|
||||
makeContext({
|
||||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc', avgMode }),
|
||||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||||
profiles: [profile],
|
||||
})
|
||||
|
||||
// valid:100 队只按两条有效成绩平均(20)→ 第一。
|
||||
const valid = computeEventScore(makeRows(), context('valid'))
|
||||
close(valid.teamPoints.get(100) ?? 0, 10)
|
||||
close(valid.teamPoints.get(101) ?? 0, 5)
|
||||
|
||||
// all:第 3 条没成绩按 0 计入,100 队平均 40/3 → 落到第二。
|
||||
const all = computeEventScore(makeRows(), context('all'))
|
||||
close(all.teamPoints.get(101) ?? 0, 10)
|
||||
close(all.teamPoints.get(100) ?? 0, 5)
|
||||
})
|
||||
|
||||
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
|
||||
const rows: ScorableRow[] = [
|
||||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 计分通道与「个人+团体」口径的归一化。
|
||||
*
|
||||
* 后端旧版本 / 旧缓存里的对象可能没有 avgMode、countToTeam 这两个新字段,
|
||||
* 页面直接读会得到 undefined;统一在这里补默认值,读取处不必各写一份兜底。
|
||||
*/
|
||||
|
||||
import type { AvgMode, Event, EventInput, ScoreChannel } from './types'
|
||||
|
||||
export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
|
||||
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
|
||||
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
|
||||
export const DEFAULT_COUNT_TO_TEAM = true
|
||||
|
||||
export function normalizeAvgMode(value: unknown): AvgMode {
|
||||
return typeof value === 'string' && (AVG_MODES as readonly string[]).includes(value)
|
||||
? (value as AvgMode)
|
||||
: DEFAULT_AVG_MODE
|
||||
}
|
||||
|
||||
export function normalizeCountToTeam(value: unknown): boolean {
|
||||
return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_TEAM
|
||||
}
|
||||
|
||||
/** 只有存在「按队内平均」通道时,录入页才需要显示平均值口径选择。 */
|
||||
export function hasAggregateChannel(channels: readonly ScoreChannel[] | null | undefined): boolean {
|
||||
return (channels ?? []).some((channel) => channel.basis === 'aggregate')
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装 PUT /api/events/{id} 的载荷。
|
||||
*
|
||||
* 服务端按整体替换处理,所以必须把当前小项的全部可写字段原样回传,
|
||||
* 只改一个 avgMode 而漏掉别的字段会把它们清空。
|
||||
*/
|
||||
export function eventInputOf(event: Event, overrides: Partial<EventInput> = {}): EventInput {
|
||||
const input: EventInput = {
|
||||
sportId: event.sportId,
|
||||
code: event.code,
|
||||
name: event.name,
|
||||
eventType: event.eventType,
|
||||
resultMode: event.resultMode,
|
||||
resultType: event.resultType,
|
||||
compare: event.compare,
|
||||
recordEligible: event.recordEligible,
|
||||
scored: event.scored,
|
||||
multiplier: event.multiplier,
|
||||
status: event.status,
|
||||
scheduledAt: event.scheduledAt,
|
||||
venue: event.venue,
|
||||
sortOrder: event.sortOrder,
|
||||
remark: event.remark,
|
||||
avgMode: normalizeAvgMode(event.avgMode),
|
||||
}
|
||||
return { ...input, ...overrides }
|
||||
}
|
||||
@@ -8,6 +8,15 @@
|
||||
export * from './types'
|
||||
export { ApiClient, ApiError, createApiClient } from './api'
|
||||
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
|
||||
export {
|
||||
AVG_MODES,
|
||||
DEFAULT_AVG_MODE,
|
||||
DEFAULT_COUNT_TO_TEAM,
|
||||
eventInputOf,
|
||||
hasAggregateChannel,
|
||||
normalizeAvgMode,
|
||||
normalizeCountToTeam,
|
||||
} from './channels'
|
||||
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
|
||||
export { resolveAssetUrl } from './asset'
|
||||
export { RealtimeClient } from './realtime'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* 校验口径与服务端 rules.Canonical 对齐,避免前端拿到的规则包和实际生效规则不一致。
|
||||
*/
|
||||
|
||||
import { normalizeCountToTeam } from './channels'
|
||||
import {
|
||||
asRecord,
|
||||
emptyToNull,
|
||||
@@ -219,6 +220,8 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
|
||||
scope: oneOf(rec.scope, SCOPES, 'athlete'),
|
||||
basis: oneOf(rec.basis, BASES, 'result'),
|
||||
profile,
|
||||
// 旧规则包不写这个字段,缺省按「计入单位总分」处理。
|
||||
countToTeam: normalizeCountToTeam(rec.countToTeam),
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { Event, ScoreChannel } from '../types'
|
||||
import {
|
||||
eventInputOf,
|
||||
hasAggregateChannel,
|
||||
normalizeAvgMode,
|
||||
normalizeCountToTeam,
|
||||
} from '../channels'
|
||||
|
||||
// 运行:node --test --import ./src/shared/tests/register.mjs src/shared/tests/
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 7,
|
||||
sportId: 1,
|
||||
code: 'TPULL',
|
||||
name: '团体引体向上',
|
||||
eventType: 'individual_team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
multiplier: 2,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: '体育馆',
|
||||
sortOrder: 3,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 7, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
test('avgMode 缺字段 / 非法值时归一为 valid', () => {
|
||||
assert.equal(normalizeAvgMode('all'), 'all')
|
||||
assert.equal(normalizeAvgMode('valid'), 'valid')
|
||||
assert.equal(normalizeAvgMode(undefined), 'valid')
|
||||
assert.equal(normalizeAvgMode(null), 'valid')
|
||||
assert.equal(normalizeAvgMode('avg'), 'valid')
|
||||
assert.equal(normalizeAvgMode(1), 'valid')
|
||||
})
|
||||
|
||||
test('countToTeam 缺字段时默认为 true(旧数据不进单位总分才是例外)', () => {
|
||||
assert.equal(normalizeCountToTeam(true), true)
|
||||
assert.equal(normalizeCountToTeam(false), false)
|
||||
assert.equal(normalizeCountToTeam(undefined), true)
|
||||
assert.equal(normalizeCountToTeam('false'), true)
|
||||
})
|
||||
|
||||
test('只有存在 aggregate 通道时才需要平均值口径', () => {
|
||||
assert.equal(hasAggregateChannel([]), false)
|
||||
assert.equal(hasAggregateChannel(undefined), false)
|
||||
assert.equal(hasAggregateChannel([makeChannel({ basis: 'result' })]), false)
|
||||
assert.equal(hasAggregateChannel([makeChannel({ basis: 'result' }), makeChannel({ basis: 'aggregate' })]), true)
|
||||
})
|
||||
|
||||
test('eventInputOf 回传全部可写字段,并可覆盖 avgMode', () => {
|
||||
const event = makeEvent({ avgMode: 'all' })
|
||||
const input = eventInputOf(event, { avgMode: 'valid' })
|
||||
|
||||
assert.deepEqual(input, {
|
||||
sportId: 1,
|
||||
code: 'TPULL',
|
||||
name: '团体引体向上',
|
||||
eventType: 'individual_team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
multiplier: 2,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: '体育馆',
|
||||
sortOrder: 3,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
})
|
||||
// 旧数据没有 avgMode 时按默认值回传,不能把字段丢掉。
|
||||
assert.equal(eventInputOf({ ...event, avgMode: undefined as unknown as Event['avgMode'] }).avgMode, 'valid')
|
||||
})
|
||||
@@ -72,6 +72,12 @@ export type EventCategoryInput = Omit<EventCategory, 'id' | 'eventId'>
|
||||
export type ChannelScope = 'athlete' | 'team'
|
||||
export type ChannelBasis = 'result' | 'manual' | 'aggregate'
|
||||
|
||||
/**
|
||||
* 「个人+团体」小项里团体成绩的平均值分母口径:
|
||||
* `valid` 只算有有效成绩的人;`all` 全员计入,没成绩的按 0 计入(分母 = 该主体的报名条数)。
|
||||
*/
|
||||
export type AvgMode = 'valid' | 'all'
|
||||
|
||||
export interface ScoreChannel {
|
||||
id: number
|
||||
eventId: number
|
||||
@@ -79,6 +85,8 @@ export interface ScoreChannel {
|
||||
basis: ChannelBasis
|
||||
profileId: number
|
||||
sortOrder: number
|
||||
/** false 表示该通道产生的分只记在个人头上,不进单位总分。 */
|
||||
countToTeam: boolean
|
||||
}
|
||||
|
||||
export type ScoreChannelInput = Omit<ScoreChannel, 'id' | 'eventId'>
|
||||
@@ -101,6 +109,8 @@ export interface Event {
|
||||
venue: string | null
|
||||
sortOrder: number
|
||||
remark: string | null
|
||||
/** 仅「个人+团体」有意义:团体成绩取平均时的分母口径。 */
|
||||
avgMode: AvgMode
|
||||
/** 详情接口才带出,列表接口可能省略。 */
|
||||
categories?: EventCategory[]
|
||||
channels?: ScoreChannel[]
|
||||
@@ -556,6 +566,8 @@ export interface RuleChannel {
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profile: string
|
||||
/** 缺省视为 true;「个人+团体」模板的个人通道会显式写 false。 */
|
||||
countToTeam?: boolean
|
||||
}
|
||||
|
||||
export interface ChannelTemplateMatch {
|
||||
|
||||
Reference in new issue
Block a user