Files
Scoreboard/web/src/admin/pages/ExportPage.vue
T

789 lines
25 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 } from 'vue'
import { formatDateTime } 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,
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'
/**
* 导出与赛事包(devdocs/07 第 5 节)。
*
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
*/
type TabKey = 'results' | 'project' | 'rules'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'results', 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: '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 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)
const isAdmin = computed(() => auth.user?.role === 'admin')
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 = ''
} catch (caught) {
projectError.value = describeError(caught)
} finally {
projectImporting.value = false
}
}
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>
<!-- 赛事项目包 -->
<div v-else-if="activeTab === 'project'" class="project-grid">
<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>(字体可选)。
整包拷走即可在另一台机器复现整场赛事。
</p>
<button class="mx-btn mx-btn-primary full" type="button" :disabled="projectExporting" @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"
@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 class="hint">如需立即切换到这个赛事,请到「系统 · 赛事信息」确认配置后重启服务端。</p>
</div>
</div>
</section>
</div>
<!-- 规则包 -->
<div v-else 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"
/>
<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;
}
.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 {
grid-template-columns: 1fr;
}
}
@media (max-width: 1023px) {
.export-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>