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
@@ -213,6 +213,7 @@
|
||||
|
||||
- **批量退赛 / 恢复**会**立即**按新名次重算积分并推到大屏——**延迟结算(见 10.5)也照算**,因为退赛 / 恢复改的是「谁还算数」,不是「录了多少」,名次和分数必须当场跟着改。已经是「取消资格(dq)」「未出场(dns)」的行会被**跳过、不覆盖原判定**(跳过的人会在确认框里逐条列出原因,例如「已被判罚取消资格(dq),跳过」)。
|
||||
- **批量移出报名**的确认框会列出**将被删除的人**(姓名|号码布|单位,超过 20 人只列前 20 并报剩余数量)以及**将重算的小项与组别**——看完再确认,删了不能恢复;删除后同样**立即重算并推榜**,不受延迟结算影响。
|
||||
- **移出之后想重新报名**:不用跑回「基础数据 → 报名导入」——录入界面的工具栏右侧就有 **「补报名」** 按钮(和当前小项、组别在同一屏里)。点开后是同一套「从已有名单勾选」界面,并**预选了当前小项与当前组别**;勾选完点「加入报名」,本组名单会立刻刷新,补上的人直接出现在成绩表里。详见 10.7。
|
||||
- 一次最多处理 **500 条**报名项,超出请分批提交;有未保存的成绩修改时,请先「存草稿」再批量操作;示例数据模式下不能批量操作。
|
||||
|
||||
### 4.9 团体名次(按单位录入)
|
||||
@@ -657,6 +658,8 @@
|
||||
|
||||
报名有两种方式,都在本页完成:**从已有名单勾选**适合零散补报,**表格导入**适合批量录入。
|
||||
|
||||
> 本页的「从已有名单勾选」在**成绩录入页的工具栏里也有一个入口**(按钮叫 **「补报名」**),打开的是同一套勾选界面,只是预选了你正在录的那个小项与组别——在录入界面把某人移出报名之后,就地补回来即可(见 4.8)。
|
||||
|
||||
**从已有名单勾选**
|
||||
|
||||
这张卡片分成「个人报名 / 团体报名」两个页签,页签下方的小项与组别选择是两个页签共用的。选中某个小项后会自动切到与该小项类型相符的页签(个人项目 → 个人报名;团体 / 对抗 / 个人+团体 → 团体报名),也可以手动切换;切到的页签与小项类型不符时页面会提示一句,生成的报名以当前页签为准。
|
||||
|
||||
@@ -564,6 +564,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
|
||||
- **批量退赛**:标记弃权(0 分、不占名次),**随时可以批量恢复**;已经是「取消资格 / 未出场」的行会被跳过、不覆盖原判定;
|
||||
- **批量移出报名**:**物理删除**报名项连同他的成绩与积分流水,**不可恢复**,确认框会列出将被删的人与将重算的小项 / 组别;
|
||||
- **移出后想加回来**:还在「成绩录入」页,点工具栏右侧的 **「补报名」**——打开的是「基础数据 → 报名导入」里的同一套勾选界面,并预选当前小项与组别,勾完点「加入报名」本组名单立刻刷新(见使用手册 4.8 / 10.7)。**退赛**的人不用走这条路:直接「批量恢复」即可。
|
||||
- 一次最多 500 条;有未保存的成绩修改时请先「存草稿」再操作。
|
||||
- 这三个动作(以及**撤销发布**、**作废单条成绩**、**改积分模板 / 组别权重 / 计分通道**)都**立即重算并推榜**,**不受「分批发布时实时更新榜单」这个延迟结算开关影响**——它们改的是「谁还算数」「按什么规则算」,不是「录了多少」,留着旧分就会变成「榜上还有分、成绩却撤了」。延迟结算只作用于**发布成绩**这一步(见 Q90)。
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, /点上方「补报名」从已有名单勾选,或到「基础数据 → 报名导入」上传报名表/)
|
||||
})
|
||||
Reference in new issue
Block a user