feat(entry): 录入界面加「补报名」入口 —— 移出报名后可以重新指定项目/组别

需求(主人):「导入时给运动员指定了绑定的项目和组别,但在录入界面把该项报名取消之后,
没有任何地方可以把已录入的运动员重新指定项目报名。」

其实功能一直有(`EntryPicker`「从已有名单勾选报名」,支持小项+组别+个人/团体),
只是它只出现在「基础数据 → 报名导入」页里 —— 用户在哪儿取消的、就去哪儿找,这是入口位置问题。

- `EntryPage.vue`:工具栏右侧(组别页签同一排、表格正上方)加「补报名」按钮;
  只读/示例数据模式禁用;走与批量操作同口径的三道拦截(示例数据/只读/有未保存修改);
  点击时按需加载基础数据这份名单;提交后若提交的是本页当前小项则整表重拉并提示。
- `EntryPicker.vue`:新增 `dialog` / `presetEventId` / `presetCategoryId` 三个 props 与 `submitted` emit,
  预选当前小项与组别(组别对不上时回落到第一个)。**不传 props 时渲染与行为与以前完全一致**,
  「基础数据 → 报名导入」页的内联用法零改动(有测试钉着 `<EntryPicker />` 仍在)。
- 移出报名后的提示补一句指路「如需重新报名,点上方『补报名』…」;本组空了的空态同样指向「补报名」。
- 文档:使用手册 4.8 / 10.7 补操作路径,常见问题 Q91(批量移出那节)补「移出后想加回来」。

验证:`vue-tsc` 通过;前端 761 例全过(基线 754,新增 7)。
接口级 E2E(独立数据目录)走通「勾选报名 → 批量移出 → 补报名」,名单恢复 1 人、无重复建档、未写进别的组别。
浏览器 E2E(真实 Chromium + 临时同源代理)走通「按钮可见 → 打开同一 picker 且预选 100米/男子组 →
加入后出现在成绩表 → 移出提示 → 空态指路」,无 console 报错。
未验证:弹窗里「团体报名」页签的实际提交、窄屏布局、只读/示例数据两道拦截(仅代码核对)。
This commit is contained in:
AskaEth committed 2026-09-13 23:46:52 +08:00
1 parent a57df78f99
commit 2888200045
5 files changed
+321 -8

No files matched your search

@@ -0,0 +1,118 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
/**
* 录入界面必须能「补报名」。
*
* 背景:报名是在「基础数据 → 报名导入」里指定的,而录入界面又能把某人移出报名(批量移出、
* 或导入时漏报)。取消之后原先没有任何入口能把人加回来——「从已有名单勾选报名」只长在
* 报名导入页里,而操作员是在录入界面取消的,自然回录入界面找。
*
* 这里钉住五件事:入口在录入界面显眼处(工具栏里,与当前小项 / 组别同一视野)、
* 打开的是同一个 EntryPicker(不复制第二份勾选逻辑)、打开时预选当前小项与组别、
* 提交成功后重拉本组名单,以及「移出报名」之后的指路文案。
*
* .vue 跑不进 node --test(无 jsdom,见 entryGuards.ts:6),所以按仓库既有做法读源码断言。
*/
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const PICKER_SOURCE = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
const IMPORT_SOURCE = readFileSync(new URL('../components/baseData/ImportSection.vue', import.meta.url), 'utf8')
/** 截出函数体(到下一个顶格 `}` 为止),断言限定在这段里。 */
function bodyOf(source: string, header: string): string {
const start = source.indexOf(header)
assert.notEqual(start, -1, `找不到 ${header}`)
const end = source.indexOf('\n}', start)
assert.notEqual(end, -1, `${header} 没有正常闭合`)
return source.slice(start, end)
}
/** 工具栏那一段(从 .entry-toolbar 到它下面的录入进度条为止)。 */
function toolbarBlock(): string {
const start = ENTRY_SOURCE.indexOf('class="entry-toolbar"')
assert.notEqual(start, -1, '录入页里找不到 .entry-toolbar')
const end = ENTRY_SOURCE.indexOf('class="entry-progress"', start)
assert.notEqual(end, -1, '工具栏与录入进度条之间没有闭合')
return ENTRY_SOURCE.slice(start, end)
}
/** 录入页里那一处 <EntryPicker … /> 标签。 */
function pickerTag(): string {
const tag = /<EntryPicker[\s\S]*?\/>/.exec(ENTRY_SOURCE)
assert.ok(tag !== null, '录入页里找不到 <EntryPicker />')
return tag[0]
}
test('补报名按钮长在录入界面的工具栏里,和当前小项 / 组别同一视野', () => {
const toolbar = toolbarBlock()
const button = /<button[^>]*@click="openSupplement"[\s\S]*?<\/button>/.exec(toolbar)
assert.ok(button !== null, '工具栏里没有「补报名」按钮')
assert.match(button[0], /补报名/, '按钮文案要写清是「补报名」')
// 不能挂在勾选行上:刚把最后一个人移出报名时,一行都没得勾,按钮还得点得着。
assert.doesNotMatch(button[0], /selectedRows/, '补报名不该只在勾了行之后才出现')
// 工具栏在表格上方:取消报名的人就在这张表前,按钮得在同一屏里看得到。
assert.ok(
ENTRY_SOURCE.indexOf('class="entry-toolbar"') < ENTRY_SOURCE.indexOf('class="mx-table-wrap"'),
'补报名按钮要排在成绩表格之前',
)
})
test('补报名打开的是同一个 EntryPicker,没有复制第二份勾选逻辑', () => {
assert.match(ENTRY_SOURCE, /import EntryPicker from '\.\.\/components\/baseData\/EntryPicker\.vue'/)
assert.equal(
[...ENTRY_SOURCE.matchAll(/<EntryPicker/g)].length,
1,
'录入界面只该有一处 EntryPicker(复用那份实现,不要另写一份勾选界面)',
)
// 老用法照旧:报名导入页仍然是内联的那张卡片。
assert.match(IMPORT_SOURCE, /<EntryPicker \/>/, '基础数据 → 报名导入页的内联用法不能被改坏')
})
test('打开补报名时预选当前小项与组别(补的就是正在录的这组)', () => {
const tag = pickerTag()
assert.match(tag, /:dialog="true"/, '录入页里这一份要按弹窗形态渲染')
assert.match(tag, /:preset-event-id="selectedEventId"/)
assert.match(tag, /:preset-category-id="selectedCategoryId"/)
// 弹窗外壳自己套一层,内容仍是同一个组件。
assert.match(ENTRY_SOURCE, /v-if="supplementOpen" class="mx-modal-overlay open"/)
assert.match(ENTRY_SOURCE, /@click\.self="supplementOpen = false"/)
})
test('EntryPicker 收下预选:预选组别优先,对不上才落回第一个组别', () => {
assert.match(PICKER_SOURCE, /presetEventId\?: number \| null/)
assert.match(PICKER_SOURCE, /presetCategoryId\?: number \| null/)
const sync = bodyOf(PICKER_SOURCE, 'async function syncEvent(')
assert.match(sync, /presetCategory: number \| null = null/, 'syncEvent 要能收下预选组别')
assert.match(sync, /list\.find\(\(category\) => category\.id === presetCategory\) \?\? list\[0\]/)
const preset = bodyOf(PICKER_SOURCE, 'async function applyPreset()')
assert.match(preset, /props\.presetEventId/)
assert.match(preset, /props\.presetCategoryId/)
// 只有弹窗用法才同步预选,内联的那张卡片仍由用户自己选小项 / 组别。
assert.match(PICKER_SOURCE, /if \(props\.dialog\) void applyPreset\(\)/)
})
test('补报名成功后录入界面重拉本组名单', () => {
// 组件侧:一轮提交走完就把「哪个小项、哪个组别」交回调用方。
const submit = bodyOf(PICKER_SOURCE, 'async function importSelection')
assert.match(submit, /emit\('submitted', id, /)
// 页面侧:接住事件后整表重拉,刚补上的人才会出现在成绩表里。
assert.match(pickerTag(), /@submitted="onSupplementSubmitted"/)
const handler = bodyOf(ENTRY_SOURCE, 'async function onSupplementSubmitted')
assert.match(handler, /await reload\(\)/)
assert.match(handler, /if \(eventId !== selectedEventId\.value\) return/, '弹窗里改选了别的小项时不该刷本页')
})
test('移出报名后的提示里指路「补报名」', () => {
const hint = bodyOf(ENTRY_SOURCE, 'function batchOutcomeDetail(')
assert.match(hint, /如需重新报名,点上方「补报名」/)
// 只有移出报名才指这句路:退赛 / 恢复本来就能用「批量恢复」撤回。
assert.match(hint, /if \(action !== 'remove'\) return outcome/)
assert.match(ENTRY_SOURCE, /toast\.success\(BATCH_SUCCESS_TITLE\[plan\.action\], batchOutcomeDetail\(result, plan\.action\)\)/)
})
test('本组一条报名项都没有时,空态也指向「补报名」', () => {
assert.match(ENTRY_SOURCE, /点上方「补报名」从已有名单勾选,或到「基础数据 → 报名导入」上传报名表/)
})