Files
Scoreboard/web/src/admin/pages/ExportPage.vue
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +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>