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;
|
||||
|
||||
@@ -21,6 +21,13 @@ import { adminApi } from '../api/client'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import {
|
||||
downloadBaseDataXLSX,
|
||||
downloadEntriesXLSX,
|
||||
importBaseDataXLSX,
|
||||
type BaseDataImportResult,
|
||||
} from '@/shared/basedata'
|
||||
import type { EventCategory } from '@/shared'
|
||||
|
||||
/**
|
||||
* 导出与赛事包(devdocs/07 第 5 节)。
|
||||
@@ -32,10 +39,11 @@ import { useToastStore } from '../stores/toast'
|
||||
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
|
||||
*/
|
||||
|
||||
type TabKey = 'results' | 'project' | 'rules'
|
||||
type TabKey = 'results' | 'basedata' | 'project' | 'rules'
|
||||
|
||||
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
||||
{ key: 'results', label: '结果导出' },
|
||||
{ key: 'basedata', label: '基础数据' },
|
||||
{ key: 'project', label: '赛事项目包' },
|
||||
{ key: 'rules', label: '规则包' },
|
||||
]
|
||||
@@ -119,6 +127,147 @@ const rulesPreview = ref<RulePreview | null>(null)
|
||||
const rulesApplyResult = ref<RuleApplyResult | null>(null)
|
||||
const confirmRulesApply = ref(false)
|
||||
|
||||
// ── 报名名单(xlsx,可按小项 / 组别 / 单位 / 批次筛) ────────────────────────
|
||||
const entryEventId = ref<number | null>(null)
|
||||
const entryCategoryId = ref<number | null>(null)
|
||||
const entryTeamId = ref<number | null>(null)
|
||||
/** 批次用字符串存:空串 = 不筛,交给后端时再转成数字。 */
|
||||
const entryHeat = ref('')
|
||||
const entryCategories = ref<EventCategory[]>([])
|
||||
const entryExporting = ref(false)
|
||||
|
||||
/**
|
||||
* 换小项就把组别重置并重新拉取:组别 id 只在小项内有意义,
|
||||
* 沿用上一个小项的组别会把筛选条件悄悄变成「没有数据」。
|
||||
*/
|
||||
watch(entryEventId, async (id) => {
|
||||
entryCategoryId.value = null
|
||||
entryCategories.value = []
|
||||
if (id === null) return
|
||||
try {
|
||||
entryCategories.value = await adminApi.client.events.categories(id)
|
||||
} catch (caught) {
|
||||
toast.error('读取组别失败', describeError(caught))
|
||||
}
|
||||
})
|
||||
|
||||
async function exportEntries(): Promise<void> {
|
||||
let heat: number | undefined
|
||||
const rawHeat = entryHeat.value.trim()
|
||||
if (rawHeat !== '') {
|
||||
const parsed = Number(rawHeat)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
toast.warning('批次需要是正整数(留空表示不筛)')
|
||||
return
|
||||
}
|
||||
heat = parsed
|
||||
}
|
||||
entryExporting.value = true
|
||||
try {
|
||||
const file = await downloadEntriesXLSX({
|
||||
eventId: entryEventId.value ?? undefined,
|
||||
categoryId: entryCategoryId.value ?? undefined,
|
||||
teamId: entryTeamId.value ?? undefined,
|
||||
heat,
|
||||
})
|
||||
adminApi.client.exports.save(file.blob, file.filename)
|
||||
toast.success(`已导出 ${file.filename}`)
|
||||
} catch (caught) {
|
||||
toast.error('导出失败', describeError(caught))
|
||||
} finally {
|
||||
entryExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 基础数据包(单位 / 大项 / 小项 / 组别) ──────────────────────────────────
|
||||
const baseDataFile = ref<File | null>(null)
|
||||
const baseDataInput = ref<HTMLInputElement | null>(null)
|
||||
const baseDataExporting = ref(false)
|
||||
const baseDataPreviewing = ref(false)
|
||||
const baseDataApplying = ref(false)
|
||||
const baseDataError = ref<string | null>(null)
|
||||
const baseDataResult = ref<BaseDataImportResult | null>(null)
|
||||
const confirmBaseDataApply = ref(false)
|
||||
|
||||
async function exportBaseData(): Promise<void> {
|
||||
baseDataExporting.value = true
|
||||
baseDataError.value = null
|
||||
try {
|
||||
const file = await downloadBaseDataXLSX()
|
||||
adminApi.client.exports.save(file.blob, file.filename)
|
||||
toast.success('基础数据包已导出', '含 单位 / 大项 / 小项 / 组别 四张工作表,可原样导回')
|
||||
} catch (caught) {
|
||||
baseDataError.value = describeError(caught)
|
||||
} finally {
|
||||
baseDataExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickBaseDataFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
baseDataResult.value = null
|
||||
baseDataError.value = null
|
||||
if (file === null) {
|
||||
baseDataFile.value = null
|
||||
return
|
||||
}
|
||||
if (!/\.xlsx$/i.test(file.name)) {
|
||||
baseDataError.value = '基础数据包必须是 .xlsx 文件:单位 / 大项 / 小项 / 组别分在四张工作表里'
|
||||
baseDataFile.value = null
|
||||
input.value = ''
|
||||
return
|
||||
}
|
||||
baseDataFile.value = file
|
||||
}
|
||||
|
||||
/** 第一步:只算差异不写库。用户看清了再点确认写入。 */
|
||||
async function previewBaseData(): Promise<void> {
|
||||
const file = baseDataFile.value
|
||||
if (file === null) {
|
||||
baseDataError.value = '请先选择 .xlsx 基础数据包'
|
||||
return
|
||||
}
|
||||
baseDataPreviewing.value = true
|
||||
baseDataError.value = null
|
||||
baseDataResult.value = null
|
||||
try {
|
||||
baseDataResult.value = await importBaseDataXLSX(file, { dryRun: true })
|
||||
} catch (caught) {
|
||||
baseDataError.value = describeError(caught)
|
||||
} finally {
|
||||
baseDataPreviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestBaseDataApply(): void {
|
||||
if (baseDataResult.value === null) return
|
||||
confirmBaseDataApply.value = true
|
||||
}
|
||||
|
||||
async function applyBaseData(): Promise<void> {
|
||||
const file = baseDataFile.value
|
||||
if (file === null) return
|
||||
confirmBaseDataApply.value = false
|
||||
baseDataApplying.value = true
|
||||
baseDataError.value = null
|
||||
try {
|
||||
const result = await importBaseDataXLSX(file, { dryRun: false })
|
||||
baseDataResult.value = result
|
||||
toast.success(
|
||||
'基础数据已写入',
|
||||
`新增 ${result.added} 条 / 更新 ${result.updated} 条` +
|
||||
(result.errorCount > 0 ? ` / ${result.errorCount} 行出错未写入` : ''),
|
||||
)
|
||||
// 导入会新增单位 / 大项 / 小项,页面上的下拉与基础数据页都要跟着刷新。
|
||||
void data.load()
|
||||
} catch (caught) {
|
||||
baseDataError.value = describeError(caught)
|
||||
} finally {
|
||||
baseDataApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'admin')
|
||||
|
||||
const activeProjectName = computed(() => projects.value.find((item) => item.active)?.name ?? null)
|
||||
@@ -484,8 +633,203 @@ async function applyRules(): Promise<void> {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 报名名单:同样是 xlsx 导出,但读的是报名数据(批次、号码布、状态都在这一侧) -->
|
||||
<section v-if="activeTab === 'results'" class="mx-card entries-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">报名名单(xlsx)</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p class="hint">
|
||||
按小项 / 组别 / 单位 / 批次筛选,留空表示该维度不筛。表里含小项编码与名称、组别、单位、姓名、证件号、号码布、<strong>批次</strong>、性别、年龄、状态与备注;号码布按文本导出,前导零不会丢。
|
||||
</p>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label class="mx-label" for="entries-event">小项</label>
|
||||
<div class="mx-input-wrap">
|
||||
<select id="entries-event" v-model="entryEventId" class="mx-input">
|
||||
<option :value="null">全部小项</option>
|
||||
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="entries-category">组别</label>
|
||||
<div class="mx-input-wrap">
|
||||
<select
|
||||
id="entries-category"
|
||||
v-model="entryCategoryId"
|
||||
class="mx-input"
|
||||
:disabled="entryEventId === null || entryCategories.length === 0"
|
||||
>
|
||||
<option :value="null">全部组别</option>
|
||||
<option v-for="item in entryCategories" :key="item.id" :value="item.id">{{ item.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="entries-team">单位</label>
|
||||
<div class="mx-input-wrap">
|
||||
<select id="entries-team" v-model="entryTeamId" class="mx-input">
|
||||
<option :value="null">全部单位</option>
|
||||
<option v-for="team in data.teams" :key="team.id" :value="team.id">
|
||||
{{ team.name }}({{ team.code }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="mx-label" for="entries-heat">批次</label>
|
||||
<div class="mx-input-wrap">
|
||||
<input
|
||||
id="entries-heat"
|
||||
v-model="entryHeat"
|
||||
class="mx-input"
|
||||
inputmode="numeric"
|
||||
placeholder="全部批次(留空)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="entryExporting"
|
||||
@click="exportEntries"
|
||||
>
|
||||
<MxIcon name="export" :size="15" />
|
||||
{{ entryExporting ? '导出中…' : '导出报名名单' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 基础数据包:导出可原样导回;导入按编码 upsert,先预览差异再写入 -->
|
||||
<div v-if="activeTab === 'basedata'" class="basedata-grid">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">导出基础数据包</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<p class="hint">
|
||||
一个工作簿含四张工作表:单位 / 大项 / 小项 / 组别(组别带所属小项编码)。列名是中文表头,导出的文件可以直接原样导回来,识别到的差异为 0。
|
||||
</p>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary full"
|
||||
type="button"
|
||||
:disabled="baseDataExporting"
|
||||
@click="exportBaseData"
|
||||
>
|
||||
<MxIcon name="export" :size="15" />
|
||||
{{ baseDataExporting ? '导出中…' : '导出 xlsx' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head"><span class="mx-card-title">导入基础数据包</span></div>
|
||||
<div class="mx-card-body">
|
||||
<p v-if="!isAdmin" class="notice">导入基础数据会批量写入单位 / 大项 / 小项 / 组别,只有管理员可操作。</p>
|
||||
|
||||
<div class="file-row">
|
||||
<input
|
||||
ref="baseDataInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
@change="pickBaseDataFile"
|
||||
/>
|
||||
<button class="mx-btn mx-btn-secondary" type="button" @click="baseDataInput?.click()">
|
||||
<MxIcon name="database" :size="15" /> 选择 .xlsx
|
||||
</button>
|
||||
<span class="file-name mono">{{ baseDataFile?.name ?? '未选择文件' }}</span>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
按编码 upsert:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;存在就更新、不存在就新建。
|
||||
文件里没写的数据一条都不会删。
|
||||
</p>
|
||||
<p class="hint">导入分两步:先「预览差异」,确认无误后再写入。</p>
|
||||
|
||||
<p v-if="baseDataError !== null" class="notice error">{{ baseDataError }}</p>
|
||||
|
||||
<button
|
||||
class="mx-btn mx-btn-secondary full"
|
||||
type="button"
|
||||
:disabled="!isAdmin || baseDataPreviewing || baseDataFile === null"
|
||||
@click="previewBaseData"
|
||||
>
|
||||
{{ baseDataPreviewing ? '比对中…' : '预览差异' }}
|
||||
</button>
|
||||
|
||||
<div v-if="baseDataResult !== null" class="preview-box">
|
||||
<div class="preview-head">
|
||||
<span class="mx-badge mx-badge-neutral">新增 {{ baseDataResult.added }} 条</span>
|
||||
<span class="mx-badge mx-badge-warning">更新 {{ baseDataResult.updated }} 条</span>
|
||||
<span class="mx-badge" :class="baseDataResult.errorCount > 0 ? 'mx-badge-error' : 'mx-badge-neutral'">
|
||||
错误 {{ baseDataResult.errorCount }} 条
|
||||
</span>
|
||||
<span class="preview-meta">
|
||||
{{ baseDataResult.dryRun ? '预览(未写库)' : '已写入' }}
|
||||
<template v-if="baseDataResult.unchanged > 0">· 无变化 {{ baseDataResult.unchanged }} 条</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="baseDataResult.errors.length > 0" class="error-list">
|
||||
<div v-for="(message, index) in baseDataResult.errors" :key="index" class="error-line">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="baseDataResult.changes.length === 0" class="mx-empty">
|
||||
<div class="mx-empty-title">没有差异</div>
|
||||
<div class="mx-empty-desc">文件内容与库里一致,无需写入</div>
|
||||
</div>
|
||||
<div v-else class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工作表</th>
|
||||
<th>行</th>
|
||||
<th>变化</th>
|
||||
<th>对象</th>
|
||||
<th>字段差异</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in baseDataResult.changes" :key="`${row.sheet}-${row.line}-${index}`">
|
||||
<td>{{ row.sheet }}</td>
|
||||
<td class="mono">第 {{ row.line }} 行</td>
|
||||
<td>
|
||||
<span class="mx-badge" :class="row.action === 'added' ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||
{{ row.action === 'added' ? '新增' : '更新' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mono">{{ row.key }}</td>
|
||||
<td class="detail">
|
||||
<span v-for="f in row.fields" :key="f.field" class="field-diff">
|
||||
{{ f.field }}:{{ f.from === '' ? '(空)' : f.from }} → {{ f.to === '' ? '(空)' : f.to }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="baseDataResult.dryRun"
|
||||
class="mx-btn mx-btn-danger full"
|
||||
type="button"
|
||||
:disabled="!isAdmin || baseDataApplying"
|
||||
@click="requestBaseDataApply"
|
||||
>
|
||||
{{ baseDataApplying ? '写入中…' : '确认写入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 赛事项目包 -->
|
||||
<div v-else-if="activeTab === 'project'" class="project-grid">
|
||||
<div v-if="activeTab === 'project'" class="project-grid">
|
||||
<section class="mx-card wide">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">赛事项目列表</span>
|
||||
@@ -676,7 +1020,7 @@ async function applyRules(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<!-- 规则包 -->
|
||||
<div v-else class="rules-grid">
|
||||
<div v-if="activeTab === 'rules'" class="rules-grid">
|
||||
<section class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">导出规则包</span>
|
||||
@@ -815,6 +1159,24 @@ async function applyRules(): Promise<void> {
|
||||
@cancel="confirmRulesApply = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="confirmBaseDataApply"
|
||||
tone="danger"
|
||||
title="写入基础数据?"
|
||||
:message="`将按文件内容新增 ${baseDataResult?.added ?? 0} 条、更新 ${baseDataResult?.updated ?? 0} 条单位 / 大项 / 小项 / 组别。`"
|
||||
:details="[
|
||||
'按编码 upsert:存在就更新、不存在就新建',
|
||||
'文件里没写的既有数据不会被删除',
|
||||
baseDataResult !== null && baseDataResult.errorCount > 0
|
||||
? `${baseDataResult.errorCount} 行有错误,这些行会被跳过,其余行照常写入`
|
||||
: '本次预览没有行级错误',
|
||||
]"
|
||||
confirm-text="确认写入"
|
||||
:pending="baseDataApplying"
|
||||
@confirm="applyBaseData"
|
||||
@cancel="confirmBaseDataApply = false"
|
||||
/>
|
||||
|
||||
<p class="footer-note">当前数据更新于 {{ formatDateTime(data.lastRefreshAt) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -879,6 +1241,33 @@ async function applyRules(): Promise<void> {
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
/* 报名名单排在结果导出卡片下方,同样占满整行。 */
|
||||
.entries-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
/* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */
|
||||
.basedata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.error-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--mx-r-xs);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
.error-line {
|
||||
font-size: 12px;
|
||||
color: var(--mx-error);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.field-diff {
|
||||
display: block;
|
||||
}
|
||||
/* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */
|
||||
.project-grid > .wide {
|
||||
grid-column: 1 / -1;
|
||||
@@ -993,7 +1382,8 @@ async function applyRules(): Promise<void> {
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
.project-grid,
|
||||
.rules-grid {
|
||||
.rules-grid,
|
||||
.basedata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
import { liveOnPublishOf } from '../components/system/systemApi'
|
||||
import { judgeEntryRow, type EntryRowJudgement } from './entryGuards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
@@ -72,6 +73,11 @@ const returnTarget = ref<DraftGroup | null>(null)
|
||||
const returnedKeys = ref<string[]>([])
|
||||
|
||||
const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
||||
/**
|
||||
* 延迟结算:发布只落成绩,榜单等该组全部录完才更新。
|
||||
* 这里用的是快照里的设置;读不到(非 admin 且快照未到)时按默认「实时结算」处理。
|
||||
*/
|
||||
const deferredSettlement = computed(() => !liveOnPublishOf(data.settings))
|
||||
/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */
|
||||
const weight = computed(() => {
|
||||
const w = data.settings?.standings.individualToTeamWeight ?? 1
|
||||
@@ -352,6 +358,20 @@ function requestPublish(group: DraftGroup): void {
|
||||
publishTarget.value = group
|
||||
}
|
||||
|
||||
/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */
|
||||
const publishMessage = computed(() => {
|
||||
const group = publishTarget.value
|
||||
if (group === null) return ''
|
||||
const scope =
|
||||
skippedCount(group) > 0
|
||||
? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。`
|
||||
: `本次将发布该组全部 ${publishableCount(group)} 条。`
|
||||
const tail = deferredSettlement.value
|
||||
? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。'
|
||||
: '发布后立即上屏并重算积分榜。'
|
||||
return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}`
|
||||
})
|
||||
|
||||
async function confirmPublish(): Promise<void> {
|
||||
const group = publishTarget.value
|
||||
if (group === null) return
|
||||
@@ -361,7 +381,11 @@ async function confirmPublish(): Promise<void> {
|
||||
publishTarget.value = null
|
||||
await load()
|
||||
await Promise.all([data.refreshStandings(), data.refreshEvents()])
|
||||
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||||
if (deferredSettlement.value) {
|
||||
toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`)
|
||||
} else {
|
||||
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
|
||||
}
|
||||
} catch (caught) {
|
||||
toast.error('发布失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
@@ -633,13 +657,7 @@ watch(
|
||||
:open="publishTarget !== null"
|
||||
tone="warning"
|
||||
title="确认发布该组成绩?"
|
||||
:message="
|
||||
publishTarget === null
|
||||
? ''
|
||||
: skippedCount(publishTarget) > 0
|
||||
? `本组还有 ${skippedCount(publishTarget)} 条没有成绩,本次将发布已填的 ${publishableCount(publishTarget)} 条;其余保持未发布,补录后再发布即可。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||||
: `本次将发布该组全部 ${publishableCount(publishTarget)} 条。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
|
||||
"
|
||||
:message="publishMessage"
|
||||
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
|
||||
confirm-text="确认发布"
|
||||
:pending="publishing"
|
||||
|
||||
@@ -4,20 +4,23 @@ import PageHeader from '../components/PageHeader.vue'
|
||||
import UsersPanel from '../components/system/UsersPanel.vue'
|
||||
import BackupPanel from '../components/system/BackupPanel.vue'
|
||||
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
|
||||
import SessionPanel from '../components/system/SessionPanel.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
|
||||
/**
|
||||
* 系统页(devdocs/07 第 1 节)。
|
||||
*
|
||||
* 三块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
|
||||
* 四块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
|
||||
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
|
||||
*/
|
||||
|
||||
type TabKey = 'users' | 'backup' | 'event'
|
||||
type TabKey = 'users' | 'session' | 'backup' | 'event'
|
||||
|
||||
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
|
||||
{ key: 'users', label: '用户管理' },
|
||||
// 登录有效期是机器级配置(system.db),与赛事信息那种随赛事包走的配置分开摆。
|
||||
{ key: 'session', label: '登录有效期' },
|
||||
{ key: 'backup', label: '备份' },
|
||||
{ key: 'event', label: '赛事信息' },
|
||||
]
|
||||
@@ -42,7 +45,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="系统" subtitle="用户管理 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
|
||||
<PageHeader title="系统" subtitle="用户管理 / 登录有效期 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
|
||||
|
||||
<div class="system-toolbar">
|
||||
<div class="mx-tabs">
|
||||
@@ -70,6 +73,7 @@ watch(
|
||||
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
|
||||
|
||||
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
|
||||
<SessionPanel v-else-if="isAdmin && activeTab === 'session'" />
|
||||
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
|
||||
<EventInfoPanel v-else />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { ApiError } from '@/shared'
|
||||
import type { ApiErrorCode, EntryStatus } from '@/shared'
|
||||
|
||||
/**
|
||||
* 报名项批量操作(退赛 / 恢复 / 移出报名)的前端逻辑。
|
||||
*
|
||||
* 网络层直接 fetch 而不是走 adminApi:client.ts 的接口表与 shared/api.ts 归口在共享层,
|
||||
* 本功能只服务录入页,先把请求构造与确认文案收在这一个模块里,便于单测;
|
||||
* CSRF 与 X-Request-Id 的补法与 shared/api.ts 的写请求保持一致,否则写操作会被 403 挡回。
|
||||
*/
|
||||
|
||||
/** 与 server/internal/httpapi/entries_batch.go 的 maxEntryBatch 一致。 */
|
||||
export const MAX_ENTRY_BATCH = 500
|
||||
|
||||
export const ENTRY_BATCH_PATH = '/api/entries/batch'
|
||||
|
||||
/** 确认框里逐条列出「将被删的人」时最多列几行,超出的只报数。 */
|
||||
const MAX_LISTED_PEOPLE = 20
|
||||
|
||||
export type EntryBatchAction = 'scratch' | 'restore' | 'remove'
|
||||
|
||||
export interface EntryBatchPayload {
|
||||
action: EntryBatchAction
|
||||
ids: number[]
|
||||
reason?: string
|
||||
requestId?: string
|
||||
}
|
||||
|
||||
export interface EntryBatchItem {
|
||||
id: number
|
||||
ok: boolean
|
||||
error?: string
|
||||
skipped?: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface EntryBatchSummary {
|
||||
ok: number
|
||||
failed: number
|
||||
skipped: number
|
||||
changed: number
|
||||
removedEntries: number
|
||||
removedResults: number
|
||||
removedLedger: number
|
||||
removedBreaks: number
|
||||
recomputedEvents: number[]
|
||||
}
|
||||
|
||||
export interface EntryBatchResult {
|
||||
items: EntryBatchItem[]
|
||||
summary: EntryBatchSummary
|
||||
}
|
||||
|
||||
/** 批量操作作用于的勾选行;字段只取确认文案与跳过判定需要的部分。 */
|
||||
export interface EntryBatchRow {
|
||||
entryId: number
|
||||
/** 展示名:运动员姓名 → 队员首人 → 单位(与表格行文案同口径)。 */
|
||||
name: string
|
||||
bib: string | null
|
||||
team: string
|
||||
categoryId: number
|
||||
entryStatus: EntryStatus
|
||||
}
|
||||
|
||||
export interface EntryBatchPlan {
|
||||
action: EntryBatchAction
|
||||
/** 去重后的报名项 id(服务端同样会去重,这里先去掉重复勾选)。 */
|
||||
ids: number[]
|
||||
/** 真正会被改动的行数。 */
|
||||
changed: number
|
||||
/** 会被跳过的行及其原因(dq / dns 是别人的判定,不覆盖)。 */
|
||||
skipped: { name: string; note: string }[]
|
||||
/** 会重算的组别数量:重算按 (小项, 组别) 走,本页只有当前小项。 */
|
||||
scopeCount: number
|
||||
message: string
|
||||
details: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重并丢掉非法 id(0 / 负数 / 非整数),保持勾选顺序。
|
||||
* 与服务端 dedupeIDs 同一口径:重复勾选不该变成两条请求明细。
|
||||
*/
|
||||
export function dedupeEntryIds(ids: number[]): number[] {
|
||||
const out: number[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const id of ids) {
|
||||
if (!Number.isInteger(id) || id <= 0 || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 批量条数校验;通过返回 null,否则返回可直接上屏的中文提示。 */
|
||||
export function entryBatchSizeError(count: number): string | null {
|
||||
if (count === 0) return '请先勾选要操作的报名项'
|
||||
if (count > MAX_ENTRY_BATCH) {
|
||||
return `一次最多处理 ${MAX_ENTRY_BATCH} 条报名项,请分批提交`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 该行在当前动作下会不会被改动;不改动时返回跳过原因(与服务端文案一致)。 */
|
||||
export function entryBatchSkipNote(action: EntryBatchAction, status: EntryStatus): string | null {
|
||||
if (action === 'remove') return null
|
||||
if (action === 'scratch' && status === 'registered') return null
|
||||
if (action === 'restore' && status === 'scratched') return null
|
||||
switch (status) {
|
||||
case 'scratched':
|
||||
return '已是退赛状态,跳过'
|
||||
case 'registered':
|
||||
return '当前正常参赛,无需恢复'
|
||||
case 'dq':
|
||||
return '已被判罚取消资格(dq),跳过,不覆盖原判定'
|
||||
case 'dns':
|
||||
return '已标记未出场(dns),跳过,不覆盖原判定'
|
||||
}
|
||||
return `当前状态为「${status}」,跳过`
|
||||
}
|
||||
|
||||
/** 行在确认框里的身份行:姓名|号码布|单位。 */
|
||||
function personLine(row: EntryBatchRow): string {
|
||||
return `${row.name || '(未署名)'}|号码 ${row.bib ?? '—'}|${row.team || '未知单位'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次批量操作整理成确认框能直接用的内容:
|
||||
* 前端先算清「动谁、跳过谁、重算几个组别」,不让操作员点完才知道。
|
||||
*/
|
||||
export function planEntryBatch(action: EntryBatchAction, rows: EntryBatchRow[]): EntryBatchPlan {
|
||||
const ids = dedupeEntryIds(rows.map((row) => row.entryId))
|
||||
// 重复勾选不该在「动几个人」里数两遍:按 entryId 去重后再统计。
|
||||
const seen = new Set<number>()
|
||||
const unique = rows.filter((row) => {
|
||||
if (seen.has(row.entryId)) return false
|
||||
seen.add(row.entryId)
|
||||
return true
|
||||
})
|
||||
const scopes = new Set(unique.map((row) => row.categoryId))
|
||||
const changedRows: EntryBatchRow[] = []
|
||||
const skipped: { name: string; note: string }[] = []
|
||||
for (const row of unique) {
|
||||
const note = entryBatchSkipNote(action, row.entryStatus)
|
||||
if (note === null) changedRows.push(row)
|
||||
else skipped.push({ name: row.name || '(未署名)', note })
|
||||
}
|
||||
|
||||
const changed = changedRows.length
|
||||
const scopeCount = scopes.size
|
||||
const details: string[] = []
|
||||
if (action === 'remove') {
|
||||
details.push(`将删除以下 ${changed} 人(物理删除,不可恢复):`)
|
||||
for (const row of changedRows.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${personLine(row)}`)
|
||||
if (changedRows.length > MAX_LISTED_PEOPLE) {
|
||||
details.push(`…还有 ${changedRows.length - MAX_LISTED_PEOPLE} 人未列出`)
|
||||
}
|
||||
} else if (skipped.length > 0) {
|
||||
details.push(`以下 ${skipped.length} 人会被跳过(不覆盖已有判定):`)
|
||||
for (const item of skipped.slice(0, MAX_LISTED_PEOPLE)) details.push(`· ${item.name}:${item.note}`)
|
||||
if (skipped.length > MAX_LISTED_PEOPLE) {
|
||||
details.push(`…还有 ${skipped.length - MAX_LISTED_PEOPLE} 人未列出`)
|
||||
}
|
||||
}
|
||||
|
||||
let message = ''
|
||||
if (action === 'scratch') {
|
||||
message = `将把选中的 ${changed} 人标记为退赛(0 分且不占名次),随后按新名次重算积分榜。退赛随时可以用「批量恢复」撤回。`
|
||||
} else if (action === 'restore') {
|
||||
message = `将把选中的 ${changed} 人恢复为正常参赛,随后按新名次重算积分榜。`
|
||||
} else {
|
||||
message = `将从报名名单中物理删除选中的 ${changed} 人,成绩与积分流水一并删除,不可恢复。`
|
||||
}
|
||||
|
||||
// 本页只操作当前小项;重算按 (小项, 组别) 走,所以组别数就是这里的规模。
|
||||
if (scopeCount > 0) {
|
||||
details.push(`会重算 1 个小项(当前项目)、${scopeCount} 个组别的名次与积分。`)
|
||||
}
|
||||
if (action === 'remove' && changed > MAX_LISTED_PEOPLE) {
|
||||
message += `这会重算 1 个小项(当前项目)、${scopeCount} 个组别,别人的名次会变。`
|
||||
}
|
||||
|
||||
return { action, ids, changed, skipped, scopeCount, message, details }
|
||||
}
|
||||
|
||||
/** 批量操作成功后的 toast 副标题:逐条成败 + 删除规模 + 重算范围。 */
|
||||
export function entryBatchOutcome(result: EntryBatchResult): string {
|
||||
const { summary } = result
|
||||
const parts: string[] = []
|
||||
parts.push(`成功 ${summary.ok} 条${summary.skipped > 0 ? `(跳过 ${summary.skipped} 条)` : ''}`)
|
||||
if (summary.failed > 0) parts.push(`失败 ${summary.failed} 条`)
|
||||
if (summary.removedEntries > 0) parts.push(`删除报名 ${summary.removedEntries} 条`)
|
||||
if (summary.removedResults > 0) parts.push(`删除成绩 ${summary.removedResults} 条`)
|
||||
if (summary.removedLedger > 0) parts.push(`删除流水 ${summary.removedLedger} 条`)
|
||||
if (summary.recomputedEvents.length > 0) parts.push(`重算 ${summary.recomputedEvents.length} 个小项`)
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie)
|
||||
return match?.[1] === undefined ? null : decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
function defaultRequestId(): string {
|
||||
const cryptoApi: Crypto | undefined = globalThis.crypto
|
||||
if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') return cryptoApi.randomUUID()
|
||||
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
const KNOWN_CODES: readonly string[] = [
|
||||
'AUTH_REQUIRED',
|
||||
'AUTH_FAILED',
|
||||
'FORBIDDEN',
|
||||
'VALIDATION_FAILED',
|
||||
'CONFLICT',
|
||||
'STATE_INVALID',
|
||||
'RATE_LIMITED',
|
||||
'INTERNAL',
|
||||
'NETWORK',
|
||||
'TIMEOUT',
|
||||
'PARSE_FAILED',
|
||||
]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function apiError(code: unknown, message: string, status: number, detail?: unknown): ApiError {
|
||||
const safe: ApiErrorCode = typeof code === 'string' && KNOWN_CODES.includes(code) ? (code as ApiErrorCode) : 'INTERNAL'
|
||||
return new ApiError(safe, message, status, detail)
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交批量操作。写操作不降级到示例数据:示例数据模式下的「成功」会让操作员
|
||||
* 以为已经退赛 / 删干净了,页面必须先看 data.isMock 再决定是否调用这里。
|
||||
*/
|
||||
export async function submitEntryBatch(payload: EntryBatchPayload): Promise<EntryBatchResult> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'X-Request-Id': defaultRequestId(),
|
||||
}
|
||||
const token = readCsrfToken()
|
||||
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(ENTRY_BATCH_PATH, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ ...payload, requestId: payload.requestId ?? defaultRequestId() }),
|
||||
})
|
||||
} catch (caught) {
|
||||
throw apiError('NETWORK', '无法连接服务端,批量操作没有生效', 0, caught)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await response.json()
|
||||
} catch (caught) {
|
||||
throw apiError('PARSE_FAILED', `服务端响应无法解析(HTTP ${response.status})`, response.status, caught)
|
||||
}
|
||||
if (!isRecord(body) || typeof body['ok'] !== 'boolean') {
|
||||
throw apiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', response.status, body)
|
||||
}
|
||||
if (body['ok'] !== true) {
|
||||
const error = isRecord(body['error']) ? body['error'] : {}
|
||||
const message = typeof error['message'] === 'string' ? error['message'] : `批量操作失败(HTTP ${response.status})`
|
||||
throw apiError(error['code'], message, response.status, error['detail'])
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw apiError('INTERNAL', `批量操作失败(HTTP ${response.status})`, response.status, body)
|
||||
}
|
||||
return body['data'] as EntryBatchResult
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 批次录入进度(纯函数):当前筛选范围内的「已录 / 总数」、可视化百分比与完成态。
|
||||
*
|
||||
* 抽出来的原因有两条:
|
||||
* 一是「已录」的判据必须与发布防呆、行徽标完全同源,所以这里直接调用
|
||||
* entryGuards 的 judgeEntryRow,绝不另写一套成绩解析——否则会出现
|
||||
* 「进度条说录完了、点发布却说缺 8 条」这种自相矛盾的提示;
|
||||
* 二是分母必须是**当前筛选范围**的行数(选中某一批时只数这一批),
|
||||
* 这条容易写错也最难在界面上核对,需要能被 node --test 直接断言。
|
||||
*/
|
||||
|
||||
import { heatLabel, filterRowsByHeat, type HeatFilter } from './entryHeat'
|
||||
import { judgeEntryRow } from './entryGuards'
|
||||
|
||||
/** 只要求进度需要的字段,便于表格行与测试夹具共用。 */
|
||||
export interface ProgressRow {
|
||||
/** 批次号(null = 未分批);只影响分组,不参与算分。 */
|
||||
heat: number | null
|
||||
resultText: string
|
||||
rank: number | null
|
||||
/** 成绩状态(ok / dns / dq / dnf / withdrawn)。 */
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface EntryProgressInput {
|
||||
/** 组别下的全部行(未做批次筛选)。 */
|
||||
rows: readonly ProgressRow[]
|
||||
filter: HeatFilter
|
||||
unit: string
|
||||
/** 该项目是否以手工名次为成绩(manual / compare=none),与发布口径一致。 */
|
||||
needsRank: boolean
|
||||
}
|
||||
|
||||
export interface EntryProgress {
|
||||
total: number
|
||||
filled: number
|
||||
missing: number
|
||||
/** 写错的行数:既不计入「已录」,也不阻塞进度条显示,但要在文案里点出来。 */
|
||||
error: number
|
||||
/** 是否全部录完(有错的行不算已录,所以有错时永远不是完成态)。 */
|
||||
done: boolean
|
||||
/** 进度条宽度百分比(0-100,四舍五入)。 */
|
||||
percent: number
|
||||
/** 作用域文案:全部 / 第 N 批 / 未分批。 */
|
||||
scope: string
|
||||
/** 可直接展示的完整文案,如「第 2 批 已录 5/8 · 3 条有错」。 */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 进度的作用域称谓,与批次下拉的选项文案同源。 */
|
||||
export function progressScopeLabel(filter: HeatFilter): string {
|
||||
return filter === 'all' ? '全部' : heatLabel(filter === 'none' ? null : filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计当前筛选范围内的录入进度。
|
||||
*
|
||||
* 分母 = filterRowsByHeat 后的行数;分子只算 judgeEntryRow 判为 filled 的行,
|
||||
* 未录(missing)与有错(error)都不算,有错时额外在文案里报数。
|
||||
*/
|
||||
export function entryProgress(input: EntryProgressInput): EntryProgress {
|
||||
const rows = filterRowsByHeat(input.rows, input.filter)
|
||||
let filled = 0
|
||||
let missing = 0
|
||||
let error = 0
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
// 行标签只进 blocker / warning 文案;这里只取 state,给行号即可定位。
|
||||
const judged = judgeEntryRow({
|
||||
label: `第 ${index + 1} 行`,
|
||||
resultText: row.resultText,
|
||||
unit: input.unit,
|
||||
status: row.status,
|
||||
rank: row.rank,
|
||||
needsRank: input.needsRank,
|
||||
})
|
||||
if (judged.state === 'filled') filled += 1
|
||||
else if (judged.state === 'error') error += 1
|
||||
else missing += 1
|
||||
}
|
||||
|
||||
const total = rows.length
|
||||
const done = total > 0 && filled === total
|
||||
const scope = progressScopeLabel(input.filter)
|
||||
const head = done ? `${scope} 已录完 ${filled}/${total}` : `${scope} 已录 ${filled}/${total}`
|
||||
return {
|
||||
total,
|
||||
filled,
|
||||
missing,
|
||||
error,
|
||||
done,
|
||||
percent: total === 0 ? 0 : Math.round((filled / total) * 100),
|
||||
scope,
|
||||
// 有错的行会被发布防呆拦下,进度里必须显形,否则「已录 5/8」看不出差在哪。
|
||||
text: error > 0 ? `${head} · ${error} 条有错` : head,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 录入表格「运动员 / 队」单元格的展示片段(纯函数)。
|
||||
*
|
||||
* 抽出来的原因:号码布原本只在左边单独一列,录成绩时眼睛要在姓名与号码之间来回跳,
|
||||
* 所以姓名紧后面要再显示一次号码布;而「姓名怎么回落」与「号码布为空时不占位」
|
||||
* 这两件事必须能被 node --test 直接断言——`.vue` 组件跑不进测试。
|
||||
*/
|
||||
|
||||
export interface EntryNameLike {
|
||||
athleteName: string
|
||||
/** 团体 / 接力行的队员名单;个人行为 null。 */
|
||||
memberNames: string[] | null
|
||||
}
|
||||
|
||||
/** 姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时给占位符。 */
|
||||
export function athleteCellName(row: EntryNameLike): string {
|
||||
return row.athleteName || row.memberNames?.join('、') || '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧跟姓名后的号码布文本;没有号码布(null / 空串 / 只有空白)时返回空串。
|
||||
*
|
||||
* 返回空串让模板用 v-if 直接不渲染这个 span:号码布为空的报名项很多,
|
||||
* 渲染一个空节点会留出一段空白把表格列推歪。号码布按原样去掉首尾空白展示。
|
||||
*/
|
||||
export function inlineBibLabel(bib: string | null | undefined): string {
|
||||
return bib === null || bib === undefined ? '' : bib.trim()
|
||||
}
|
||||
Reference in new issue
Block a user