diff --git a/docs/使用手册.md b/docs/使用手册.md index e9db23b..b4c8b1d 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -213,6 +213,7 @@ - **批量退赛 / 恢复**会**立即**按新名次重算积分并推到大屏——**延迟结算(见 10.5)也照算**,因为退赛 / 恢复改的是「谁还算数」,不是「录了多少」,名次和分数必须当场跟着改。已经是「取消资格(dq)」「未出场(dns)」的行会被**跳过、不覆盖原判定**(跳过的人会在确认框里逐条列出原因,例如「已被判罚取消资格(dq),跳过」)。 - **批量移出报名**的确认框会列出**将被删除的人**(姓名|号码布|单位,超过 20 人只列前 20 并报剩余数量)以及**将重算的小项与组别**——看完再确认,删了不能恢复;删除后同样**立即重算并推榜**,不受延迟结算影响。 +- **移出之后想重新报名**:不用跑回「基础数据 → 报名导入」——录入界面的工具栏右侧就有 **「补报名」** 按钮(和当前小项、组别在同一屏里)。点开后是同一套「从已有名单勾选」界面,并**预选了当前小项与当前组别**;勾选完点「加入报名」,本组名单会立刻刷新,补上的人直接出现在成绩表里。详见 10.7。 - 一次最多处理 **500 条**报名项,超出请分批提交;有未保存的成绩修改时,请先「存草稿」再批量操作;示例数据模式下不能批量操作。 ### 4.9 团体名次(按单位录入) @@ -657,6 +658,8 @@ 报名有两种方式,都在本页完成:**从已有名单勾选**适合零散补报,**表格导入**适合批量录入。 +> 本页的「从已有名单勾选」在**成绩录入页的工具栏里也有一个入口**(按钮叫 **「补报名」**),打开的是同一套勾选界面,只是预选了你正在录的那个小项与组别——在录入界面把某人移出报名之后,就地补回来即可(见 4.8)。 + **从已有名单勾选** 这张卡片分成「个人报名 / 团体报名」两个页签,页签下方的小项与组别选择是两个页签共用的。选中某个小项后会自动切到与该小项类型相符的页签(个人项目 → 个人报名;团体 / 对抗 / 个人+团体 → 团体报名),也可以手动切换;切到的页签与小项类型不符时页面会提示一句,生成的报名以当前页签为准。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 5adfab8..eedfbe3 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -564,6 +564,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - **批量退赛**:标记弃权(0 分、不占名次),**随时可以批量恢复**;已经是「取消资格 / 未出场」的行会被跳过、不覆盖原判定; - **批量移出报名**:**物理删除**报名项连同他的成绩与积分流水,**不可恢复**,确认框会列出将被删的人与将重算的小项 / 组别; +- **移出后想加回来**:还在「成绩录入」页,点工具栏右侧的 **「补报名」**——打开的是「基础数据 → 报名导入」里的同一套勾选界面,并预选当前小项与组别,勾完点「加入报名」本组名单立刻刷新(见使用手册 4.8 / 10.7)。**退赛**的人不用走这条路:直接「批量恢复」即可。 - 一次最多 500 条;有未保存的成绩修改时请先「存草稿」再操作。 - 这三个动作(以及**撤销发布**、**作废单条成绩**、**改积分模板 / 组别权重 / 计分通道**)都**立即重算并推榜**,**不受「分批发布时实时更新榜单」这个延迟结算开关影响**——它们改的是「谁还算数」「按什么规则算」,不是「录了多少」,留着旧分就会变成「榜上还有分、成绩却撤了」。延迟结算只作用于**发布成绩**这一步(见 Q90)。 diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index 5701620..fef2494 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -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(null) const entries = ref([]) const entriesLoading = ref(false) const submitting = ref(false) @@ -237,6 +261,8 @@ async function importSelection(rows: string[][], id: number): Promise { } 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 { +/** + * 换小项后重拉组别与已报名;`presetCategory` 是弹窗入口带进来的预选组别。 + * + * 预选组别只在这个小项下确实有这一组时才生效(调用方可能刚换过小项、组别已对不上), + * 否则照旧落在第一个组别上,不会留下一个选不中的空下拉。 + */ +async function syncEvent(id: string, presetCategory: number | null = null): Promise { resetSelection() categoryId.value = '' entries.value = [] @@ -262,17 +294,45 @@ async function syncEvent(id: string): Promise { 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 { + 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 {