用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
1397 lines
49 KiB
Vue
1397 lines
49 KiB
Vue
<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>
|