fix(entry): 回到录入页时记住上次的小项(按 id);feat: 新增「清空本组 / 本批成绩」;版本 dev_0.5.12

## 一、修「改小项显示名称后,撤销发布按钮消失」

**根因不是改名字。** 我一开始怀疑「某处按 name 关联」,验下来那条路是干净的:
加载录入页报名行的查询、成绩查询、引擎读数据全是按 event_id / category_id / code 关联的
(并补了一条「小项名 + 组别名一起改」的回归用例,证明录入页状态 / 流水 / 榜单逐字节不变)。

真机制:**进入录入页时不记得上次录的是哪个小项**,兜底挑「第一个**待录入**(pending)的小项」。
而**刚发布完的小项状态已经是 published**,恰好被这条兜底排除 → 回去时右侧换成了**另一个小项**,
而「撤销发布」按钮只按「当前表里有没有已发布的行」显示,自然就不在。

⇒ 现场看到的「左栏仍显示已发布、单位成绩仍有加分、但按钮没了」**三条本来不矛盾 ——
它们说的是两个不同的小项**。

**修法**:`pickInitialEventId()` 记住上次录入的小项(**按 id** 存 localStorage),
优先级 = URL 查询参数 → 上次录入的(**不排除已发布**)→ 第一个 pending → 列表第一个;
记忆失效(小项被删、隐私模式读不到存储)一律退回原兜底,不把人挡在录入页外。
**纯前端,零数据迁移。**

真机复现与复验(CDP 驱动真实浏览器):
- 修前:「离开再回来 → 换成了别的小项、没有撤销发布按钮」;
- 修后:「离开再回来 → 仍是那个小项、按钮在」。

## 二、新增「清空本组 / 本批成绩」(危险操作)

`POST /api/results/clear`(recorder 及以上 / CSRF / 幂等):
- `heat` **省略 = 清整组**、数字 = 该批次、显式 `null` = 只清未分批的行(三种要能分清);
- `confirm` 必须为 `true`(**服务端兜底**「没勾选不让提交」,不只是前端拦);
- **不提供「清空整个小项」** —— 爆炸半径太大;
- 语义 = **先撤销发布 + 再清内容**:成绩行整行归零 → **清整组时一并清掉「团体名次」**
  (否则会出现「清空了、榜上还有分」)→ 作废旧流水并按剩余已发布内容重写 →
  **小项状态对称退回**(只在原为 published 时降级,正好避免「左栏写着已发布却没有已发布成绩」)
  → 写审计 + 推榜。

界面:工具栏「清空本组」常驻;「清空本批」只在筛了批次时出现(没筛批次时它等于清整组,不重复给);
两粒都是危险色,示例数据模式 / 访客只读时禁用并说明原因。

**与「撤销发布」的区别**(界面上都写清了,很容易混):
- **撤销发布**:成绩内容**保留**,只把分收回;
- **清空**:成绩内容**一起清掉**,分也收回。

## 三、顺带:按名字关联的同类扫描

扫了 13 处「按名字关联」的地方并逐条判定,结论:
**数据关联一律按 id/code**;只有当「人写的名字要在表里对齐」时才按名字
(导入报名表里的组别名、Excel 工作表名、缺证件号时的同名兜底)——
那几处是**有意为之**、报错也能照着改,**未改**。

## 四、验证

- 全量自检两轮**全绿、退出码 0**:服务端 14 包 / 前端 **974 例** 0 fail / Console 96 例 0 fail。
- 新增用例:`event_rename_publish_test.go`(改名后状态/流水/榜单逐字节不变)、
  `entry-initial-event.test.ts`(8 例)、`results_clear_test.go`、`entry-clear.test.ts`。

## 版本

dev_0.5.11 → dev_0.5.12(无数据迁移)。
This commit is contained in:
AskaEth committed 2026-09-17 01:14:18 +08:00
1 parent 7a64e83ecf
commit 72362239c6
20 files changed
+1448 -18

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
CLEAR_ACK_LABEL,
CLEAR_BATCH_LABEL,
CLEAR_GROUP_LABEL,
CLEAR_RESULT_TITLE,
clearConfirmDetails,
clearPayload,
clearScopeOf,
clearScopeWord,
clearSummary,
canSubmitClear,
keepPublishedNote,
} from '../pages/entryClear'
/**
* 「清空当前组 / 批次成绩」的判定与文案(危险操作)。
*
* 语义(主人定的):「清空」= **先撤销发布**(已发布的收回、该组产生的积分流水作废)
* **+ 把成绩内容清空**(名次 / 成绩值 / 备注都清,回到「没录过」)。
* 与「撤销发布」必须分得清:撤销发布**保留成绩内容**、只把分数收回;清空连内容一起清掉。
*
* 所以这里钉三件事:
* 1. 作用范围与请求体:整组**不带** heat 键,批次带数字,未分批带 null(三者在服务端是三条路);
* 2. 确认框要写明「清多少条、哪个小项哪个组 / 批、已发布的分会被收回」;
* 3. 没勾选「我知道这会清掉已发布的分数」不许提交。
*/
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
const row = (heat: number | null, state: string | null) => ({ heat, resultState: state })
test('作用范围:全部 = 整组,数字 = 该批次,未分批 = 只清未分批的行', () => {
assert.equal(clearScopeOf('all'), 'group')
assert.equal(clearScopeOf(2), 'batch')
assert.equal(clearScopeOf('none'), 'unbatched')
})
test('请求体:整组不带 heat 键,批次带数字,未分批带 null', () => {
const group = clearPayload({ eventId: 4, categoryId: 3, filter: 'all', reason: '' })
assert.equal('heat' in group, false, '整组不能带 heat:服务端把「有 heat 键」当批次处理')
assert.equal(group.eventId, 4)
assert.equal(group.categoryId, 3)
const batch = clearPayload({ eventId: 4, categoryId: 3, filter: 2, reason: '现场重赛' })
assert.equal(batch.heat, 2)
assert.equal(batch.reason, '现场重赛')
const unbatched = clearPayload({ eventId: 4, categoryId: 3, filter: 'none', reason: '' })
assert.equal('heat' in unbatched, true)
assert.equal(unbatched.heat, null)
})
test('请求体一律带 confirm:true —— 服务端也要求勾选,界面禁用只是第一道', () => {
const payload = clearPayload({ eventId: 1, categoryId: 2, filter: 'all', reason: '' })
assert.equal(payload.confirm, true)
})
test('条数统计:按当前可见行算,已发布条数单独数出来', () => {
const rows = [row(1, 'published'), row(1, 'draft'), row(2, 'published')]
assert.deepEqual(clearSummary(rows), { cleared: 3, published: 2 })
assert.deepEqual(clearSummary([]), { cleared: 0, published: 0 })
})
test('称呼:整组说「本组」,批次说「第 N 批」,未分批说「未分批」', () => {
assert.equal(clearScopeWord('group', null), '本组')
assert.equal(clearScopeWord('batch', 2), '第 2 批')
assert.equal(clearScopeWord('unbatched', null), '未分批')
})
test('按钮文案逐字:两个粒度各一个入口,且点明是「清空」不是「撤销发布」', () => {
assert.equal(CLEAR_GROUP_LABEL, '清空本组')
assert.equal(CLEAR_BATCH_LABEL, '清空本批')
assert.equal(CLEAR_RESULT_TITLE, '清空该组成绩')
assert.notEqual(CLEAR_GROUP_LABEL, '撤销发布')
})
test('确认框明细:点名小项与组别、清多少条、其中多少条已发布、分数被收回', () => {
const details = clearConfirmDetails({
eventName: '100米跑',
categoryName: '男子',
scope: 'batch',
heat: 2,
cleared: 8,
published: 8,
teamRanks: 0,
})
const text = details.join('\n')
assert.match(text, /100米跑/)
assert.match(text, /男子/)
assert.match(text, /第 2 批/)
assert.match(text, /8 条/)
assert.match(text, /已发布 8 条/)
assert.match(text, /收回/)
// 与「撤销发布」的区别必须写在框里,不能让人以为两者一样。
assert.match(text, /撤销发布/)
})
test('清整组时会点名「团体名次一并清空」,清批次时不会', () => {
const group = clearConfirmDetails({
eventName: '篮球', categoryName: '男子', scope: 'group', heat: null,
cleared: 4, published: 4, teamRanks: 6,
}).join('\n')
assert.match(group, /团体名次/)
const batch = clearConfirmDetails({
eventName: '篮球', categoryName: '男子', scope: 'batch', heat: 1,
cleared: 4, published: 4, teamRanks: 0,
}).join('\n')
assert.doesNotMatch(batch, /团体名次/)
})
test('一条已发布都没有时如实说「不涉及榜单变化」,不吓唬人也不谎报', () => {
const details = clearConfirmDetails({
eventName: '100米跑', categoryName: '男子', scope: 'group', heat: null,
cleared: 3, published: 0, teamRanks: 0,
}).join('\n')
assert.match(details, /没有已发布的成绩/)
// 「本批没有已发布的分」时不该出现「会一并收回」这种后果描述(那句话在别的分支里)。
assert.doesNotMatch(details, /会一并收回/)
assert.match(keepPublishedNote(), /撤销发布/)
})
test('没勾选确认时不允许提交', () => {
assert.equal(canSubmitClear(false), false)
assert.equal(canSubmitClear(true), true)
assert.equal(CLEAR_ACK_LABEL, '我知道这会清掉已发布的分数')
})
test('录入页接线:两个入口 + 勾选门禁 + 审计(清空走 /results/clear)', () => {
// 两个粒度的入口都在工具栏(发布按钮附近),清空本批只在选了批次时出现。
assert.match(ENTRY_SOURCE, /v-if="heatScoped"[\s\S]{0,400}openClear\('batch'\)/)
assert.match(ENTRY_SOURCE, /\{\{ CLEAR_BATCH_LABEL \}\}/)
assert.match(ENTRY_SOURCE, /openClear\('group'\)/)
assert.match(ENTRY_SOURCE, /\{\{ CLEAR_GROUP_LABEL \}\}/)
// 勾选门禁与确认框接线。
assert.match(ENTRY_SOURCE, /:ack-required="true"/)
assert.match(ENTRY_SOURCE, /adminApi\.clearResults\(/)
})
test('权限与录成绩同档:访客只读与示例数据模式都置灰,并在 title 里说明原因', () => {
// 两颗按钮都跟着 canWrite / isMock 走(与「撤销发布」「存草稿」同一把尺子)。
const groupBtn = ENTRY_SOURCE.slice(ENTRY_SOURCE.indexOf('openClear(\'group\')') - 900)
assert.match(groupBtn, /:disabled="!canWrite \|\| data\.isMock"/)
assert.match(groupBtn, /示例数据模式下不能清空成绩/)
assert.match(groupBtn, /需要登录后才能清空成绩/)
// 访客只读时的入口:置灰 + 说明,而不是点了才被告知。
assert.match(ENTRY_SOURCE, /toast\.error\('当前不可清空成绩', auth\.readOnly \? '请先登录' : '服务端未连接'\)/)
})
test('确认框支持「必须勾选」:勾选框未勾上时确认键不可点', () => {
assert.match(DIALOG_SOURCE, /ackRequired/)
assert.match(DIALOG_SOURCE, /:disabled="pending \|\| ackMissing"/)
})
@@ -0,0 +1,126 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
LAST_ENTRY_EVENT_KEY,
entryMemoryStorage,
pickInitialEventId,
readLastEntryEventId,
rememberEntryEventId,
} from '../pages/entryInitialEvent'
/**
* 进入录入页时选哪个小项。
*
* 现场踩到的坑:在录入页录完成绩、发布、去基础数据改了显示名称,回来时「撤销发布」按钮
* 不见了——右侧表里根本不是那个小项。原因是进入页面时**不记得上次录的是哪个小项**,
* 兜底挑「第一个待录入(pending)的小项」,而刚发布完的小项状态已经是 published,
* 恰好被这条兜底排除掉。
*
* 所以这里钉死两件事:
* 1. 上次录入的小项要按 **id** 记住并优先选回来(改名字不影响,名字根本不参与判断);
* 2. 记忆失效(小项被删、存储读不出来)时,行为退回改动前那套兜底,不因为一条坏记忆卡住录入。
*
* 项目没有组件测试工具,判定逻辑抽在 entryInitialEvent 里直接测,界面接线读源码断言。
*/
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */
function memoryStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial))
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => void map.set(key, value),
dump: () => Object.fromEntries(map),
}
}
test('上次录入的小项即使已经发布,回到录入页也要选回它', () => {
// 4 号是刚发布完的那个(status=published),3 号是另一个待录入的小项。
const events = [
{ id: 3, status: 'pending' },
{ id: 4, status: 'published' },
]
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 4, events }), 4)
})
test('按 id 认小项:名字改了、同名了都照样选回原来那一个', () => {
// 两个同名小项(现场确实会有「预赛 / 决赛」这类重名),只认 id。
const events = [
{ id: 7, status: 'pending' },
{ id: 9, status: 'pending' },
]
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 9, events }), 9)
})
test('URL 带 eventId 时优先于记忆', () => {
const events = [
{ id: 3, status: 'pending' },
{ id: 4, status: 'published' },
]
assert.equal(pickInitialEventId({ fromQuery: 3, lastId: 4, events }), 3)
})
test('记忆里的小项已经被删掉时,退回原来的兜底:第一个待录入,其次第一个', () => {
const events = [
{ id: 3, status: 'finished' },
{ id: 4, status: 'pending' },
]
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 99, events }), 4)
assert.equal(
pickInitialEventId({ fromQuery: null, lastId: 99, events: [{ id: 3, status: 'finished' }] }),
3,
)
assert.equal(pickInitialEventId({ fromQuery: null, lastId: 99, events: [] }), null)
})
test('没有记忆、URL 也没带时,行为与改动前一致(第一个 pending)', () => {
const events = [
{ id: 3, status: 'ongoing' },
{ id: 4, status: 'pending' },
]
assert.equal(pickInitialEventId({ fromQuery: null, lastId: null, events }), 4)
assert.equal(pickInitialEventId({ fromQuery: null, lastId: null, events: [{ id: 3, status: 'ongoing' }] }), 3)
})
test('记忆的读写:只存数字 id,脏数据一律当成没有记忆', () => {
const storage = memoryStorage()
assert.equal(readLastEntryEventId(storage), null)
rememberEntryEventId(storage, 12)
assert.equal(storage.dump()[LAST_ENTRY_EVENT_KEY], '12')
assert.equal(readLastEntryEventId(storage), 12)
// 非数字 / 负数 / 空白 → 不认(会被当成「没有记忆」,走兜底)
for (const bad of ['abc', '', ' ', '-1', '0', '3.5', 'null']) {
assert.equal(readLastEntryEventId(memoryStorage({ [LAST_ENTRY_EVENT_KEY]: bad })), null, bad)
}
// 清空(null)时把键写空串,读回来同样是「没有记忆」
rememberEntryEventId(storage, null)
assert.equal(readLastEntryEventId(storage), null)
})
test('存储不可用(隐私模式 / 禁用存储)时不抛错,只是没有记忆', () => {
const throwing = {
getItem: () => {
throw new Error('禁用了存储')
},
setItem: () => {
throw new Error('禁用了存储')
},
}
assert.equal(readLastEntryEventId(throwing), null)
assert.doesNotThrow(() => rememberEntryEventId(throwing, 5))
assert.equal(readLastEntryEventId(null), null)
assert.doesNotThrow(() => rememberEntryEventId(null, 5))
// node 里没有 window.localStorage:取存储这一步本身也不能抛。
assert.equal(entryMemoryStorage(), null)
})
test('录入页接线:进入时用记忆,成功加载后写回记忆(按 id)', () => {
assert.match(ENTRY_SOURCE, /readLastEntryEventId\(entryMemoryStorage\(\)\)/)
assert.match(ENTRY_SOURCE, /rememberEntryEventId\(entryMemoryStorage\(\), id\)/)
// 兜底仍是「第一个 pending」,但不再是进入页面时的第一顺位。
assert.match(ENTRY_SOURCE, /lastId:/)
})
@@ -50,12 +50,16 @@ test('两个按钮在示例数据模式下直接禁用,避免点了才被告
assert.match(ENTRY_PAGE_SOURCE, /:disabled="!canWrite \|\| data\.isMock"/)
})
test('「继续录入」的注释按实现写:第一个 pending,没有持久化的「上次编辑」', () => {
test('「继续录入」优先选上次录入的小项(按 id),兜底才是第一个 pending', () => {
const pickInitial = section(ENTRY_PAGE_SOURCE, '进入录入页时默认选中小项', 'onMounted(')
assert.doesNotMatch(pickInitial, /默认聚焦上次编辑/, '实现里没有「上次编辑」的记忆,注释不能那么写')
// 上次录入的小项按 id 记在本地:刚发布完的那个小项状态已是 published,
// 只按「第一个 pending」兜底会把它排除掉,回录入页就换成了别的小项(撤销发布按钮跟着消失)。
assert.match(pickInitial, /lastId: readLastEntryEventId\(entryMemoryStorage\(\)\)/)
assert.match(pickInitial, /events: data\.events/)
// 兜底行为没变:没有记忆时仍是「第一个未开始(pending)的项目,都没有就取列表第一个」。
assert.match(pickInitial, /第一个未开始(pending)的项目/)
assert.match(pickInitial, /event\.status === 'pending'/)
assert.match(pickInitial, /pickInitialEventId/)
})
test('整份 EntryPage 里不再残留「默认聚焦上次编辑」这类与实现不符的说法', () => {