Files
Scoreboard/web/src/admin/pages/ExportPage.vue
T
AskaEth 631c8dd8e1 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。
2026-09-11 17:14:02 +08:00

1397 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { formatDateTime, type ProjectInfo } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import MockBadge from '../components/system/MockBadge.vue'
import type { IconName } from '../components/icons'
import {
describeError,
projectApi,
resultsExportApi,
rulesApi,
waitForServerOnline,
type ProjectImportResult,
type ResultExportKind,
type RuleApplyResult,
type RulePreview,
} from '../components/system/systemApi'
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 节)。
*
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
*
* 赛事包导入只是把目录解到数据目录里,服务端只打开「当前赛事」那一项,
* 因此导入完成后还要在项目列表里切换,页面才看得到导入的数据。
*/
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: '规则包' },
]
interface ExportOption {
key: ResultExportKind
title: string
desc: string
icon: IconName
}
const EXPORT_OPTIONS: ReadonlyArray<ExportOption> = [
{
key: 'standings',
title: '总表',
desc: '总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿',
icon: 'standings',
},
{ key: 'all', title: '全部', desc: '总表 + 全部分项,多 sheet 工作簿,适合归档打印', icon: 'database' },
{
key: 'allFlat',
title: '全部(单页)',
desc: '所有单位、所有项目、所有成绩平铺在一张表里,适合筛选与二次统计',
icon: 'entry',
},
{ key: 'event', title: '分项成绩表', desc: '按小项导出成绩与积分,可单独给项目裁判', icon: 'export' },
{ key: 'team', title: '单位积分明细', desc: '按单位逐项列出得分来源,可解释每一分', icon: 'user' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const activeTab = ref<TabKey>('results')
// ── 结果导出 ────────────────────────────────────────────────────────────────
const pendingExport = ref<ResultExportKind | null>(null)
const resultsMock = ref(false)
const selectedEventId = ref<number | null>(null)
const selectedTeamId = ref<number | null>(null)
const eventOptions = computed(() =>
data.events.map((event) => ({
id: event.id,
label: `${data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'} / ${event.name}`,
})),
)
// ── 赛事项目包 ──────────────────────────────────────────────────────────────
const projectFile = ref<File | null>(null)
const projectOverwrite = ref(false)
const projectRename = ref('')
const projectFileInput = ref<HTMLInputElement | null>(null)
const projectImporting = ref(false)
const projectExporting = ref(false)
const projectMock = ref(false)
const confirmProjectImport = ref(false)
const projectResult = ref<ProjectImportResult | null>(null)
const projectError = ref<string | null>(null)
// 服务端一次只打开一个赛事项目(其余目录只是躺在数据目录里的数据),
// 导入不会改变「当前赛事」,所以这里列出全部项目供切换。
const projects = ref<ProjectInfo[]>([])
const projectsLoading = ref(false)
const projectsError = ref<string | null>(null)
const switchTarget = ref<string | null>(null)
const switchError = ref<string | null>(null)
/** 已触发重启、正在等服务端回来;期间所有操作都要挡住。 */
const restarting = ref(false)
// ── 规则包 ──────────────────────────────────────────────────────────────────
const rulesFileInput = ref<HTMLInputElement | null>(null)
const rulesFileName = ref('')
const rulesYaml = ref('')
const rulesExporting = ref(false)
const rulesPreviewing = ref(false)
const rulesApplying = ref(false)
const rulesMock = ref(false)
const rulesError = ref<string | null>(null)
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)
/**
* 刚导入的赛事还不是当前赛事时,页面上的数据仍旧来自原来的当前赛事,需要就地给出切换入口。
* 列表读不到时也按「还不是当前」处理:宁可多提示一次,也不要谎报已生效。
*/
const importedNotActive = computed(() => {
const imported = projectResult.value
if (imported === null) return null
return activeProjectName.value === imported.name ? null : imported
})
const CHANGE_LABEL: Record<string, string> = {
added: '新增',
removed: '删除',
modified: '修改',
unchanged: '未变',
}
function changeTone(change: string): string {
if (change === 'added') return 'mx-badge-success'
if (change === 'removed') return 'mx-badge-error'
if (change === 'modified') return 'mx-badge-warning'
return 'mx-badge-neutral'
}
async function download(kind: ResultExportKind): Promise<void> {
const request =
kind === 'event'
? { kind, eventId: selectedEventId.value ?? undefined }
: kind === 'team'
? { kind, teamId: selectedTeamId.value ?? undefined }
: { kind }
if (kind === 'event' && selectedEventId.value === null) {
toast.warning('请先选择要导出的小项')
return
}
if (kind === 'team' && selectedTeamId.value === null) {
toast.warning('请先选择要导出的单位')
return
}
pendingExport.value = kind
try {
const result = await resultsExportApi.download(request)
resultsMock.value = resultsMock.value || result.mock
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
} catch (caught) {
toast.error('导出失败', describeError(caught))
} finally {
pendingExport.value = null
}
}
async function exportProject(): Promise<void> {
projectExporting.value = true
projectError.value = null
try {
const result = await projectApi.exportZip()
projectMock.value = projectMock.value || result.mock
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
} catch (caught) {
projectError.value = describeError(caught)
} finally {
projectExporting.value = false
}
}
function pickProjectFile(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
projectResult.value = null
projectError.value = null
if (file === null) {
projectFile.value = null
return
}
if (!/\.zip$/i.test(file.name)) {
projectError.value = '请选择 .zip 格式的赛事项目包'
projectFile.value = null
input.value = ''
return
}
projectFile.value = file
}
function requestProjectImport(): void {
if (projectFile.value === null) {
projectError.value = '请先选择要导入的 .zip 包'
return
}
// 覆盖会替换掉同名赛事目录,属于不可逆操作,必须先确认。
if (projectOverwrite.value) {
confirmProjectImport.value = true
return
}
void runProjectImport()
}
async function runProjectImport(): Promise<void> {
const file = projectFile.value
if (file === null) return
confirmProjectImport.value = false
projectImporting.value = true
projectError.value = null
try {
const result = await projectApi.importZip(file, {
overwrite: projectOverwrite.value,
rename: projectRename.value,
})
projectMock.value = projectMock.value || result.mock
projectResult.value = result.data
toast.success(`已导入「${result.data.name}」`, result.mock ? '示例数据:未真正解压落盘' : null)
projectFile.value = null
if (projectFileInput.value !== null) projectFileInput.value.value = ''
// 新导入的项目要出现在列表里,用户才能就地切成当前赛事。
void loadProjects()
} catch (caught) {
projectError.value = describeError(caught)
} finally {
projectImporting.value = false
}
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
async function loadProjects(): Promise<void> {
if (!isAdmin.value) return
projectsLoading.value = true
projectsError.value = null
try {
projects.value = await adminApi.client.projects.list()
} catch (caught) {
projects.value = []
projectsError.value = describeError(caught)
} finally {
projectsLoading.value = false
}
}
// 页面挂载时 AdminLayout 还在探测会话,isAdmin 之后才变 true,所以要跟着它再拉一次列表。
watch(
isAdmin,
(value) => {
if (value) void loadProjects()
},
{ immediate: true },
)
/**
* 切换当前赛事。
*
* 服务端会把项目名写进记忆文件后优雅退出,由 Console 自动重启服务端并打开新赛事;
* 这段时间页面必须轮询健康检查等它回来,不能像普通写操作那样立刻认为已经生效。
*/
async function activateProject(name: string): Promise<void> {
if (!isAdmin.value) {
toast.warning('只有管理员可以切换当前赛事')
return
}
if (restarting.value || switchTarget.value !== null) return
switchTarget.value = name
switchError.value = null
try {
const result = await adminApi.client.projects.activate(name)
if (!result.restarting) {
// 本来就是当前赛事,服务端不会退出,直接刷新拿最新数据。
toast.success(`「${result.name}」已是当前赛事`)
window.location.reload()
return
}
restarting.value = true
toast.info('正在重启服务端…', `正在切换到「${result.name}」,请勿关闭服务管理窗口`)
const online = await waitForServerOnline({
probe: () => adminApi.client.health(),
waitForDown: true,
})
if (online) {
toast.success('服务端已恢复,正在刷新页面')
window.location.reload()
return
}
restarting.value = false
switchError.value =
'等待服务端重启超时(60 秒)。请打开服务管理窗口确认服务端状态,待其「运行中」后手动刷新本页。'
toast.error('服务端重启未在 60 秒内完成', '请在服务管理窗口查看服务端状态')
void loadProjects()
} catch (caught) {
switchError.value = describeError(caught)
} finally {
switchTarget.value = null
}
}
async function exportRules(): Promise<void> {
rulesExporting.value = true
rulesError.value = null
try {
const result = await rulesApi.exportYaml()
rulesMock.value = rulesMock.value || result.mock
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('规则包已导出', result.data.filename)
} catch (caught) {
rulesError.value = describeError(caught)
} finally {
rulesExporting.value = false
}
}
async function pickRulesFile(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
rulesPreview.value = null
rulesApplyResult.value = null
rulesError.value = null
if (file === null) {
rulesFileName.value = ''
rulesYaml.value = ''
return
}
if (!/\.(ya?ml)$/i.test(file.name)) {
rulesError.value = '规则包必须是 .yaml / .yml 文件'
rulesFileName.value = ''
rulesYaml.value = ''
input.value = ''
return
}
rulesFileName.value = file.name
try {
rulesYaml.value = await file.text()
} catch {
rulesError.value = '读取文件失败,请重试'
rulesYaml.value = ''
}
}
async function previewRules(): Promise<void> {
if (rulesYaml.value.trim() === '') {
rulesError.value = '请先选择规则包文件'
return
}
rulesPreviewing.value = true
rulesError.value = null
rulesApplyResult.value = null
try {
const result = await rulesApi.preview(rulesYaml.value)
rulesMock.value = rulesMock.value || result.mock
rulesPreview.value = result.data
} catch (caught) {
rulesPreview.value = null
rulesError.value = describeError(caught)
} finally {
rulesPreviewing.value = false
}
}
function requestRulesApply(): void {
if (rulesPreview.value === null) return
confirmRulesApply.value = true
}
async function applyRules(): Promise<void> {
confirmRulesApply.value = false
rulesApplying.value = true
rulesError.value = null
try {
const result = await rulesApi.apply(rulesYaml.value)
rulesMock.value = rulesMock.value || result.mock
rulesApplyResult.value = result.data
rulesPreview.value = result.data
toast.success(
'规则包已导入',
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
)
} catch (caught) {
rulesError.value = describeError(caught)
} finally {
rulesApplying.value = false
}
}
</script>
<template>
<div>
<PageHeader title="导出" subtitle="结果导出 / 赛事项目包 / 规则包(devdocs/07 第 5 节)">
<template #actions>
<MockBadge v-if="data.isMock" />
</template>
</PageHeader>
<div class="mx-tabs export-tabs">
<button
v-for="tab in TABS"
:key="tab.key"
class="mx-tab"
:class="{ active: activeTab === tab.key }"
type="button"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- 结果导出 -->
<section v-if="activeTab === 'results'" class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">结果导出(xlsx)</span>
<MockBadge v-if="resultsMock" />
</div>
<div class="mx-card-body">
<p class="hint">
表头、合并单元格与字体都已按打印模板预设,导出即可直接打印。「总表」「全部」由服务端合成一个多 sheet 工作簿;「全部(单页)」把所有单位所有项目的成绩平铺成一张表,便于筛选与二次统计。
</p>
<div class="export-grid">
<article v-for="option in EXPORT_OPTIONS" :key="option.key" class="export-item">
<div class="export-item-head">
<span class="export-icon"><MxIcon :name="option.icon" :size="18" /></span>
<strong>{{ option.title }}</strong>
</div>
<p class="export-desc">{{ option.desc }}</p>
<div v-if="option.key === 'event'" class="mx-input-wrap">
<select v-model="selectedEventId" class="mx-input">
<option :value="null" disabled>选择小项…</option>
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.label }}</option>
</select>
</div>
<div v-else-if="option.key === 'team'" class="mx-input-wrap">
<select v-model="selectedTeamId" class="mx-input">
<option :value="null" disabled>选择单位…</option>
<option v-for="team in data.teams" :key="team.id" :value="team.id">
{{ team.name }}({{ team.code }})
</option>
</select>
</div>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="pendingExport !== null"
@click="download(option.key)"
>
<MxIcon name="export" :size="15" />
{{ pendingExport === option.key ? '导出中…' : '下载' }}
</button>
</article>
</div>
<div v-if="data.events.length === 0" class="mx-empty">
<div class="mx-empty-title">还没有小项</div>
<div class="mx-empty-desc">先在基础数据里建立大项 / 小项,分项成绩表才有内容可导</div>
</div>
</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-if="activeTab === 'project'" class="project-grid">
<section class="mx-card wide">
<div class="mx-card-head">
<span class="mx-card-title">赛事项目列表</span>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!isAdmin || projectsLoading || restarting"
@click="loadProjects"
>
<MxIcon name="refresh" :size="14" /> {{ projectsLoading ? '读取中…' : '刷新' }}
</button>
</div>
<div class="mx-card-body">
<p class="hint">
服务端一次只打开一个赛事项目,页面上看到的数据全部来自标记为「当前」的那一项。
导入项目包只是把数据解到数据目录,必须切换为当前赛事才会生效。
</p>
<p v-if="!isAdmin" class="notice">切换当前赛事会让服务端重启,只有管理员可操作。</p>
<p v-if="restarting" class="notice warn">
正在重启服务端…切换赛事需要服务端退出后由服务管理窗口自动拉起,请勿关闭该窗口(最多等待 60 秒)。
</p>
<p v-if="switchError !== null" class="notice error">{{ switchError }}</p>
<p v-if="projectsError !== null" class="notice error">读取赛事项目列表失败:{{ projectsError }}</p>
<div v-if="projects.length === 0 && projectsError === null" class="mx-empty">
<div class="mx-empty-title">
{{
!isAdmin
? '赛事项目列表仅管理员可见'
: projectsLoading
? '正在读取赛事项目…'
: '没有可切换的赛事项目'
}}
</div>
<div v-if="isAdmin && !projectsLoading" class="mx-empty-desc">
导入一个赛事项目包后,它会出现在这个列表里
</div>
</div>
<div v-else-if="projects.length > 0" 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="item in projects" :key="item.name">
<td class="mono">{{ item.name }}</td>
<td>
<span v-if="item.active" class="mx-badge mx-badge-success">当前</span>
<span v-else-if="!item.hasEvent" class="mx-badge mx-badge-error">缺 event.db</span>
<span v-else class="mx-badge mx-badge-neutral">未打开</span>
</td>
<td>{{ formatDateTime(item.updatedAt) }}</td>
<td>{{ formatBytes(item.sizeBytes) }}</td>
<td>
<button
class="mx-btn mx-btn-secondary mx-btn-sm"
type="button"
:disabled="!isAdmin || item.active || !item.hasEvent || restarting || switchTarget !== null"
@click="activateProject(item.name)"
>
{{ switchTarget === item.name ? '切换中…' : '切换为当前赛事' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出项目包</span>
<MockBadge v-if="projectMock" />
</div>
<div class="mx-card-body">
<p class="hint">
包内含 <code class="mono">meta.yaml</code> + <code class="mono">event.db</code> +
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>。会标、背景图与上传的字体
都在 <code class="mono">uploads/</code> 里(字体在 <code class="mono">uploads/fonts/</code>),
连同字体设置一并随包带走,整包拷到另一台机器导入后即可复现整场赛事。
</p>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="projectExporting || restarting"
@click="exportProject"
>
<MxIcon name="export" :size="15" /> {{ projectExporting ? '打包中…' : '导出 zip' }}
</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="projectFileInput"
class="file-input"
type="file"
accept=".zip,application/zip"
@change="pickProjectFile"
/>
<button class="mx-btn mx-btn-secondary" type="button" @click="projectFileInput?.click()">
<MxIcon name="database" :size="15" /> 选择 .zip
</button>
<span class="file-name mono">{{ projectFile?.name ?? '未选择文件' }}</span>
</div>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="project-rename">重命名(可选)</label>
<div class="mx-input-wrap">
<input
id="project-rename"
v-model="projectRename"
class="mx-input"
placeholder="留空则用包内项目名"
/>
</div>
</div>
<div class="field switch-field">
<span class="mx-label">覆盖同名项目</span>
<span class="switch-row">
<label class="mx-switch">
<input v-model="projectOverwrite" type="checkbox" />
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
</label>
<span class="switch-text">{{ projectOverwrite ? '覆盖(危险)' : '不覆盖,重名时失败' }}</span>
</span>
</div>
</div>
<p v-if="projectError !== null" class="notice error">{{ projectError }}</p>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!isAdmin || projectImporting || projectFile === null || restarting"
@click="requestProjectImport"
>
{{ projectImporting ? '导入中…' : '导入' }}
</button>
<div v-if="projectResult !== null" class="result-box">
<div class="result-title"><MxIcon name="check" :size="15" /> 导入完成</div>
<dl class="result-list">
<div><dt>项目名</dt><dd>{{ projectResult.name }}</dd></div>
<div><dt>落盘目录</dt><dd class="mono">{{ projectResult.dir }}</dd></div>
<div><dt>数据库</dt><dd class="mono">{{ projectResult.eventDB }}</dd></div>
</dl>
<p v-if="importedNotActive === null" class="hint">
「{{ projectResult.name }}」已经是当前赛事,直接刷新即可看到数据。
</p>
<template v-else>
<p class="notice warn">
已导入为「{{ projectResult.name }}」,但它还不是当前赛事:服务端当前打开的是「{{
activeProjectName ?? 'default'
}}」,页面上看到的是那一项的数据。
</p>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!isAdmin || restarting || switchTarget !== null"
@click="activateProject(projectResult.name)"
>
<MxIcon name="refresh" :size="15" />
{{ switchTarget === projectResult.name ? '切换中…' : '立即切换并重启服务端' }}
</button>
<p class="hint">切换会让服务端退出并由服务管理窗口自动拉起,期间页面会短暂连不上。</p>
</template>
<p v-if="!isAdmin" class="notice">只有管理员可以切换当前赛事。</p>
</div>
</div>
</section>
</div>
<!-- 规则包 -->
<div v-if="activeTab === 'rules'" class="rules-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出规则包</span>
<MockBadge v-if="rulesMock" />
</div>
<div class="mx-card-body">
<p class="hint">
导出为 YAML,含积分模板、并列规则、破纪录加分、计分通道与项目覆盖,可直接给另一场赛事复用。
</p>
<button class="mx-btn mx-btn-primary full" type="button" :disabled="rulesExporting" @click="exportRules">
<MxIcon name="export" :size="15" /> {{ rulesExporting ? '导出中…' : '导出 rules.yaml' }}
</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="rulesFileInput"
class="file-input"
type="file"
accept=".yaml,.yml,text/yaml"
@change="pickRulesFile"
/>
<button class="mx-btn mx-btn-secondary" type="button" @click="rulesFileInput?.click()">
<MxIcon name="database" :size="15" /> 选择 YAML
</button>
<span class="file-name mono">{{ rulesFileName === '' ? '未选择文件' : rulesFileName }}</span>
</div>
<p class="hint">导入分两步:先 <code class="mono">?preview=1</code> 预览差异,确认后再落地。</p>
<p v-if="rulesError !== null" class="notice error">{{ rulesError }}</p>
<button
class="mx-btn mx-btn-secondary full"
type="button"
:disabled="!isAdmin || rulesPreviewing || rulesYaml === ''"
@click="previewRules"
>
{{ rulesPreviewing ? '比对中…' : '预览差异' }}
</button>
<div v-if="rulesPreview !== null" class="preview-box">
<div class="preview-head">
<span class="mx-badge" :class="rulesPreview.compatible ? 'mx-badge-success' : 'mx-badge-error'">
{{ rulesPreview.compatible ? '格式兼容' : '格式不兼容,禁止导入' }}
</span>
<span class="preview-meta">影响 {{ rulesPreview.affectedEventCount }} 个小项</span>
</div>
<div v-if="rulesPreview.rows.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>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rulesPreview.rows" :key="`${row.kind}-${row.target}-${index}`">
<td>{{ row.kind }}</td>
<td class="mono">{{ row.target }}</td>
<td>
<span class="mx-badge" :class="changeTone(row.change)">
{{ CHANGE_LABEL[row.change] ?? row.change }}
</span>
</td>
<td class="detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div>
<p v-if="rulesPreview.unknownEvents.length > 0" class="notice warn">
以下项目编码在当前赛事库中不存在,导入时会被跳过:{{ rulesPreview.unknownEvents.join('、') }}
</p>
<div v-if="rulesApplyResult !== null" class="result-box">
<div class="result-title"><MxIcon name="check" :size="15" /> 已全量重算</div>
<p class="hint">
规则已落库,服务端重算了
<strong class="mono">{{ rulesApplyResult.recalculatedLedgerRows }}</strong>
条积分流水;大屏会随实时推送自动刷新。
</p>
</div>
<button
class="mx-btn mx-btn-danger full"
type="button"
:disabled="!isAdmin || !rulesPreview.compatible || rulesApplying"
@click="requestRulesApply"
>
{{ rulesApplying ? '导入中…' : '确认导入并全量重算' }}
</button>
</div>
</div>
</section>
</div>
<ConfirmDialog
:open="confirmProjectImport"
tone="danger"
title="覆盖同名赛事项目?"
:message="`将用「${projectFile?.name ?? ''}」覆盖同名赛事目录,旧目录会先改名保留但不会出现在列表里。`"
:details="['覆盖后如需回退,需要手工把旧目录改回来', '导入期间请勿停止服务端进程']"
confirm-text="覆盖导入"
:pending="projectImporting"
@confirm="runProjectImport"
@cancel="confirmProjectImport = false"
/>
<ConfirmDialog
:open="confirmRulesApply"
tone="danger"
title="导入规则包并全量重算积分?"
:message="'规则一旦落地,全部已发布成绩会按新分值重算,积分榜与大屏会立即变化。'"
:details="[
`影响 ${rulesPreview?.affectedEventCount ?? 0} 个小项`,
`将产生 ${rulesPreview?.rows.length ?? 0} 处规则改动`,
'重算完成后不可一键撤销,纠错需重新导入旧规则包',
]"
confirm-text="导入并重算"
:pending="rulesApplying"
@confirm="applyRules"
@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>
<style scoped>
.export-tabs {
margin-bottom: 14px;
}
.hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.export-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.export-item {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
border: 1px solid var(--mx-border);
}
.export-item-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.export-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--mx-r-xs);
background: var(--mx-primary-glow);
color: var(--mx-primary);
}
.export-desc {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
min-height: 38px;
}
.full {
width: 100%;
}
.project-grid,
.rules-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
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;
}
.file-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.file-input {
display: none;
}
.file-name {
font-size: 12px;
color: var(--mx-t3);
word-break: break-all;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
}
.switch-field {
justify-content: flex-end;
}
.switch-row {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
}
.switch-text {
font-size: 12px;
color: var(--mx-t3);
}
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.notice.warn {
color: var(--mx-amber);
background: rgba(251, 191, 36, 0.08);
}
.result-box,
.preview-box {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
border: 1px solid var(--mx-border);
}
.result-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
color: var(--mx-green);
}
.result-list {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.result-list div {
display: flex;
gap: 10px;
}
.result-list dt {
color: var(--mx-t3);
min-width: 64px;
}
.result-list dd {
color: var(--mx-t2);
word-break: break-all;
}
.preview-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.preview-meta {
font-size: 12px;
color: var(--mx-t3);
}
.detail {
font-size: 12px;
color: var(--mx-t3);
max-width: 260px;
}
.footer-note {
margin-top: 16px;
font-size: 12px;
color: var(--mx-t3);
}
@media (max-width: 1279px) {
.project-grid,
.rules-grid,
.basedata-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1023px) {
.export-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>