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

+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;