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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user