789 lines
25 KiB
Vue
789 lines
25 KiB
Vue
<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>
|