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
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user