feat(server): 用户管理与变更历史接口;修复 result_revisions.parent_id 从未写入

This commit is contained in:
AskaEth committed 2026-09-09 19:51:50 +08:00
1 parent f3813171d3
commit 72462792ec
39 files changed
+10001 -28

No files matched your search

+787
View File
@@ -0,0 +1,787 @@
<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 {
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('导出失败', caught instanceof Error ? caught.message : null)
} 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 = caught instanceof Error ? caught.message : '导出失败'
} 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 = caught instanceof Error ? caught.message : '导入失败'
} 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 = caught instanceof Error ? caught.message : '导出失败'
} 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 = caught instanceof Error ? caught.message : '预览失败'
} 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 = caught instanceof Error ? caught.message : '导入失败'
} 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>