fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500

This commit is contained in:
AskaEth committed 2026-09-09 19:57:23 +08:00
1 parent 72462792ec
commit 5ae55b8057
36 files changed
+2672 -108

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import AthletesSection from '../components/baseData/AthletesSection.vue'
import CategoriesSection from '../components/baseData/CategoriesSection.vue'
import ChannelsSection from '../components/baseData/ChannelsSection.vue'
import ImportSection from '../components/baseData/ImportSection.vue'
import ProfilesSection from '../components/baseData/ProfilesSection.vue'
import SportsEventsSection from '../components/baseData/SportsEventsSection.vue'
import TeamsSection from '../components/baseData/TeamsSection.vue'
import { BASE_DATA_TABS, type BaseDataTabKey } from '../components/baseData/tabs'
import { useBaseData } from '../components/baseData/useBaseData'
import { useToastStore } from '../stores/toast'
/**
* 基础数据页(07 第 1 节)。
*
* 七个分区共用一份数据状态(见 useBaseData);分区之间通过 `goTab` 跳转,
* 例如「大项 / 小项」里点「配置组别」直接切到组别分区。
*/
const data = useBaseData()
const toast = useToastStore()
const activeTab = ref<BaseDataTabKey>('teams')
function setTab(tab: BaseDataTabKey): void {
activeTab.value = tab
}
async function refresh(): Promise<void> {
await data.load(true)
const selected = data.selectedEventId.value
if (selected !== null) await data.loadEventDetail(selected, true)
toast.success('基础数据已刷新')
}
onMounted(() => {
void data.load()
})
</script>
<template>
<div>
<PageHeader title="基础数据" subtitle="单位 / 大项 / 小项 / 组别 / 计分通道 / 积分模板 / 运动员 / 报名导入">
<template #actions>
<span v-if="data.isMock.value" class="mx-badge mx-badge-warning">示例数据</span>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="data.loading.value" @click="refresh">
<MxIcon name="refresh" :size="15" /> {{ data.loading.value ? '刷新中…' : '刷新' }}
</button>
</template>
</PageHeader>
<div v-if="data.isMock.value" class="mock-note">
<MxIcon name="warning" :size="15" />
<span>{{ data.mockLabel.value }},本页改动只写入内存示例数据,不会影响真实赛事。</span>
</div>
<div v-if="data.error.value" class="error-note">
<span class="mx-badge mx-badge-error">加载失败</span>
<span>{{ data.error.value }}</span>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="refresh">重试</button>
</div>
<div class="mx-tabs page-tabs">
<button
v-for="tab in BASE_DATA_TABS"
:key="tab.key"
class="mx-tab"
:class="{ active: activeTab === tab.key }"
type="button"
@click="setTab(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div v-if="!data.loaded.value && data.loading.value" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">正在加载基础数据…</div>
<div class="mx-empty-desc">单位 / 项目 / 模板 / 运动员</div>
</div>
</div>
</div>
<template v-else>
<TeamsSection v-if="activeTab === 'teams'" />
<SportsEventsSection v-else-if="activeTab === 'events'" @go-tab="setTab" />
<CategoriesSection v-else-if="activeTab === 'categories'" @go-tab="setTab" />
<ChannelsSection v-else-if="activeTab === 'channels'" @go-tab="setTab" />
<ProfilesSection v-else-if="activeTab === 'profiles'" />
<AthletesSection v-else-if="activeTab === 'athletes'" />
<ImportSection v-else />
</template>
</div>
</template>
<style>
/* 页面级共享布局类(分区组件 scoped 样式里也会用到)。 */
@import '../components/baseData/baseData.css';
</style>
<style scoped>
.mock-note,
.error-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 14px;
border-radius: var(--mx-r-sm);
font-size: 13px;
}
.mock-note {
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.error-note {
background: rgba(248, 113, 113, 0.1);
color: var(--mx-red);
}
.error-note .mx-btn {
margin-left: auto;
}
.page-tabs {
margin-bottom: 16px;
flex-wrap: wrap;
}
</style>
+7 -6
View File
@@ -7,6 +7,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
import MockBadge from '../components/system/MockBadge.vue'
import type { IconName } from '../components/icons'
import {
describeError,
projectApi,
resultsExportApi,
rulesApi,
@@ -137,7 +138,7 @@ async function download(kind: ResultExportKind): Promise<void> {
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)
toast.error('导出失败', describeError(caught))
} finally {
pendingExport.value = null
}
@@ -152,7 +153,7 @@ async function exportProject(): Promise<void> {
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
} catch (caught) {
projectError.value = caught instanceof Error ? caught.message : '导出失败'
projectError.value = describeError(caught)
} finally {
projectExporting.value = false
}
@@ -206,7 +207,7 @@ async function runProjectImport(): Promise<void> {
projectFile.value = null
if (projectFileInput.value !== null) projectFileInput.value.value = ''
} catch (caught) {
projectError.value = caught instanceof Error ? caught.message : '导入失败'
projectError.value = describeError(caught)
} finally {
projectImporting.value = false
}
@@ -221,7 +222,7 @@ async function exportRules(): Promise<void> {
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('规则包已导出', result.data.filename)
} catch (caught) {
rulesError.value = caught instanceof Error ? caught.message : '导出失败'
rulesError.value = describeError(caught)
} finally {
rulesExporting.value = false
}
@@ -268,7 +269,7 @@ async function previewRules(): Promise<void> {
rulesPreview.value = result.data
} catch (caught) {
rulesPreview.value = null
rulesError.value = caught instanceof Error ? caught.message : '预览失败'
rulesError.value = describeError(caught)
} finally {
rulesPreviewing.value = false
}
@@ -293,7 +294,7 @@ async function applyRules(): Promise<void> {
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
)
} catch (caught) {
rulesError.value = caught instanceof Error ? caught.message : '导入失败'
rulesError.value = describeError(caught)
} finally {
rulesApplying.value = false
}
+483
View File
@@ -0,0 +1,483 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
formatDateTime,
type EntryWithResult,
type Event,
type EventCategory,
type ResultStatus,
type ScoreChannel,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import RevisionGraph from '../components/history/RevisionGraph.vue'
import RevisionDetail from '../components/history/RevisionDetail.vue'
import { buildGraph, describeRow, diffSnapshots, laneColor } from '../components/history/graph'
import { fetchRevisions } from '../components/history/revisionsSource'
import { createMockRevisions } from '../components/history/mockRevisions'
import { ACTION_LABELS, type Revision, type RevisionDiffRow, type TeamImpact } from '../components/history/types'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { useEventStore } from '../stores/event'
type TimeFilter = 'all' | '24h' | '7d'
/** 计算 diff / 影响面所需的小项上下文,按 eventId 缓存。 */
interface EventContext {
event: Event
categories: EventCategory[]
channels: ScoreChannel[]
entryById: Map<number, EntryWithResult>
}
const data = useEventStore()
const revisions = ref<Revision[]>([])
const loading = ref(false)
const usingMock = ref(false)
const mockReason = ref<string | null>(null)
const eventFilter = ref<number | 'all'>('all')
const timeFilter = ref<TimeFilter>('all')
const selectedId = ref<number | null>(null)
const contexts = ref<Record<number, EventContext>>({})
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const visibleRevisions = computed(() => {
const threshold = timeThreshold(timeFilter.value)
const list = threshold === null ? revisions.value : revisions.value.filter((item) => item.createdAt >= threshold)
return [...list].sort((a, b) => (a.createdAt === b.createdAt ? b.id - a.id : a.createdAt < b.createdAt ? 1 : -1))
})
const layout = computed(() => buildGraph(visibleRevisions.value))
const selectedRevision = computed<Revision | null>(
() => visibleRevisions.value.find((item) => item.id === selectedId.value) ?? null,
)
const parentRevision = computed<Revision | null>(() => {
const revision = selectedRevision.value
if (revision === null || revision.parentId === null) return null
return revisions.value.find((item) => item.id === revision.parentId) ?? null
})
const selectedContext = computed<EventContext | null>(() => {
const revision = selectedRevision.value
if (revision === null) return null
return contexts.value[revision.eventId] ?? null
})
const selectedEventName = computed(() => {
const revision = selectedRevision.value
if (revision === null) return '—'
return data.eventById(revision.eventId)?.name ?? selectedContext.value?.event.name ?? `项目 ${revision.eventId}`
})
const actorLabel = computed(() => {
const revision = selectedRevision.value
if (revision === null) return '—'
if (revision.actorName !== null && revision.actorName !== '') return revision.actorName
return revision.actorId === null ? '系统' : `用户 #${revision.actorId}`
})
const impacts = computed<TeamImpact[]>(() => {
const revision = selectedRevision.value
if (revision === null) return []
const context = selectedContext.value
const current = teamTotals(revision, context)
const previous = parentRevision.value === null ? new Map<number, number>() : teamTotals(parentRevision.value, context)
const ids = new Set<number>([...current.keys(), ...previous.keys()])
const list: TeamImpact[] = []
for (const teamId of ids) {
const delta = round((current.get(teamId) ?? 0) - (previous.get(teamId) ?? 0))
if (delta === 0) continue
list.push({ teamId, label: teamLabel(teamId), delta })
}
return list.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
})
const diffRows = computed<RevisionDiffRow[]>(() => {
const revision = selectedRevision.value
if (revision === null) return []
const context = selectedContext.value
const individual = context?.event.eventType === 'individual'
const factor = individual ? weight.value : 1
return diffSnapshots(revision.snapshot, parentRevision.value?.snapshot ?? null).map((entry) => {
const info = context?.entryById.get(entry.entryId)
const teamId = entry.after?.teamId ?? entry.before?.teamId ?? info?.teamId ?? null
const beforePoints = entry.before?.points ?? null
const afterPoints = entry.after?.points ?? null
const pointsDelta = round(((afterPoints ?? 0) - (beforePoints ?? 0)) * factor)
return {
entryId: entry.entryId,
kind: entry.kind,
teamLabel: teamId === null ? '—' : teamLabel(teamId),
athleteLabel: info?.athleteName ?? info?.memberNames?.join('、') ?? '',
beforeText: describeRow(entry.before),
afterText: describeRow(entry.after),
beforePoints,
afterPoints,
pointsDelta,
}
})
})
const eventOptions = computed(() =>
data.events.map((event) => ({ id: event.id, name: event.name })).sort((a, b) => a.name.localeCompare(b.name)),
)
function round(value: number): number {
return Number(value.toFixed(2))
}
function timeThreshold(filter: TimeFilter): string | null {
if (filter === 'all') return null
const hours = filter === '24h' ? 24 : 24 * 7
return new Date(Date.now() - hours * 3_600_000).toISOString()
}
function teamLabel(teamId: number): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
}
function normalizeStatus(value: string | null): ResultStatus {
if (value === 'dq' || value === 'dns' || value === 'dnf' || value === 'withdrawn') return value
return 'ok'
}
/** 快照带 points 时用服务端真值;缺 points 时用录入页同一套纯函数重算。 */
function teamTotals(revision: Revision, context: EventContext | null): Map<number, number> {
const individual = context?.event.eventType === 'individual'
const factor = individual ? weight.value : 1
// 只有已发布成绩才产生积分流水;草稿行即使带分值也不计入。
const publishedRows = revision.snapshot.filter((row) => row.state !== 'draft')
const hasPoints = publishedRows.length > 0 && publishedRows.every((row) => row.points !== null)
if (context === null || hasPoints) {
const totals = new Map<number, number>()
for (const row of publishedRows) {
if (row.points === null) continue
const teamId = row.teamId ?? context?.entryById.get(row.entryId)?.teamId ?? null
if (teamId === null) continue
totals.set(teamId, (totals.get(teamId) ?? 0) + row.points * factor)
}
return totals
}
const scorable: ScorableRow[] = []
for (const row of publishedRows) {
const teamId = row.teamId ?? context.entryById.get(row.entryId)?.teamId
if (teamId === undefined) continue
scorable.push({
entryId: row.entryId,
teamId,
resultValue: row.resultValue,
rank: row.rank,
status: normalizeStatus(row.status),
isTie: false,
})
}
const category = context.categories.length === 1 ? (context.categories[0] ?? null) : null
return computeEventScore(scorable, {
event: context.event,
category,
channels: context.channels,
profiles: data.profiles,
weight: weight.value,
}).teamPoints
}
async function ensureContext(eventId: number): Promise<void> {
if (contexts.value[eventId] !== undefined) return
try {
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
contexts.value = {
...contexts.value,
[eventId]: {
event,
categories: event.categories ?? [],
channels: event.channels ?? [],
entryById: new Map(entries.map((entry) => [entry.id, entry])),
},
}
} catch {
// 上下文拉不到只影响单位名与重算,快照本身的 diff 仍然可读。
}
}
async function load(): Promise<void> {
loading.value = true
const eventId = eventFilter.value === 'all' ? undefined : eventFilter.value
try {
revisions.value = await fetchRevisions(eventId === undefined ? {} : { eventId })
usingMock.value = false
mockReason.value = null
} catch (caught) {
const all = createMockRevisions()
revisions.value = eventId === undefined ? all : all.filter((item) => item.eventId === eventId)
usingMock.value = true
mockReason.value = caught instanceof Error ? caught.message : '变更历史接口不可用'
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
})
watch(eventFilter, () => {
void load()
})
watch(
visibleRevisions,
(list) => {
if (list.length === 0) {
selectedId.value = null
return
}
if (!list.some((item) => item.id === selectedId.value)) selectedId.value = list[0]?.id ?? null
},
{ immediate: true },
)
watch(
selectedRevision,
(revision) => {
if (revision !== null) void ensureContext(revision.eventId)
},
{ immediate: true },
)
</script>
<template>
<div class="history-page">
<PageHeader title="变更历史" subtitle="仿 Git Graph 的操作图谱,只读;纠错走「撤销发布 → 改 → 重新发布」">
<template #actions>
<span class="mx-badge mx-badge-neutral"><MxIcon name="lock" :size="13" /> 只读</span>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<div v-if="usingMock" class="mock-banner">
<MxIcon name="warning" :size="15" />
<span>示例数据:{{ mockReason ?? '/api/revisions 暂不可用' }}</span>
<span class="mock-hint">接口就绪后自动切换为真实数据</span>
</div>
<div class="filter-bar">
<label class="filter">
<span>小项</span>
<select v-model="eventFilter" class="mx-cell-input">
<option value="all">全部小项</option>
<option v-for="event in eventOptions" :key="event.id" :value="event.id">{{ event.name }}</option>
</select>
</label>
<div class="mx-tabs">
<button class="mx-tab" :class="{ active: timeFilter === 'all' }" type="button" @click="timeFilter = 'all'">全部时间</button>
<button class="mx-tab" :class="{ active: timeFilter === '24h' }" type="button" @click="timeFilter = '24h'">近 24 小时</button>
<button class="mx-tab" :class="{ active: timeFilter === '7d' }" type="button" @click="timeFilter = '7d'">近 7 天</button>
</div>
<span class="filter-count">{{ visibleRevisions.length }} 个节点 · {{ layout.laneCount }} 条泳道</span>
</div>
<div v-if="loading" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载变更历史…</div>
</div>
</div>
</div>
<div v-else-if="visibleRevisions.length === 0" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="history" :size="28" /></div>
<div class="mx-empty-title">没有符合条件的修订</div>
<div class="mx-empty-desc">发布、撤销发布、编辑成绩都会生成节点</div>
</div>
</div>
</div>
<div v-else class="history-grid">
<section class="mx-card graph-card">
<div class="mx-card-head">
<span class="mx-card-title">时间轴</span>
<span class="mx-badge mx-badge-info">最新在上</span>
</div>
<div class="graph-scroll">
<RevisionGraph
:rows="layout.rows"
:width="layout.width"
:selected-id="selectedId"
@select="selectedId = $event"
>
<template #default="{ row }">
<span class="node" :class="{ selected: row.revision.id === selectedId }">
<span class="node-line">
<span class="mx-tag" :class="`tag-${row.revision.action}`">{{ ACTION_LABELS[row.revision.action] }}</span>
<span class="node-event">{{ data.eventById(row.revision.eventId)?.name ?? `项目 ${row.revision.eventId}` }}</span>
<span v-if="row.isBranch" class="mx-tag mx-tag-warning">分支</span>
</span>
<span class="node-meta">
<span :style="{ color: laneColor(row.lane) }" class="mono">#{{ row.revision.id }}</span>
<span>{{ row.revision.actorName ?? (row.revision.actorId === null ? '系统' : `用户 #${row.revision.actorId}`) }}</span>
<span class="mono">{{ formatDateTime(row.revision.createdAt) }}</span>
</span>
<span v-if="row.revision.message" class="node-message">{{ row.revision.message }}</span>
</span>
</template>
</RevisionGraph>
</div>
</section>
<aside class="mx-card detail-card">
<div class="mx-card-head">
<span class="mx-card-title">节点详情</span>
<span v-if="selectedRevision" class="mx-badge mx-badge-neutral mono">#{{ selectedRevision.id }}</span>
</div>
<div class="mx-card-body">
<RevisionDetail
v-if="selectedRevision"
:revision="selectedRevision"
:parent="parentRevision"
:event-name="selectedEventName"
:actor-label="actorLabel"
:diff="diffRows"
:impacts="impacts"
/>
<div v-else class="mx-empty">
<div class="mx-empty-title">选择左侧一个节点</div>
<div class="mx-empty-desc">查看操作人、影响单位与成绩 diff</div>
</div>
</div>
</aside>
</div>
<p class="foot-hint">
<MxIcon name="lock" :size="13" />
本页只读,不提供一键回滚:纠错请到成绩录入页「撤销发布 → 修改 → 重新发布」,每步都会留审计。
</p>
</div>
</template>
<style scoped>
.mock-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
font-size: 13px;
margin-top: 14px;
}
.mock-hint {
color: var(--mx-t3);
font-size: 12px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin: 14px 0;
}
.filter {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
}
.filter .mx-cell-input {
width: 200px;
}
.filter-count {
font-size: 12px;
color: var(--mx-t3);
margin-left: auto;
}
.history-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 16px;
align-items: start;
}
.graph-card {
min-width: 0;
}
.graph-scroll {
max-height: calc(100dvh - 260px);
overflow: auto;
padding: 8px;
}
.detail-card {
position: sticky;
top: 0;
}
.node {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding-right: 10px;
}
.node-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.node-event {
font-size: 13px;
font-weight: 600;
}
.node-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: var(--mx-t3);
}
.node-message {
font-size: 11px;
color: var(--mx-t3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-publish {
background: rgba(52, 211, 153, 0.12);
color: var(--mx-green);
}
.tag-unpublish {
background: rgba(248, 113, 113, 0.12);
color: var(--mx-red);
}
.tag-edit {
background: rgba(251, 191, 36, 0.12);
color: var(--mx-amber);
}
.foot-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 18px;
font-size: 12px;
color: var(--mx-t3);
}
@media (max-width: 1279px) {
.history-grid {
grid-template-columns: 1fr;
}
.detail-card {
position: static;
}
}
</style>
+1 -1
View File
@@ -41,7 +41,7 @@ function asGuest(): void {
<div class="mx-card login-card">
<div class="mx-card-body">
<div class="login-brand">
<span class="mx-logo-icon"><MxIcon name="standings" :size="20" /></span>
<img class="app-icon" src="/scoreboard.png" alt="" width="36" height="36" />
<div>
<h1>积分榜管理面板</h1>
<p>2026 职工运动会 · 成绩录入与实时积分</p>
+3 -2
View File
@@ -5,6 +5,7 @@ import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import MockBadge from '../components/system/MockBadge.vue'
import JsonDiff from '../components/system/JsonDiff.vue'
import { describeError } from '../components/system/systemApi'
import { adminApi, mockMode } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
@@ -90,8 +91,8 @@ async function load(): Promise<void> {
usingMock.value = mockMode.value
if (page.value > pageCount.value) page.value = 1
} catch (caught) {
loadError.value = caught instanceof Error ? caught.message : '加载审计日志失败'
toast.error('加载审计日志失败', caught instanceof Error ? caught.message : null)
loadError.value = describeError(caught)
toast.error('加载审计日志失败', describeError(caught))
} finally {
loading.value = false
}