feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
parseResultValue,
|
||||
type AvgMode,
|
||||
type DraftResultInput,
|
||||
type EntryStatus,
|
||||
type EntryWithResult,
|
||||
type Event,
|
||||
type ResultState,
|
||||
@@ -26,6 +27,17 @@ import {
|
||||
type EntryRowJudgement,
|
||||
type ResultCellHint,
|
||||
} from './entryGuards'
|
||||
import { entryProgress } from './entryProgress'
|
||||
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
|
||||
import {
|
||||
entryBatchOutcome,
|
||||
entryBatchSizeError,
|
||||
planEntryBatch,
|
||||
submitEntryBatch,
|
||||
type EntryBatchAction,
|
||||
type EntryBatchPlan,
|
||||
type EntryBatchRow,
|
||||
} from './entryBatch'
|
||||
import {
|
||||
DEFAULT_HEAT_SIZE,
|
||||
MAX_HEAT,
|
||||
@@ -41,6 +53,7 @@ import {
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -58,6 +71,8 @@ interface EditableRow {
|
||||
memberNames: string[] | null
|
||||
/** 批次号(null = 未分批);只影响录入 / 展示分组,不参与算分。 */
|
||||
heat: number | null
|
||||
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
|
||||
entryStatus: EntryStatus
|
||||
resultText: string
|
||||
rank: number | null
|
||||
status: ResultStatus
|
||||
@@ -105,6 +120,10 @@ const publishing = ref(false)
|
||||
const unpublishing = ref(false)
|
||||
const publishDialog = ref(false)
|
||||
const unpublishDialog = ref(false)
|
||||
/** 分批结算状态:延迟结算开启时,当前组可能「已发布但还没结算」。 */
|
||||
const settlement = ref<SettlementStatus | null>(null)
|
||||
const settling = ref(false)
|
||||
const settleDialog = ref(false)
|
||||
const focusedEntryId = ref<number | null>(null)
|
||||
/** 成绩格专属的聚焦标记:悬浮提示只跟着它走,别让名次 / 状态格也弹成绩提示。 */
|
||||
const focusedResultId = ref<number | null>(null)
|
||||
@@ -238,8 +257,17 @@ const splitPlan = computed(() => planHeatSplit(visibleRows.value, heatSize.value
|
||||
const scopeWord = computed(() => (selectedHeat.value === 'all' ? '该组' : '当前批次'))
|
||||
const heatScoped = computed(() => selectedHeat.value !== 'all')
|
||||
|
||||
/**
|
||||
* 报名项是否参与排名:与服务端 domain.EntryStatus.rankable() 同口径——
|
||||
* 只有 registered 才计分,退赛 / dq / dns 一律 0 分且不占名次。
|
||||
* 本地预览若不按这条过滤,退赛行会继续显示原来的名次与分数,和榜单对不上。
|
||||
*/
|
||||
function rankableRow(row: EditableRow): boolean {
|
||||
return row.entryStatus === 'registered'
|
||||
}
|
||||
|
||||
function scorableOf(rows: EditableRow[]): ScorableRow[] {
|
||||
return rows.map((row) => ({
|
||||
return rows.filter(rankableRow).map((row) => ({
|
||||
entryId: row.entryId,
|
||||
teamId: row.teamId,
|
||||
athleteId: row.athleteId,
|
||||
@@ -310,6 +338,22 @@ const allPublished = computed(
|
||||
() => visibleRows.value.length > 0 && visibleRows.value.every((row) => row.resultState === 'published'),
|
||||
)
|
||||
const publishedCount = computed(() => visibleRows.value.filter((row) => row.resultState === 'published').length)
|
||||
|
||||
/**
|
||||
* 当前 (小项, 组别) 的未结算状态:非 null 才提示。
|
||||
*
|
||||
* 延迟结算关闭时服务端返回空清单,这条提示天然不出现;读不到状态(示例数据 / 断线)时也不提示,
|
||||
* 辅助信息不该打断现场录入。
|
||||
*/
|
||||
const pendingSettlement = computed<SettlementGroup | null>(() => {
|
||||
if (selectedEventId.value === null || selectedCategoryId.value === null) return null
|
||||
const groups = settlement.value?.groups ?? []
|
||||
return (
|
||||
groups.find(
|
||||
(group) => group.eventId === selectedEventId.value && group.categoryId === selectedCategoryId.value,
|
||||
) ?? null
|
||||
)
|
||||
})
|
||||
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
||||
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
||||
@@ -456,6 +500,19 @@ const stateCounts = computed(() => {
|
||||
return counts
|
||||
})
|
||||
|
||||
/**
|
||||
* 批次录入进度:分母是当前筛选范围的行数(选中某一批就只数这一批),
|
||||
* 「已录」判据与发布防呆 / 行徽标共用 entryProgress → judgeEntryRow,不另写一套解析。
|
||||
*/
|
||||
const entryBatchProgress = computed(() =>
|
||||
entryProgress({
|
||||
rows: categoryRows.value,
|
||||
filter: selectedHeat.value,
|
||||
unit: unit.value,
|
||||
needsRank: manualRank.value,
|
||||
}),
|
||||
)
|
||||
|
||||
/** 本次发布真正会带上屏的行:未发布且已填(或已录状态)。 */
|
||||
const pendingRows = computed(() =>
|
||||
visibleRows.value.filter((row) => row.resultState !== 'published' && rowJudgement(row)?.state === 'filled'),
|
||||
@@ -529,7 +586,12 @@ const publishMessage = computed(() => {
|
||||
const scope = heatScoped.value
|
||||
? '注意:发布按「小项 + 组别」整体生效,其它批次已保存的草稿也会一起发布。'
|
||||
: ''
|
||||
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。发布后立即上屏并重算积分榜。`
|
||||
// 延迟结算下「发布即重算积分榜」不再成立,确认框不能再说反话。
|
||||
const tail =
|
||||
settlement.value?.liveOnPublish === false
|
||||
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后再更新。'
|
||||
: '发布后立即上屏并重算积分榜。'
|
||||
return `${head}${scope}本次影响 ${affectedTeams.value} 个单位,最大分差变化 ${formatPoints(maxDelta.value)} 分。${tail}`
|
||||
})
|
||||
|
||||
/** 团体 / 接力行没有 athleteName(服务端 omitempty 不下发),回落到队员名单首人 / 单位。 */
|
||||
@@ -567,6 +629,7 @@ function toRow(entry: EntryWithResult): EditableRow {
|
||||
athleteName: entry.athleteName ?? '',
|
||||
memberNames: entry.memberNames,
|
||||
heat: entry.heat,
|
||||
entryStatus: entry.status,
|
||||
resultText: result?.resultText ?? '',
|
||||
rank: result?.rank ?? null,
|
||||
status: result?.status ?? 'ok',
|
||||
@@ -805,12 +868,16 @@ async function confirmPublish(): Promise<void> {
|
||||
categoryId: selectedCategoryId.value ?? undefined,
|
||||
})
|
||||
publishDialog.value = false
|
||||
await reload()
|
||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||
toast.success(
|
||||
'发布成功,积分榜已更新',
|
||||
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null,
|
||||
)
|
||||
await reloadAfterSettlementChange()
|
||||
// 延迟结算下这次发布可能只落成绩:提示要如实说清楚,别让现场以为榜单已经跟着变了。
|
||||
const deferred = settlement.value?.liveOnPublish === false
|
||||
const leftover =
|
||||
missing > 0 ? `本次发布 ${published} 人;其余 ${missing} 人保持未发布,补录后再次发布即可` : null
|
||||
if (deferred && pendingSettlement.value !== null) {
|
||||
toast.success('已发布本批成绩', `延迟结算:名次与榜单等该组全部录完后再更新${leftover === null ? '' : `;${leftover}`}`)
|
||||
} else {
|
||||
toast.success('发布成功,积分榜已更新', leftover)
|
||||
}
|
||||
} catch (caught) {
|
||||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
@@ -827,8 +894,7 @@ async function confirmUnpublish(): Promise<void> {
|
||||
categoryId: selectedCategoryId.value ?? undefined,
|
||||
})
|
||||
unpublishDialog.value = false
|
||||
await reload()
|
||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||
await reloadAfterSettlementChange()
|
||||
toast.warning('已撤销发布,大屏将回退该组成绩')
|
||||
} catch (caught) {
|
||||
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
|
||||
@@ -837,6 +903,49 @@ async function confirmUnpublish(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 分批结算(延迟结算下的「榜单还没反映这批」提示 + 人工兜底) ────────────────
|
||||
|
||||
/** 读当前 (小项, 组别) 的结算状态;失败时按「没有提示」处理,不打断录入。 */
|
||||
async function refreshSettlement(): Promise<void> {
|
||||
const id = selectedEventId.value
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (id === null) {
|
||||
settlement.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const status = await settlementApi.status(id, categoryId)
|
||||
// 请求期间可能已经切了项目 / 组别,旧响应不许覆盖新状态。
|
||||
if (id !== selectedEventId.value || categoryId !== selectedCategoryId.value) return
|
||||
settlement.value = status
|
||||
} catch {
|
||||
settlement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布 / 撤销 / 结算之后一起刷新:榜单、项目列表与结算状态。 */
|
||||
async function reloadAfterSettlementChange(): Promise<void> {
|
||||
await reload()
|
||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||
await refreshSettlement()
|
||||
}
|
||||
|
||||
/** 立即结算:这是「有人永远录不出成绩」时的人工兜底,成功后榜单按已发布部分更新。 */
|
||||
async function confirmSettle(): Promise<void> {
|
||||
if (selectedEventId.value === null) return
|
||||
settling.value = true
|
||||
try {
|
||||
await settlementApi.settle(selectedEventId.value, selectedCategoryId.value ?? undefined)
|
||||
settleDialog.value = false
|
||||
await reloadAfterSettlementChange()
|
||||
toast.success('已按当前已发布成绩结算榜单', '未录完的人仍可继续补录,录完后会再自动结算')
|
||||
} catch (caught) {
|
||||
toast.error('立即结算失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
settling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 批次(录入 / 展示分组,不参与算分) ──────────────────────────────────────
|
||||
|
||||
/** 批量设置批次的共同前置检查;不通过时给出具体原因。 */
|
||||
@@ -919,6 +1028,146 @@ function clearSelection(): void {
|
||||
selectedEntryIds.value = []
|
||||
}
|
||||
|
||||
// ── 批量退赛 / 恢复 / 移出报名(作用范围 = 勾选行) ────────────────────────────
|
||||
|
||||
/** 批量操作的确认框状态;计划由 planEntryBatch 先算好,确认框只负责展示。 */
|
||||
const batchPlan = ref<EntryBatchPlan | null>(null)
|
||||
const batchDialog = ref(false)
|
||||
const batchBusy = ref(false)
|
||||
|
||||
/** 勾选行里已退赛的人数:给「批量恢复」按钮的可用性与文案做依据。 */
|
||||
const selectedScratchedCount = computed(
|
||||
() => selectedRows.value.filter((row) => row.entryStatus === 'scratched').length,
|
||||
)
|
||||
|
||||
/** 勾选行里非正常参赛的人数(退赛 / dq / dns),决定「批量退赛」是否还有意义。 */
|
||||
const selectedUnrankableCount = computed(
|
||||
() => selectedRows.value.filter((row) => !rankableRow(row)).length,
|
||||
)
|
||||
|
||||
/** 勾选行的报名项状态文案;徽标与确认框共用。 */
|
||||
const ENTRY_STATUS_LABEL: Record<EntryStatus, string> = {
|
||||
registered: '正常参赛',
|
||||
scratched: '已退赛',
|
||||
dq: '取消资格',
|
||||
dns: '未出场',
|
||||
}
|
||||
|
||||
function entryStatusTitle(status: EntryStatus): string {
|
||||
switch (status) {
|
||||
case 'scratched':
|
||||
return '已退赛(弃权):0 分且不占名次,可用「批量恢复」撤回'
|
||||
case 'dq':
|
||||
return '已被判罚取消资格:0 分且不占名次'
|
||||
case 'dns':
|
||||
return '已标记未出场:0 分且不占名次'
|
||||
}
|
||||
return '正常参赛'
|
||||
}
|
||||
|
||||
function batchRows(): EntryBatchRow[] {
|
||||
return selectedRows.value.map((row) => ({
|
||||
entryId: row.entryId,
|
||||
name: rowLabel(row),
|
||||
bib: row.bib,
|
||||
team: row.teamShort || row.teamName,
|
||||
categoryId: row.categoryId,
|
||||
entryStatus: row.entryStatus,
|
||||
}))
|
||||
}
|
||||
|
||||
/** 批量操作的共同前置检查;不通过时给出具体原因。 */
|
||||
function canBatch(): boolean {
|
||||
if (data.isMock) {
|
||||
toast.error('示例数据模式不能批量操作', '后端未就绪,改动不会写入真实赛事')
|
||||
return false
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可批量操作', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return false
|
||||
}
|
||||
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
|
||||
if (dirty.value) {
|
||||
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 点批量按钮:先算清「动谁、跳过谁、重算几个组别」,再让操作员确认。 */
|
||||
function requestBatchAction(action: EntryBatchAction): void {
|
||||
if (!canBatch()) return
|
||||
const rows = batchRows()
|
||||
const sizeError = entryBatchSizeError(rows.length)
|
||||
if (sizeError !== null) {
|
||||
toast.error('批量操作未提交', sizeError)
|
||||
return
|
||||
}
|
||||
const plan = planEntryBatch(action, rows)
|
||||
if (plan.changed === 0) {
|
||||
const first = plan.skipped[0]
|
||||
toast.info(
|
||||
'没有可处理的行',
|
||||
first === undefined ? null : `${first.name}:${first.note}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
batchPlan.value = plan
|
||||
batchDialog.value = true
|
||||
}
|
||||
|
||||
function cancelBatchAction(): void {
|
||||
batchDialog.value = false
|
||||
batchPlan.value = null
|
||||
}
|
||||
|
||||
async function confirmBatchAction(): Promise<void> {
|
||||
const plan = batchPlan.value
|
||||
if (plan === null || batchBusy.value) return
|
||||
batchBusy.value = true
|
||||
try {
|
||||
const result = await submitEntryBatch({ action: plan.action, ids: plan.ids })
|
||||
batchDialog.value = false
|
||||
batchPlan.value = null
|
||||
// 整表重载:状态、名次与积分都由服务端重算后的口径决定,不能只在本地的行上打补丁。
|
||||
await reload()
|
||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||
clearSelection()
|
||||
const failed = result.items.filter((item) => !item.ok)
|
||||
if (failed.length > 0) {
|
||||
// 部分失败必须逐条说清,不能只报一句「批量操作完成」。
|
||||
toast.warning(
|
||||
`批量操作完成(${failed.length} 条未处理)`,
|
||||
failed.map((item) => `${item.id}:${item.error ?? '失败'}`).join(';'),
|
||||
)
|
||||
} else {
|
||||
toast.success(BATCH_SUCCESS_TITLE[plan.action], entryBatchOutcome(result))
|
||||
}
|
||||
} catch (caught) {
|
||||
toast.error('批量操作失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
batchBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const BATCH_SUCCESS_TITLE: Record<EntryBatchAction, string> = {
|
||||
scratch: '已批量退赛,积分榜已重算',
|
||||
restore: '已批量恢复,积分榜已重算',
|
||||
remove: '已移出报名,积分榜已重算',
|
||||
}
|
||||
|
||||
const BATCH_CONFIRM_TITLE: Record<EntryBatchAction, string> = {
|
||||
scratch: '确认批量退赛?',
|
||||
restore: '确认批量恢复?',
|
||||
remove: '确认移出报名?此操作不可恢复',
|
||||
}
|
||||
|
||||
const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
|
||||
scratch: '确认退赛',
|
||||
restore: '确认恢复',
|
||||
remove: '确认移出',
|
||||
}
|
||||
|
||||
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
|
||||
function requestSplit(): void {
|
||||
const plan = splitPlan.value
|
||||
@@ -1054,6 +1303,9 @@ watch(
|
||||
if (loaded) void pickInitialEvent()
|
||||
},
|
||||
)
|
||||
|
||||
// 换项目 / 换组别后结算状态跟着变;提示是辅助信息,读失败不影响录入。
|
||||
watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1175,6 +1427,43 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
批次录入进度:贴在工具栏正下方、横跨整行,扫一眼就知道这一批还差几条。
|
||||
分母是当前筛选范围的行数,判据与发布防呆同源(entryProgress → judgeEntryRow)。
|
||||
-->
|
||||
<div class="entry-progress" :class="{ 'is-done': entryBatchProgress.done }">
|
||||
<span class="entry-progress-text">{{ entryBatchProgress.text }}</span>
|
||||
<div
|
||||
class="mx-progress mx-progress-lg entry-progress-track"
|
||||
:class="{
|
||||
'mx-progress-success': entryBatchProgress.done,
|
||||
'mx-progress-warning': !entryBatchProgress.done && entryBatchProgress.error > 0,
|
||||
}"
|
||||
>
|
||||
<div class="mx-progress-bar" :style="{ width: `${entryBatchProgress.percent}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
延迟结算提示:低调地放在进度条下面,只说明「这一组还没结算、榜单不跟批次走」,
|
||||
不挡录入。右侧「立即结算」是人工兜底(比如有人退赛但没标 scratched)。
|
||||
-->
|
||||
<div v-if="pendingSettlement !== null" class="entry-settlement">
|
||||
<MxIcon name="warning" :size="14" />
|
||||
<span class="entry-settlement-text">
|
||||
延迟结算:本组 {{ pendingSettlement.published }}/{{ pendingSettlement.required }} 人已出成绩,
|
||||
名次与榜单等该组全部录完后一起更新(本页预览的名次仍是实时的)。
|
||||
</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || settling"
|
||||
@click="settleDialog = true"
|
||||
>
|
||||
立即结算
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRows.length > 0" class="heat-bar">
|
||||
<span class="heat-bar-title"><MxIcon name="entry" :size="15" /> 已选 {{ selectedRows.length }} 行</span>
|
||||
<label class="heat-field">
|
||||
@@ -1233,6 +1522,52 @@ watch(
|
||||
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="clearSelection">取消选择</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
报名项批量操作栏:作用范围就是上面勾选的行(= 当前批次筛选范围内的行)。
|
||||
退赛可逆、移出不可逆,所以两者都过确认框,先把「动谁、重算几个组别」说清。
|
||||
dq / dns 是别人的判定,服务端会跳过,这里也把按钮的可用性说白。
|
||||
-->
|
||||
<div v-if="selectedRows.length > 0" class="entry-batch-bar">
|
||||
<span class="entry-batch-title">
|
||||
<MxIcon name="entry" :size="15" /> 批量操作 {{ selectedRows.length }} 行
|
||||
</span>
|
||||
<span class="entry-batch-hint">
|
||||
仅作用于当前筛选范围({{ scopeWord }});{{ selectedUnrankableCount }} 人已非正常参赛
|
||||
</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || batchBusy || selectedUnrankableCount === selectedRows.length"
|
||||
:title="
|
||||
selectedUnrankableCount === selectedRows.length
|
||||
? '选中的行都已是退赛 / 取消资格 / 未出场,没有可退赛的人'
|
||||
: '标记为退赛(0 分且不占名次),可随时批量恢复'
|
||||
"
|
||||
@click="requestBatchAction('scratch')"
|
||||
>
|
||||
<MxIcon name="close" :size="14" /> 批量退赛
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || batchBusy || selectedScratchedCount === 0"
|
||||
:title="selectedScratchedCount === 0 ? '选中的行里没有已退赛的人' : `把 ${selectedScratchedCount} 人恢复为正常参赛`"
|
||||
@click="requestBatchAction('restore')"
|
||||
>
|
||||
<MxIcon name="check" :size="14" /> 批量恢复<span v-if="selectedScratchedCount > 0">({{ selectedScratchedCount }})</span>
|
||||
</button>
|
||||
<span class="heat-bar-split"></span>
|
||||
<button
|
||||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || batchBusy"
|
||||
title="从报名名单里物理删除,连同成绩与积分流水一起删除,不可恢复"
|
||||
@click="requestBatchAction('remove')"
|
||||
>
|
||||
<MxIcon name="warning" :size="14" /> 批量移出报名
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showAvgMode" class="avg-mode-bar">
|
||||
<span class="avg-mode-title"><MxIcon name="adjust" :size="15" /> 平均值口径</span>
|
||||
<label
|
||||
@@ -1273,7 +1608,7 @@ watch(
|
||||
type="checkbox"
|
||||
:checked="allVisibleSelected"
|
||||
:disabled="visibleRows.length === 0"
|
||||
title="全选当前可见行"
|
||||
:title="`全选当前筛选范围内的行(${scopeWord} / 当前组别,共 ${visibleRows.length} 行)`"
|
||||
@change="toggleAllVisible"
|
||||
/>
|
||||
</th>
|
||||
@@ -1328,7 +1663,8 @@ watch(
|
||||
<td>
|
||||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
</td>
|
||||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||||
<!-- 号码布紧跟姓名重复一次:左边那列还在(有人习惯了),这一个是给眼睛省路的。 -->
|
||||
<td>{{ athleteCellName(row) }} <span v-if="inlineBibLabel(row.bib)" class="bib-inline mono">{{ inlineBibLabel(row.bib) }}</span></td>
|
||||
<td class="result-cell">
|
||||
<input
|
||||
v-model="row.resultText"
|
||||
@@ -1373,7 +1709,13 @@ watch(
|
||||
/>
|
||||
<span v-else class="rank-auto mono">{{ rankOf(row) ?? '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="status-cell">
|
||||
<span
|
||||
v-if="row.entryStatus !== 'registered'"
|
||||
class="mx-badge entry-status-badge"
|
||||
:class="row.entryStatus === 'scratched' ? 'mx-badge-warning' : 'mx-badge-error'"
|
||||
:title="entryStatusTitle(row.entryStatus)"
|
||||
>{{ ENTRY_STATUS_LABEL[row.entryStatus] }}</span>
|
||||
<select
|
||||
v-model="row.status"
|
||||
data-cell
|
||||
@@ -1503,6 +1845,23 @@ watch(
|
||||
@cancel="unpublishDialog = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="settleDialog"
|
||||
tone="warning"
|
||||
title="立即结算该组积分?"
|
||||
:message="`将按本组当前已发布的成绩重算各单位总分并刷新奖牌榜,未录完的人不计入。`"
|
||||
:details="[
|
||||
pendingSettlement === null
|
||||
? '本组已无未结算内容'
|
||||
: `本组已发布 ${pendingSettlement.published}/${pendingSettlement.required} 人`,
|
||||
'结算后继续补录并发布,全组录完时还会再自动结算一次',
|
||||
]"
|
||||
confirm-text="立即结算"
|
||||
:pending="settling"
|
||||
@confirm="confirmSettle"
|
||||
@cancel="settleDialog = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="splitDialog"
|
||||
tone="warning"
|
||||
@@ -1532,6 +1891,19 @@ watch(
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
|
||||
<!-- 批量操作确认:移出报名不可恢复,用 danger;退赛 / 恢复可逆,用 warning / info。 -->
|
||||
<ConfirmDialog
|
||||
:open="batchDialog"
|
||||
:tone="batchPlan?.action === 'remove' ? 'danger' : batchPlan?.action === 'restore' ? 'info' : 'warning'"
|
||||
:title="BATCH_CONFIRM_TITLE[batchPlan?.action ?? 'scratch']"
|
||||
:message="batchPlan?.message ?? ''"
|
||||
:details="batchPlan?.details ?? []"
|
||||
:confirm-text="BATCH_CONFIRM_TEXT[batchPlan?.action ?? 'scratch']"
|
||||
:pending="batchBusy"
|
||||
@confirm="confirmBatchAction"
|
||||
@cancel="cancelBatchAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1673,6 +2045,46 @@ watch(
|
||||
height: 20px;
|
||||
background: var(--mx-divider);
|
||||
}
|
||||
/*
|
||||
* 报名项批量操作栏:与批次操作条同排出现,勾了行才显示。
|
||||
* 用警示色边框把「这里有不可恢复动作」和批次操作区分开。
|
||||
*/
|
||||
.entry-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
border: 1px solid var(--mx-error);
|
||||
background: var(--mx-error-container);
|
||||
}
|
||||
.entry-batch-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mx-error);
|
||||
}
|
||||
.entry-batch-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
/* 状态列同时放报名项徽标(已退赛等)与成绩状态下拉,横排不换行。 */
|
||||
.status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.status-cell .status-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.entry-status-badge {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/*
|
||||
* 批次列只放「第 N 批 / 未分批」这类短文本,窄列即可:
|
||||
* 自动布局下 nowrap + width:1% 会收缩到文字宽度,不会挤坏单位 / 运动员列
|
||||
@@ -1943,6 +2355,55 @@ watch(
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
/*
|
||||
* 批次录入进度:横跨整行、文字加粗,是这一屏最显眼的「还差几条」指示;
|
||||
* 进度条本身用 MIUIx 的 mx-progress / mx-progress-success(录完变绿),不引新样式。
|
||||
*/
|
||||
.entry-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--mx-border);
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s3);
|
||||
}
|
||||
.entry-progress-text {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--mx-t2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.entry-progress.is-done .entry-progress-text {
|
||||
color: var(--mx-green);
|
||||
}
|
||||
/* 延迟结算提示:中性色 + 细边,属于「知道就好」的信息,不抢进度条与成绩格的注意力。 */
|
||||
.entry-settlement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px dashed var(--mx-border);
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.entry-settlement-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.entry-progress-track {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
/* 姓名后的号码布:小号、弱化、等宽,补一次号码但不抢姓名的位置。 */
|
||||
.bib-inline {
|
||||
font-size: 11px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.hotkey-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user