平均值口径
@@ -1328,7 +1663,8 @@ watch(
{{ row.teamShort || row.teamName }}
|
-
{{ row.athleteName || row.memberNames?.join('、') || '—' }} |
+
+
{{ athleteCellName(row) }} {{ inlineBibLabel(row.bib) }} |
{{ rankOf(row) ?? '—' }}
|
-
+ |
+ {{ ENTRY_STATUS_LABEL[row.entryStatus] }}
+
+
+
+
+
@@ -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;
diff --git a/web/src/admin/pages/ExportPage.vue b/web/src/admin/pages/ExportPage.vue
index b007796..aae2763 100644
--- a/web/src/admin/pages/ExportPage.vue
+++ b/web/src/admin/pages/ExportPage.vue
@@ -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(null)
const rulesApplyResult = ref(null)
const confirmRulesApply = ref(false)
+// ── 报名名单(xlsx,可按小项 / 组别 / 单位 / 批次筛) ────────────────────────
+const entryEventId = ref(null)
+const entryCategoryId = ref(null)
+const entryTeamId = ref(null)
+/** 批次用字符串存:空串 = 不筛,交给后端时再转成数字。 */
+const entryHeat = ref('')
+const entryCategories = ref([])
+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 {
+ 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(null)
+const baseDataInput = ref(null)
+const baseDataExporting = ref(false)
+const baseDataPreviewing = ref(false)
+const baseDataApplying = ref(false)
+const baseDataError = ref(null)
+const baseDataResult = ref(null)
+const confirmBaseDataApply = ref(false)
+
+async function exportBaseData(): Promise {
+ 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 {
+ 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 {
+ 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 {
+
+
+
+ 报名名单(xlsx)
+
+
+
+ 按小项 / 组别 / 单位 / 批次筛选,留空表示该维度不筛。表里含小项编码与名称、组别、单位、姓名、证件号、号码布、批次、性别、年龄、状态与备注;号码布按文本导出,前导零不会丢。
+
+
+
+
+
+
+
+
+
+
+ 导出基础数据包
+
+
+
+ 一个工作簿含四张工作表:单位 / 大项 / 小项 / 组别(组别带所属小项编码)。列名是中文表头,导出的文件可以直接原样导回来,识别到的差异为 0。
+
+
+
+
+
+
+ 导入基础数据包
+
+ 导入基础数据会批量写入单位 / 大项 / 小项 / 组别,只有管理员可操作。
+
+
+
+
+ {{ baseDataFile?.name ?? '未选择文件' }}
+
+
+
+ 按编码 upsert:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;存在就更新、不存在就新建。
+ 文件里没写的数据一条都不会删。
+
+ 导入分两步:先「预览差异」,确认无误后再写入。
+
+ {{ baseDataError }}
+
+
+
+
+
+ 新增 {{ baseDataResult.added }} 条
+ 更新 {{ baseDataResult.updated }} 条
+
+ 错误 {{ baseDataResult.errorCount }} 条
+
+
+ {{ baseDataResult.dryRun ? '预览(未写库)' : '已写入' }}
+ · 无变化 {{ baseDataResult.unchanged }} 条
+
+
+
+
+
+
+ 没有差异
+ 文件内容与库里一致,无需写入
+
+
+
+
+
+ | 工作表 |
+ 行 |
+ 变化 |
+ 对象 |
+ 字段差异 |
+
+
+
+
+ | {{ row.sheet }} |
+ 第 {{ row.line }} 行 |
+
+
+ {{ row.action === 'added' ? '新增' : '更新' }}
+
+ |
+ {{ row.key }} |
+
+
+ {{ f.field }}:{{ f.from === '' ? '(空)' : f.from }} → {{ f.to === '' ? '(空)' : f.to }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
-
+
赛事项目列表
@@ -676,7 +1020,7 @@ async function applyRules(): Promise {
-
+
导出规则包
@@ -815,6 +1159,24 @@ async function applyRules(): Promise {
@cancel="confirmRulesApply = false"
/>
+
+
@@ -879,6 +1241,33 @@ async function applyRules(): Promise {
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 {
}
@media (max-width: 1279px) {
.project-grid,
- .rules-grid {
+ .rules-grid,
+ .basedata-grid {
grid-template-columns: 1fr;
}
}
diff --git a/web/src/admin/pages/ReviewPage.vue b/web/src/admin/pages/ReviewPage.vue
index 44a3497..3b1b979 100644
--- a/web/src/admin/pages/ReviewPage.vue
+++ b/web/src/admin/pages/ReviewPage.vue
@@ -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(null)
const returnedKeys = ref([])
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 {
const group = publishTarget.value
if (group === null) return
@@ -361,7 +381,11 @@ async function confirmPublish(): Promise {
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"
diff --git a/web/src/admin/pages/SystemPage.vue b/web/src/admin/pages/SystemPage.vue
index 400ded0..abdca38 100644
--- a/web/src/admin/pages/SystemPage.vue
+++ b/web/src/admin/pages/SystemPage.vue
@@ -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(
|