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:
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, /点上方「补报名」从已有名单勾选,或到「基础数据 → 报名导入」上传报名表/)
|
||||
})
|
||||
Reference in new issue
Block a user