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:
AskaEth committed 2026-09-13 15:20:18 +08:00
1 parent 78b14efaa9
commit 877ff15823
181 files changed
+8747 -371

No files matched your search

+41 -20
View File
@@ -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,
})
}
}
+11 -6
View File
@@ -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),
+187 -47
View File
@@ -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;
+81 -8
View File
@@ -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;
+56 -12
View File
@@ -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'/)
})
+6 -1
View File
@@ -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('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
+197 -33
View File
@@ -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]}`)
})