修复花样跳绳 计分规则与显示

- 修正个人及同步花样表现分失误重复计算
- 合并表现裁判与主裁失误计算得分系数 M
- 修正车轮跳规定元素数量及 Q 系数
- 修正同步花样规定元素等级和小数缺失量
- 统一 D/P/Q/M 舍入精度及总分计算
- 统一平板、后端、工作台和投屏显示口径
- 增加计分回归用例和上线规则迁移脚本
This commit is contained in:
马梓彭 2026-09-29 17:19:15 +08:00
parent 9dc6151714
commit 6e625c7b7d
3 changed files with 53 additions and 9 deletions

View File

@ -0,0 +1,42 @@
// 花样展示值来自后端评分单/计算快照,查询页面不重算历史总分。
export function isFactorRule(code) {
return /FREESTYLE|SHOWCASE/i.test(String(code || ''));
}
export function fixedScore(value, decimals = 2) {
if (value === null || value === undefined || value === '') return '—';
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(decimals) : String(value);
}
export function judgeScoreText(sheet, ruleCode) {
if (!sheet) return '—';
if (sheet.isDisqualified) return 'DQ';
if (!isFactorRule(ruleCode)) return fixedScore(sheet.totalScore);
const role = String(sheet.judgeRoleType || '').toUpperCase();
if (role === 'T') {
if (sheet.deductionScore == null) return '—';
return fixedScore(Math.max(0, 1 - Number(sheet.deductionScore)), 3);
}
if (role === 'P') return fixedScore(sheet.performanceScore);
if (role.startsWith('D')) return fixedScore(sheet.difficultyScore);
return fixedScore(sheet.totalScore);
}
export function calculationSummary(json) {
if (!json) return '';
try {
const data = typeof json === 'string' ? JSON.parse(json) : json;
const factors = data.factors;
if (!factors) return '';
const lines = [`公式:${data.formula};D=${fixedScore(factors.d)},P=${fixedScore(factors.p)},Q=${fixedScore(factors.q)},M=${fixedScore(factors.m, 3)}`];
const failure = data.failureAggregation;
if (failure?.performanceAverageFailureCount != null) {
lines.push(`失误:表现裁判 [${failure.performanceFailureCounts.join('、')}],平均 ${failure.performanceAverageFailureCount};主裁 ${failure.chiefFailureCount};合计 ${failure.combinedFailureCount} → ${failure.roundedFailureCount} 次;失误扣分 ${fixedScore(failure.failureDeduction, 3)},其他扣分 ${fixedScore(failure.otherDeduction, 3)}`);
} else if (failure) {
lines.push(`失误(历史口径):表现裁判 [${failure.performanceFailureCounts.join('、')}],主裁 ${failure.chiefFailureCount};平均 ${failure.averageFailureCount} → ${failure.roundedFailureCount} 次;失误扣分 ${fixedScore(failure.failureDeduction, 3)},其他扣分 ${fixedScore(failure.otherDeduction, 3)}`);
}
lines.push(data.calculationVersion ? '按 BUG-71:因子先舍入,再计算总分。' : '历史计算快照:可能尚未按新规则重算。');
return lines.join('\n');
} catch { return ''; }
}

View File

@ -8,7 +8,7 @@
<section class="context-bar"><div><span class="context-icon"><el-icon><User /></el-icon></span><small>项目 / 组别</small><b>{{ projectGroup }}</b></div><i></i><div><span class="context-icon"><el-icon><Location /></el-icon></span><small>场次 / 场地</small><b>{{ sessionVenue }}</b></div></section>
<section v-if="viewType === 'score'" class="score-layout">
<div class="hero-card"><div class="panel-shine"></div><div class="entity-chip"><el-icon><component :is="displayKind === 'team' ? UserFilled : User" /></el-icon><span>{{ displayKind === 'team' ? 'TEAM' : 'PLAYER' }}</span>{{ displayKindLabel }}成绩</div><div class="entity-name">{{ singleName }}</div><div v-if="singleAffiliation" class="entity-affiliation">{{ singleAffiliation }}</div><div class="hero-meta"><span v-if="payload.entryNo">出场号 <b>{{ payload.entryNo }}</b></span><span v-if="payload.rank">当前排名 <b>第 {{ payload.rank }} 名</b></span></div><div class="hero-score"><strong class="score-ticker">{{ animatedValue('hero', payload.totalScore ?? payload.score) }}</strong><small>分</small></div><div class="score-beam"><i></i></div><div v-if="payload.isDisqualified" class="dq-stamp">DQ</div></div>
<div class="score-metrics" :style="{ gridTemplateColumns: `repeat(${Math.max(scoreCards.length, 1)}, 1fr)` }"><article v-for="(item,index) in scoreCards" :key="item.key" :class="['metric-card', item.key]" :style="{ '--delay': `${index * 90}ms` }"><div class="metric-head"><span class="metric-icon"><el-icon><component :is="scoreIconComponents[item.key]" /></el-icon></span><small>{{ item.en }}</small></div><strong class="score-ticker">{{ animatedValue(`metric:${item.key}`, payload[item.field]) }}</strong><div class="metric-line"></div><b>{{ item.label }}</b></article></div>
<div class="score-metrics" :style="{ gridTemplateColumns: `repeat(${Math.max(scoreCards.length, 1)}, 1fr)` }"><article v-for="(item,index) in scoreCards" :key="item.key" :class="['metric-card', item.key]" :style="{ '--delay': `${index * 90}ms` }"><div class="metric-head"><span class="metric-icon"><el-icon><component :is="scoreIconComponents[item.key]" /></el-icon></span><small>{{ item.en }}</small></div><strong class="score-ticker">{{ item.field === 'deductionFactor' ? Number(payload[item.field]).toFixed(3) : animatedValue(`metric:${item.key}`, payload[item.field]) }}</strong><div class="metric-line"></div><b>{{ item.label }}</b></article></div>
</section>
<section v-else class="ranking-layout">
<div class="ranking-heading"><div><span><i></i>{{ displayKind === 'team' ? 'TEAM RANKING' : 'INDIVIDUAL RANKING' }}</span><h2>{{ displayKindLabel }}排名</h2></div><div class="ranking-status"><small>共 {{ rankingTotal }} 个{{ displayKind === 'team' ? '队伍' : '参赛者' }}<template v-if="rankingPageCount > 1"> · 第 {{ rankingPageIndex + 1 }}/{{ rankingPageCount }} 页</template></small><div v-if="rankingPageCount > 1" class="page-dots"><i v-for="page in rankingPageCount" :key="page" :class="{ active: page - 1 === rankingPageIndex }"></i></div></div></div>
@ -55,13 +55,13 @@ const sessionVenue=computed(()=>[formatScreenSession(effectiveCast.value?.sessio
const singleName=computed(()=>displayKind.value==='team'?(payload.value.teamName||payload.value.participantName||'未命名队伍'):(payload.value.participantName||payload.value.teamName||'未命名选手'));
const singleAffiliation=computed(()=>displayKind.value==='team'?(payload.value.unitName||''):(payload.value.teamName&&payload.value.teamName!==singleName.value?payload.value.teamName:(payload.value.unitName||'')));
const scoreIconComponents={difficulty:Setting,performance:Medal,required:DocumentChecked,deduction:Flag};
const scoreCards=computed(()=>[{key:'difficulty',label:'难度',en:'DIFFICULTY',field:'difficultyScore'},{key:'performance',label:'表现',en:'PERFORMANCE',field:'performanceScore'},{key:'required',label:'规定',en:'REQUIRED',field:'requiredScore'},{key:'deduction',label:'扣分',en:'DEDUCTION',field:'deductionScore'}].filter(item=>item.key!=='difficulty'||!String(effectiveCast.value?.scoreRuleCode||'').toUpperCase().includes('PRESCRIBED')));
const scoreCards=computed(()=>[{key:'difficulty',label:'难度',en:'DIFFICULTY',field:'difficultyScore'},{key:'performance',label:'表现',en:'PERFORMANCE',field:'performanceScore'},{key:'required',label:'规定',en:'REQUIRED',field:'requiredScore'},{key:'deduction',label:payload.value.deductionFactor!=null?'得分系数 M':'扣分',en:'DEDUCTION',field:payload.value.deductionFactor!=null?'deductionFactor':'deductionScore'}].filter(item=>item.key!=='difficulty'||!String(effectiveCast.value?.scoreRuleCode||'').toUpperCase().includes('PRESCRIBED')));
const rankingAll=computed(()=>Array.isArray(payload.value)?payload.value:[]),rankingTotal=computed(()=>rankingAll.value.length),rankingPageCount=computed(()=>Math.max(1,Math.ceil(rankingTotal.value/6))),rankingPageIndex=computed(()=>rankPage.value%rankingPageCount.value),rankingRows=computed(()=>rankingAll.value.slice(rankingPageIndex.value*6,rankingPageIndex.value*6+6)),maxRankScore=computed(()=>Math.max(...rankingAll.value.map(row=>Number(row.totalScore??row.score)||0),1));
function displayName(row){return displayKind.value==='team'?(row.teamName||row.participantName||'未命名队伍'):(row.participantName||row.teamName||'未命名选手')}
function displaySubName(row){if(displayKind.value==='team')return row.unitName||'';return row.teamName&&row.teamName!==displayName(row)?row.teamName:(row.unitName||'')}
function rankNumber(row,index){const rank=Number(row.rank);return Number.isFinite(rank)&&rank>0?rank:rankingPageIndex.value*6+index+1}
function rankWidth(row){return `${Math.max(12,Math.round((Number(row.totalScore??row.score)||0)/maxRankScore.value*100))}%`}
function value(input){if(input===undefined||input===null||input==='')return '0';const n=Number(input);return Number.isFinite(n)?n.toFixed(2).replace(/\.00$/,'').replace(/(\.\d)0$/,'$1'):input}
function value(input){if(input===undefined||input===null||input==='')return '0';const n=Number(input);return Number.isFinite(n)?n.toFixed(2):input}
function rankAnimationKey(row,index){return `rank:${row.entryNo||displayName(row)||rankingPageIndex.value*6+index}`}
function animatedValue(key,fallback){return Object.prototype.hasOwnProperty.call(animatedNumbers.value,key)?value(animatedNumbers.value[key]):value(fallback)}
function animateScoreNumbers(){

View File

@ -57,14 +57,14 @@
<el-table-column label="单位" min-width="180" fixed="left"><template #default="{ row }"><span class="unit-name">{{ row.unitName || row.teamName || '—' }}</span></template></el-table-column>
<el-table-column v-for="group in judgeMatrixGroups" :key="group.key" :label="group.label" align="center" header-align="center">
<el-table-column v-for="judge in group.judges" :key="judge.key" :label="judge.label" width="112" align="center">
<template #default="{ row }"><span v-if="judgeScoreFor(row, judge.key)" :class="['judge-score', { pending: judgeScoreFor(row, judge.key).sheetStatus < 10 }]">{{ judgeScoreFor(row, judge.key).sheetStatus >= 10 ? score(judgeScoreFor(row, judge.key).totalScore) : '待提交' }}</span><span v-else class="muted">—</span></template>
<template #default="{ row }"><span v-if="judgeScoreFor(row, judge.key)" :class="['judge-score', { pending: judgeScoreFor(row, judge.key).sheetStatus < 10 }]">{{ judgeScoreFor(row, judge.key).sheetStatus >= 10 ? judgeScoreText(judgeScoreFor(row, judge.key), row._session?.scoreRuleCode) : '待提交' }}</span><span v-else class="muted">—</span></template>
</el-table-column>
</el-table-column>
<el-table-column label="裁判提交" width="92" align="center"><template #default="{ row }"><span :class="['submit-progress', { done: allSubmitted(row) }]">{{ submittedCount(row) }}/{{ row.judgeScores?.length || 0 }}</span></template></el-table-column>
<el-table-column v-if="showDifficulty" label="难度分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.difficultyScore) }}</template></el-table-column>
<el-table-column label="表现分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.performanceScore) }}</template></el-table-column>
<el-table-column label="规定分" width="88" align="center"><template #default="{ row }">{{ score(row.result?.requiredScore) }}</template></el-table-column>
<el-table-column label="扣分" width="78" align="center"><template #default="{ row }">{{ score(row.result?.deductionScore) }}</template></el-table-column>
<el-table-column :label="showFactorScores ? '表现系数 P' : '表现分'" width="110" align="center"><template #default="{ row }">{{ score(row.result?.performanceScore) }}</template></el-table-column>
<el-table-column :label="showFactorScores ? '规定系数 Q' : '规定分'" width="110" align="center"><template #default="{ row }">{{ score(row.result?.requiredScore) }}</template></el-table-column>
<el-table-column :label="showFactorScores ? '得分系数 M' : '扣分'" width="110" align="center"><template #default="{ row }">{{ isFactorRule(row._session?.scoreRuleCode) ? fixedScore(row.result?.deductionFactor, 3) : score(row.result?.deductionScore) }}</template></el-table-column>
<el-table-column label="总分" width="96" align="center"><template #default="{ row }"><b class="total" :class="{ dq: row.result?.isDisqualified }">{{ row.result?.isDisqualified ? 'DQ' : score(row.result?.totalScore ?? row.result?.scoreText) }}</b></template></el-table-column>
<el-table-column label="状态" width="96" fixed="right" align="center"><template #default="{ row }"><el-tag v-if="row.result" :type="resultTag(row.result.resultStatus)" effect="light">{{ row.result.resultStatusName }}</el-tag><span v-else class="muted">计算中</span></template></el-table-column>
<el-table-column label="操作" :width="activeTab === 'screen' ? 190 : 250" fixed="right" align="center"><template #default="{ row }">
@ -82,7 +82,7 @@
</section>
</section>
<el-dialog v-model="detailVisible" title="评分详情" width="92%" destroy-on-close><div v-if="detailRow" class="detail-summary"><div><small>选手/队伍</small><b>{{detailRow.teamName || detailRow.participantName}}</b></div><div><small>总分</small><b>{{score(detailRow.result?.totalScore)}}</b></div><div><small>成绩版本</small><b>V{{detailRow.result?.resultVersion || 0}}</b></div></div><el-table :data="detailRows" max-height="520"><el-table-column prop="judgeRole" label="席位" width="80" fixed/><el-table-column prop="judgeRoleName" label="裁判类型" min-width="110" fixed/><el-table-column prop="judgeName" label="裁判" width="100" fixed/><el-table-column v-for="col in detailColumns" :key="col.key" :label="col.label" min-width="105" align="center"><template #default="{row}">{{score(row.itemValues[col.key])}}</template></el-table-column><el-table-column prop="totalScore" label="合计" width="90"/><el-table-column prop="sheetStatusName" label="状态" width="95"/></el-table><template #footer><el-button @click="detailVisible=false">关闭</el-button><el-button type="primary" :loading="actionLoading" :disabled="!canCastForKind(detailRow)" @click="castDetailScore">投出{{ castEntityLabel }}成绩</el-button></template></el-dialog>
<el-dialog v-model="detailVisible" title="评分详情" width="92%" destroy-on-close><div v-if="detailRow" class="detail-summary"><div><small>选手/队伍</small><b>{{detailRow.teamName || detailRow.participantName}}</b></div><div><small>总分</small><b>{{score(detailRow.result?.totalScore)}}</b></div><div><small>成绩版本</small><b>V{{detailRow.result?.resultVersion || 0}}</b></div></div><p style="white-space: pre-line">{{ calculationSummary(detailRow.result?.calculationJson) }}</p><p v-if="isFactorRule(detailRow._session?.scoreRuleCode)">主裁列为本裁判得分系数;综合得分系数包含表现裁判失误。</p><el-table :data="detailRows" max-height="520"><el-table-column prop="judgeRole" label="席位" width="80" fixed/><el-table-column prop="judgeRoleName" label="裁判类型" min-width="110" fixed/><el-table-column prop="judgeName" label="裁判" width="100" fixed/><el-table-column v-for="col in detailColumns" :key="col.key" :label="col.label" min-width="105" align="center"><template #default="{row}">{{score(row.itemValues[col.key])}}</template></el-table-column><el-table-column label="分数/本裁判系数" width="140"><template #default="{row}">{{ judgeScoreText(row, detailRow._session?.scoreRuleCode) }}</template></el-table-column><el-table-column prop="sheetStatusName" label="状态" width="95"/></el-table><template #footer><el-button @click="detailVisible=false">关闭</el-button><el-button type="primary" :loading="actionLoading" :disabled="!canCastForKind(detailRow)" @click="castDetailScore">投出{{ castEntityLabel }}成绩</el-button></template></el-dialog>
<el-dialog v-model="rankingVisible" :title="`投出${castEntityLabel}排名`" width="540px"><el-alert type="info" :closable="false" title="大屏将仅展示后台选中的项目组别排名"/><el-form label-position="top" class="ranking-form"><el-form-item label="展示对象"><el-radio-group v-model="castEntityKind"><el-radio-button label="individual">个人排名</el-radio-button><el-radio-button label="team">团队排名</el-radio-button></el-radio-group></el-form-item><el-form-item label="项目 / 组别" required><el-select v-model="rankingKey" placeholder="选择项目 / 组别" style="width:100%"><el-option v-for="x in rankingOptions" :key="x.key" :label="`${x.projectName} / ${x.groupName}`" :value="x.key"/></el-select></el-form-item></el-form><template #footer><el-button @click="rankingVisible=false">取消</el-button><el-button type="primary" :loading="actionLoading" :disabled="!rankingKey" @click="castRanking">确定投屏</el-button></template></el-dialog>
@ -93,6 +93,7 @@
</template>
<script setup>
import { fixedScore, isFactorRule, judgeScoreText, calculationSummary } from '../score-format.mjs';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
@ -151,6 +152,7 @@ const castEntityLabel=computed(()=>castEntityKind.value==='team'?'团队':'个
const rankingKey=ref('');
const rankingOptions=computed(()=>{const map=new Map();for(const x of sessions.value){if(x.eventProjectId&&x.eventProjectGroupId){const key=`${x.eventProjectId}:${x.eventProjectGroupId}`;if(!map.has(key))map.set(key,{...x,key})}}return [...map.values()]});
const rankingSelection=computed(()=>rankingOptions.value.find(x=>x.key===rankingKey.value));
const showFactorScores=computed(()=>selectedProjectSessions.value.some(session=>isFactorRule(session.scoreRuleCode)));
const showDifficulty=computed(()=>selectedProjectSessions.value.some(session=>!String(session.scoreRuleCode||'').toUpperCase().includes('PRESCRIBED')));
const judgeMatrixGroups=computed(()=>{
const groups=new Map();
@ -288,7 +290,7 @@ function canCast(row){return !!row.result&&allSubmitted(row)&&row.result.resultS
function canCastForKind(row){return canCast(row)&&(castEntityKind.value!=='team'||Boolean(row.teamName))}
function castDisabledReason(row){if(!row.result)return '尚未生成成绩';if(!allSubmitted(row))return '裁判成绩尚未全部提交';if(row.result.isDisqualified)return 'DQ 成绩不可投屏';if(row.result.resultStatus<20)return '成绩尚未确认';if(castEntityKind.value==='team'&&!row.teamName)return '该记录没有团队名称,请切换为个人展示';return '该成绩不可投屏'}
function canConfirm(row){return row.result?.resultStatus===10}
function score(value){if(value===null||value===undefined||value==='')return '—';const n=Number(value);return Number.isFinite(n)?n.toFixed(2).replace(/\.00$/,'').replace(/(\.\d)0$/,'$1'):value}
function score(value){return fixedScore(value)}
function resultTag(status){return ({10:'warning',20:'success',30:'primary',40:'success',90:'danger'})[status]||'info'}
function formatTime(value){return value?String(value).replace('T',' ').slice(0,19):'—'}
function formatSessionName(session, index) {