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

@@ -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]}`)
})