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

@@ -33,13 +33,37 @@ import {
*
* 勾选结果不走新接口:转成与导入表头一致的 CSV 后复用 /entries/import,
* 服务端的匹配、upsert 与行级校验只有一份实现。
*
* 两种用法共用这一份实现:
* - 内联:基础数据 → 报名导入页里的一张常驻卡片(不传 `dialog`,行为与过去一致);
* - 弹窗:成绩录入页的「补报名」入口(传 `dialog` + 预选的小项 / 组别),外壳由调用方按
* 页面里既有的 .mx-modal-overlay 写法套一层,这里只多做一件事——把预选的组别带进来。
*/
const props = withDefaults(
defineProps<{
/** 弹窗形态:由调用方包在弹窗里。只影响两处——卡片去掉自己的底色与阴影,以及打开时同步预选。 */
dialog?: boolean
/** 预选小项:调用方正选着它,补报名补的就是它。 */
presetEventId?: number | null
/** 预选组别:与预选小项配套。 */
presetCategoryId?: number | null
}>(),
{ dialog: false, presetEventId: null, presetCategoryId: null },
)
const emit = defineEmits<{
/** 一轮提交走完(成功的行已写入),带回这次动的是哪个小项 / 哪个组别,调用方据此刷新自己的列表。 */
submitted: [eventId: number, categoryId: number | null]
}>()
const data = useBaseData()
const toast = useToastStore()
const eventId = ref('')
const categoryId = ref('')
/** 换小项时一次性带过去的预选组别:只有弹窗入口会放值,用完即清。 */
const pendingPresetCategory = ref<number | null>(null)
const entries = ref<EntryWithResult[]>([])
const entriesLoading = ref(false)
const submitting = ref(false)
@@ -237,6 +261,8 @@ async function importSelection(rows: string[][], id: number): Promise<void> {
}
if (result.errors.length > 0) error.value = result.errors.join(';')
await loadEntries(id)
// 提交结果交给调用方:成绩录入页要靠它重拉正在录的那一组(补上的人得立刻出现在表格里)。
emit('submitted', id, categoryId.value === '' ? null : Number(categoryId.value))
}
function resetTeamPick(): void {
@@ -252,7 +278,13 @@ function resetSelection(): void {
resetTeamPick()
}
async function syncEvent(id: string): Promise<void> {
/**
* 换小项后重拉组别与已报名;`presetCategory` 是弹窗入口带进来的预选组别。
*
* 预选组别只在这个小项下确实有这一组时才生效(调用方可能刚换过小项、组别已对不上),
* 否则照旧落在第一个组别上,不会留下一个选不中的空下拉。
*/
async function syncEvent(id: string, presetCategory: number | null = null): Promise<void> {
resetSelection()
categoryId.value = ''
entries.value = []
@@ -262,17 +294,45 @@ async function syncEvent(id: string): Promise<void> {
if (id === '') return
// loadEventDetail 自带缓存,重复选同一个小项不会二次请求。
await data.loadEventDetail(Number(id))
const first = data.categoriesOf(Number(id))[0]
categoryId.value = first === undefined ? '' : String(first.id)
const list = data.categoriesOf(Number(id))
const target = list.find((category) => category.id === presetCategory) ?? list[0]
categoryId.value = target === undefined ? '' : String(target.id)
// 换小项就回到该类型对应的部分,省得在上一部分里继续填。
tab.value = eventMode.value
await loadEntries(Number(id))
}
watch(eventId, (id) => {
void syncEvent(id)
const preset = pendingPresetCategory.value
pendingPresetCategory.value = null
void syncEvent(id, preset)
})
/**
* 弹窗入口打开时把调用方正录着的小项 / 组别带进来。
*
* 预选的小项与当前值相同时不会触发上面那个 watch(值没变),所以这里要自己同步一次;
* 不同时就交给 watch 走,把预选组别挂到 `pendingPresetCategory` 上一起带过去,避免同步两遍。
*/
async function applyPreset(): Promise<void> {
const event = props.presetEventId
if (event === null) return
if (eventId.value === String(event)) {
await syncEvent(String(event), props.presetCategoryId)
return
}
pendingPresetCategory.value = props.presetCategoryId
eventId.value = String(event)
}
watch(
() => [props.dialog, props.presetEventId, props.presetCategoryId] as const,
() => {
if (props.dialog) void applyPreset()
},
{ immediate: true },
)
// 换组别 / 换单位后勾选不再适用,清空避免把上一组的名单带过来。
watch([categoryId, individualTeamId], () => {
checked.value = []
@@ -341,9 +401,10 @@ async function submitTeam(): Promise<void> {
</script>
<template>
<section class="mx-card">
<!-- 弹窗形态下卡片与弹窗同色,去掉卡片自己的底色与阴影,避免两层浮层叠在一起。 -->
<section class="mx-card" :class="{ 'is-dialog': dialog }">
<div class="mx-card-head">
<span class="mx-card-title">从已有名单勾选报名</span>
<span class="mx-card-title">{{ dialog ? '从已有名单勾选' : '从已有名单勾选报名' }}</span>
<span v-if="tab === 'individual' && individualTeamRegistered" class="mx-badge mx-badge-warning">该单位已报名</span>
<span v-else-if="tab === 'team' && pickedTeamCount > 0" class="mx-badge mx-badge-info">
已勾选 {{ pickedTeamCount }} 个单位
@@ -589,6 +650,12 @@ async function submitTeam(): Promise<void> {
</template>
<style scoped>
/* 弹窗里卡片不再是一层浮层:底色、阴影与悬浮效果都交给外面的 .mx-modal。 */
.mx-card.is-dialog,
.mx-card.is-dialog:hover {
background: transparent;
box-shadow: none;
}
.picker-tabs {
margin-bottom: 14px;
}
+126 -2
View File
@@ -46,6 +46,7 @@ import {
submitEntryBatch,
type EntryBatchAction,
type EntryBatchPlan,
type EntryBatchResult,
type EntryBatchRow,
} from './entryBatch'
import {
@@ -74,7 +75,9 @@ import {
} from './entryHeat'
import { adminApi, sampleDataBlockedNotice } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import EntryPicker from '../components/baseData/EntryPicker.vue'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { useBaseData } from '../components/baseData/useBaseData'
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -176,6 +179,13 @@ const heatSizeInput = ref(String(DEFAULT_HEAT_SIZE))
const heatBusy = ref(false)
const splitDialog = ref(false)
const splitMessage = ref('')
/**
* 「补报名」弹窗:录入页这边取消过报名(批量移出 / 导入时漏报)后,回到这里就能把已有名单上的人
* 重新勾进当前小项的当前组别。弹窗内容直接复用基础数据 → 报名导入页的 EntryPicker。
*/
const supplementOpen = ref(false)
/** EntryPicker 读的是基础数据(运动员 / 参赛单位 / 小项组别),录入页平时不加载这一份,打开时补一次。 */
const baseData = useBaseData()
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
/** 未保存修改的确认框:切换项目与离开页面共用同一个,风格与发布确认一致。 */
const discardDialog = ref(false)
@@ -197,6 +207,10 @@ const eventGroups = computed(() => {
const selectedEvent = computed(() => detail.value ?? (selectedEventId.value === null ? null : (data.eventById(selectedEventId.value) ?? null)))
const categories = computed(() => detail.value?.categories ?? [])
const channels = computed(() => detail.value?.channels ?? [])
/** 补报名弹窗要在标题下点名「补的是哪一组」,组别名按当前选中组别取。 */
const currentCategoryName = computed(
() => categories.value.find((category) => category.id === selectedCategoryId.value)?.name ?? '—',
)
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
@@ -900,6 +914,48 @@ async function reload(): Promise<void> {
}
}
// ── 补报名(复用基础数据 → 报名导入页的「从已有名单勾选」) ────────────────────
/**
* 打开补报名弹窗。
*
* 三道前置检查与批量操作、行操作同一口径:示例数据模式写不进真实赛事、只读身份不能写、
* 有未保存的成绩时先落草稿——提交成功后要整表重拉,没落草稿的编辑会被服务端口径覆盖掉。
*/
async function openSupplement(): Promise<void> {
if (data.isMock) {
const notice = sampleDataBlockedNotice('补报名')
toast.error(notice.title, notice.detail)
return
}
if (!canWrite.value) {
toast.error('当前不可报名', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (dirty.value) {
toast.error('有未保存的修改', '补报名会刷新整表,请先「存草稿」/「保存团体名次」再操作')
return
}
supplementOpen.value = true
// 勾选名单要的是基础数据这一份(运动员 / 参赛单位 / 组别),load 自带缓存,重复打开不会重复请求。
await baseData.load()
}
/**
* 补报名成功:重拉当前小项的报名项。
*
* 服务端建完报名会连带重算名次与积分,整表重载才能同时拿到新行与新口径;只在本地拼一行会留下
* 「表格里有人、名次还是旧的」这种半成品。弹窗里预选的就是当前小项,所以正常情况下提交的
* eventId 就是它;万一在弹窗里换了别的小项,本页不动(那是另一张表的事)。
*/
async function onSupplementSubmitted(eventId: number, categoryId: number | null): Promise<void> {
if (eventId !== selectedEventId.value) return
await reload()
if (categoryId === null || categoryId === selectedCategoryId.value) {
toast.success('补报名成功,本组名单已刷新', '刚补上的人已经排进本组表格')
}
}
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
@@ -1373,6 +1429,18 @@ function cancelBatchAction(): void {
batchPlan.value = null
}
/**
* 批量操作成功提示的副标题。
*
* 「移出报名」是物理删除、界面上没有撤销键,所以这里多指一句路:要重新报名不必去翻基础数据页,
* 录入界面上就有「补报名」。
*/
function batchOutcomeDetail(result: EntryBatchResult, action: EntryBatchAction): string {
const outcome = entryBatchOutcome(result)
if (action !== 'remove') return outcome
return `${outcome};如需重新报名,点上方「补报名」从已有名单重新勾选`
}
async function confirmBatchAction(): Promise<void> {
const plan = batchPlan.value
if (plan === null || batchBusy.value) return
@@ -1393,7 +1461,7 @@ async function confirmBatchAction(): Promise<void> {
failed.map((item) => `${item.id}:${item.error ?? '失败'}`).join(';'),
)
} else {
toast.success(BATCH_SUCCESS_TITLE[plan.action], entryBatchOutcome(result))
toast.success(BATCH_SUCCESS_TITLE[plan.action], batchOutcomeDetail(result, plan.action))
}
} catch (caught) {
toast.error('批量操作失败', caught instanceof Error ? caught.message : null)
@@ -1789,6 +1857,23 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<span v-if="stateCounts.error > 0" class="mx-badge mx-badge-error">有错 {{ stateCounts.error }}</span>
</span>
<span v-if="selectedEvent.resultType !== 'none'" class="unit-hint mono">单位:{{ unit || '—' }}</span>
<!--
补报名:取消过报名的人要重新报上,入口就在这里(当前小项 / 组别同一视野内),
不必再跑去「基础数据 → 报名导入」找。打开的是同一个 picker,预选当前小项与组别。
-->
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!canWrite || data.isMock"
:title="
data.isMock
? '示例数据模式下不能补报名'
: '从已有名单勾选,为当前小项的当前组别补录报名项(复用「基础数据 → 报名导入」的勾选界面)'
"
@click="openSupplement"
>
<MxIcon name="entry" :size="14" /> 补报名
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="autoSort">
<MxIcon name="adjust" :size="14" /> 按成绩重排
</button>
@@ -2197,7 +2282,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<div class="mx-empty-desc">
{{
categoryRows.length === 0
? '请先导入报名表或在基础数据里添加'
? '点上方「补报名」从已有名单勾选,或到「基础数据 → 报名导入」上传报名表'
: '换一个批次筛选,或选「全部」'
}}
</div>
@@ -2337,6 +2422,38 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@cancel="publishDialog = false"
/>
<!--
补报名:外壳照页面里既有的写法(RecordsPage 的新增纪录弹窗)自己套一层,
里面的勾选界面直接复用基础数据 → 报名导入页的 EntryPicker,预选当前小项与组别。
做成弹窗而不是第二个页面,是因为补报名就发生在录入过程中间,不该把人从这张表上带走。
-->
<div v-if="supplementOpen" class="mx-modal-overlay open" @click.self="supplementOpen = false">
<div class="mx-modal mx-modal-xl" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">补报名</span>
<button class="mx-modal-close" type="button" @click="supplementOpen = false">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="supplement-hint">
给<strong>{{ selectedEvent?.name }}</strong> · <strong>{{ currentCategoryName }}</strong>
补录报名项:勾选的人 / 单位立即加入本组,下面的成绩表会跟着刷新。要补的是别的小项或组别,
在弹窗里改选即可。
</p>
<EntryPicker
:dialog="true"
:preset-event-id="selectedEventId"
:preset-category-id="selectedCategoryId"
@submitted="onSupplementSubmitted"
/>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" @click="supplementOpen = false">关闭</button>
</div>
</div>
</div>
<ConfirmDialog
:open="unpublishDialog"
tone="danger"
@@ -2431,6 +2548,13 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
</template>
<style scoped>
/* 补报名弹窗的说明行:与弹窗正文同宽,先点名「补的是哪一组」再让人勾选。 */
.supplement-hint {
margin-bottom: 14px;
font-size: 13px;
line-height: 1.6;
color: var(--mx-t2);
}
.mock-note {
display: flex;
align-items: center;
@@ -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, /点上方「补报名」从已有名单勾选,或到「基础数据 → 报名导入」上传报名表/)
})