feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
1 parent
78b14efaa9
commit
877ff15823
181 files changed
+8747
-371
No files matched your search
+41
-20
@@ -697,25 +697,40 @@ class MockDb {
|
||||
}
|
||||
}
|
||||
|
||||
return this.teams
|
||||
.map((team) => {
|
||||
const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 }
|
||||
return {
|
||||
teamId: team.id,
|
||||
teamCode: team.code,
|
||||
teamName: team.name,
|
||||
shortName: team.shortName,
|
||||
color: team.color,
|
||||
logoPath: team.logoPath,
|
||||
rank: 0,
|
||||
gold: bucket.gold,
|
||||
silver: bucket.silver,
|
||||
bronze: bucket.bronze,
|
||||
medalTotal: bucket.gold + bucket.silver + bucket.bronze,
|
||||
totalPoints: round(bucket.points),
|
||||
rankChange: null,
|
||||
}
|
||||
})
|
||||
const rows: StandingRow[] = this.teams.map((team) => {
|
||||
const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 }
|
||||
return {
|
||||
teamId: team.id,
|
||||
teamCode: team.code,
|
||||
teamName: team.name,
|
||||
shortName: team.shortName,
|
||||
color: team.color,
|
||||
logoPath: team.logoPath,
|
||||
rank: 0,
|
||||
medalRank: 0,
|
||||
gold: bucket.gold,
|
||||
silver: bucket.silver,
|
||||
bronze: bucket.bronze,
|
||||
medalTotal: bucket.gold + bucket.silver + bucket.bronze,
|
||||
totalPoints: round(bucket.points),
|
||||
rankChange: null,
|
||||
}
|
||||
})
|
||||
|
||||
// 单位奖牌榜的名次与总积分榜无关(金 → 银 → 铜 → 单位编码),示例数据照服务端同一口径算,
|
||||
// 数组顺序仍保持积分榜顺序(服务端就是这么下发的:rank 管顺序,medalRank 单独带)。
|
||||
const medalRank = new Map<number, number>()
|
||||
;[...rows]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.gold - a.gold ||
|
||||
b.silver - a.silver ||
|
||||
b.bronze - a.bronze ||
|
||||
a.teamCode.localeCompare(b.teamCode),
|
||||
)
|
||||
.forEach((row, index) => medalRank.set(row.teamId, index + 1))
|
||||
|
||||
return rows
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.totalPoints - a.totalPoints ||
|
||||
@@ -724,7 +739,7 @@ class MockDb {
|
||||
b.bronze - a.bronze ||
|
||||
a.teamCode.localeCompare(b.teamCode),
|
||||
)
|
||||
.map((row, index) => ({ ...row, rank: index + 1 }))
|
||||
.map((row, index) => ({ ...row, rank: index + 1, medalRank: medalRank.get(row.teamId) ?? index + 1 }))
|
||||
}
|
||||
|
||||
athleteStandings(): AthleteStanding[] {
|
||||
@@ -784,6 +799,12 @@ class MockDb {
|
||||
resultText: result.resultText,
|
||||
points,
|
||||
createdAt: result.recordedAt ?? new Date().toISOString(),
|
||||
// 示例数据没有版本链(result_revisions 只有真实服务端才写),四个字段一律 null:
|
||||
// 追不出来就显示为空,界面正好演示「没改过」那一档,不编一个假的修订次数。
|
||||
revisionCount: null,
|
||||
lastRevisionAt: null,
|
||||
lastRevisionReason: null,
|
||||
lastRevisedBy: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
ChannelBasis,
|
||||
@@ -182,16 +182,20 @@ function sortManual(cands: Candidate[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合通道:先把同一主体的成绩取平均,再按平均值排名(scoring.go scoreByAggregate)。
|
||||
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和),再按汇总值排名
|
||||
* (scoring.go scoreByAggregate)。
|
||||
*
|
||||
* 分母由 avgMode 决定:valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,
|
||||
* 没成绩的按 0 计入。scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
|
||||
* 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定:
|
||||
* valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。
|
||||
* sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。
|
||||
* scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
|
||||
*/
|
||||
function aggregateCandidates(
|
||||
rows: readonly ScorableRow[],
|
||||
channel: ScoreChannel,
|
||||
avgMode: AvgMode,
|
||||
): Candidate[] {
|
||||
const sumMode = normalizeAggregateMode(channel.aggregateMode) === 'sum'
|
||||
const order: number[] = []
|
||||
const buckets = new Map<number, { row: ScorableRow; total: number; valid: number; registered: number }>()
|
||||
for (const row of rows) {
|
||||
@@ -200,7 +204,7 @@ function aggregateCandidates(
|
||||
const value = row.status === 'ok' ? row.resultValue : null
|
||||
let bucket = buckets.get(subject)
|
||||
if (bucket === undefined) {
|
||||
// valid 口径下没有任何有效成绩的主体不参与排名。
|
||||
// valid 口径下没有任何有效成绩的主体不参与排名(两种汇总口径都一样)。
|
||||
if (value === null && avgMode !== 'all') continue
|
||||
bucket = { row, total: 0, valid: 0, registered: 0 }
|
||||
buckets.set(subject, bucket)
|
||||
@@ -220,9 +224,10 @@ function aggregateCandidates(
|
||||
registered: number
|
||||
}
|
||||
const count = avgMode === 'all' ? bucket.registered : bucket.valid
|
||||
const value = sumMode ? bucket.total : count > 0 ? bucket.total / count : 0
|
||||
return {
|
||||
row: bucket.row,
|
||||
value: count > 0 ? bucket.total / count : 0,
|
||||
value,
|
||||
rankIn: 0,
|
||||
subject,
|
||||
teamId: bucket.row.teamId,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeCountToMedals, normalizeCountToTeam, normalizeCountToTeamMedals } from '@/shared'
|
||||
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
|
||||
import {
|
||||
normalizeAggregateMode,
|
||||
normalizeCountToMedals,
|
||||
normalizeCountToTeam,
|
||||
normalizeCountToTeamMedals,
|
||||
} from '@/shared'
|
||||
import type { AggregateMode, ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import type { BaseDataTabKey } from './tabs'
|
||||
import { newChannelRow, type ChannelRow } from './channelRows'
|
||||
|
||||
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
|
||||
|
||||
@@ -23,18 +29,14 @@ const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
|
||||
{ value: 'aggregate', label: '按队内平均' },
|
||||
]
|
||||
|
||||
interface ChannelRow {
|
||||
key: number
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profileId: string
|
||||
sortOrder: string
|
||||
countToTeam: boolean
|
||||
/** 该通道名次是否发奖牌(缺省发)。 */
|
||||
countToMedals: boolean
|
||||
/** 该通道的个人奖牌是否并入单位金牌榜(缺省并入)。scope=team 的行不显示,但值照样回传。 */
|
||||
countToTeamMedals: boolean
|
||||
}
|
||||
/**
|
||||
* 聚合口径的两个选项:现场在「按成员平均」与「按成员总和」之间快速切。
|
||||
* 与 basis=aggregate 配套使用,点一下即选中(不做二次确认,现场要的是快)。
|
||||
*/
|
||||
const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint: string }> = [
|
||||
{ value: 'avg', label: '平均', hint: '团体成绩 = 该单位成员成绩的平均值(缺省)' },
|
||||
{ value: 'sum', label: '总和', hint: '团体成绩 = 该单位成员成绩的总和' },
|
||||
]
|
||||
|
||||
interface RowError {
|
||||
scope?: string
|
||||
@@ -84,6 +86,9 @@ function toRow(channel: ScoreChannel): ChannelRow {
|
||||
basis: channel.basis,
|
||||
profileId: String(channel.profileId),
|
||||
sortOrder: String(channel.sortOrder),
|
||||
// 已存库的通道按原值回填:新建行的默认值只作用于新增行,不能改写现场已经配好的通道。
|
||||
// 聚合口径同理:响应里缺字段(avg 是缺省值,服务端不下发)时按 avg 处理。
|
||||
aggregateMode: normalizeAggregateMode(channel.aggregateMode),
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(channel.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(channel.countToTeamMedals),
|
||||
@@ -91,20 +96,11 @@ function toRow(channel: ScoreChannel): ChannelRow {
|
||||
}
|
||||
|
||||
function addRow(): void {
|
||||
rows.value = [
|
||||
...rows.value,
|
||||
{
|
||||
key: (nextKey += 1),
|
||||
scope: 'athlete',
|
||||
basis: 'result',
|
||||
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
|
||||
sortOrder: String(rows.value.length + 1),
|
||||
countToTeam: true,
|
||||
// 新通道默认发奖牌并并入单位榜,与库表的两个默认值一致。
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
},
|
||||
]
|
||||
// 「计入单位总分」按小项类型取默认值:「个人+团体」的个人通道默认不计入(与规则包模板一致),
|
||||
// 否则同一条成绩的个人分与团体分会同时进单位总分(见 channelRows.ts)。
|
||||
const eventType = selectedEvent.value?.eventType ?? 'individual'
|
||||
const profileId = data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id)
|
||||
rows.value = [...rows.value, newChannelRow((nextKey += 1), eventType, profileId, rows.value.length + 1)]
|
||||
}
|
||||
|
||||
function removeRow(key: number): void {
|
||||
@@ -144,6 +140,8 @@ function buildInputs(): ScoreChannelInput[] {
|
||||
basis: row.basis,
|
||||
profileId: Number(row.profileId),
|
||||
sortOrder: Number(row.sortOrder) || index + 1,
|
||||
// 聚合口径随行回传(非聚合通道也带着,切来切去不会把现场选好的口径清掉)。
|
||||
aggregateMode: row.aggregateMode,
|
||||
countToTeam: row.countToTeam,
|
||||
// 两个奖牌开关必须一起回传:PUT 是整体替换,漏掉就等于把它们重置成缺省(发牌)。
|
||||
// scope=team 的行不显示 countToTeamMedals,但值原样带上,字段不会因为切换 scope 被清掉。
|
||||
@@ -224,7 +222,15 @@ async function save(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<div class="bd-note count-to-team-note">
|
||||
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和</b>直接切换——
|
||||
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和;切换后点「保存全部」生效,
|
||||
该小项立即重算。两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
|
||||
平均值口径(只算有效成绩 / 全员计入)仍按小项的「平均值口径」设置,求和时它只决定
|
||||
「一个人都没成绩的单位算不算 0 分参与排名」。
|
||||
<br />
|
||||
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
|
||||
新增行的默认值按小项类型来:「个人+团体」的个人通道默认「不计入」(与规则包模板一致),
|
||||
其它类型的通道默认「计入」。
|
||||
<br />
|
||||
「发奖牌」关掉后该通道只出积分不出奖牌;「个人奖牌并入单位金牌榜」只对个人名次的通道有意义,
|
||||
关掉后这些个人奖牌仍进个人奖牌榜,但不计入单位金牌榜。
|
||||
@@ -236,6 +242,7 @@ async function save(): Promise<void> {
|
||||
<tr>
|
||||
<th class="bd-select">名次范围</th>
|
||||
<th class="bd-select">名次来源</th>
|
||||
<th class="bd-select">聚合口径</th>
|
||||
<th>积分模板</th>
|
||||
<th class="bd-select">计入单位总分</th>
|
||||
<th class="bd-select">奖牌通道</th>
|
||||
@@ -255,10 +262,32 @@ async function save(): Promise<void> {
|
||||
<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">
|
||||
团体成绩 = 该单位成员成绩的平均值
|
||||
团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }}
|
||||
</span>
|
||||
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<!--
|
||||
现场要在「按成员平均」与「按成员总和」之间临时定口径,所以不放进下拉:
|
||||
两个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
|
||||
-->
|
||||
<div v-if="row.basis === 'aggregate'" class="agg-mode" role="group" aria-label="聚合口径">
|
||||
<button
|
||||
v-for="option in AGGREGATE_MODE_OPTIONS"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="agg-mode-btn"
|
||||
:class="{ 'is-active': row.aggregateMode === option.value }"
|
||||
:title="option.hint"
|
||||
:aria-pressed="row.aggregateMode === option.value"
|
||||
:disabled="!data.canWrite.value"
|
||||
@click="row.aggregateMode = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="bd-note">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<select v-model="row.profileId" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.profile }">
|
||||
<option value="" disabled>请选择积分模板</option>
|
||||
@@ -312,7 +341,7 @@ async function save(): Promise<void> {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="7">
|
||||
<td colspan="8">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">该小项没有计分通道</div>
|
||||
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
|
||||
@@ -363,6 +392,38 @@ async function save(): Promise<void> {
|
||||
.count-to-team-note {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/*
|
||||
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」还是「总和」,
|
||||
* 点目标按钮即选中(高亮),配合「保存全部」生效。
|
||||
*/
|
||||
.agg-mode {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--mx-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.agg-mode-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--mx-t2);
|
||||
background: var(--mx-bg);
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agg-mode-btn + .agg-mode-btn {
|
||||
border-left: 1px solid var(--mx-divider);
|
||||
}
|
||||
.agg-mode-btn.is-active {
|
||||
background: var(--mx-primary);
|
||||
color: var(--mx-primary-on);
|
||||
font-weight: 600;
|
||||
}
|
||||
.agg-mode-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.count-to-team {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
normalizeAggregateMode,
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
@@ -118,7 +119,12 @@ 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'
|
||||
? '手动名次'
|
||||
: `按队内${normalizeAggregateMode(channel.aggregateMode) === 'sum' ? '总和' : '平均'}`,
|
||||
profile: profile?.name ?? `模板 #${channel.profileId}`,
|
||||
award: profile?.awardRanks ?? null,
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AggregateMode, ChannelBasis, ChannelScope, EventType } from '@/shared'
|
||||
import { DEFAULT_AGGREGATE_MODE } from '@/shared'
|
||||
|
||||
/** 计分通道编辑器里的一行:新建行或已存库通道的回填(已存库的行见 ChannelsSection.vue 的 toRow)。 */
|
||||
export interface ChannelRow {
|
||||
key: number
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profileId: string
|
||||
sortOrder: string
|
||||
/** basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和。 */
|
||||
aggregateMode: AggregateMode
|
||||
countToTeam: boolean
|
||||
/** 该通道名次是否发奖牌(缺省发)。 */
|
||||
countToMedals: boolean
|
||||
/** 该通道的个人奖牌是否并入单位金牌榜(缺省并入)。scope=team 的行不显示,但值照样回传。 */
|
||||
countToTeamMedals: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* defaultCountToTeam 是「新增通道行」的「计入单位总分」默认值,口径与规则包的通道模板逐条一致:
|
||||
*
|
||||
* - 普通个人项目 / 团体 / 对抗项目的模板都没写 `countToTeam`(缺省计入),而普通个人项目的
|
||||
* 个人分正是单位总分的主要来源,默认不计入会让单位总分静默归零;
|
||||
* - 只有「个人+团体」(`individual_team`)小项的**个人通道**默认不计入——模板里写的就是
|
||||
* `countToTeam: false`(个人榜计分、单位总分只看团体通道)。默认勾上会让同一条成绩
|
||||
* 「个人分 + 团体分」同时进单位总分,手工配一次就双计(工单 A-3)。
|
||||
*
|
||||
* 只作用于**新建行**:已存库的通道按原值回填,不会被这里的默认值静默改写。
|
||||
*/
|
||||
export function defaultCountToTeam(eventType: EventType, scope: ChannelScope): boolean {
|
||||
return !(eventType === 'individual_team' && scope === 'athlete')
|
||||
}
|
||||
|
||||
/**
|
||||
* newChannelRow 构造一条新增通道行:名次范围「个人名次」+ 名次来源「按成绩自动排名」+
|
||||
* 指定的积分模板;「计入单位总分」按 defaultCountToTeam 取默认值。
|
||||
*/
|
||||
export function newChannelRow(key: number, eventType: EventType, profileId: string, sortOrder: number): ChannelRow {
|
||||
return {
|
||||
key,
|
||||
scope: 'athlete',
|
||||
basis: 'result',
|
||||
profileId,
|
||||
sortOrder: String(sortOrder),
|
||||
// 聚合口径默认「按成员平均」,与库表默认值、升级前的既有口径一致;要用求和时现场一下切过去。
|
||||
aggregateMode: DEFAULT_AGGREGATE_MODE,
|
||||
countToTeam: defaultCountToTeam(eventType, 'athlete'),
|
||||
// 新通道默认发奖牌并并入单位榜,与库表的两个默认值一致。
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
normalizeAggregateMode,
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
@@ -115,6 +116,8 @@ interface EventSeed {
|
||||
countToMedals?: boolean
|
||||
/** 该通道的个人奖牌是否并入单位榜;缺省 true。 */
|
||||
countToTeamMedals?: boolean
|
||||
/** 聚合通道的汇总口径(basis=aggregate);缺省 avg。 */
|
||||
aggregateMode?: ScoreChannel['aggregateMode']
|
||||
/** 团体成绩的平均口径;缺省 valid。 */
|
||||
avgMode?: Event['avgMode']
|
||||
/** 小项是否结算奖牌;缺省 true。 */
|
||||
@@ -290,6 +293,8 @@ export class MockBaseData {
|
||||
basis: seed.basis,
|
||||
profileId: seed.profileId,
|
||||
sortOrder: 1,
|
||||
// 聚合口径缺省 avg(种子数据里没写就是平均值,与库表默认值一致)。
|
||||
aggregateMode: normalizeAggregateMode(seed.aggregateMode),
|
||||
countToTeam: seed.countToTeam ?? true,
|
||||
countToMedals: seed.countToMedals ?? true,
|
||||
countToTeamMedals: seed.countToTeamMedals ?? true,
|
||||
@@ -493,6 +498,8 @@ export class MockBaseData {
|
||||
...input,
|
||||
id: (this.nextChannelId += 1),
|
||||
eventId,
|
||||
// 与 PUT /channels 的服务端口径一致:缺省(未带字段)落成具体值 avg。
|
||||
aggregateMode: normalizeAggregateMode(input.aggregateMode),
|
||||
countToTeam: normalizeCountToTeam(input.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(input.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(input.countToTeamMedals),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
eventInputOf,
|
||||
@@ -55,7 +55,10 @@ import {
|
||||
RESULT_ROW_ACTION_TITLE,
|
||||
RESULT_ROW_ACTION_WORD,
|
||||
resultActionTarget,
|
||||
resultActionTargets,
|
||||
resultCellAction,
|
||||
type ResultActionTarget,
|
||||
type ResultRowAction,
|
||||
} from './entryResultActions'
|
||||
import {
|
||||
DEFAULT_HEAT_SIZE,
|
||||
@@ -125,6 +128,17 @@ const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
|
||||
{ value: 'withdrawn', label: '退赛' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 成绩状态文案(下拉选项的反查表)。
|
||||
*
|
||||
* 已发布的行不再渲染下拉(disabled 的表单控件收不到点击,正是「点不动」的来源),
|
||||
* 改用只读按钮显示当前状态,文案必须与下拉选项同源,免得两处各写一份慢慢说岔。
|
||||
*/
|
||||
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((option) => [option.value, option.label])) as Record<
|
||||
ResultStatus,
|
||||
string
|
||||
>
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const data = useEventStore()
|
||||
@@ -459,37 +473,49 @@ onBeforeRouteLeave((to) => {
|
||||
return false
|
||||
})
|
||||
|
||||
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
|
||||
/**
|
||||
* 已发布的行由服务端锁定:直接改会让「榜单上是旧数、表格里是新数」。
|
||||
* 退回草稿(reopen)后 state 变回 draft,这里随即放行——「修改本条」正是靠这一步解锁。
|
||||
*/
|
||||
function rowLocked(row: EditableRow): boolean {
|
||||
return row.resultState === 'published'
|
||||
}
|
||||
|
||||
/**
|
||||
* 每行的行操作(作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
|
||||
* 已发布(含已作废)行的锁定提示:这些格子改不了时,悬停要直接指出出路。
|
||||
*
|
||||
* `clickable` 指**这一格自己点得动**(成绩格 / 状态格:点一下弹退回草稿的确认框)——
|
||||
* 名次 / 备注格仍不参与点击,只能说「点成绩格或右侧『修改』」,
|
||||
* 否则提示就变成一句点不出来的空头承诺。
|
||||
*
|
||||
* 已发布行的出路有两条——退回这一条草稿,或整组「撤销发布」;已作废行的出路只有一条——「重新录入」。
|
||||
*/
|
||||
function lockedHint(row: EditableRow, clickable = false): string {
|
||||
if (!rowLocked(row)) return ''
|
||||
if (row.status === 'withdrawn') {
|
||||
return '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
|
||||
}
|
||||
return clickable
|
||||
? '已发布:点击这一格可退回草稿修改,改完需重新发布本组;也可以点右侧「修改」或整组「撤销发布」'
|
||||
: '已发布:点成绩格或右侧「修改」退回草稿后可改,改完需重新发布本组'
|
||||
}
|
||||
|
||||
/**
|
||||
* 每行的行操作(修改本条 / 作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
|
||||
*
|
||||
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
|
||||
*/
|
||||
const rowActions = computed<Map<number, ResultActionTarget>>(() => {
|
||||
const map = new Map<number, ResultActionTarget>()
|
||||
const rowActions = computed<Map<number, ResultActionTarget[]>>(() => {
|
||||
const map = new Map<number, ResultActionTarget[]>()
|
||||
for (const row of visibleRows.value) {
|
||||
const target = resultActionTarget(row)
|
||||
if (target !== null) map.set(row.entryId, target)
|
||||
const targets = resultActionTargets(row)
|
||||
if (targets.length > 0) map.set(row.entryId, targets)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function rowAction(row: EditableRow): ResultActionTarget | null {
|
||||
return rowActions.value.get(row.entryId) ?? null
|
||||
}
|
||||
|
||||
function rowActionLabel(row: EditableRow): string {
|
||||
const target = rowAction(row)
|
||||
return target === null ? '' : RESULT_ROW_ACTION_LABEL[target.action]
|
||||
}
|
||||
|
||||
function rowActionTitle(row: EditableRow): string {
|
||||
const target = rowAction(row)
|
||||
return target === null ? '' : RESULT_ROW_ACTION_TITLE[target.action]
|
||||
function rowActionsOf(row: EditableRow): ResultActionTarget[] {
|
||||
return rowActions.value.get(row.entryId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,10 +571,9 @@ const focusedHint = computed<ResultCellHint | null>(() => {
|
||||
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
|
||||
function resultTitle(row: EditableRow): string {
|
||||
if (rowLocked(row)) {
|
||||
// 已作废的行同样是「锁定」的,但原因是作废而不是发布:提示要指向真正的出路。
|
||||
return row.status === 'withdrawn'
|
||||
? '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
|
||||
: '已发布,请先撤销发布'
|
||||
// 已发布 / 已作废的行都锁着,但出路不同:统一由 lockedHint 说清该点哪个按钮。
|
||||
// 成绩格自己点得动,用 clickable 那一份文案(「点击这一格」)。
|
||||
return lockedHint(row, true)
|
||||
}
|
||||
const hint = resultHintOf(row)
|
||||
return hint !== null && hint.level !== 'ok' ? hint.message : ''
|
||||
@@ -1395,24 +1420,27 @@ const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
|
||||
remove: '确认移出',
|
||||
}
|
||||
|
||||
// ── 单条作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────────
|
||||
// ── 单条修改 / 作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────
|
||||
|
||||
/** 待确认的行操作;非 null 时确认框打开。 */
|
||||
const resultAction = ref<ResultActionTarget | null>(null)
|
||||
const resultActionBusy = ref(false)
|
||||
|
||||
/** 确认框文案随动作变(作废 / 退回草稿的后果不同)。 */
|
||||
/** 确认框文案随动作变(修改 / 作废 / 退回草稿的后果不同)。 */
|
||||
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
|
||||
|
||||
/**
|
||||
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
|
||||
*
|
||||
* 一个按钮一次只带一个动作(同一行可能并排着「修改本条」与「作废本条」),
|
||||
* 判定仍由纯函数给出:传进来的动作必须在该行成立,否则当没点。
|
||||
*
|
||||
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
|
||||
* 否则刚填的名次会被服务端返回的口径覆盖掉。
|
||||
*/
|
||||
function requestResultAction(row: EditableRow): void {
|
||||
const target = rowAction(row)
|
||||
if (target === null) return
|
||||
function requestResultAction(target: ResultActionTarget): void {
|
||||
const current = rowActions.value.get(target.entryId)
|
||||
if (current === undefined || !current.some((item) => item.action === target.action)) return
|
||||
if (data.isMock) {
|
||||
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
|
||||
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
|
||||
@@ -1424,12 +1452,41 @@ function requestResultAction(row: EditableRow): void {
|
||||
return
|
||||
}
|
||||
if (dirty.value) {
|
||||
toast.error('有未保存的修改', '作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||||
toast.error('有未保存的修改', '修改 / 作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||||
return
|
||||
}
|
||||
resultAction.value = target
|
||||
}
|
||||
|
||||
/** 已发布行的格子点一下走哪个动作;草稿行 / 已作废行返回 null(照常直接编辑 / 保持禁用)。 */
|
||||
function cellAction(row: EditableRow): ResultRowAction | null {
|
||||
return resultCellAction(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一格是否保持原来的 disabled 锁定(收不到点击)。
|
||||
*
|
||||
* 已作废的行走这里:它的出路是按钮上的「重新录入」,格子不该装成点得动。
|
||||
* 可点的已发布格子改用 readonly——disabled 的控件连 click 事件都不会派发,弹不出确认框。
|
||||
*/
|
||||
function cellDisabled(row: EditableRow): boolean {
|
||||
return rowLocked(row) && cellAction(row) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* 点已发布行的成绩格 / 状态格 = 点「修改本条」。
|
||||
*
|
||||
* 必须复用 requestResultAction 这一条路:确认框(含原因必填)、示例数据 / 只读 / 未保存修改
|
||||
* 三道拦截、确认后整表重拉都在里面。格子这里绝不能自己调接口——否则就成了
|
||||
* 「点一下成绩就悄悄从榜上退下来」。用户取消时也什么都回不去(见 cancelResultAction)。
|
||||
*/
|
||||
function requestCellAction(row: EditableRow): void {
|
||||
const action = cellAction(row)
|
||||
if (action === null) return
|
||||
const target = resultActionTarget(row, action)
|
||||
if (target !== null) requestResultAction(target)
|
||||
}
|
||||
|
||||
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
|
||||
async function confirmResultAction(reason: string): Promise<void> {
|
||||
const target = resultAction.value
|
||||
@@ -1439,25 +1496,48 @@ async function confirmResultAction(reason: string): Promise<void> {
|
||||
if (target.action === 'void') {
|
||||
await adminApi.voidResult(target.resultId, { reason })
|
||||
} else {
|
||||
// 「修改本条」与「重新录入」是同一个接口:两者都把这一条退回草稿,只是入口语境不同。
|
||||
await adminApi.reopenResult(target.resultId, { reason })
|
||||
}
|
||||
resultAction.value = null
|
||||
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
|
||||
await reloadAfterSettlementChange()
|
||||
// 退回草稿后把焦点送回那一格,操作员不必再拿鼠标找一遍就能接着改;
|
||||
// 作废没有可改的格子(它的出路是「重新录入」),不聚焦。
|
||||
if (target.action !== 'void') await focusResultCell(target.entryId)
|
||||
const success = RESULT_ACTION_SUCCESS[target.action]
|
||||
toast.success(success.title, success.detail)
|
||||
} catch (caught) {
|
||||
toast.error(target.action === 'void' ? '作废失败' : '退回草稿失败', caught instanceof Error ? caught.message : null)
|
||||
toast.error(`${RESULT_ROW_ACTION_WORD[target.action]}失败`, caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
resultActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消确认框:什么都不做。
|
||||
*
|
||||
* 这里只能清掉待办的动作——退回草稿的接口一次都不能提前发出去,
|
||||
* 否则「点一下格子又点了取消」就会把一条已发布成绩悄悄从榜上退下来。
|
||||
*/
|
||||
function cancelResultAction(): void {
|
||||
if (resultActionBusy.value) return
|
||||
resultAction.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 把焦点落到某一行的成绩输入格(退回草稿成功后调用)。
|
||||
*
|
||||
* 必须等 nextTick:整表刚被 reload 换过一轮,DOM 还没更新完,这时候查不到新那一格的节点。
|
||||
*/
|
||||
async function focusResultCell(entryId: number): Promise<void> {
|
||||
await nextTick()
|
||||
const input = tableRef.value?.querySelector<HTMLInputElement>(`[data-result-cell="${entryId}"]`)
|
||||
if (input === null || input === undefined) return
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
|
||||
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
|
||||
function requestSplit(): void {
|
||||
const plan = splitPlan.value
|
||||
@@ -1529,6 +1609,10 @@ function onBeforeUnload(event: BeforeUnloadEvent): void {
|
||||
function onPaste(event: ClipboardEvent, startEntryId: number): void {
|
||||
const text = event.clipboardData?.getData('text/plain') ?? ''
|
||||
if (text.trim() === '') return
|
||||
// 已发布的行现在是 readonly:浏览器照样会派发 paste 事件(只是不插入文本),
|
||||
// 放过去的话这一条成绩会被本地悄悄改掉、连确认框都不弹,必须在这里挡住。
|
||||
const start = visibleRows.value.find((row) => row.entryId === startEntryId)
|
||||
if (start === undefined || rowLocked(start)) return
|
||||
event.preventDefault()
|
||||
|
||||
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '')
|
||||
@@ -1891,6 +1975,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<MxIcon name="lock" :size="15" />
|
||||
{{ scopeWord }}已有 {{ publishedCount }} 条成绩发布并锁定上屏(共 {{ visibleRows.length }} 条):未发布的行可继续补录,
|
||||
录完再点一次「发布本批」即可,名次与积分会按已发布成绩整体重算。
|
||||
要改某一条已发布的成绩,直接点那一格(或右侧「修改本条」)退回草稿,改完再重新发布本组。
|
||||
</div>
|
||||
|
||||
<div class="entry-body">
|
||||
@@ -1972,14 +2057,22 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||||
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
|
||||
<td class="result-cell">
|
||||
<!--
|
||||
已发布的行不再用 :disabled 锁(disabled 的表单控件收不到点击,
|
||||
「点不动」正是现场反馈的观感),改成 readonly + 点一下弹确认框:
|
||||
确认后才退回草稿,取消则什么都不会发生。
|
||||
-->
|
||||
<input
|
||||
v-model="row.resultText"
|
||||
data-cell
|
||||
:data-result-cell="row.entryId"
|
||||
class="mx-cell-input mono result-input"
|
||||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error', 'is-locked-cell': cellAction(row) !== null }"
|
||||
:disabled="!canWrite || cellDisabled(row)"
|
||||
:readonly="cellAction(row) !== null"
|
||||
:title="resultTitle(row)"
|
||||
:placeholder="resultPlaceholder"
|
||||
@click="requestCellAction(row)"
|
||||
@focus="onResultFocus(row, $event)"
|
||||
@blur="onResultBlur"
|
||||
@input="onResultInput"
|
||||
@@ -2007,7 +2100,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
min="1"
|
||||
class="mx-cell-input rank-input"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:title="lockedHint(row)"
|
||||
@input="row.rank = rankInputValue($event)"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@@ -2022,12 +2115,30 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
|
||||
:title="entryStatusTitle(row.entryStatus)"
|
||||
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
|
||||
<!--
|
||||
已发布(未作废)的行不渲染下拉:下拉在锁定态只能 disabled,而 disabled 控件
|
||||
收不到点击。换成一个可点的只读按钮,点一下与「修改本条」同一条路
|
||||
(确认后才退回草稿);已作废的行仍走下面那个 disabled 下拉,保持现状。
|
||||
-->
|
||||
<button
|
||||
v-if="cellAction(row) !== null"
|
||||
type="button"
|
||||
data-cell
|
||||
class="mx-cell-input status-select is-locked-cell"
|
||||
:disabled="!canWrite"
|
||||
:title="lockedHint(row, true)"
|
||||
@click="requestCellAction(row)"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
>{{ STATUS_LABEL[row.status] }}</button>
|
||||
<select
|
||||
v-else
|
||||
v-model="row.status"
|
||||
data-cell
|
||||
class="mx-cell-input status-select"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:title="lockedHint(row)"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
@@ -2048,7 +2159,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
data-cell
|
||||
class="mx-cell-input"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:title="lockedHint(row)"
|
||||
placeholder="可留空"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@@ -2057,21 +2168,24 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</td>
|
||||
<!--
|
||||
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
|
||||
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能作废」而不是一片空白。
|
||||
正常已发布的行并排两个按钮——「修改本条」(退回草稿改一改,改完重新发布)与
|
||||
「作废本条」(这一条不算了);已作废的行只有一个「重新录入」。
|
||||
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能改 / 能作废」而不是一片空白。
|
||||
-->
|
||||
<td class="center action-cell">
|
||||
<button
|
||||
v-if="rowAction(row) !== null"
|
||||
class="mx-btn mx-btn-sm"
|
||||
:class="rowAction(row)?.action === 'reopen' ? 'mx-btn-secondary' : 'mx-btn-danger'"
|
||||
v-for="target in rowActionsOf(row)"
|
||||
:key="target.action"
|
||||
class="mx-btn mx-btn-sm action-btn"
|
||||
:class="target.action === 'void' ? 'mx-btn-danger' : 'mx-btn-secondary'"
|
||||
type="button"
|
||||
:disabled="!canWrite || resultActionBusy"
|
||||
:title="rowActionTitle(row)"
|
||||
@click="requestResultAction(row)"
|
||||
:title="RESULT_ROW_ACTION_TITLE[target.action]"
|
||||
@click="requestResultAction(target)"
|
||||
>
|
||||
{{ rowActionLabel(row) }}
|
||||
{{ RESULT_ROW_ACTION_LABEL[target.action] }}
|
||||
</button>
|
||||
<span v-else class="muted">—</span>
|
||||
<span v-if="rowActionsOf(row).length === 0" class="muted">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && visibleRows.length === 0">
|
||||
@@ -2584,14 +2698,21 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 行操作区:窄列、不换行,按钮别把整张表撑宽。 */
|
||||
/*
|
||||
* 行操作区:已发布的行要并排放「修改本条」+「作废本条」两个按钮,
|
||||
* 列宽按两者之和留(4 字 ×12px + 内边距 + 单元格 padding),否则按钮会换行把这一行撑高一倍。
|
||||
* 按钮内边距收窄(12 → 10px)是为了把列宽压住,不与上面的列宽估算脱节。
|
||||
*/
|
||||
.action-col,
|
||||
.action-cell {
|
||||
width: 96px;
|
||||
width: 180px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-cell .mx-btn {
|
||||
width: 100%;
|
||||
.action-cell .action-btn {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.action-cell .action-btn + .action-btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.action-cell .muted {
|
||||
color: var(--mx-t3);
|
||||
@@ -2732,6 +2853,25 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.status-select {
|
||||
width: 110px;
|
||||
}
|
||||
/*
|
||||
* 已发布行的可点格子(成绩格 / 状态格):从「彻底禁用」改成「点一下退回草稿改」。
|
||||
* 底色与草稿格(--mx-s3)不同 + 常态描边 + cursor: pointer,一眼看出这里点得动;
|
||||
* 点下去只是弹与「修改本条」同一个确认框,不确认则什么都不会发生。
|
||||
* 已作废行的格子 cellAction 为 null:拿不到这个类,也仍走 disabled(保持原来的禁用观感)。
|
||||
*/
|
||||
.is-locked-cell {
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: var(--mx-s2);
|
||||
border-color: var(--mx-border);
|
||||
}
|
||||
.is-locked-cell:hover:not(:disabled) {
|
||||
border-color: var(--mx-primary);
|
||||
box-shadow: 0 0 0 3px var(--mx-primary-glow);
|
||||
}
|
||||
.is-locked-cell:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.rank-auto {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDateTime, formatPoints, formatRankChange, type TeamBreakdown } from '@/shared'
|
||||
import {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
breakdownRevisionBadge,
|
||||
breakdownRevisionTitle,
|
||||
breakdownSourceLabel,
|
||||
formatDateTime,
|
||||
formatPoints,
|
||||
formatRankChange,
|
||||
medalBoardRows,
|
||||
type TeamBreakdown,
|
||||
} from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
@@ -42,6 +53,9 @@ const filteredStandings = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 单位奖牌榜有自己的一套名次与顺序(金→银→铜→单位编码,服务端 medalRank),不再跟着总分榜走。
|
||||
const medalBoard = computed(() => medalBoardRows(filteredStandings.value))
|
||||
|
||||
const filteredAthletes = computed(() => {
|
||||
const keyword = search.value.trim()
|
||||
if (keyword === '') return data.athleteStandings
|
||||
@@ -289,8 +303,8 @@ async function refresh(): Promise<void> {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in filteredStandings" :key="row.teamId">
|
||||
<td><span class="rank-badge" :class="{ top: row.rank <= 3 }">{{ row.rank }}</span></td>
|
||||
<tr v-for="row in medalBoard" :key="row.teamId">
|
||||
<td><span class="rank-badge" :class="{ top: row.medalRank <= 3 }">{{ row.medalRank }}</span></td>
|
||||
<td>{{ row.teamName }}</td>
|
||||
<td class="center mono gold">{{ row.gold }}</td>
|
||||
<td class="center mono silver">{{ row.silver }}</td>
|
||||
@@ -377,15 +391,40 @@ async function refresh(): Promise<void> {
|
||||
<span>合计</span>
|
||||
<strong class="mono">{{ formatPoints(activeBreakdown.totalPoints) }}</strong>
|
||||
</div>
|
||||
<!-- 列头与「推送到大屏」的覆盖层同一套列:项目/组别 · 运动员 · 来源 · 成绩 · 名次 · 积分 -->
|
||||
<div v-if="activeBreakdown.items.length > 0" class="detail-item detail-head">
|
||||
<span>项目 / 组别</span>
|
||||
<span>运动员</span>
|
||||
<span>来源</span>
|
||||
<span>成绩</span>
|
||||
<span>名次</span>
|
||||
<span>积分</span>
|
||||
</div>
|
||||
<ul class="detail-list">
|
||||
<li v-for="item in activeBreakdown.items" :key="item.ledgerId" class="detail-item">
|
||||
<span class="detail-main">
|
||||
<strong>{{ item.eventName ?? '—' }}</strong>
|
||||
<small>{{ item.categoryName ?? '' }} · {{ item.athleteName ?? '团体' }}</small>
|
||||
<strong :title="item.eventName ?? ''">{{ item.eventName ?? '—' }}</strong>
|
||||
<small>{{ breakdownCategoryName(item) }}</small>
|
||||
</span>
|
||||
<span class="detail-athlete" :title="breakdownAthleteName(item, activeBreakdown.teamName)">
|
||||
{{ breakdownAthleteName(item, activeBreakdown.teamName) }}
|
||||
</span>
|
||||
<!-- 来源与导出的「来源」列同一套词(成绩 / 破纪录加分 / 人工调整),见 breakdownSourceLabel -->
|
||||
<span class="detail-source">{{ breakdownSourceLabel(item) }}</span>
|
||||
<span class="detail-result mono">{{ item.resultText ?? '—' }}</span>
|
||||
<span class="detail-rank mono">第 {{ item.rank ?? '—' }} 名</span>
|
||||
<span class="detail-points mono">+{{ formatPoints(item.points) }}</span>
|
||||
<!--
|
||||
行尾角标:只有真被改过的成绩才有(没改过的行连元素都没有)。
|
||||
屏幕放不下的时间 / 原因 / 操作人全在悬停里,见 breakdownRevisionTitle。
|
||||
-->
|
||||
<span
|
||||
v-if="breakdownRevisionBadge(item)"
|
||||
class="detail-revision"
|
||||
:title="breakdownRevisionTitle(item)"
|
||||
>
|
||||
{{ breakdownRevisionBadge(item) }}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="activeBreakdown.items.length === 0" class="detail-empty">暂无已发布的得分记录</li>
|
||||
</ul>
|
||||
@@ -421,7 +460,8 @@ async function refresh(): Promise<void> {
|
||||
}
|
||||
.standings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
/* 明细卡多了一列「运动员」,360px 下五列会挤成一团,放宽到 400px。 */
|
||||
grid-template-columns: minmax(0, 1fr) 400px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -511,14 +551,37 @@ tr.expanded {
|
||||
}
|
||||
.detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
/* 七条轨道:项目/组别 · 运动员 · 来源 · 成绩 · 名次 · 积分 · 修订角标。
|
||||
前两条可伸缩,其余按内容宽度 —— 没被改过的行不渲染最后一条,轨道空着不影响对齐。 */
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) auto auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: var(--mx-s2);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 列头与明细行共用同一套栅格,保证标题落在各自列上方;列头本身不要行底色。 */
|
||||
.detail-head {
|
||||
background: none;
|
||||
padding: 2px 10px 6px;
|
||||
color: var(--mx-t3);
|
||||
font-size: 11px;
|
||||
}
|
||||
/* 姓名可能是「单位名」(团体项目回退),长了就省略,不把后面几列挤歪。 */
|
||||
.detail-athlete {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 来源(成绩 / 破纪录加分 / 人工调整)与导出的「来源」列同一套词,保持窄列、不换行。 */
|
||||
.detail-source {
|
||||
color: var(--mx-t3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -543,6 +606,16 @@ tr.expanded {
|
||||
color: var(--mx-green);
|
||||
font-weight: 800;
|
||||
}
|
||||
/* 修订角标:淡色小药丸,比同行的数字更轻,一眼看出「这几分被改过」但抢不了成绩的位置。 */
|
||||
.detail-revision {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--mx-s3);
|
||||
color: var(--mx-t3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
.detail-empty {
|
||||
color: var(--mx-t3);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -10,8 +10,14 @@ import type { ResultState, ResultStatus } from '@/shared'
|
||||
* 不是报名项 id。两者在页面上挨着,拿错就会把别人的成绩作废掉,所以这里单独强调。
|
||||
*/
|
||||
|
||||
/** 行操作区能对一条已发布成绩做的事。 */
|
||||
export type ResultRowAction = 'void' | 'reopen'
|
||||
/**
|
||||
* 行操作区能对一条成绩做的事。
|
||||
*
|
||||
* `modify` 与 `reopen` 打的是同一个接口(`POST /api/results/{id}/reopen`),差别只在语义与文案:
|
||||
* `modify` 是「已发布的行填错了,退回草稿改一改」,`reopen` 是「已作废的行要重新录入」。
|
||||
* 拆成两个动作是因为按钮文案与确认框后果必须一眼看出不同——「填错了要改」不是「作废」。
|
||||
*/
|
||||
export type ResultRowAction = 'modify' | 'void' | 'reopen'
|
||||
|
||||
export interface ResultRowState {
|
||||
resultState: ResultState | null
|
||||
@@ -19,14 +25,32 @@ export interface ResultRowState {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一行此刻该显示哪个操作;null 表示不显示(草稿行本来就能直接改,不需要作废入口)。
|
||||
* 这一行此刻该显示哪些操作(按显示顺序);空数组表示不显示(草稿行本来就能直接改)。
|
||||
*
|
||||
* 口径来自服务端 void 的实际行为:只把 `results.status` 改成 withdrawn,`state` 仍是 published,
|
||||
* 所以「已发布 + withdrawn」= 已作废(显示「重新录入」),「已发布 + 其它状态」= 正常已发布(显示「作废本条」)。
|
||||
* 口径来自服务端 void 的实际行为:只把 `results.status` 改成 withdrawn,`state` 仍是 published。
|
||||
* 所以「已发布 + withdrawn」= 已作废(只给「重新录入」),「已发布 + 其它状态」= 正常已发布
|
||||
* (给「修改本条」+「作废本条」)。已发布的行不再只有作废一条出路:改错成绩的直接入口是
|
||||
* 「修改本条」,退回草稿改完再重新发布本组,不必先作废、更不必撤销整组发布。
|
||||
*/
|
||||
export function resultRowAction(row: ResultRowState): ResultRowAction | null {
|
||||
export function resultRowActions(row: ResultRowState): ResultRowAction[] {
|
||||
if (row.resultState !== 'published') return []
|
||||
return row.status === 'withdrawn' ? ['reopen'] : ['modify', 'void']
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发布行的**格子**(成绩输入格 / 状态格)点一下该走哪个动作;null = 这一格点不动。
|
||||
*
|
||||
* 现场裁判长的直觉是「去点格子」而不是去右边找按钮,所以已发布(未作废)的格子给出 `modify`:
|
||||
* 与「修改本条」按钮同一个动作、同一个确认框——点一下只是把确认框弹出来,
|
||||
* 用户不确认就什么都不会发生(绝不能「点一下成绩就悄悄从榜上退下去」)。
|
||||
*
|
||||
* 已作废的行(published + withdrawn)返回 null:它的出路只有按钮上的「重新录入」,
|
||||
* 格子保持禁用,免得「改错字」与「这条不算了」两种语义在同一个格子上混掉。
|
||||
* 草稿行也返回 null——它本来就能直接改,不需要弹窗。
|
||||
*/
|
||||
export function resultCellAction(row: ResultRowState): ResultRowAction | null {
|
||||
if (row.resultState !== 'published') return null
|
||||
return row.status === 'withdrawn' ? 'reopen' : 'void'
|
||||
return row.status === 'withdrawn' ? null : 'modify'
|
||||
}
|
||||
|
||||
export interface ResultActionRow extends ResultRowState {
|
||||
@@ -44,31 +68,47 @@ export interface ResultActionTarget {
|
||||
resultText: string
|
||||
}
|
||||
|
||||
/** 把一行收窄成一次可提交的操作;判定不成立(草稿行 / 没有成绩 id)时为 null。 */
|
||||
export function resultActionTarget(row: ResultActionRow): ResultActionTarget | null {
|
||||
const action = resultRowAction(row)
|
||||
if (action === null || row.resultId === null) return null
|
||||
/** 把一行收窄成一次可提交的操作;该动作在这一行不成立(草稿行 / 没有成绩 id)时为 null。 */
|
||||
export function resultActionTarget(row: ResultActionRow, action: ResultRowAction): ResultActionTarget | null {
|
||||
if (!resultRowActions(row).includes(action)) return null
|
||||
if (row.resultId === null) return null
|
||||
return { action, resultId: row.resultId, entryId: row.entryId, resultText: row.resultText }
|
||||
}
|
||||
|
||||
/** 一行此刻所有可提交的操作,顺序与按钮渲染顺序一致。 */
|
||||
export function resultActionTargets(row: ResultActionRow): ResultActionTarget[] {
|
||||
return resultRowActions(row)
|
||||
.map((action) => resultActionTarget(row, action))
|
||||
.filter((target): target is ResultActionTarget => target !== null)
|
||||
}
|
||||
|
||||
export const RESULT_ROW_ACTION_LABEL: Record<ResultRowAction, string> = {
|
||||
modify: '修改本条',
|
||||
void: '作废本条',
|
||||
reopen: '重新录入',
|
||||
}
|
||||
|
||||
export const RESULT_ROW_ACTION_TITLE: Record<ResultRowAction, string> = {
|
||||
modify: '退回草稿后这一条可直接改,改完再「发布本批」重新上屏;不是作废,原来的成绩还在格里',
|
||||
void: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新',
|
||||
reopen: '退回草稿后可继续修改,改完再发布本组',
|
||||
}
|
||||
|
||||
/** 示例数据模式下拦下写操作时的动作名(拼进「示例数据模式不能X」)。 */
|
||||
export const RESULT_ROW_ACTION_WORD: Record<ResultRowAction, string> = {
|
||||
modify: '修改成绩',
|
||||
void: '作废成绩',
|
||||
reopen: '退回草稿',
|
||||
}
|
||||
|
||||
/** 确认框文案:后果必须写在弹窗里,不能只让操作员凭经验猜(07 §8)。 */
|
||||
export const RESULT_ACTION_CONFIRM: Record<ResultRowAction, { title: string; message: string; confirmText: string; reasonLabel: string }> = {
|
||||
modify: {
|
||||
title: '确认修改本条已发布的成绩?',
|
||||
message: '这一条将退回草稿(成绩还在格里,可直接改):积分榜与大屏先把它退下来,名次与积分按「没有这条成绩」重算;改完请再点「发布本批」重新发布本组,让它重新上屏。只是填错了要改用这里;这一条确实不算了,请改用「作废本条」。',
|
||||
confirmText: '退回草稿修改',
|
||||
reasonLabel: '修改原因',
|
||||
},
|
||||
void: {
|
||||
title: '确认作废本条成绩?',
|
||||
message: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
|
||||
@@ -83,8 +123,12 @@ export const RESULT_ACTION_CONFIRM: Record<ResultRowAction, { title: string; mes
|
||||
},
|
||||
}
|
||||
|
||||
/** 成功提示:作废与退回的后续动作不同,提示要各说各的。 */
|
||||
/** 成功提示:修改 / 作废 / 退回的后续动作不同,提示要各说各的。 */
|
||||
export const RESULT_ACTION_SUCCESS: Record<ResultRowAction, { title: string; detail: string }> = {
|
||||
modify: {
|
||||
title: '已退回草稿,这一行可以改了',
|
||||
detail: '名次与积分暂时按「没有这条成绩」重算;改完点「发布本批」把它重新上屏',
|
||||
},
|
||||
void: {
|
||||
title: '已作废该条成绩,名次与积分已重算',
|
||||
detail: '大屏已更新;需要改回来时用同一行的「重新录入」退回草稿',
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { DEFAULT_AGGREGATE_MODE, normalizeAggregateMode } from '@/shared'
|
||||
import { newChannelRow } from '../components/baseData/channelRows'
|
||||
|
||||
// 聚合口径(basis=aggregate 的通道按成员平均还是按成员总和排名)的前端接线。
|
||||
// 纯逻辑在 shared/channels.ts 与 channelRows.ts,界面接线按仓库既有做法读源码断言
|
||||
// (项目没有组件测试工具,见 channels-count-to-team.test.ts)。
|
||||
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const CHANNEL_ROWS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/channelRows.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('聚合口径缺字段 / 认不出的取值一律按 avg(老数据的口径不变)', () => {
|
||||
assert.equal(DEFAULT_AGGREGATE_MODE, 'avg')
|
||||
assert.equal(normalizeAggregateMode('sum'), 'sum')
|
||||
assert.equal(normalizeAggregateMode('avg'), 'avg')
|
||||
// 老服务端不下发该字段(缺省值不下发),旧缓存里的脏值同样按缺省处理。
|
||||
assert.equal(normalizeAggregateMode(undefined), 'avg')
|
||||
assert.equal(normalizeAggregateMode(null), 'avg')
|
||||
assert.equal(normalizeAggregateMode('SUM'), 'avg')
|
||||
assert.equal(normalizeAggregateMode('bogus'), 'avg')
|
||||
})
|
||||
|
||||
test('新增通道行默认按成员平均(avg),要用总和时现场切', () => {
|
||||
assert.equal(newChannelRow(1, 'individual_team', '3', 1).aggregateMode, 'avg')
|
||||
assert.match(CHANNEL_ROWS_SOURCE, /aggregateMode: DEFAULT_AGGREGATE_MODE/)
|
||||
})
|
||||
|
||||
test('通道编辑器读写聚合口径:存库值回填、保存时随行回传', () => {
|
||||
// 读:已存库的通道按原值回填(含「缺字段按 avg」的归一化),不被新建默认值改写。
|
||||
assert.match(CHANNELS_SOURCE, /aggregateMode: normalizeAggregateMode\(channel\.aggregateMode\)/)
|
||||
// 写:PUT 是整体替换,聚合口径必须随行回传,否则会被重置成缺省。
|
||||
assert.match(CHANNELS_SOURCE, /aggregateMode: row\.aggregateMode/)
|
||||
})
|
||||
|
||||
test('聚合口径是「一点即切」的显眼控件,且只在聚合通道上出现', () => {
|
||||
// 两个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'avg', label: '平均', hint: /)
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'sum', label: '总和', hint: /)
|
||||
assert.match(CHANNELS_SOURCE, /class="agg-mode-btn"/)
|
||||
assert.match(CHANNELS_SOURCE, /:class="\{ 'is-active': row\.aggregateMode === option\.value \}"/)
|
||||
assert.match(CHANNELS_SOURCE, /@click="row\.aggregateMode = option\.value"/)
|
||||
// 只有 basis=aggregate 的行才显示该控件:别的通道没有汇总口径可言。
|
||||
assert.match(CHANNELS_SOURCE, /<div v-if="row\.basis === 'aggregate'" class="agg-mode"/)
|
||||
// 表头要让人找到它。
|
||||
assert.match(CHANNELS_SOURCE, /<th class="bd-select">聚合口径<\/th>/)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { defaultCountToTeam, newChannelRow } from '../components/baseData/channelRows'
|
||||
|
||||
// 工单 A-3:通道编辑器给 scope=athlete 的行默认「计入单位总分」,手工给「个人+团体」小项
|
||||
// 配一次通道就会「个人分 + 团体分」同时进单位总分(双计)。
|
||||
// 纯逻辑在 channelRows.ts,界面接线按仓库既有做法读源码断言(项目没有组件测试工具)。
|
||||
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('A-3:新增的个人通道在「个人+团体」小项默认不计入单位总分', () => {
|
||||
assert.equal(newChannelRow(1, 'individual_team', '3', 1).countToTeam, false)
|
||||
assert.equal(defaultCountToTeam('individual_team', 'athlete'), false)
|
||||
})
|
||||
|
||||
test('默认值与规则包的通道模板逐条一致:只有双计项目的个人通道默认不计入', () => {
|
||||
// channelTemplates:individual 的个人通道、team / match 的团体通道都没写 countToTeam(缺省计入)。
|
||||
assert.equal(defaultCountToTeam('individual', 'athlete'), true)
|
||||
assert.equal(defaultCountToTeam('team', 'athlete'), true)
|
||||
assert.equal(defaultCountToTeam('match', 'athlete'), true)
|
||||
// 普通个人项目的个人分正是单位总分的主要来源,默认不计入会让单位总分静默归零。
|
||||
assert.equal(newChannelRow(1, 'individual', '1', 1).countToTeam, true)
|
||||
assert.equal(newChannelRow(1, 'team', '1', 1).countToTeam, true)
|
||||
// 团体名次本来就记在单位头上,团体通道一律默认计入。
|
||||
assert.equal(defaultCountToTeam('individual_team', 'team'), true)
|
||||
})
|
||||
|
||||
test('新增行只带默认值:名次范围 / 来源、排序、聚合口径、两个奖牌开关', () => {
|
||||
assert.deepEqual(newChannelRow(7, 'individual_team', '5', 4), {
|
||||
key: 7,
|
||||
scope: 'athlete',
|
||||
basis: 'result',
|
||||
profileId: '5',
|
||||
sortOrder: '4',
|
||||
// 聚合口径的默认值:缺省就是「按成员平均」,与库表默认值一致。
|
||||
aggregateMode: 'avg',
|
||||
countToTeam: false,
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('默认值只作用于新增行:已存库的通道按原值回填', () => {
|
||||
// toRow 必须走通道自身的值(含「留空按 true」的归一化),不能被新建默认值改写。
|
||||
assert.match(CHANNELS_SOURCE, /countToTeam: normalizeCountToTeam\(channel\.countToTeam\)/)
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: normalizeCountToMedals\(channel\.countToMedals\)/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeamMedals: normalizeCountToTeamMedals\(channel\.countToTeamMedals\)/)
|
||||
// addRow 必须把当前小项的类型交给 newChannelRow,否则默认值拿不到小项类型。
|
||||
assert.match(CHANNELS_SOURCE, /newChannelRow\(\(nextKey \+= 1\), eventType, profileId, rows\.value\.length \+ 1\)/)
|
||||
assert.match(CHANNELS_SOURCE, /const eventType = selectedEvent\.value\?\.eventType \?\? 'individual'/)
|
||||
})
|
||||
@@ -9,6 +9,11 @@ const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 新增行的默认值(含「计入单位总分」按小项类型取值)在 channelRows.ts,见 channels-count-to-team.test.ts。
|
||||
const CHANNEL_ROWS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/channelRows.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const EVENTS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -31,7 +36,7 @@ test('两个开关一起序列化进 ScoreChannelInput,scope=team 的值不被
|
||||
})
|
||||
|
||||
test('新通道默认发奖牌并并入单位榜(与库表默认值一致)', () => {
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
|
||||
assert.match(CHANNEL_ROWS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
|
||||
})
|
||||
|
||||
test('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
|
||||
|
||||
@@ -8,14 +8,21 @@ import {
|
||||
RESULT_ROW_ACTION_TITLE,
|
||||
RESULT_ROW_ACTION_WORD,
|
||||
resultActionTarget,
|
||||
resultRowAction,
|
||||
resultActionTargets,
|
||||
resultCellAction,
|
||||
resultRowActions,
|
||||
} from '../pages/entryResultActions'
|
||||
|
||||
/**
|
||||
* 成绩录入页「作废 / 重新录入」(P2 A16)。
|
||||
* 成绩录入页「修改 / 作废 / 重新录入」(P2 A16)。
|
||||
*
|
||||
* 后端早有 `POST /api/results/{id}/void`,管理端却没有任何入口:现场改一个人填错的成绩,
|
||||
* 只能「整组撤销发布 → 改 → 重发」,同组其它人的名次与积分会被整体回退再重算。
|
||||
* 已发布的行由服务端锁着(直接改会让「榜单是旧数、表格是新数」),所以每行要有明确的退回入口。
|
||||
* 「填错了要改」与「这条不算了」是两件事:已发布的行并排给「修改本条」与「作废本条」,
|
||||
* 前者退回草稿(`POST /api/results/{id}/reopen`)改完重新发布,后者才是作废(`.../void`);
|
||||
* 已作废的行只给「重新录入」。想改一条已发布的成绩不必先作废。
|
||||
*
|
||||
* 现场裁判长的第一直觉是**去点那一格**(成绩格 / 状态格),所以这两格也从「彻底禁用」改成可点:
|
||||
* 点一下弹的是与「修改本条」同一个确认框,确认后才退回草稿——取消则一个接口都不发。
|
||||
*
|
||||
* 项目没有组件测试工具(无 @vue/test-utils / jsdom),判定逻辑抽在 entryResultActions 里直接测,
|
||||
* 界面接线按仓库既有做法读源码断言。
|
||||
@@ -31,78 +38,123 @@ function section(source: string, start: string, end: string): string {
|
||||
return source.slice(from, to)
|
||||
}
|
||||
|
||||
// ── 哪一行显示哪个按钮 ──────────────────────────────────────────────────────
|
||||
// ── 哪一行显示哪些按钮 ──────────────────────────────────────────────────────
|
||||
|
||||
test('已发布且未作废的行显示「作废本条」', () => {
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'ok' }), 'void')
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'dq' }), 'void')
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'dns' }), 'void')
|
||||
test('已发布且未作废的行同时给「修改本条」与「作废本条」', () => {
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'ok' }), ['modify', 'void'])
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'dq' }), ['modify', 'void'])
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'dns' }), ['modify', 'void'])
|
||||
})
|
||||
|
||||
test('已作废(published + withdrawn)的行显示「重新录入」', () => {
|
||||
test('已作废(published + withdrawn)的行只给「重新录入」', () => {
|
||||
// 服务端 void 只改 status、不动 state,所以「已作废」等于「已发布 + withdrawn」。
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'withdrawn' }), 'reopen')
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'withdrawn' }), ['reopen'])
|
||||
})
|
||||
|
||||
test('草稿行(含退回草稿后的行)不显示任何行操作:本来就是可编辑的', () => {
|
||||
assert.equal(resultRowAction({ resultState: 'draft', status: 'withdrawn' }), null)
|
||||
assert.equal(resultRowAction({ resultState: 'draft', status: 'ok' }), null)
|
||||
assert.equal(resultRowAction({ resultState: null, status: 'ok' }), null)
|
||||
assert.deepEqual(resultRowActions({ resultState: 'draft', status: 'withdrawn' }), [])
|
||||
assert.deepEqual(resultRowActions({ resultState: 'draft', status: 'ok' }), [])
|
||||
assert.deepEqual(resultRowActions({ resultState: null, status: 'ok' }), [])
|
||||
})
|
||||
|
||||
test('按钮文案逐字:作废本条 / 重新录入', () => {
|
||||
test('按钮文案逐字:修改本条 / 作废本条 / 重新录入,且「修改」说清不是作废', () => {
|
||||
assert.equal(RESULT_ROW_ACTION_LABEL.modify, '修改本条')
|
||||
assert.equal(RESULT_ROW_ACTION_LABEL.void, '作废本条')
|
||||
assert.equal(RESULT_ROW_ACTION_LABEL.reopen, '重新录入')
|
||||
assert.match(RESULT_ROW_ACTION_TITLE.modify, /不是作废/)
|
||||
assert.match(RESULT_ROW_ACTION_TITLE.void, /不再计入名次与积分/)
|
||||
assert.match(RESULT_ROW_ACTION_WORD.void, /作废/)
|
||||
assert.match(RESULT_ROW_ACTION_WORD.modify, /修改/)
|
||||
})
|
||||
|
||||
// ── 动的是哪一条成绩 ────────────────────────────────────────────────────────
|
||||
|
||||
test('操作目标是成绩 id(接口按它定位),不是报名项 id', () => {
|
||||
const target = resultActionTarget({
|
||||
const row = {
|
||||
entryId: 7001,
|
||||
resultId: 9001,
|
||||
resultState: 'published',
|
||||
status: 'ok',
|
||||
resultState: 'published' as const,
|
||||
status: 'ok' as const,
|
||||
resultText: '11.58',
|
||||
}
|
||||
assert.deepEqual(resultActionTarget(row, 'modify'), {
|
||||
action: 'modify',
|
||||
resultId: 9001,
|
||||
entryId: 7001,
|
||||
resultText: '11.58',
|
||||
})
|
||||
assert.deepEqual(target, {
|
||||
assert.deepEqual(resultActionTarget(row, 'void'), {
|
||||
action: 'void',
|
||||
resultId: 9001,
|
||||
entryId: 7001,
|
||||
resultText: '11.58',
|
||||
})
|
||||
// 一行的两个按钮都打在这一条成绩上(同一 resultId),点哪个都不会动到别人的成绩。
|
||||
assert.deepEqual(
|
||||
resultActionTargets(row).map((target) => [target.action, target.resultId]),
|
||||
[
|
||||
['modify', 9001],
|
||||
['void', 9001],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('没有成绩 id 或不是已发布行时,不产生可提交的目标', () => {
|
||||
test('该动作在这一行不成立时不产生可提交的目标', () => {
|
||||
const base = { entryId: 7001, resultState: 'published' as const, status: 'ok' as const, resultText: '11.58' }
|
||||
assert.equal(resultActionTarget({ ...base, resultId: null }), null)
|
||||
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }), null)
|
||||
// 草稿行不该能作废 / 退回。
|
||||
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }, 'void'), null)
|
||||
// 未作废的行没有「重新录入」这个动作(它的退回入口叫「修改本条」)。
|
||||
assert.equal(resultActionTarget({ ...base, resultId: 9001 }, 'reopen'), null)
|
||||
// 没有成绩 id 就定位不到接口目标。
|
||||
assert.equal(resultActionTarget({ ...base, resultId: null }, 'modify'), null)
|
||||
assert.deepEqual(resultActionTargets({ ...base, resultId: null }), [])
|
||||
// 已作废的行只剩「重新录入」,拿不到作废入口(作废两次没有意义)。
|
||||
assert.deepEqual(resultActionTargets({ ...base, resultId: 9001, status: 'withdrawn' }), [
|
||||
{ action: 'reopen', resultId: 9001, entryId: 7001, resultText: '11.58' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── 界面接线 ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('确认框文案写清了后果,且作废原因必填', () => {
|
||||
test('确认框文案写清了后果,「修改」与「作废」一眼能分开,且原因必填', () => {
|
||||
assert.equal(RESULT_ACTION_CONFIRM.void.title, '确认作废本条成绩?')
|
||||
assert.equal(
|
||||
RESULT_ACTION_CONFIRM.void.message,
|
||||
'作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
|
||||
)
|
||||
assert.equal(RESULT_ACTION_CONFIRM.modify.title, '确认修改本条已发布的成绩?')
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /退回草稿/)
|
||||
// 点格子与点按钮共用这一份文案:已发布 / 退回草稿 / 重新发布本组 / 榜单先退下来,四点都要说清。
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /积分榜与大屏先把它退下来/)
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /重新发布本组/)
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /不是作废|确实不算了/)
|
||||
assert.notEqual(RESULT_ACTION_CONFIRM.modify.message, RESULT_ACTION_CONFIRM.void.message)
|
||||
assert.equal(RESULT_ACTION_CONFIRM.modify.reasonLabel, '修改原因')
|
||||
assert.equal(RESULT_ACTION_CONFIRM.void.reasonLabel, '作废原因')
|
||||
assert.equal(RESULT_ACTION_CONFIRM.reopen.title, '确认重新录入本条成绩?')
|
||||
assert.match(RESULT_ACTION_CONFIRM.reopen.message, /退回草稿(可编辑)/)
|
||||
assert.match(ENTRY_SOURCE, /:reason-required="true"/)
|
||||
assert.match(ENTRY_SOURCE, /@confirm="confirmResultAction"/)
|
||||
})
|
||||
|
||||
test('作废调用 void、退回草稿调用 reopen,提交的都是成绩 id', () => {
|
||||
test('「修改」走 reopen 接口、「作废」走 void 接口,提交的都是成绩 id', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /await adminApi\.voidResult\(target\.resultId, \{ reason \}\)/)
|
||||
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
|
||||
assert.doesNotMatch(confirm, /(voidResult|reopenResult)\(target\.entryId/)
|
||||
})
|
||||
|
||||
test('作废 / 退回成功后刷新本页数据与榜单', () => {
|
||||
test('按钮按纯函数给出的顺序渲染,一个按钮一个动作', () => {
|
||||
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
|
||||
assert.match(cell, /v-for="target in rowActionsOf\(row\)"/)
|
||||
assert.match(cell, /:key="target\.action"/)
|
||||
assert.match(cell, /\{\{ RESULT_ROW_ACTION_LABEL\[target\.action\] \}\}/)
|
||||
assert.match(cell, /@click="requestResultAction\(target\)"/)
|
||||
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
|
||||
assert.match(cell, /v-if="rowActionsOf\(row\)\.length === 0"/)
|
||||
})
|
||||
|
||||
test('修改 / 作废 / 退回成功后刷新本页数据与榜单', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
// 名次与积分由服务端重算:必须整表重拉 + 刷新榜单,不能只在本地行上打补丁。
|
||||
assert.match(confirm, /await reloadAfterSettlementChange\(\)/)
|
||||
@@ -119,8 +171,9 @@ test('成功后给 toast,失败时原样报错', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /const success = RESULT_ACTION_SUCCESS\[target\.action\]/)
|
||||
assert.match(confirm, /toast\.success\(success\.title, success\.detail\)/)
|
||||
assert.match(confirm, /toast\.error\(target\.action === 'void' \? '作废失败' : '退回草稿失败'/)
|
||||
assert.match(confirm, /toast\.error\(`\$\{RESULT_ROW_ACTION_WORD\[target\.action\]\}失败`/)
|
||||
assert.match(RESULT_ACTION_SUCCESS.void.title, /名次与积分已重算/)
|
||||
assert.match(RESULT_ACTION_SUCCESS.modify.title, /可以改了/)
|
||||
})
|
||||
|
||||
test('示例数据模式在发请求前拦下,提示与既有口径一致', () => {
|
||||
@@ -133,13 +186,6 @@ test('示例数据模式在发请求前拦下,提示与既有口径一致', ()
|
||||
)
|
||||
})
|
||||
|
||||
test('访客只读时按钮保留但置灰(与既有 data.canWrite 同一口径)', () => {
|
||||
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
|
||||
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
|
||||
assert.match(cell, /\{\{ rowActionLabel\(row\) \}\}/)
|
||||
assert.match(cell, /@click="requestResultAction\(row\)"/)
|
||||
})
|
||||
|
||||
test('行能不能改只看服务端返回的 state:退回草稿后必须重新可编辑', () => {
|
||||
// 退回草稿(reopen)由服务端把 state 改回 draft;页面只有按 state 判定锁,行才会重新可编辑。
|
||||
assert.match(
|
||||
@@ -148,6 +194,26 @@ test('行能不能改只看服务端返回的 state:退回草稿后必须重
|
||||
)
|
||||
assert.match(ENTRY_SOURCE, /resultState: result\.state \?\? null|resultState: result\?\.state \?\? null/)
|
||||
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| rowLocked\(row\)"/)
|
||||
// 成绩格用 readonly 锁可点的已发布行:disabled 的表单控件收不到点击,也就弹不出确认框。
|
||||
assert.match(ENTRY_SOURCE, /:readonly="cellAction\(row\) !== null"/)
|
||||
assert.match(ENTRY_SOURCE, /@click="requestCellAction\(row\)"/)
|
||||
})
|
||||
|
||||
test('已发布行的悬停提示指向「点这一格 / 修改本条」这条出路,不再只说撤销发布', () => {
|
||||
const hint = section(ENTRY_SOURCE, 'function lockedHint(', '/**\n * 每行的行操作')
|
||||
// 点得动的格子(成绩格 / 状态格)说「点击这一格」。
|
||||
assert.match(
|
||||
hint,
|
||||
/'已发布:点击这一格可退回草稿修改,改完需重新发布本组;也可以点右侧「修改」或整组「撤销发布」'/,
|
||||
)
|
||||
// 名次 / 备注格点不动,提示只能指路,不能承诺「点这一格」。
|
||||
assert.match(hint, /'已发布:点成绩格或右侧「修改」退回草稿后可改,改完需重新发布本组'/)
|
||||
assert.match(hint, /重新录入/)
|
||||
assert.doesNotMatch(hint, /已发布,请先撤销发布/)
|
||||
// 成绩格的 title 走 resultTitle → lockedHint(row, true);名次 / 状态下拉 / 备注走不带 clickable 的那份。
|
||||
assert.match(ENTRY_SOURCE, /:title="resultTitle\(row\)"/)
|
||||
assert.equal(ENTRY_SOURCE.match(/:title="lockedHint\(row\)"/g)?.length, 3)
|
||||
assert.equal(ENTRY_SOURCE.match(/:title="lockedHint\(row, true\)"/g)?.length, 1)
|
||||
})
|
||||
|
||||
test('已作废的行有独立徽标,不会被显示成「已发布」', () => {
|
||||
@@ -164,6 +230,104 @@ test('表格多了一列「操作」,空状态行的 colspan 跟着改', () =>
|
||||
assert.doesNotMatch(ENTRY_SOURCE, /<td colspan="11">/)
|
||||
})
|
||||
|
||||
// ── 点格子改成绩:有反应,但没有静默副作用 ──────────────────────────────────
|
||||
|
||||
test('已发布行点格子只走「修改本条」:改错字的入口,绝不作废', () => {
|
||||
assert.equal(resultCellAction({ resultState: 'published', status: 'ok' }), 'modify')
|
||||
assert.equal(resultCellAction({ resultState: 'published', status: 'dnf' }), 'modify')
|
||||
// 「作废本条」是独立动作:格子点一万次也点不出 void,要作废只能点那个按钮。
|
||||
for (const status of ['ok', 'dns', 'dq', 'dnf', 'withdrawn'] as const) {
|
||||
assert.notEqual(resultCellAction({ resultState: 'published', status }), 'void')
|
||||
}
|
||||
})
|
||||
|
||||
test('草稿行与已作废行的格子点不动:各走各的路', () => {
|
||||
// 草稿行本来就能直接改,点格子不该弹窗。
|
||||
assert.equal(resultCellAction({ resultState: 'draft', status: 'ok' }), null)
|
||||
assert.equal(resultCellAction({ resultState: null, status: 'ok' }), null)
|
||||
// 已作废的行保持现状:出路是按钮上的「重新录入」。
|
||||
assert.equal(resultCellAction({ resultState: 'published', status: 'withdrawn' }), null)
|
||||
})
|
||||
|
||||
test('点格子先弹确认框:不直接改成绩,也不直接调接口', () => {
|
||||
const request = section(ENTRY_SOURCE, 'function requestCellAction(', 'async function confirmResultAction(')
|
||||
// 复用按钮那条路:确认框(含原因必填)、示例数据 / 只读 / 未保存拦截都跟着一起生效。
|
||||
assert.match(request, /const action = cellAction\(row\)/)
|
||||
assert.match(request, /resultActionTarget\(row, action\)/)
|
||||
assert.match(request, /requestResultAction\(target\)/)
|
||||
assert.doesNotMatch(request, /adminApi\./)
|
||||
assert.doesNotMatch(request, /resultText\s*=/)
|
||||
})
|
||||
|
||||
test('确认之后才真的调 reopen:点击路径里一次写接口都没有', () => {
|
||||
const request = section(ENTRY_SOURCE, 'function requestCellAction(', 'async function confirmResultAction(')
|
||||
assert.doesNotMatch(request, /reopenResult|voidResult/)
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
|
||||
})
|
||||
|
||||
test('取消确认框不调接口、行状态一个字节都不动', () => {
|
||||
// 取消只清掉待办动作:退回草稿的接口绝不能提前发出去,否则「点一下格子又点取消」
|
||||
// 也会把一条已发布成绩悄悄从榜上退下来。整个函数体就这么两行。
|
||||
assert.match(
|
||||
ENTRY_SOURCE,
|
||||
/function cancelResultAction\(\): void \{\n if \(resultActionBusy\.value\) return\n resultAction\.value = null\n\}/,
|
||||
)
|
||||
const cancel = section(ENTRY_SOURCE, 'function cancelResultAction(', '/**\n * 把焦点落到')
|
||||
assert.doesNotMatch(cancel, /adminApi\.|allRows|resultState|reload|resultText/)
|
||||
})
|
||||
|
||||
test('退回草稿成功后把焦点落回那一格(作废不聚焦)', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /if \(target\.action !== 'void'\) await focusResultCell\(target\.entryId\)/)
|
||||
const focus = section(ENTRY_SOURCE, 'async function focusResultCell(', 'function requestSplit(')
|
||||
// 整表刚被 reload 换过一轮,必须等 nextTick 才查得到新那一格的节点。
|
||||
assert.match(focus, /await nextTick\(\)/)
|
||||
assert.match(focus, /\[data-result-cell="\$\{entryId\}"\]/)
|
||||
assert.match(focus, /input\.focus\(\)/)
|
||||
assert.match(ENTRY_SOURCE, /:data-result-cell="row\.entryId"/)
|
||||
})
|
||||
|
||||
test('成绩格 / 状态格各有一个点击入口,并给出「点得动」的视觉暗示', () => {
|
||||
assert.equal(ENTRY_SOURCE.match(/@click="requestCellAction\(row\)"/g)?.length, 2)
|
||||
// 成绩格:readonly 收点击 + 动态挂可点样式;状态格:可点时直接换成带该类名的按钮。
|
||||
assert.match(ENTRY_SOURCE, /:readonly="cellAction\(row\) !== null"/)
|
||||
assert.match(ENTRY_SOURCE, /'is-locked-cell': cellAction\(row\) !== null/)
|
||||
assert.match(ENTRY_SOURCE, /class="mx-cell-input status-select is-locked-cell"/)
|
||||
// 光标 + 与草稿格(--mx-s3)不同的底色与描边:一眼看出这一格点得动。
|
||||
const style = section(ENTRY_SOURCE, '.is-locked-cell {', '.rank-auto {')
|
||||
assert.match(style, /cursor: pointer/)
|
||||
assert.match(style, /background: var\(--mx-s2\)/)
|
||||
assert.match(style, /border-color: var\(--mx-border\)/)
|
||||
})
|
||||
|
||||
test('已发布行的状态格是收得到点击的按钮;已作废行仍是那个点不动的下拉', () => {
|
||||
const cell = section(ENTRY_SOURCE, '<td class="status-cell">', '</td>')
|
||||
assert.match(cell, /v-if="cellAction\(row\) !== null"/)
|
||||
assert.match(cell, /<button[\s\S]*?@click="requestCellAction\(row\)"/)
|
||||
assert.match(cell, /STATUS_LABEL\[row\.status\]/)
|
||||
// v-else 分支照旧:草稿行的下拉可编辑,已作废行的下拉仍 disabled(格子点不动)。
|
||||
assert.match(cell, /<select[\s\S]*?v-model="row\.status"/)
|
||||
assert.match(cell, /<select[\s\S]*?:disabled="!canWrite \|\| rowLocked\(row\)"/)
|
||||
// 状态文案与下拉选项同源,不在两处各写一份。
|
||||
assert.match(ENTRY_SOURCE, /const STATUS_LABEL = Object\.fromEntries\(STATUS_OPTIONS\.map/)
|
||||
})
|
||||
|
||||
test('已作废行的成绩格保持原来的禁用,可点样式也不挂上去', () => {
|
||||
// cellDisabled = 锁着且点不动(已作废)→ 仍旧 disabled;可点的已发布行才走 readonly。
|
||||
assert.match(
|
||||
ENTRY_SOURCE,
|
||||
/function cellDisabled\(row: EditableRow\): boolean \{\n\s*return rowLocked\(row\) && cellAction\(row\) === null\n\}/,
|
||||
)
|
||||
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| cellDisabled\(row\)"/)
|
||||
})
|
||||
|
||||
test('点得动的格子不给成绩留静默后门:粘贴落在已发布行上直接返回', () => {
|
||||
// readonly 的格子照样会派发 paste 事件(只是不插入文本),放过去就会绕过确认框改掉这一条。
|
||||
const paste = section(ENTRY_SOURCE, 'function onPaste(', 'function moveFocusBy(')
|
||||
assert.match(paste, /if \(start === undefined \|\| rowLocked\(start\)\) return/)
|
||||
})
|
||||
|
||||
// ── ConfirmDialog 的必填原因 ────────────────────────────────────────────────
|
||||
|
||||
test('原因为空时不提交(emit confirm),只亮出提示', () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AggregateMode, Event, ScoreChannel, ScoringProfile } from '@/shared'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
|
||||
// 聚合通道的汇总口径(avg 平均 / sum 总和)在录入页预览引擎里的口径,
|
||||
// 逐条对齐服务端 internal/domain/scoring.go 的 scoreByAggregate(同款用例在 domain 包里也有)。
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 1,
|
||||
sportId: 1,
|
||||
code: 'TEAM_PULLUP',
|
||||
name: '团体引体向上',
|
||||
eventType: 'team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProfile(): ScoringProfile {
|
||||
return {
|
||||
id: 2,
|
||||
code: 'team_top6',
|
||||
name: '团体项目前六名',
|
||||
tieMode: 'skip',
|
||||
recordBonusMode: 'max',
|
||||
rules: [
|
||||
{ rank: 1, points: 15 },
|
||||
{ rank: 2, points: 10 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makeChannel(aggregateMode: AggregateMode | undefined): ScoreChannel {
|
||||
return {
|
||||
id: 3,
|
||||
eventId: 1,
|
||||
scope: 'team',
|
||||
basis: 'aggregate',
|
||||
profileId: 2,
|
||||
sortOrder: 1,
|
||||
aggregateMode,
|
||||
countToTeam: true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRow(entryId: number, resultValue: number | null, teamId: number): ScorableRow {
|
||||
return { entryId, teamId, athleteId: entryId, resultValue, rank: null, status: 'ok', isTie: false }
|
||||
}
|
||||
|
||||
function context(
|
||||
aggregateMode: AggregateMode | undefined,
|
||||
avgMode: Event['avgMode'] = 'valid',
|
||||
): ScoreContext {
|
||||
return {
|
||||
event: makeEvent({ avgMode }),
|
||||
category: null,
|
||||
channels: [makeChannel(aggregateMode)],
|
||||
profiles: [makeProfile()],
|
||||
weight: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** 某个单位在这次计分里拿到的团体分(单位总分只来自这条聚合通道),按单位号排序后返回。 */
|
||||
function teamPoints(rows: ScorableRow[], aggregateMode: AggregateMode | undefined, avgMode: Event['avgMode'] = 'valid'): Array<[number, number]> {
|
||||
const points = computeEventScore(rows, context(aggregateMode, avgMode)).teamPoints
|
||||
return [...points.entries()].sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
test('各队人数相同时,avg 与 sum 的排名完全一致', () => {
|
||||
// 三队各 2 人:总和与平均只差一个共同的倍数,排名必然相同。
|
||||
const rows = [
|
||||
makeRow(1, 18, 100),
|
||||
makeRow(2, 20, 100),
|
||||
makeRow(3, 16, 101),
|
||||
makeRow(4, 14, 101),
|
||||
makeRow(5, 12, 102),
|
||||
makeRow(6, 10, 102),
|
||||
]
|
||||
// 100 队(平均 19 / 总和 38)第一,101 队(15 / 30)第二,102 队(11 / 22)第三(只录取前 2)。
|
||||
assert.deepEqual(teamPoints(rows, 'avg'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
[102, 0],
|
||||
])
|
||||
assert.deepEqual(teamPoints(rows, 'sum'), teamPoints(rows, 'avg'))
|
||||
})
|
||||
|
||||
test('各队人数不同时,avg 与 sum 的排名可能不同(现场必须明确选哪种)', () => {
|
||||
// 100 队 2 人(10 + 1,平均 5.5 / 总和 11),101 队 1 人(9,平均 9 / 总和 9)。
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 1, 100), makeRow(3, 9, 101)]
|
||||
// 平均:101 队第一;总和:100 队第一。名次互换。
|
||||
assert.deepEqual(teamPoints(rows, 'avg'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
assert.deepEqual(teamPoints(rows, 'sum'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
})
|
||||
|
||||
test('缺省(不带 aggregateMode)就是 avg:老数据的口径一字不变', () => {
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 1, 100), makeRow(3, 9, 101)]
|
||||
assert.deepEqual(teamPoints(rows, undefined), teamPoints(rows, 'avg'))
|
||||
// 与 sum 明显不同,证明确实落在 avg 一侧,而不是「怎么算都行」。
|
||||
assert.notDeepEqual(teamPoints(rows, undefined), teamPoints(rows, 'sum'))
|
||||
})
|
||||
|
||||
test('sum 只累加有效成绩:没成绩的人按 0 计,avgMode 只决定零成绩单位参不参与排名', () => {
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 1, 100), makeRow(3, 9, 101)]
|
||||
for (const avgMode of ['valid', 'all'] as const) {
|
||||
// 100 队总和 11 第一,101 队总和 9 第二:加不加 0 都不改变和。
|
||||
assert.deepEqual(teamPoints(rows, 'sum', avgMode), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
}
|
||||
|
||||
// 一个人都没有效成绩的单位:valid 不参与排名(榜单上根本没有这一队)、all 按 0 参与——
|
||||
// 与平均口径同一条规则。
|
||||
const noScore = [makeRow(1, null, 100), makeRow(2, 9, 101)]
|
||||
assert.deepEqual(teamPoints(noScore, 'sum', 'valid'), [[101, 15]])
|
||||
assert.deepEqual(teamPoints(noScore, 'sum', 'all'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'vue/compiler-sfc'
|
||||
import {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
breakdownRevisionBadge,
|
||||
breakdownRevisionTitle,
|
||||
breakdownSourceLabel,
|
||||
type BreakdownItem,
|
||||
} from '@/shared'
|
||||
|
||||
/**
|
||||
* 管理端积分榜下钻明细的「运动员」列(与推送到大屏的覆盖层同一套口径)。
|
||||
*
|
||||
* 明细的意义是回答「单位这一分是谁在哪个项目挣的」:只有项目 / 名次 / 成绩 / 积分时,
|
||||
* 现场没法解释单位名字为什么升上去。数据服务端一直下发(BreakdownItem.athleteName),
|
||||
* 这里钉住页面上确实把它显示成一列,且团体流水(athleteName 为空)降级成单位名。
|
||||
*
|
||||
* `.vue` 跑不进 node --test,沿用仓库做法:模板源码契约 + 纯函数用例。
|
||||
*/
|
||||
|
||||
const PAGE_SOURCE = readFileSync(new URL('../pages/StandingsPage.vue', import.meta.url), 'utf8')
|
||||
|
||||
/** 取模板里以某个固定串开头、到下一个 </div> 或 </li> 为止的片段(这些行里没有嵌套块)。 */
|
||||
function block(startPattern: RegExp, endTag: string): string {
|
||||
const found = startPattern.exec(PAGE_SOURCE)
|
||||
assert.ok(found !== null, `模板里应有 ${startPattern}`)
|
||||
const rest = PAGE_SOURCE.slice(found.index)
|
||||
const end = rest.indexOf(endTag)
|
||||
assert.ok(end > 0, `行片段应以 ${endTag} 结束`)
|
||||
return rest.slice(0, end)
|
||||
}
|
||||
|
||||
const HEAD_ROW = block(/<div v-if="activeBreakdown\.items\.length > 0" class="detail-item detail-head">/, '</div>')
|
||||
const BODY_ROW = block(/<li v-for="item in activeBreakdown\.items" :key="item\.ledgerId" class="detail-item">/, '</li>')
|
||||
|
||||
function item(over: Partial<BreakdownItem> = {}): BreakdownItem {
|
||||
return {
|
||||
ledgerId: 1,
|
||||
sourceType: 'result',
|
||||
eventId: 1,
|
||||
eventCode: 'M100',
|
||||
eventName: '100米跑',
|
||||
categoryName: '男子',
|
||||
athleteId: 7,
|
||||
athleteName: '张伟',
|
||||
rank: 1,
|
||||
resultText: '11.58',
|
||||
points: 10,
|
||||
createdAt: '2026-09-13 10:00:00',
|
||||
revisionCount: null,
|
||||
lastRevisionAt: null,
|
||||
lastRevisionReason: null,
|
||||
lastRevisedBy: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function styleText(source: string): string {
|
||||
const { descriptor } = parse(source, { filename: 'style.vue' })
|
||||
return descriptor.styles[0]?.content ?? ''
|
||||
}
|
||||
|
||||
test('明细表有列头,依次是项目/组别 · 运动员 · 来源 · 成绩 · 名次 · 积分', () => {
|
||||
const labels = [...HEAD_ROW.matchAll(/<span>([^<]*)<\/span>/g)].map((match) => (match[1] ?? '').trim())
|
||||
assert.deepEqual(labels, ['项目 / 组别', '运动员', '来源', '成绩', '名次', '积分'])
|
||||
})
|
||||
|
||||
test('明细行渲染运动员格,绑定团队共用的降级函数', () => {
|
||||
assert.match(BODY_ROW, /<span class="detail-athlete"[^>]*>[\s\S]*breakdownAthleteName\(item, activeBreakdown\.teamName\)/)
|
||||
// 悬停能看到被省略号截掉的完整姓名 / 单位名。
|
||||
assert.match(BODY_ROW, /:title="breakdownAthleteName\(item, activeBreakdown\.teamName\)"/)
|
||||
})
|
||||
|
||||
test('项目 / 组别 / 成绩 / 名次 / 积分照旧,只是旁边多了一列', () => {
|
||||
assert.match(BODY_ROW, /\{\{ item\.eventName \?\? '—' \}\}/)
|
||||
assert.match(BODY_ROW, /breakdownCategoryName\(item\)/)
|
||||
assert.match(BODY_ROW, /\{\{ item\.resultText \?\? '—' \}\}/)
|
||||
assert.match(BODY_ROW, /第 \{\{ item\.rank \?\? '—' \}\} 名/)
|
||||
assert.match(BODY_ROW, /\+{{ formatPoints\(item\.points\) \}\}/)
|
||||
})
|
||||
|
||||
test('个人流水那一行看得到是谁;四个老格子照旧', () => {
|
||||
const row = item()
|
||||
const rendered = [
|
||||
row.eventName ?? '—',
|
||||
breakdownCategoryName(row),
|
||||
breakdownAthleteName(row, '第一分公司'),
|
||||
row.resultText ?? '—',
|
||||
row.rank === null ? '—' : `第 ${row.rank} 名`,
|
||||
row.points,
|
||||
]
|
||||
assert.deepEqual(rendered, ['100米跑', '男子', '张伟', '11.58', '第 1 名', 10])
|
||||
})
|
||||
|
||||
test('团体项目流水没有运动员名:降级成单位名,不出现空白格或 null', () => {
|
||||
const cell = breakdownAthleteName(item({ athleteName: null, athleteId: null }), '第一分公司')
|
||||
assert.equal(cell, '第一分公司')
|
||||
assert.doesNotMatch(cell, /null|undefined/)
|
||||
})
|
||||
|
||||
test('明细卡放得下五列:加列之后侧栏放宽到 ≥ 400px', () => {
|
||||
const grid = /grid-template-columns:\s*minmax\(0, 1fr\) (\d+)px/.exec(styleText(PAGE_SOURCE))
|
||||
assert.ok(grid !== null, '.standings-grid 应声明「主表 + 明细侧栏」两列')
|
||||
assert.ok(Number(grid[1]) >= 400, `明细侧栏 ${grid[1]}px 放不下五列`)
|
||||
})
|
||||
|
||||
/**
|
||||
* 「来源」列与「已修订」角标:现场裁判长问的第二件事是「这几分怎么来的、裁判是不是改过」。
|
||||
*
|
||||
* 来源列与导出的「来源」列同一套词;角标只在真被改过时出现,时间 / 原因 / 操作人塞进悬停。
|
||||
*/
|
||||
test('明细行有来源列,绑的是与导出同一套词的 breakdownSourceLabel', () => {
|
||||
assert.match(BODY_ROW, /class="detail-source"[\s\S]*breakdownSourceLabel\(item\)/)
|
||||
assert.equal(breakdownSourceLabel(item()), '成绩')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'adjustment', eventName: null })), '人工调整')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'record_bonus' })), '破纪录加分')
|
||||
})
|
||||
|
||||
test('改过的成绩行尾有角标,悬停给出时间 + 原因 + 操作人', () => {
|
||||
assert.match(BODY_ROW, /v-if="breakdownRevisionBadge\(item\)"/)
|
||||
assert.match(BODY_ROW, /class="detail-revision"/)
|
||||
assert.match(BODY_ROW, /:title="breakdownRevisionTitle\(item\)"/)
|
||||
})
|
||||
|
||||
test('角标只在 >0 时渲染;没改过 / 追不出来时整行连元素都没有', () => {
|
||||
// v-if 的判据是空串:0 与 null 都渲染不出角标,也就不会出现「其实没改过却标了已修订」。
|
||||
assert.equal(breakdownRevisionBadge(item()), '')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: 0 })), '')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: null })), '')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: 2 })), '已修订 ×2')
|
||||
assert.equal(
|
||||
breakdownRevisionTitle(
|
||||
item({ revisionCount: 2, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '成绩录入笔误' }),
|
||||
).includes('已修订 ×2(成绩录入笔误)'),
|
||||
true,
|
||||
)
|
||||
assert.equal(breakdownRevisionTitle(item()), '')
|
||||
})
|
||||
|
||||
test('明细卡七条轨道都放得下:侧栏不放宽到 400 以下', () => {
|
||||
const grid = /grid-template-columns:\s*minmax\(0, 1fr\) (\d+)px/.exec(styleText(PAGE_SOURCE))
|
||||
assert.ok(grid !== null, '.standings-grid 应声明「主表 + 明细侧栏」两列')
|
||||
assert.ok(Number(grid[1]) >= 400, `明细侧栏 ${grid[1]}px 放不下明细列`)
|
||||
// 行内栅格:一条轨道对应一列(项目/组别、运动员、来源、成绩、名次、积分、修订角标)。
|
||||
const tracks = /grid-template-columns:\s*(minmax\(0,\s*1\.1fr\)[^;]+);/.exec(styleText(PAGE_SOURCE))
|
||||
assert.ok(tracks !== null, '.detail-item 应声明行内栅格')
|
||||
const parts = (tracks[1] ?? '').trim().split(/\s+(?![^(]*\))/)
|
||||
assert.equal(parts.length, 7, `行内轨道数应为 7:${tracks[1]}`)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag } from '@/shared'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import AnimatedNumber from './AnimatedNumber.vue'
|
||||
|
||||
@@ -31,6 +32,8 @@ onUnmounted(() => window.clearInterval(timer))
|
||||
|
||||
const items = computed(() => detail.value?.detail?.items ?? [])
|
||||
const totalPoints = computed(() => detail.value?.detail?.totalPoints ?? 0)
|
||||
/** 团体项目 / 人工调整的流水没有运动员,退回单位名(见 breakdownAthleteName)。 */
|
||||
const teamName = computed(() => detail.value?.detail?.teamName ?? '')
|
||||
const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
|
||||
</script>
|
||||
|
||||
@@ -55,6 +58,7 @@ const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
|
||||
<div v-else class="table">
|
||||
<div class="row head-row">
|
||||
<span class="col-name">项目 / 组别</span>
|
||||
<span class="col-athlete">运动员</span>
|
||||
<span class="col-rank">名次</span>
|
||||
<span class="col-result">成绩</span>
|
||||
<span class="col-points">积分</span>
|
||||
@@ -63,10 +67,20 @@ const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
|
||||
<span class="col-name">
|
||||
{{ item.eventName ?? '人工调整' }}
|
||||
<em v-if="item.categoryName">{{ item.categoryName }}</em>
|
||||
<!-- 普通成绩不打来源标签:这一列绝大多数行都是成绩,打出来只是噪声(见 breakdownSourceTag)。 -->
|
||||
<em v-if="breakdownSourceTag(item)" class="col-source">{{ breakdownSourceTag(item) }}</em>
|
||||
</span>
|
||||
<span class="col-athlete">{{ breakdownAthleteName(item, teamName) }}</span>
|
||||
<span class="col-rank">{{ item.rank ?? '—' }}</span>
|
||||
<span class="col-result">{{ item.resultText ?? '—' }}</span>
|
||||
<span class="col-points">{{ item.points }}</span>
|
||||
<!--
|
||||
修订痕迹占满一整行、只在这一分确实被改过时才出现:大屏不能悬停,观众要一眼看到
|
||||
「这几分是后来改过的」。没改过的行连这一行元素都没有,行高与改造前完全一致。
|
||||
-->
|
||||
<span v-if="breakdownRevisionLine(item)" class="col-revision">
|
||||
{{ breakdownRevisionLine(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +156,10 @@ h2 {
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 110px 160px 130px;
|
||||
/* 覆盖层是 16:9 屏幕右侧的一半:五列都要在 960px 内读得清,姓名列给得最宽。
|
||||
后四列按内容定宽(名次只有一位数、积分两三位),省下来的宽度全给「项目 / 组别」——
|
||||
破纪录加分那一行还要在项目名后面挤下一个来源标签(见 .col-source)。 */
|
||||
grid-template-columns: minmax(0, 1fr) 190px 76px 140px 96px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 8px;
|
||||
@@ -160,6 +177,14 @@ h2 {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* 项目名 / 运动员名(团体项目退回单位名)可能很长:省略号收尾,不把名次成绩挤出行外。 */
|
||||
.col-name,
|
||||
.col-athlete {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-points {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
@@ -170,6 +195,25 @@ h2 {
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
/* 破纪录加分 / 人工调整这类「非普通成绩」的来源标签:跟在项目名后面,不单占一列。 */
|
||||
.col-name em.col-source {
|
||||
padding: 2px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--primary-line);
|
||||
font-size: 19px;
|
||||
color: var(--primary-text);
|
||||
}
|
||||
/* 修订痕迹:整行小字,比正文矮一号、颜色更淡,不跟成绩抢注意力。 */
|
||||
.col-revision {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { medalBoardRows } from '@/shared'
|
||||
import { useScoreboard } from '../useScoreboard'
|
||||
import { DEFAULT_ROTATE_SECONDS } from '../guards'
|
||||
|
||||
@@ -12,8 +13,8 @@ interface TickerItem {
|
||||
|
||||
const { standings, latestResults, announceText, detail, settings } = useScoreboard()
|
||||
|
||||
// 与单位奖牌榜页同一口径:按服务端名次(总分优先)取前 3,不在前端按金→银→铜重排。
|
||||
const topMedals = computed(() => standings.value.slice(0, 3))
|
||||
// 与单位奖牌榜页同一口径:按服务端的奖牌榜顺序(金→银→铜)取前 3,不在前端自己重排。
|
||||
const topMedals = computed(() => medalBoardRows(standings.value).slice(0, 3))
|
||||
|
||||
const latestGroup = computed(() => latestResults.value[0] ?? null)
|
||||
|
||||
@@ -61,7 +62,7 @@ onUnmounted(() => window.clearInterval(timer))
|
||||
<div v-if="current" :key="current.key" class="item">
|
||||
<template v-if="current.key === 'medals'">
|
||||
<div v-for="row in topMedals" :key="row.teamId" class="chip">
|
||||
<span class="chip-rank">{{ row.rank }}</span>
|
||||
<span class="chip-rank">{{ row.medalRank }}</span>
|
||||
<span class="chip-name">{{ row.shortName ?? row.teamName }}</span>
|
||||
<span class="chip-stat gold">金 {{ row.gold }}</span>
|
||||
<span class="chip-stat silver">银 {{ row.silver }}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { medalBoardRows } from '@/shared'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||
import { useScoreboard } from '../../useScoreboard'
|
||||
import PageFrame from '../PageFrame.vue'
|
||||
@@ -7,8 +8,9 @@ import TeamLogo from '../TeamLogo.vue'
|
||||
|
||||
const { standings } = useScoreboard()
|
||||
|
||||
// 排序与名次都取服务端口径(总分优先),前端不再按金→银→铜重排,否则名次会与总积分榜打架。
|
||||
const rows = computed(() => standings.value)
|
||||
// 顺序与名次都取服务端的奖牌榜口径(金→银→铜→单位编码,与总积分榜解耦)。
|
||||
// 前端不按金/银/铜重排、也不拿总榜 rank 当奖牌榜名次,否则会和导出件打架。
|
||||
const rows = computed(() => medalBoardRows(standings.value))
|
||||
|
||||
const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX))
|
||||
</script>
|
||||
@@ -25,7 +27,7 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX)
|
||||
<span class="total">合计</span>
|
||||
</div>
|
||||
<div v-for="row in rows" :key="row.teamId" class="row">
|
||||
<span class="rank" :class="`top-${Math.min(row.rank, 4)}`">{{ row.rank }}</span>
|
||||
<span class="rank" :class="`top-${Math.min(row.medalRank, 4)}`">{{ row.medalRank }}</span>
|
||||
<span class="name">
|
||||
<TeamLogo :path="row.logoPath" />
|
||||
<span class="team-name">{{ row.teamName }}</span>
|
||||
|
||||
@@ -194,9 +194,12 @@ export function parseStandingRow(source: unknown): StandingRow {
|
||||
const gold = num(rec.gold)
|
||||
const silver = num(rec.silver)
|
||||
const bronze = num(rec.bronze)
|
||||
const rank = num(rec.rank)
|
||||
return {
|
||||
...parseTeamRef(rec),
|
||||
rank: num(rec.rank),
|
||||
rank,
|
||||
// 单位奖牌榜名次独立于总积分榜(金→银→铜);旧服务端不下发它,退回总榜名次(旧行为)。
|
||||
medalRank: num(rec.medalRank, rank),
|
||||
gold,
|
||||
silver,
|
||||
bronze,
|
||||
@@ -542,6 +545,11 @@ export function parseBreakdownItem(source: unknown): BreakdownItem {
|
||||
resultText: strOrNull(rec.resultText),
|
||||
points: num(rec.points),
|
||||
createdAt: str(rec.createdAt),
|
||||
// 修订痕迹:老服务端不下发,一律解析成 null(= 追不出来),界面据此不显示角标。
|
||||
revisionCount: numOrNull(rec.revisionCount),
|
||||
lastRevisionAt: strOrNull(rec.lastRevisionAt),
|
||||
lastRevisionReason: strOrNull(rec.lastRevisionReason),
|
||||
lastRevisedBy: strOrNull(rec.lastRevisedBy),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -70,7 +70,7 @@ const STANDING_SEEDS: readonly StandingSeed[] = [
|
||||
]
|
||||
|
||||
function buildStandings(): StandingRow[] {
|
||||
return MOCK_TEAMS.map((item, index) => {
|
||||
const rows: StandingRow[] = MOCK_TEAMS.map((item, index) => {
|
||||
const seed = STANDING_SEEDS[index] ?? STANDING_SEEDS[STANDING_SEEDS.length - 1]
|
||||
const row = seed ?? { rank: index + 1, totalPoints: 0, gold: 0, silver: 0, bronze: 0, rankChange: null }
|
||||
return {
|
||||
@@ -81,6 +81,7 @@ function buildStandings(): StandingRow[] {
|
||||
color: item.color,
|
||||
logoPath: item.logoPath,
|
||||
rank: row.rank,
|
||||
medalRank: 0,
|
||||
gold: row.gold,
|
||||
silver: row.silver,
|
||||
bronze: row.bronze,
|
||||
@@ -89,6 +90,20 @@ function buildStandings(): StandingRow[] {
|
||||
rankChange: row.rankChange,
|
||||
}
|
||||
})
|
||||
|
||||
// 单位奖牌榜的名次独立于总积分榜(金 → 银 → 铜 → 单位编码);示例数据照服务端同一口径算,
|
||||
// 数组顺序仍保持积分榜顺序(服务端就是这么下发的:rank 管顺序,medalRank 单独带)。
|
||||
const medalRank = new Map<number, number>()
|
||||
;[...rows]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.gold - a.gold ||
|
||||
b.silver - a.silver ||
|
||||
b.bronze - a.bronze ||
|
||||
a.teamCode.localeCompare(b.teamCode),
|
||||
)
|
||||
.forEach((row, index) => medalRank.set(row.teamId, index + 1))
|
||||
return rows.map((row) => ({ ...row, medalRank: medalRank.get(row.teamId) ?? row.rank }))
|
||||
}
|
||||
|
||||
function buildEvents(): Event[] {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'vue/compiler-sfc'
|
||||
import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag, type BreakdownItem } from '@/shared'
|
||||
|
||||
/**
|
||||
* 大屏积分明细覆盖层的「运动员」列。
|
||||
*
|
||||
* 现场裁判长的原话:推到大屏的明细只有项目 / 名次 / 成绩 / 积分,看不出「谁在哪个项目拿了多少分」,
|
||||
* 这种明细说服不了观众单位名字为什么升上去。数据(athleteName)服务端一直有,是覆盖层漏了这一列。
|
||||
*
|
||||
* 项目没有 jsdom 与组件测试工具,这里沿用仓库既有做法:`.vue` 模板用源码契约断言
|
||||
* (列头顺序、每个格子绑了什么),文案内容交给 `breakdownAthleteName` 这个纯函数用例守。
|
||||
*/
|
||||
|
||||
const OVERLAY_SOURCE = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8')
|
||||
|
||||
/** 取模板里以某个固定串开头、到下一个 </div> 为止的片段(这些行里没有嵌套 div)。 */
|
||||
function block(startPattern: RegExp): string {
|
||||
const found = startPattern.exec(OVERLAY_SOURCE)
|
||||
assert.ok(found !== null, `模板里应有 ${startPattern}`)
|
||||
const rest = OVERLAY_SOURCE.slice(found.index)
|
||||
const end = rest.indexOf('</div>')
|
||||
assert.ok(end > 0, '行片段应以 </div> 结束')
|
||||
return rest.slice(0, end)
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
cls: string
|
||||
inner: string
|
||||
}
|
||||
|
||||
function cellsOf(fragment: string): Cell[] {
|
||||
return [...fragment.matchAll(/<span class="(col-[a-z]+)">([\s\S]*?)<\/span>/g)].map((match) => ({
|
||||
cls: match[1] ?? '',
|
||||
inner: (match[2] ?? '').replace(/\s+/g, ' ').trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
const HEAD_ROW = block(/<div class="row head-row">/)
|
||||
const BODY_ROW = block(/<div v-for="item in items" :key="item\.ledgerId" class="row">/)
|
||||
|
||||
function styleText(source: string): string {
|
||||
const { descriptor } = parse(source, { filename: 'style.vue' })
|
||||
return descriptor.styles[0]?.content ?? ''
|
||||
}
|
||||
|
||||
function cssRule(style: string, selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style)
|
||||
assert.ok(match !== null, `样式里应存在 ${selector} 规则`)
|
||||
return match[1] ?? ''
|
||||
}
|
||||
|
||||
function item(over: Partial<BreakdownItem> = {}): BreakdownItem {
|
||||
return {
|
||||
ledgerId: 1,
|
||||
sourceType: 'result',
|
||||
eventId: 1,
|
||||
eventCode: 'M100',
|
||||
eventName: '100米跑',
|
||||
categoryName: '男子',
|
||||
athleteId: 7,
|
||||
athleteName: '张伟',
|
||||
rank: 1,
|
||||
resultText: '11.58',
|
||||
points: 10,
|
||||
createdAt: '2026-09-13 10:00:00',
|
||||
revisionCount: null,
|
||||
lastRevisionAt: null,
|
||||
lastRevisionReason: null,
|
||||
lastRevisedBy: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
test('列头按「项目/组别 · 运动员 · 名次 · 成绩 · 积分」排列,运动员插在项目与名次之间', () => {
|
||||
const labels = [...HEAD_ROW.matchAll(/<span class="col-[a-z]+">([^<]*)<\/span>/g)].map((match) =>
|
||||
(match[1] ?? '').trim(),
|
||||
)
|
||||
assert.deepEqual(labels, ['项目 / 组别', '运动员', '名次', '成绩', '积分'])
|
||||
})
|
||||
|
||||
test('明细行渲染运动员格,绑定的是团队共用的降级函数', () => {
|
||||
const cells = cellsOf(BODY_ROW)
|
||||
assert.deepEqual(
|
||||
cells.map((cell) => cell.cls),
|
||||
['col-name', 'col-athlete', 'col-rank', 'col-result', 'col-points'],
|
||||
)
|
||||
assert.equal(cells[1]?.inner, '{{ breakdownAthleteName(item, teamName) }}')
|
||||
})
|
||||
|
||||
test('项目 / 名次 / 成绩 / 积分四列原样保留(是加列不是改列)', () => {
|
||||
const cells = cellsOf(BODY_ROW)
|
||||
assert.match(cells[0]?.inner ?? '', /item\.eventName \?\? '人工调整'/)
|
||||
assert.equal(cells[2]?.inner, "{{ item.rank ?? '—' }}")
|
||||
assert.equal(cells[3]?.inner, "{{ item.resultText ?? '—' }}")
|
||||
assert.equal(cells[4]?.inner, '{{ item.points }}')
|
||||
})
|
||||
|
||||
test('个人流水那一行大屏上看得见是谁:运动员格是姓名,四个老格子照旧', () => {
|
||||
const row = item()
|
||||
const rendered = [
|
||||
row.eventName ?? '人工调整',
|
||||
breakdownAthleteName(row, '第一分公司'),
|
||||
row.rank ?? '—',
|
||||
row.resultText ?? '—',
|
||||
row.points,
|
||||
]
|
||||
assert.deepEqual(rendered, ['100米跑', '张伟', 1, '11.58', 10])
|
||||
})
|
||||
|
||||
test('团体项目流水没有运动员名:降级成单位名,不出现空白格或 null', () => {
|
||||
const row = item({ athleteName: null, athleteId: null, sourceType: 'result' })
|
||||
const cell = breakdownAthleteName(row, '第一分公司')
|
||||
assert.equal(cell, '第一分公司')
|
||||
assert.doesNotMatch(cell, /null|undefined/)
|
||||
})
|
||||
|
||||
test('覆盖层是 16:9 右半屏:五列加间距放得下,姓名列留得够宽', () => {
|
||||
const row = cssRule(styleText(OVERLAY_SOURCE), '.row')
|
||||
const tracks = /grid-template-columns:\s*([^;]+);/.exec(row)
|
||||
assert.ok(tracks !== null, '.row 应声明 grid-template-columns')
|
||||
const parts = (tracks[1] ?? '').trim().split(/\s+(?![^(]*\))/)
|
||||
assert.equal(parts.length, 5, '五列:项目/组别、运动员、名次、成绩、积分')
|
||||
|
||||
const gap = Number(/gap:\s*(\d+)px/.exec(row)?.[1] ?? 0)
|
||||
const fixed = parts.reduce((sum, part) => sum + Number(/(\d+)px/.exec(part)?.[1] ?? 0), 0)
|
||||
// 1920 宽的 16:9 屏幕:覆盖层占右半边 960px,减去左右内边距 44px×2。
|
||||
const available = 960 - 44 * 2
|
||||
assert.ok(fixed + gap * (parts.length - 1) <= available, `五列 ${fixed}px + 间距会挤出右半屏`)
|
||||
|
||||
const nameTrack = /minmax\(0,\s*1fr\)/.test(parts[0] ?? '')
|
||||
assert.ok(nameTrack, '项目 / 组别列应可伸缩')
|
||||
const athleteWidth = Number(/(\d+)px/.exec(parts[1] ?? '')?.[1] ?? 0)
|
||||
assert.ok(athleteWidth >= 180, '运动员列至少 180px,单位名才不至于只剩几个字')
|
||||
|
||||
const athlete = cssRule(styleText(OVERLAY_SOURCE), '.col-athlete')
|
||||
assert.match(athlete, /text-overflow:\s*ellipsis/, '长姓名 / 单位名要省略号收尾')
|
||||
assert.match(athlete, /white-space:\s*nowrap/)
|
||||
})
|
||||
|
||||
/**
|
||||
* 「这几分怎么来的、有没有被改过」——大屏不能悬停,所以:
|
||||
* - 非普通成绩(破纪录加分)在项目名后面打一个来源小标签;
|
||||
* - 被改过的成绩多一行整行小字 `10:42 已修订(成绩录入笔误)`。
|
||||
*
|
||||
* 两处都只在需要时才渲染(v-if),没改过的行连元素都没有 —— 大屏行高与加这一行之前完全一致。
|
||||
*/
|
||||
test('来源标签只在非普通成绩时渲染,普通成绩不打(行高与列宽都不受影响)', () => {
|
||||
assert.match(BODY_ROW, /v-if="breakdownSourceTag\(item\)"/)
|
||||
assert.match(BODY_ROW, /<em v-if="breakdownSourceTag\(item\)" class="col-source">/)
|
||||
assert.equal(breakdownSourceTag(item()), '')
|
||||
// 大屏标签用短词:项目名后面那一行放不下「破纪录加分」五个字。
|
||||
assert.equal(breakdownSourceTag(item({ sourceType: 'record_bonus' })), '破纪录')
|
||||
})
|
||||
|
||||
test('修订小字占满一整行(grid-column: 1 / -1),不与五列抢宽度', () => {
|
||||
assert.match(BODY_ROW, /v-if="breakdownRevisionLine\(item\)"/)
|
||||
assert.match(BODY_ROW, /class="col-revision"/)
|
||||
assert.match(BODY_ROW, /\{\{ breakdownRevisionLine\(item\) \}\}/)
|
||||
const css = cssRule(styleText(OVERLAY_SOURCE), '.col-revision')
|
||||
assert.match(css, /grid-column:\s*1\s*\/\s*-1/, '修订小字要横跨整行,不能挤第六列')
|
||||
})
|
||||
|
||||
test('没改过的成绩不渲染小字,渲染出来的也只有一行短句', () => {
|
||||
assert.equal(breakdownRevisionLine(item()), '')
|
||||
const line = breakdownRevisionLine(
|
||||
item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '成绩录入笔误' }),
|
||||
)
|
||||
assert.doesNotMatch(line, /null|undefined|NaN|×1/)
|
||||
assert.ok(line.length <= 30, `大屏一行小字要短:${line}`)
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parseAthleteStandings, parseStandingRows } from '../guards'
|
||||
import { breakdownRevisionBadge, breakdownRevisionLine } from '@/shared'
|
||||
import { parseAthleteStandings, parseStandingRows, parseTeamBreakdown } from '../guards'
|
||||
|
||||
/**
|
||||
* REST 兜底也要过 guards(P2 C39)。
|
||||
@@ -42,8 +43,73 @@ test('单位榜 REST 数据同样过 guards', () => {
|
||||
assert.equal(rows[0]?.rankChange, null)
|
||||
})
|
||||
|
||||
test('奖牌榜名次 medalRank 原样收下;旧服务端缺它时退回总榜 rank', () => {
|
||||
const withField = parseStandingRows([{ teamId: 1, rank: 4, medalRank: 1, gold: 2 }])
|
||||
assert.equal(withField[0]?.medalRank, 1)
|
||||
|
||||
// 旧服务端 / 老缓存里没有 medalRank:那时奖牌榜与积分榜同序,退回 rank 才是对的,不能给 0。
|
||||
const legacy = parseStandingRows([{ teamId: 1, rank: 4, gold: 2 }])
|
||||
assert.equal(legacy[0]?.medalRank, 4)
|
||||
})
|
||||
|
||||
test('loadRest 的两条路都走 guards,不再把裸值塞进 ref', () => {
|
||||
assert.match(USE_SCOREBOARD_SOURCE, /applyStandings\(parseStandingRows\(standingsResult\.value\)\)/)
|
||||
assert.match(USE_SCOREBOARD_SOURCE, /athletes\.value = parseAthleteStandings\(athletesResult\.value\)/)
|
||||
assert.doesNotMatch(USE_SCOREBOARD_SOURCE, /athletes\.value = athletesResult\.value/)
|
||||
})
|
||||
|
||||
/**
|
||||
* 修订痕迹也要过 guards:大屏是无人值守的,服务端(尤其升级前的老版本)不下发这几个字段时
|
||||
* 必须解析成 null(= 追不出来),绝不能让 undefined 漏进模板变成「已修订 ×undefined」。
|
||||
*/
|
||||
test('明细行的修订痕迹缺字段时收成 null,不显示角标', () => {
|
||||
const items = parseTeamBreakdown({
|
||||
teamId: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
totalPoints: 10,
|
||||
items: [{ ledgerId: 1, sourceType: 'result', eventName: '100米跑', points: 10, createdAt: '' }],
|
||||
})?.items
|
||||
|
||||
const row = items?.[0]
|
||||
assert.equal(row?.revisionCount, null)
|
||||
assert.equal(row?.lastRevisionAt, null)
|
||||
assert.equal(row?.lastRevisionReason, null)
|
||||
assert.equal(row?.lastRevisedBy, null)
|
||||
assert.ok(row !== undefined)
|
||||
assert.equal(breakdownRevisionBadge(row), '')
|
||||
assert.equal(breakdownRevisionLine(row), '')
|
||||
})
|
||||
|
||||
test('明细行的修订痕迹在位时原样收下并渲染成小字', () => {
|
||||
const row = parseTeamBreakdown({
|
||||
teamId: 1,
|
||||
teamCode: 'A',
|
||||
teamName: '第一分公司',
|
||||
totalPoints: 10,
|
||||
items: [
|
||||
{
|
||||
ledgerId: 1,
|
||||
sourceType: 'result',
|
||||
eventName: '100米跑',
|
||||
points: 10,
|
||||
createdAt: '',
|
||||
revisionCount: 2,
|
||||
lastRevisionAt: '2026-09-13 02:42:00',
|
||||
lastRevisionReason: '成绩录入笔误',
|
||||
lastRevisedBy: '张三',
|
||||
},
|
||||
],
|
||||
})?.items[0]
|
||||
|
||||
assert.equal(row?.revisionCount, 2)
|
||||
assert.equal(breakdownRevisionBadge(row), '已修订 ×2')
|
||||
assert.match(breakdownRevisionLine(row), /^(\d{2}:\d{2} )?已修订 ×2(成绩录入笔误)$/)
|
||||
// 脏值(字符串 / 负数)不算「改过」,宁可不说也不能误报。
|
||||
const dirty = parseTeamBreakdown({
|
||||
teamId: 1, teamCode: 'A', teamName: 'A', totalPoints: 0,
|
||||
items: [{ ledgerId: 1, points: 0, revisionCount: 'x' }],
|
||||
})?.items[0]
|
||||
assert.ok(dirty !== undefined)
|
||||
assert.equal(breakdownRevisionBadge(dirty), '')
|
||||
})
|
||||
@@ -5,10 +5,13 @@
|
||||
* 页面直接读会得到 undefined;统一在这里补默认值,读取处不必各写一份兜底。
|
||||
*/
|
||||
|
||||
import type { AvgMode, Event, EventInput, ScoreChannel } from './types'
|
||||
import type { AggregateMode, AvgMode, Event, EventInput, ScoreChannel } from './types'
|
||||
|
||||
export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
|
||||
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
|
||||
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum']
|
||||
/** 聚合通道默认按成员平均汇总;缺字段的老数据 / 老服务端都按它处理。 */
|
||||
export const DEFAULT_AGGREGATE_MODE: AggregateMode = 'avg'
|
||||
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
|
||||
export const DEFAULT_COUNT_TO_TEAM = true
|
||||
/** 通道默认发奖牌;老服务端不下发该字段时的口径。 */
|
||||
@@ -24,6 +27,16 @@ export function normalizeAvgMode(value: unknown): AvgMode {
|
||||
: DEFAULT_AVG_MODE
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合通道的汇总口径归一化:缺字段 / 认不出的取值一律按缺省 avg 处理。
|
||||
* 只对 basis=aggregate 的通道有意义,其它通道即使带着该字段也不参与计算。
|
||||
*/
|
||||
export function normalizeAggregateMode(value: unknown): AggregateMode {
|
||||
return typeof value === 'string' && (AGGREGATE_MODES as readonly string[]).includes(value)
|
||||
? (value as AggregateMode)
|
||||
: DEFAULT_AGGREGATE_MODE
|
||||
}
|
||||
|
||||
export function normalizeCountToTeam(value: unknown): boolean {
|
||||
return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_TEAM
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ export * from './types'
|
||||
export { ApiClient, ApiError, createApiClient } from './api'
|
||||
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
|
||||
export {
|
||||
AGGREGATE_MODES,
|
||||
AVG_MODES,
|
||||
DEFAULT_AGGREGATE_MODE,
|
||||
DEFAULT_AVG_MODE,
|
||||
DEFAULT_AWARD_MEDALS,
|
||||
DEFAULT_COUNT_TO_MEDALS,
|
||||
@@ -17,6 +19,7 @@ export {
|
||||
DEFAULT_COUNT_TO_TEAM_MEDALS,
|
||||
eventInputOf,
|
||||
hasAggregateChannel,
|
||||
normalizeAggregateMode,
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
@@ -24,6 +27,17 @@ export {
|
||||
normalizeCountToTeamMedals,
|
||||
} from './channels'
|
||||
export { readTeamRanksView, readTeamRanksViews } from './teamRanks'
|
||||
export {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
breakdownRevisionBadge,
|
||||
breakdownRevisionCount,
|
||||
breakdownRevisionLine,
|
||||
breakdownRevisionTitle,
|
||||
breakdownSourceLabel,
|
||||
breakdownSourceTag,
|
||||
medalBoardRows,
|
||||
} from './standings'
|
||||
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
|
||||
export { resolveAssetUrl } from './asset'
|
||||
export { RealtimeClient } from './realtime'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 校验口径与服务端 rules.Canonical 对齐,避免前端拿到的规则包和实际生效规则不一致。
|
||||
*/
|
||||
|
||||
import { normalizeCountToTeam } from './channels'
|
||||
import { normalizeAggregateMode, normalizeCountToTeam } from './channels'
|
||||
import {
|
||||
asRecord,
|
||||
emptyToNull,
|
||||
@@ -229,6 +229,10 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
|
||||
// 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。
|
||||
if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals
|
||||
if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals
|
||||
// 汇总口径同样是三态:只在规则包显式写了 avg / sum 时落到对象上(缺省 = avg,不写)。
|
||||
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum') {
|
||||
channel.aggregateMode = normalizeAggregateMode(rec.aggregateMode)
|
||||
}
|
||||
out.push(channel)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { formatClock, formatDateTime } from './format'
|
||||
import type { BreakdownItem, MedalRow } from './types'
|
||||
|
||||
/**
|
||||
* 单位奖牌榜的服务端顺序(金 → 银 → 铜 → 单位编码)。
|
||||
*
|
||||
* 排序与名次都由服务端算好(`domain.BuildMedalStandings`,REST / WS 快照 / xlsx 导出同一个函数),
|
||||
* 大屏页与管理端页只按 `medalRank` 排行、按 `medalRank` 显示名次——**两边各写一套金/银/铜重排
|
||||
* 迟早会和导出件对不上**,这正是 P1-14 修过的老问题。
|
||||
*
|
||||
* 旧服务端 / 老缓存不下发 `medalRank` 时退回总积分榜名次:那时奖牌榜本来就与积分榜同序,
|
||||
* 显示成 0 或空才是真的错。
|
||||
*/
|
||||
export function medalBoardRows(rows: readonly MedalRow[]): MedalRow[] {
|
||||
return rows
|
||||
.map((row) => ({ ...row, medalRank: row.medalRank > 0 ? row.medalRank : row.rank }))
|
||||
.sort((a, b) => a.medalRank - b.medalRank)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位积分明细「运动员」列的文案(管理端明细表与大屏明细覆盖层共用)。
|
||||
*
|
||||
* 借用同一份的原因是:这一列的意义是回答「单位这一分是谁挣的」,两边各写一套降级
|
||||
* 迟早会变成「大屏空白格、管理端写团体」这种对不上的情况。
|
||||
*
|
||||
* 团体项目 / 聚合通道(按队内平均)/ 人工调整的流水没有 athleteId,服务端下发
|
||||
* `athleteName` 是 null,这类行不能留空白格也不能打出 `null`:退回该流水的归属单位名——
|
||||
* 团体项目里单位就是拿到这一分的「谁」。连单位名都没有(理论上不会)时退到「团体」。
|
||||
* 人工调整本就没有项目与运动员,项目列已写明「人工调整」,这里给「—」不重复叨叨。
|
||||
*/
|
||||
export function breakdownAthleteName(
|
||||
item: Pick<BreakdownItem, 'sourceType' | 'athleteName'>,
|
||||
teamName: string,
|
||||
): string {
|
||||
const name = item.athleteName?.trim() ?? ''
|
||||
if (name !== '') return name
|
||||
if (item.sourceType === 'adjustment') return '—'
|
||||
const team = teamName.trim()
|
||||
return team === '' ? '团体' : team
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位积分明细「组别」列的文案。
|
||||
*
|
||||
* 组别的来源有两处(报名项的组别、聚合流水按单位回推的组别),服务端回推不出来时是 null
|
||||
* (单位在同一小项参加了多个组别,见 teamBreakdown);这里统一给占位符,不留空白格。
|
||||
*/
|
||||
export function breakdownCategoryName(item: Pick<BreakdownItem, 'categoryName'>): string {
|
||||
const name = item.categoryName?.trim() ?? ''
|
||||
return name === '' ? '—' : name
|
||||
}
|
||||
|
||||
/**
|
||||
* 单位积分明细「来源」列的文案。
|
||||
*
|
||||
* 词与 xlsx 导出侧完全一致(服务端 `export.sourceLabel`:成绩 / 破纪录加分 / 人工调整)——
|
||||
* 明细的意义就是「屏幕看到的和导出的对得上」,两边各写一套措辞迟早被当成两份数据。
|
||||
* 取不到 / 认不出的来源按「成绩」显示:老服务端不下发时它本来就是成绩流水。
|
||||
*/
|
||||
export function breakdownSourceLabel(item: Pick<BreakdownItem, 'sourceType'>): string {
|
||||
switch (item.sourceType) {
|
||||
case 'adjustment':
|
||||
return '人工调整'
|
||||
case 'record_bonus':
|
||||
return '破纪录加分'
|
||||
default:
|
||||
return '成绩'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大屏明细覆盖层要不要给这一行打「来源」标签:返回空串表示不打。
|
||||
*
|
||||
* 大屏只有 960px 的半屏,穷得放不下第六列:
|
||||
* - 普通成绩(result)是绝大多数,打了只是给每一行加噪声,直接省略;
|
||||
* - 人工调整流水没有项目名,项目列本来就写着「人工调整」,再打一个标签是同义重复;
|
||||
* - 破纪录加分是站在成绩旁边的额外分(项目列写的是小项名),必须打出来才看得懂这几分哪来的。
|
||||
*/
|
||||
export function breakdownSourceTag(item: Pick<BreakdownItem, 'sourceType' | 'eventName'>): string {
|
||||
if (item.sourceType === 'result') return ''
|
||||
if ((item.eventName ?? '').trim() === '') return ''
|
||||
// 大屏用短词「破纪录」:它要挤在项目名后面一行小字里,五个字会把项目名挤没;
|
||||
// 管理端明细与 xlsx 导出仍是完整的「破纪录加分」。
|
||||
if (item.sourceType === 'record_bonus') return '破纪录'
|
||||
return breakdownSourceLabel(item)
|
||||
}
|
||||
|
||||
/** 修订次数:追不出来(null / 缺字段)与没改过(0)都是 0,只有 > 0 才谈得上「已修订」。 */
|
||||
export function breakdownRevisionCount(item: Pick<BreakdownItem, 'revisionCount'>): number {
|
||||
const count = item.revisionCount
|
||||
if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) return 0
|
||||
return Math.floor(count)
|
||||
}
|
||||
|
||||
/** 管理端明细行尾的淡色角标文案;没改过返回空串(不占位、不留空角标)。 */
|
||||
export function breakdownRevisionBadge(item: Pick<BreakdownItem, 'revisionCount'>): string {
|
||||
const count = breakdownRevisionCount(item)
|
||||
return count === 0 ? '' : `已修订 ×${count}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端角标的悬停文案:`2026-09-13 10:42 · 已修订 ×2(成绩录入笔误)· 张三`。
|
||||
*
|
||||
* 屏幕放不下的一律塞进悬停里:完整时间、完整原因、操作人。缺哪段就少哪段,
|
||||
* 一点都不剩(没改过)时返回空串,调用方连 title 都不用挂。
|
||||
*/
|
||||
export function breakdownRevisionTitle(
|
||||
item: Pick<BreakdownItem, 'revisionCount' | 'lastRevisionAt' | 'lastRevisionReason' | 'lastRevisedBy'>,
|
||||
): string {
|
||||
const count = breakdownRevisionCount(item)
|
||||
if (count === 0) return ''
|
||||
const parts: string[] = []
|
||||
// formatDateTime 解析不出来时给的是占位符「—」,那不是时间,别挂到悬停里。
|
||||
const at = formatDateTime(item.lastRevisionAt ?? null)
|
||||
if (at !== '—') parts.push(at)
|
||||
const reason = (item.lastRevisionReason ?? '').trim()
|
||||
parts.push(reason === '' ? `已修订 ×${count}` : `已修订 ×${count}(${reason})`)
|
||||
const by = (item.lastRevisedBy ?? '').trim()
|
||||
if (by !== '') parts.push(by)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 大屏小字里原因最多留几个字:一行小字放不下长理由,超了截断,完整理由在管理端悬停里。 */
|
||||
const REVISION_REASON_MAX = 14
|
||||
|
||||
/**
|
||||
* 大屏明细覆盖层的一行小字:`10:42 已修订(成绩录入笔误)`;没改过返回空串。
|
||||
*
|
||||
* 大屏不能悬停,还坐着离屏幕十几米的观众,所以这一行必须短、必须一眼看懂:
|
||||
* 时间用 HH:mm(当天哪一刻改的),次数只在改过不止一次时写(一次是常态,×1 是噪声),
|
||||
* 原因截断到十几个字。宁可少写一个字,也不能让观众看到看不懂的术语。
|
||||
*/
|
||||
export function breakdownRevisionLine(
|
||||
item: Pick<BreakdownItem, 'revisionCount' | 'lastRevisionAt' | 'lastRevisionReason'>,
|
||||
): string {
|
||||
const count = breakdownRevisionCount(item)
|
||||
if (count === 0) return ''
|
||||
const reason = (item.lastRevisionReason ?? '').trim()
|
||||
const bracketed =
|
||||
reason === ''
|
||||
? ''
|
||||
: `(${reason.length > REVISION_REASON_MAX ? `${reason.slice(0, REVISION_REASON_MAX)}…` : reason})`
|
||||
const mark = count > 1 ? `已修订 ×${count}` : '已修订'
|
||||
const at = revisionClock(item.lastRevisionAt ?? null)
|
||||
return at === '' ? `${mark}${bracketed}` : `${at} ${mark}${bracketed}`
|
||||
}
|
||||
|
||||
/** `HH:mm`(本地时区);解析不出来返回空串——宁可不写时间,也不写「--:--」。 */
|
||||
function revisionClock(iso: string | null): string {
|
||||
const full = formatClock(iso)
|
||||
return full.startsWith('--') ? '' : full.slice(0, 5)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
breakdownRevisionBadge,
|
||||
breakdownRevisionCount,
|
||||
breakdownRevisionLine,
|
||||
breakdownRevisionTitle,
|
||||
breakdownSourceLabel,
|
||||
breakdownSourceTag,
|
||||
medalBoardRows,
|
||||
} from '../standings'
|
||||
import type { BreakdownItem, MedalRow } from '../types'
|
||||
|
||||
/**
|
||||
* 单位奖牌榜的顺序与名次由服务端算好(金 → 银 → 铜 → 单位编码,见 domain.BuildMedalStandings),
|
||||
* 前端只按 medalRank 应用。这一层要钉住的是:
|
||||
* 1. 名次用 medalRank,不拿总积分榜的 rank(否则"金牌多的单位排后面"会在界面上复现);
|
||||
* 2. 奖牌榜顺序与积分榜顺序确实是两套(金牌多但总分低的单位排前面);
|
||||
* 3. 旧服务端不下发 medalRank 时退回 rank,不显示成 0。
|
||||
*/
|
||||
|
||||
function row(over: Partial<MedalRow> & Pick<MedalRow, 'teamId'>): MedalRow {
|
||||
return {
|
||||
teamCode: `T${over.teamId}`,
|
||||
teamName: `第${over.teamId}队`,
|
||||
shortName: null,
|
||||
color: null,
|
||||
logoPath: null,
|
||||
rank: 0,
|
||||
medalRank: 0,
|
||||
gold: 0,
|
||||
silver: 0,
|
||||
bronze: 0,
|
||||
medalTotal: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
test('奖牌榜按 medalRank 排行:金牌多但总分低的单位排在前面', () => {
|
||||
// 服务端口径:A 金牌 1 枚(总榜第 2),B 一块牌没有(总榜第 1)。
|
||||
const rows = [row({ teamId: 1, medalRank: 2, rank: 2, gold: 1 }), row({ teamId: 2, medalRank: 1, rank: 1 })]
|
||||
|
||||
const board = medalBoardRows(rows)
|
||||
|
||||
assert.deepEqual(board.map((item) => item.teamId), [2, 1])
|
||||
assert.deepEqual(board.map((item) => item.medalRank), [1, 2])
|
||||
// 传入的数组不被就地改动(积分榜那块还在用它)。
|
||||
assert.deepEqual(rows.map((item) => item.teamId), [1, 2])
|
||||
})
|
||||
|
||||
test('名次取 medalRank,不拿总积分榜的 rank 顶替', () => {
|
||||
const board = medalBoardRows([row({ teamId: 7, rank: 5, medalRank: 3, gold: 1 })])
|
||||
|
||||
assert.equal(board[0]?.medalRank, 3)
|
||||
assert.equal(board[0]?.rank, 5, '总积分榜名次原样保留,两套名次并存')
|
||||
})
|
||||
|
||||
test('旧服务端 / 老缓存没有 medalRank 时退回 rank,不显示成 0', () => {
|
||||
const board = medalBoardRows([row({ teamId: 1, rank: 2 }), row({ teamId: 2, rank: 1 })])
|
||||
|
||||
assert.deepEqual(board.map((item) => item.medalRank), [1, 2])
|
||||
})
|
||||
|
||||
/**
|
||||
* 单位积分明细的「运动员」列:现场要能一眼看出这一分是谁挣的,
|
||||
* 团体项目(服务端不下发 athleteName)必须退回单位名而不是空白格或 null。
|
||||
*/
|
||||
function item(over: Partial<BreakdownItem> = {}): BreakdownItem {
|
||||
return {
|
||||
ledgerId: 1,
|
||||
sourceType: 'result',
|
||||
eventId: 1,
|
||||
eventCode: 'M100',
|
||||
eventName: '100米跑',
|
||||
categoryName: '男子',
|
||||
athleteId: 7,
|
||||
athleteName: '张伟',
|
||||
rank: 1,
|
||||
resultText: '11.58',
|
||||
points: 10,
|
||||
createdAt: '2026-09-13 10:00:00',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
test('明细行有运动员时显示运动员名', () => {
|
||||
assert.equal(breakdownAthleteName(item(), '第一分公司'), '张伟')
|
||||
})
|
||||
|
||||
test('团体项目(athleteName 为空 / null / 只有空白)退回单位名,不留空白格也不显示 null', () => {
|
||||
for (const athleteName of [null, '', ' ']) {
|
||||
const cell = breakdownAthleteName(item({ athleteName, athleteId: null }), '第一分公司')
|
||||
assert.equal(cell, '第一分公司')
|
||||
assert.doesNotMatch(cell, /null|undefined/)
|
||||
}
|
||||
})
|
||||
|
||||
test('单位名也拿不到时退到固定占位,不留空白格', () => {
|
||||
assert.equal(breakdownAthleteName(item({ athleteName: null }), ' '), '团体')
|
||||
})
|
||||
|
||||
test('人工调整没有运动员,项目列已写明人工调整,这里不重复', () => {
|
||||
const cell = breakdownAthleteName(
|
||||
item({ sourceType: 'adjustment', athleteName: null, athleteId: null, eventId: null, eventName: null }),
|
||||
'第一分公司',
|
||||
)
|
||||
assert.equal(cell, '—')
|
||||
})
|
||||
|
||||
test('运动员名去掉首尾空白,不把空格当成「有名字」', () => {
|
||||
assert.equal(breakdownAthleteName(item({ athleteName: ' 张伟 ' }), '第一分公司'), '张伟')
|
||||
})
|
||||
|
||||
test('明细行「组别」列拿不到组别时给占位符(聚合流水跨组别回推不出来)', () => {
|
||||
assert.equal(breakdownCategoryName(item()), '男子')
|
||||
assert.equal(breakdownCategoryName(item({ categoryName: null })), '—')
|
||||
assert.equal(breakdownCategoryName(item({ categoryName: ' ' })), '—')
|
||||
})
|
||||
|
||||
/**
|
||||
* 明细行的「来源」:屏幕与 xlsx 导出必须是同一套词(成绩 / 破纪录加分 / 人工调整),
|
||||
* 否则同一场比赛的同一分在两处叫两个名字,对账的人不知道该信哪份。
|
||||
*/
|
||||
test('来源文案与导出侧 sourceLabel 同一套词', () => {
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'result' })), '成绩')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'record_bonus' })), '破纪录加分')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'adjustment' })), '人工调整')
|
||||
})
|
||||
|
||||
test('老服务端下发认不出的来源时按成绩显示,不显示成英文枚举', () => {
|
||||
const label = breakdownSourceLabel(item({ sourceType: 'whatever' as BreakdownItem['sourceType'] }))
|
||||
assert.equal(label, '成绩')
|
||||
})
|
||||
|
||||
test('大屏只给「破纪录加分」这类非普通成绩打来源标签', () => {
|
||||
assert.equal(breakdownSourceTag(item()), '', '普通成绩不打标签:一行行都写「成绩」只是噪声')
|
||||
// 大屏那一行要短:用短词「破纪录」,管理端与导出仍是完整的「破纪录加分」。
|
||||
assert.equal(breakdownSourceTag(item({ sourceType: 'record_bonus' })), '破纪录')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'record_bonus' })), '破纪录加分')
|
||||
// 人工调整流水没有项目名,项目列本来就写着「人工调整」,不再重复打标签。
|
||||
assert.equal(breakdownSourceTag(item({ sourceType: 'adjustment', eventName: null })), '')
|
||||
assert.equal(breakdownSourceTag(item({ sourceType: 'adjustment', eventName: ' ' })), '')
|
||||
})
|
||||
|
||||
/**
|
||||
* 修订痕迹:「被改过」是现场裁判长最想知道的(分错了又改了一遍吗),也是最不能误报的。
|
||||
*/
|
||||
test('没改过(0)与追不出来(null / 缺字段)都不显示角标,只有 > 0 才算已修订', () => {
|
||||
for (const revisionCount of [0, null, undefined, -1, Number.NaN]) {
|
||||
const row = item({ revisionCount })
|
||||
assert.equal(breakdownRevisionCount(row), 0, `revisionCount=${String(revisionCount)} 不该算已修订`)
|
||||
assert.equal(breakdownRevisionBadge(row), '')
|
||||
assert.equal(breakdownRevisionLine(row), '')
|
||||
assert.equal(breakdownRevisionTitle(row), '')
|
||||
}
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: 2 })), '已修订 ×2')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: 1 })), '已修订 ×1')
|
||||
})
|
||||
|
||||
test('大屏小字:时间 + 已修订(原因),改过不止一次才写次数', () => {
|
||||
const one = item({
|
||||
revisionCount: 1,
|
||||
lastRevisionAt: '2026-09-13 10:42:00',
|
||||
lastRevisionReason: '成绩录入笔误',
|
||||
})
|
||||
// 库里存的是 UTC(SQLite datetime('now') 不带时区),按本地时区展示。
|
||||
const clock = new Date('2026-09-13T10:42:00Z')
|
||||
const hhmm = `${String(clock.getHours()).padStart(2, '0')}:${String(clock.getMinutes()).padStart(2, '0')}`
|
||||
assert.equal(breakdownRevisionLine(one), `${hhmm} 已修订(成绩录入笔误)`)
|
||||
assert.doesNotMatch(breakdownRevisionLine(one), /×1|null|undefined/)
|
||||
|
||||
const twice = item({ revisionCount: 2, lastRevisionAt: '2026-09-13 10:42:00' })
|
||||
assert.equal(breakdownRevisionLine(twice), `${hhmm} 已修订 ×2`)
|
||||
})
|
||||
|
||||
test('大屏小字:原因太长就截断,没写原因就只显示时间与「已修订」', () => {
|
||||
const long = breakdownRevisionLine(
|
||||
item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '裁判在后台把成绩点错了又重新改了一遍' }),
|
||||
)
|
||||
assert.match(long, /…)$/)
|
||||
assert.ok(long.length <= 30, `一行小字要短,得到 ${long.length} 字:${long}`)
|
||||
|
||||
const noReason = breakdownRevisionLine(item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00' }))
|
||||
assert.doesNotMatch(noReason, /(|null|undefined/)
|
||||
})
|
||||
|
||||
test('大屏小字:时间解析不出来时不写「--:--」,也不留前导空格', () => {
|
||||
const line = breakdownRevisionLine(item({ revisionCount: 1, lastRevisionAt: null, lastRevisionReason: '笔误' }))
|
||||
assert.equal(line, '已修订(笔误)')
|
||||
assert.equal(breakdownRevisionLine(item({ revisionCount: 1, lastRevisionAt: '不是时间' })), '已修订')
|
||||
})
|
||||
|
||||
test('管理端悬停:完整时间 + 次数 + 原因 + 操作人,缺哪段就少哪段', () => {
|
||||
// 悬停里是完整时间(本地时区),与大屏小字同一份 UTC 串。
|
||||
const date = new Date('2026-09-13T10:42:00Z')
|
||||
const stamp = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
date.getDate(),
|
||||
).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
assert.equal(
|
||||
breakdownRevisionTitle(
|
||||
item({
|
||||
revisionCount: 2,
|
||||
lastRevisionAt: '2026-09-13 10:42:00',
|
||||
lastRevisionReason: '成绩录入笔误',
|
||||
lastRevisedBy: '张三',
|
||||
}),
|
||||
),
|
||||
`${stamp} · 已修订 ×2(成绩录入笔误) · 张三`,
|
||||
)
|
||||
// 现场没写原因、操作人已删号:只留时间与次数,不编也不留空括号。
|
||||
assert.equal(
|
||||
breakdownRevisionTitle(item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00' })),
|
||||
`${stamp} · 已修订 ×1`,
|
||||
)
|
||||
// 时间也解析不出来时,至少还有次数。
|
||||
assert.equal(
|
||||
breakdownRevisionTitle(item({ revisionCount: 3, lastRevisionAt: 'x', lastRevisedBy: ' ' })),
|
||||
'已修订 ×3',
|
||||
)
|
||||
})
|
||||
@@ -83,6 +83,13 @@ export type ChannelBasis = 'result' | 'manual' | 'aggregate'
|
||||
*/
|
||||
export type AvgMode = 'valid' | 'all'
|
||||
|
||||
/**
|
||||
* 聚合通道(basis=aggregate)的汇总口径:
|
||||
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名。
|
||||
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」。
|
||||
*/
|
||||
export type AggregateMode = 'avg' | 'sum'
|
||||
|
||||
export interface ScoreChannel {
|
||||
id: number
|
||||
eventId: number
|
||||
@@ -90,6 +97,11 @@ export interface ScoreChannel {
|
||||
basis: ChannelBasis
|
||||
profileId: number
|
||||
sortOrder: number
|
||||
/**
|
||||
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和。
|
||||
* 缺字段 = avg:老服务端不下发它(缺省值不下发),归一化见 normalizeAggregateMode。
|
||||
*/
|
||||
aggregateMode?: AggregateMode
|
||||
/** false 表示该通道产生的分只记在个人头上,不进单位总分。 */
|
||||
countToTeam: boolean
|
||||
/**
|
||||
@@ -399,7 +411,15 @@ export interface TeamRef {
|
||||
}
|
||||
|
||||
export interface MedalRow extends TeamRef {
|
||||
/** 总积分榜名次(总分优先)。奖牌榜的名次是另一套,见 medalRank。 */
|
||||
rank: number
|
||||
/**
|
||||
* 单位奖牌榜的名次:金 → 银 → 铜 → 单位编码,与 totalPoints 无关。
|
||||
*
|
||||
* 服务端按这套口径算好下发(`domain.BuildMedalStandings`,REST / WS 快照 / xlsx 同源);
|
||||
* 前端只按它排序与显示,**不自己按金/银/铜重排**,否则名次会与导出件打架。
|
||||
*/
|
||||
medalRank: number
|
||||
gold: number
|
||||
silver: number
|
||||
bronze: number
|
||||
@@ -446,6 +466,20 @@ export interface BreakdownItem {
|
||||
resultText: string | null
|
||||
points: number
|
||||
createdAt: string
|
||||
/**
|
||||
* 这条成绩发布之后被改过几次(口径见服务端 `internal/revisions`)。
|
||||
*
|
||||
* `null` = 追不出来(聚合流水、人工调整、没有版本基线),`0` = 追出来了但没改过。
|
||||
* 只有 > 0 才显示「已修订」角标 —— 宁可不说,也不能把没改过的成绩标成改过。
|
||||
* 老服务端不下发这几个字段,所以都按可空解析(null)。
|
||||
*/
|
||||
revisionCount?: number | null
|
||||
/** 最后一次改动的时间(SQLite UTC 串,展示时按本地时区格式化)。 */
|
||||
lastRevisionAt?: string | null
|
||||
/** 最后一次改动的原因;现场没写就是 null,界面上不编。 */
|
||||
lastRevisionReason?: string | null
|
||||
/** 最后一次改动的操作人显示名;查不到(已删号 / 老服务端)就是 null。 */
|
||||
lastRevisedBy?: string | null
|
||||
}
|
||||
|
||||
export interface TeamBreakdown {
|
||||
@@ -844,6 +878,8 @@ export interface RuleChannel {
|
||||
scope: ChannelScope
|
||||
basis: ChannelBasis
|
||||
profile: string
|
||||
/** basis=aggregate 通道的汇总口径;缺省视为 avg(规则包不必写缺省值)。 */
|
||||
aggregateMode?: AggregateMode
|
||||
/** 缺省视为 true;「个人+团体」模板的个人通道会显式写 false。 */
|
||||
countToTeam?: boolean
|
||||
/** 缺省视为 true(该通道发奖牌);规则包显式写 false 才不发。 */
|
||||
|
||||
Reference in new issue
Block a user