fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500
This commit is contained in:
1 parent
72462792ec
commit
5ae55b8057
36 files changed
+2672
-108
No files matched your search
@@ -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 +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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user