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

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

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { useEventStore } from '../stores/event'
const data = useEventStore()
const SYSTEM_NAME = '运动会积分榜系统'
const SYSTEM_EN = 'Scoreboard'
// /api/health 只回 version,没有构建时间字段;构建期常量要改 vite.config,本页按约定显示 dev
const BUILD_TIME = 'dev'
const version = ref<string | null>(null)
const versionChecked = ref(false)
const versionText = computed(() => {
if (version.value !== null) return version.value
return versionChecked.value ? '未知(/api/health 不可达)' : '读取中…'
})
interface InfoItem {
name: string
usage: string
license: string
note?: string
}
const stack: InfoItem[] = [
{ name: 'Go 1.26', usage: '服务端、实时协议、静态资源内嵌', license: 'MIT(本项目)' },
{ name: 'Vue 3 + TypeScript', usage: '管理端与大屏前端', license: 'MIT' },
{ name: 'Vite', usage: '前端构建与开发服务', license: 'MIT' },
{ name: 'Pinia + Vue Router', usage: '管理端状态与路由', license: 'MIT' },
{ name: 'Electron', usage: 'Scoreboard Console 桌面宿主', license: 'MIT' },
{ name: 'MIUIx', usage: '管理端界面风格(vendor 内置,不走 CDN)', license: '随仓库分发的样式参考实现' },
]
const fonts: InfoItem[] = [
{ name: 'Noto Sans SC', usage: '默认界面字体', license: 'OFL-1.1' },
{ name: 'Noto Serif SC', usage: '标题衬线字体', license: 'OFL-1.1' },
{ name: 'Source Han Sans CN(思源黑体)', usage: '备用无衬线字体', license: 'OFL-1.1' },
{
name: 'MiSans',
usage: '可选界面字体',
license: 'MiSans Font License',
note: 'MiSans 仅限内部赛事使用,不得对外分发',
},
]
interface ShortcutItem {
keys: string[]
desc: string
}
const entryShortcuts: ShortcutItem[] = [
{ keys: ['Tab'], desc: '跳到下一个单元格(浏览器原生焦点顺序)' },
{ keys: ['Enter'], desc: '在成绩格之间移动' },
{ keys: ['Ctrl', 'Enter'], desc: '保存草稿' },
{ keys: ['Alt', 'Enter'], desc: '发布当前项目成绩' },
]
const displayShortcuts: ShortcutItem[] = [
{ keys: ['F11'], desc: '单屏窗口化模式下切换全屏;多屏 kiosk 不响应' },
]
const eventName = computed(() => data.settings?.event.name.trim() || '未配置')
const port = computed(() => {
if (window.location.port !== '') return window.location.port
return window.location.protocol === 'https:' ? '443' : '80'
})
const origin = computed(() => window.location.origin)
onMounted(async () => {
// 直接从 /api/health 取版本;不走 adminApi.health 的示例数据兜底,避免把 mock 当成真实版本
try {
const info = await adminApi.client.health()
version.value = info.version
} catch {
version.value = null
} finally {
versionChecked.value = true
}
if (!data.loaded) void data.load()
})
</script>
<template>
<div class="about-page">
<PageHeader title="关于" subtitle="系统信息、技术栈与部署参数" />
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">基本信息</span>
</div>
<div class="mx-card-body">
<dl class="kv">
<div>
<dt>系统名称</dt>
<dd>
{{ SYSTEM_NAME }}
<span class="en">{{ SYSTEM_EN }}</span>
</dd>
</div>
<div>
<dt>版本号</dt>
<dd class="mono">{{ versionText }}</dd>
</div>
<div>
<dt>构建时间</dt>
<dd class="mono">{{ BUILD_TIME }}</dd>
</div>
<div>
<dt>运行模式</dt>
<dd>{{ data.isMock ? '示例数据' : '真实服务端' }}</dd>
</div>
</dl>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">技术栈与开源许可</span>
<span class="mx-badge mx-badge-neutral">MIT · OFL-1.1</span>
</div>
<div class="mx-card-body no-pad">
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>组件</th>
<th>用途</th>
<th>许可</th>
</tr>
</thead>
<tbody>
<tr v-for="item in stack" :key="item.name">
<td class="name">{{ item.name }}</td>
<td class="muted">{{ item.usage }}</td>
<td>{{ item.license }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">字体清单</span>
</div>
<div class="mx-card-body no-pad">
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>字体</th>
<th>用途</th>
<th>授权</th>
</tr>
</thead>
<tbody>
<tr v-for="font in fonts" :key="font.name">
<td class="name">{{ font.name }}</td>
<td class="muted">{{ font.usage }}</td>
<td>
{{ font.license }}
<span v-if="font.note" class="warn-note">{{ font.note }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">键盘快捷键</span>
</div>
<div class="mx-card-body no-pad">
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>位置</th>
<th>按键</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr v-for="item in entryShortcuts" :key="`entry-${item.desc}`">
<td class="name">成绩录入</td>
<td class="keys">
<template v-for="(key, index) in item.keys" :key="key">
<span v-if="index > 0" class="plus">+</span><kbd>{{ key }}</kbd>
</template>
</td>
<td class="muted">{{ item.desc }}</td>
</tr>
<tr v-for="item in displayShortcuts" :key="`display-${item.desc}`">
<td class="name">大屏</td>
<td class="keys">
<template v-for="(key, index) in item.keys" :key="key">
<span v-if="index > 0" class="plus">+</span><kbd>{{ key }}</kbd>
</template>
</td>
<td class="muted">{{ item.desc }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">部署信息</span>
</div>
<div class="mx-card-body">
<dl class="kv">
<div>
<dt>数据目录</dt>
<dd class="mono">— <span class="muted">(服务端未暴露该接口)</span></dd>
</div>
<div>
<dt>服务端口</dt>
<dd class="mono">{{ port }}</dd>
</div>
<div>
<dt>访问地址</dt>
<dd class="mono">{{ origin }}</dd>
</div>
<div>
<dt>赛事项目名</dt>
<dd>{{ eventName }}</dd>
</div>
</dl>
</div>
</section>
<p class="foot-hint">
本系统为内部赛事使用;MiSans 字体授权仅限内部赛事,对外分发前请替换为 OFL-1.1 字体。
</p>
</div>
</template>
<style scoped>
.about-page {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 960px;
}
.kv {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px 24px;
}
.kv > div {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.kv dt {
font-size: 12px;
color: var(--mx-t3);
}
.kv dd {
font-size: 14px;
word-break: break-all;
}
.en {
margin-left: 6px;
font-size: 12px;
color: var(--mx-t3);
}
.no-pad {
padding: 0;
}
.name {
white-space: nowrap;
}
.muted {
color: var(--mx-t3);
}
.warn-note {
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--mx-amber, #fbbf24);
}
.keys {
white-space: nowrap;
}
.plus {
margin: 0 3px;
color: var(--mx-t3);
}
kbd {
padding: 1px 6px;
border: 1px solid var(--mx-border);
border-radius: 5px;
background: var(--mx-s3);
font-family: var(--mx-mono);
font-size: 11px;
}
.foot-hint {
font-size: 12px;
color: var(--mx-t3);
}
</style>
+605
View File
@@ -0,0 +1,605 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
ApiError,
formatDateTime,
formatPoints,
type AdjustmentCategory,
type PointAdjustment,
type RulePack,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
const CATEGORIES: Array<{ value: AdjustmentCategory; label: string }> = [
{ value: 'penalty', label: '处罚' },
{ value: 'bonus', label: '补分' },
{ value: 'correction', label: '更正' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const serverItems = ref<PointAdjustment[]>([])
const total = ref(0)
const page = ref(1)
const size = 20
const loading = ref(false)
const error = ref<string | null>(null)
const rulePack = ref<RulePack | null>(null)
const ruleError = ref<string | null>(null)
const search = ref('')
const categoryFilter = ref<AdjustmentCategory | 'all'>('all')
const createOpen = ref(false)
const creating = ref(false)
const formTeamId = ref<number | null>(null)
const formDelta = ref('')
const formCategory = ref<AdjustmentCategory>('penalty')
const formReason = ref('')
const formEventId = ref<number | null>(null)
const formError = ref<string | null>(null)
const voidTarget = ref<PointAdjustment | null>(null)
const voidReason = ref('')
const voiding = ref(false)
/** 服务端未就绪时的本地记录:只用于演示交互,界面必须标注。 */
const localItems = ref<PointAdjustment[]>([])
let nextLocalId = -1
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const adjustmentEnabled = computed(() => rulePack.value?.manualAdjustment.enabled ?? false)
const items = computed(() => [...localItems.value, ...serverItems.value])
const filteredItems = computed(() => {
const keyword = search.value.trim()
return items.value.filter((item) => {
if (categoryFilter.value !== 'all' && item.category !== categoryFilter.value) return false
if (keyword === '') return true
return teamLabel(item.teamId).includes(keyword) || item.reason.includes(keyword)
})
})
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
const rangeText = computed(() => {
if (total.value === 0) return '共 0 条'
const from = (page.value - 1) * size + 1
const to = Math.min(page.value * size, total.value)
return `第 ${from}–${to} 条,共 ${total.value} 条`
})
function teamLabel(teamId: number): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
}
function eventLabel(eventId: number | null): string {
if (eventId === null) return '—'
return data.eventById(eventId)?.name ?? `项目 ${eventId}`
}
function categoryLabel(category: AdjustmentCategory): string {
return CATEGORIES.find((item) => item.value === category)?.label ?? category
}
function categoryTone(category: AdjustmentCategory): string {
if (category === 'penalty') return 'mx-tag-error'
if (category === 'bonus') return 'mx-tag-success'
return 'mx-tag-info'
}
function isLocal(item: PointAdjustment): boolean {
return item.id < 0
}
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
}
async function loadRules(): Promise<void> {
try {
rulePack.value = await adminApi.client.rules.get()
ruleError.value = null
} catch (caught) {
rulePack.value = null
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
}
}
async function load(pageNumber = page.value): Promise<void> {
loading.value = true
error.value = null
try {
const result = await adminApi.adjustments({ page: pageNumber, size })
serverItems.value = result.items
total.value = result.total
page.value = result.page
} catch (caught) {
error.value = caught instanceof Error ? caught.message : '加载调整记录失败'
} finally {
loading.value = false
}
}
function openCreate(): void {
if (!adjustmentEnabled.value) {
toast.error('本期人工调整已关闭', '规则包 manualAdjustment.enabled = false')
return
}
formTeamId.value = data.teams[0]?.id ?? null
formDelta.value = ''
formCategory.value = 'penalty'
formReason.value = ''
formEventId.value = null
formError.value = null
createOpen.value = true
}
function validate(): string | null {
if (formTeamId.value === null) return '请选择单位'
const delta = Number(formDelta.value)
if (formDelta.value.trim() === '' || !Number.isFinite(delta)) return '请填写分值'
if (delta === 0) return '分值不能为 0'
if (formReason.value.trim() === '') return '理由必填'
return null
}
async function submitCreate(): Promise<void> {
const invalid = validate()
if (invalid !== null) {
formError.value = invalid
return
}
const teamId = formTeamId.value
if (teamId === null) return
creating.value = true
formError.value = null
const input = {
teamId,
delta: Number(formDelta.value),
category: formCategory.value,
reason: formReason.value.trim(),
eventId: formEventId.value,
}
try {
await adminApi.client.adjustments.create(input)
createOpen.value = false
toast.success('已记录人工调整', `${teamLabel(teamId)} ${input.delta > 0 ? '+' : ''}${formatPoints(input.delta)} 分`)
await Promise.all([load(1), data.refreshStandings()])
} catch (caught) {
if (isFallback(caught)) {
localItems.value = [
{
id: (nextLocalId -= 1),
teamId,
delta: input.delta,
category: input.category,
reason: input.reason,
eventId: input.eventId,
voided: false,
createdBy: null,
createdAt: new Date().toISOString(),
},
...localItems.value,
]
createOpen.value = false
toast.warning('已本地记录(示例数据)', '服务端未就绪,调整未真正写入')
} else {
formError.value = caught instanceof Error ? caught.message : '保存失败'
}
} finally {
creating.value = false
}
}
async function confirmVoid(): Promise<void> {
const item = voidTarget.value
if (item === null) return
const reason = voidReason.value.trim()
if (reason === '') {
toast.error('请填写作废理由')
return
}
voiding.value = true
try {
if (isLocal(item)) {
localItems.value = localItems.value.map((entry) =>
entry.id === item.id ? { ...entry, voided: true } : entry,
)
toast.info('已本地作废', '示例数据模式,未写入服务端')
} else {
await adminApi.client.adjustments.void(item.id, reason)
toast.warning('已作废该条调整', '对应积分流水一并作废,全程留审计')
await Promise.all([load(), data.refreshStandings()])
}
voidTarget.value = null
voidReason.value = ''
} catch (caught) {
if (isFallback(caught)) {
toast.error('服务端未就绪', '无法作废已保存的调整,请稍后重试')
} else {
toast.error('作废失败', caught instanceof Error ? caught.message : null)
}
} finally {
voiding.value = false
}
}
function changePage(delta: number): void {
const next = page.value + delta
if (next < 1 || next > pageCount.value) return
void load(next)
}
onMounted(() => {
void loadRules()
void load()
})
</script>
<template>
<div class="adjustments-page">
<PageHeader title="积分调整" subtitle="人工加减分,分值可正可负、理由必填、可作废、全程审计">
<template #actions>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load()">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="!adjustmentEnabled || !canWrite" @click="openCreate">
<MxIcon name="adjust" :size="15" /> 新增调整
</button>
</template>
</PageHeader>
<div class="mx-card notice-card">
<div class="mx-card-body notice-body">
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
<div>
<div class="notice-title">
{{ adjustmentEnabled ? '人工调整已启用' : '本期人工调整已关闭' }}
<span class="mx-badge" :class="adjustmentEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
manualAdjustment.enabled = {{ adjustmentEnabled ? 'true' : 'false' }}
</span>
</div>
<p class="notice-desc">
<template v-if="adjustmentEnabled">
分值可正可负,类别为处罚 / 补分 / 更正,理由必填;作废会同时作废对应积分流水。
</template>
<template v-else>
本次运动会扣分 / 处罚完全在系统外处理,系统只负责成绩分(02 第 4 节)。
功能与数据表已保留,以后赛事开一个开关即可启用。
</template>
</p>
<p v-if="ruleError !== null" class="notice-error">
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
</p>
</div>
</div>
</div>
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">调整记录</span>
<div class="head-tools">
<div class="mx-tabs">
<button class="mx-tab" :class="{ active: categoryFilter === 'all' }" type="button" @click="categoryFilter = 'all'">全部</button>
<button
v-for="item in CATEGORIES"
:key="item.value"
class="mx-tab"
:class="{ active: categoryFilter === item.value }"
type="button"
@click="categoryFilter = item.value"
>
{{ item.label }}
</button>
</div>
<div class="mx-input-wrap search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索单位 / 理由" />
</div>
</div>
</div>
<div class="mx-card-body">
<div v-if="loading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载调整记录…</div>
</div>
<div v-else-if="error !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ error }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="load()">重试</button>
</div>
<div v-else-if="filteredItems.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="adjust" :size="26" /></div>
<div class="mx-empty-title">没有调整记录</div>
<div class="mx-empty-desc">
{{ adjustmentEnabled ? '点击「新增调整」录入第一条' : '本期人工调整关闭,故为空' }}
</div>
</div>
<template v-else>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>单位</th>
<th class="right">分值</th>
<th>类别</th>
<th>理由</th>
<th>关联项目</th>
<th class="center">状态</th>
<th>时间</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredItems" :key="item.id" :class="{ voided: item.voided }">
<td>
<span class="team-name">{{ teamLabel(item.teamId) }}</span>
<span v-if="isLocal(item)" class="mx-tag mx-tag-warning local-tag">本地</span>
</td>
<td class="right mono delta" :class="item.delta > 0 ? 'up' : 'down'">
{{ item.delta > 0 ? '+' : '' }}{{ formatPoints(item.delta) }}
</td>
<td><span class="mx-tag" :class="categoryTone(item.category)">{{ categoryLabel(item.category) }}</span></td>
<td class="reason">{{ item.reason }}</td>
<td>{{ eventLabel(item.eventId) }}</td>
<td class="center">
<span v-if="item.voided" class="mx-badge mx-badge-neutral">已作废</span>
<span v-else class="mx-badge mx-badge-success">有效</span>
</td>
<td class="mono time">{{ formatDateTime(item.createdAt) }}</td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="item.voided || !canWrite"
@click="voidTarget = item; voidReason = ''"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<span class="pager-info">{{ rangeText }}</span>
<div class="pager-actions">
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="page <= 1" @click="changePage(-1)">上一页</button>
<span class="pager-page mono">{{ page }} / {{ pageCount }}</span>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="page >= pageCount" @click="changePage(1)">下一页</button>
</div>
</div>
</template>
</div>
</section>
<!-- 新增调整 -->
<div class="mx-modal-overlay" :class="{ open: createOpen }" @click.self="createOpen = false">
<div class="mx-modal" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">新增人工调整</span>
<button class="mx-modal-close" type="button" @click="createOpen = false">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<div class="form-grid">
<label class="field">
<span class="mx-label">单位 *</span>
<select v-model.number="formTeamId" class="mx-cell-input">
<option :value="null" disabled>请选择单位</option>
<option v-for="team in data.teams" :key="team.id" :value="team.id">{{ team.name }}</option>
</select>
</label>
<label class="field">
<span class="mx-label">分值 *</span>
<input v-model="formDelta" class="mx-cell-input mono" type="number" step="0.5" placeholder="正数加分 / 负数扣分" />
</label>
<label class="field">
<span class="mx-label">类别 *</span>
<select v-model="formCategory" class="mx-cell-input">
<option v-for="item in CATEGORIES" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</label>
<label class="field">
<span class="mx-label">关联项目(可选)</span>
<select v-model.number="formEventId" class="mx-cell-input">
<option :value="null">不关联</option>
<option v-for="event in data.events" :key="event.id" :value="event.id">{{ event.name }}</option>
</select>
</label>
<label class="field field-full">
<span class="mx-label">理由 *</span>
<textarea v-model="formReason" class="mx-textarea" rows="3" placeholder="必填,会写入审计日志"></textarea>
</label>
</div>
<p v-if="formError !== null" class="form-error">{{ formError }}</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="creating" @click="createOpen = false">取消</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="creating" @click="submitCreate">
{{ creating ? '保存中…' : '保存' }}
</button>
</div>
</div>
</div>
<!-- 作废 -->
<div class="mx-modal-overlay" :class="{ open: voidTarget !== null }" @click.self="voidTarget = null">
<div class="mx-modal" role="dialog" aria-modal="true">
<div class="mx-modal-head">
<span class="mx-modal-title">作废该条调整?</span>
<button class="mx-modal-close" type="button" @click="voidTarget = null">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body">
<p class="mx-modal-confirm-text">
将作废 {{ voidTarget === null ? '' : teamLabel(voidTarget.teamId) }} 的
{{ voidTarget === null ? '' : formatPoints(voidTarget.delta) }} 分调整,对应积分流水一并作废。
</p>
<label class="field">
<span class="mx-label">作废理由 *</span>
<textarea v-model="voidReason" class="mx-textarea" rows="3" placeholder="必填,会写入审计日志"></textarea>
</label>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="voiding" @click="voidTarget = null">取消</button>
<button class="mx-btn mx-btn-danger" type="button" :disabled="voiding" @click="confirmVoid">
{{ voiding ? '处理中…' : '确认作废' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.notice-card {
margin-top: 16px;
}
.notice-body {
display: flex;
gap: 12px;
align-items: flex-start;
}
.notice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.12);
color: var(--mx-amber);
flex-shrink: 0;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
margin-bottom: 6px;
flex-wrap: wrap;
}
.notice-desc {
font-size: 13px;
color: var(--mx-t2);
line-height: 1.7;
}
.notice-error {
margin-top: 6px;
font-size: 12px;
color: var(--mx-red);
}
.head-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
width: 220px;
}
.team-name {
font-weight: 600;
}
.local-tag {
margin-left: 6px;
}
.delta {
font-size: 15px;
font-weight: 800;
}
.delta.up {
color: var(--mx-green);
}
.delta.down {
color: var(--mx-red);
}
.reason {
max-width: 260px;
white-space: normal;
color: var(--mx-t2);
}
.time {
font-size: 12px;
color: var(--mx-t3);
}
tr.voided td {
opacity: 0.5;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
}
.pager-info {
font-size: 12px;
color: var(--mx-t3);
}
.pager-actions {
display: flex;
align-items: center;
gap: 10px;
}
.pager-page {
font-size: 12px;
color: var(--mx-t2);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-full {
grid-column: 1 / -1;
}
.form-error {
margin-top: 12px;
font-size: 13px;
color: var(--mx-red);
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(248, 113, 113, 0.08);
color: var(--mx-red);
font-size: 13px;
}
@media (max-width: 767px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+787
View File
@@ -0,0 +1,787 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import MockBadge from '../components/system/MockBadge.vue'
import type { IconName } from '../components/icons'
import {
projectApi,
resultsExportApi,
rulesApi,
type ProjectImportResult,
type ResultExportKind,
type RuleApplyResult,
type RulePreview,
} from '../components/system/systemApi'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/**
* 导出与赛事包(devdocs/07 第 5 节)。
*
* 三块互不相干:结果导出是只读下载(Guest 也能用),赛事包与规则包是 admin 级别的
* 整库/整规则替换,必须二次确认,且规则导入后服务端会全量重算积分。
*/
type TabKey = 'results' | 'project' | 'rules'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'results', label: '结果导出' },
{ key: 'project', label: '赛事项目包' },
{ key: 'rules', label: '规则包' },
]
interface ExportOption {
key: ResultExportKind
title: string
desc: string
icon: IconName
}
const EXPORT_OPTIONS: ReadonlyArray<ExportOption> = [
{
key: 'standings',
title: '总表',
desc: '总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿',
icon: 'standings',
},
{ key: 'all', title: '全部', desc: '总表 + 全部分项,多 sheet 工作簿,适合归档打印', icon: 'database' },
{ key: 'event', title: '分项成绩表', desc: '按小项导出成绩与积分,可单独给项目裁判', icon: 'export' },
{ key: 'team', title: '单位积分明细', desc: '按单位逐项列出得分来源,可解释每一分', icon: 'user' },
]
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const activeTab = ref<TabKey>('results')
// ── 结果导出 ────────────────────────────────────────────────────────────────
const pendingExport = ref<ResultExportKind | null>(null)
const resultsMock = ref(false)
const selectedEventId = ref<number | null>(null)
const selectedTeamId = ref<number | null>(null)
const eventOptions = computed(() =>
data.events.map((event) => ({
id: event.id,
label: `${data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'} / ${event.name}`,
})),
)
// ── 赛事项目包 ──────────────────────────────────────────────────────────────
const projectFile = ref<File | null>(null)
const projectOverwrite = ref(false)
const projectRename = ref('')
const projectFileInput = ref<HTMLInputElement | null>(null)
const projectImporting = ref(false)
const projectExporting = ref(false)
const projectMock = ref(false)
const confirmProjectImport = ref(false)
const projectResult = ref<ProjectImportResult | null>(null)
const projectError = ref<string | null>(null)
// ── 规则包 ──────────────────────────────────────────────────────────────────
const rulesFileInput = ref<HTMLInputElement | null>(null)
const rulesFileName = ref('')
const rulesYaml = ref('')
const rulesExporting = ref(false)
const rulesPreviewing = ref(false)
const rulesApplying = ref(false)
const rulesMock = ref(false)
const rulesError = ref<string | null>(null)
const rulesPreview = ref<RulePreview | null>(null)
const rulesApplyResult = ref<RuleApplyResult | null>(null)
const confirmRulesApply = ref(false)
const isAdmin = computed(() => auth.user?.role === 'admin')
const CHANGE_LABEL: Record<string, string> = {
added: '新增',
removed: '删除',
modified: '修改',
unchanged: '未变',
}
function changeTone(change: string): string {
if (change === 'added') return 'mx-badge-success'
if (change === 'removed') return 'mx-badge-error'
if (change === 'modified') return 'mx-badge-warning'
return 'mx-badge-neutral'
}
async function download(kind: ResultExportKind): Promise<void> {
const request =
kind === 'event'
? { kind, eventId: selectedEventId.value ?? undefined }
: kind === 'team'
? { kind, teamId: selectedTeamId.value ?? undefined }
: { kind }
if (kind === 'event' && selectedEventId.value === null) {
toast.warning('请先选择要导出的小项')
return
}
if (kind === 'team' && selectedTeamId.value === null) {
toast.warning('请先选择要导出的单位')
return
}
pendingExport.value = kind
try {
const result = await resultsExportApi.download(request)
resultsMock.value = resultsMock.value || result.mock
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
} catch (caught) {
toast.error('导出失败', caught instanceof Error ? caught.message : null)
} finally {
pendingExport.value = null
}
}
async function exportProject(): Promise<void> {
projectExporting.value = true
projectError.value = null
try {
const result = await projectApi.exportZip()
projectMock.value = projectMock.value || result.mock
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
} catch (caught) {
projectError.value = caught instanceof Error ? caught.message : '导出失败'
} finally {
projectExporting.value = false
}
}
function pickProjectFile(event: Event): void {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
projectResult.value = null
projectError.value = null
if (file === null) {
projectFile.value = null
return
}
if (!/\.zip$/i.test(file.name)) {
projectError.value = '请选择 .zip 格式的赛事项目包'
projectFile.value = null
input.value = ''
return
}
projectFile.value = file
}
function requestProjectImport(): void {
if (projectFile.value === null) {
projectError.value = '请先选择要导入的 .zip 包'
return
}
// 覆盖会替换掉同名赛事目录,属于不可逆操作,必须先确认。
if (projectOverwrite.value) {
confirmProjectImport.value = true
return
}
void runProjectImport()
}
async function runProjectImport(): Promise<void> {
const file = projectFile.value
if (file === null) return
confirmProjectImport.value = false
projectImporting.value = true
projectError.value = null
try {
const result = await projectApi.importZip(file, {
overwrite: projectOverwrite.value,
rename: projectRename.value,
})
projectMock.value = projectMock.value || result.mock
projectResult.value = result.data
toast.success(`已导入「${result.data.name}」`, result.mock ? '示例数据:未真正解压落盘' : null)
projectFile.value = null
if (projectFileInput.value !== null) projectFileInput.value.value = ''
} catch (caught) {
projectError.value = caught instanceof Error ? caught.message : '导入失败'
} finally {
projectImporting.value = false
}
}
async function exportRules(): Promise<void> {
rulesExporting.value = true
rulesError.value = null
try {
const result = await rulesApi.exportYaml()
rulesMock.value = rulesMock.value || result.mock
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('规则包已导出', result.data.filename)
} catch (caught) {
rulesError.value = caught instanceof Error ? caught.message : '导出失败'
} finally {
rulesExporting.value = false
}
}
async function pickRulesFile(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
rulesPreview.value = null
rulesApplyResult.value = null
rulesError.value = null
if (file === null) {
rulesFileName.value = ''
rulesYaml.value = ''
return
}
if (!/\.(ya?ml)$/i.test(file.name)) {
rulesError.value = '规则包必须是 .yaml / .yml 文件'
rulesFileName.value = ''
rulesYaml.value = ''
input.value = ''
return
}
rulesFileName.value = file.name
try {
rulesYaml.value = await file.text()
} catch {
rulesError.value = '读取文件失败,请重试'
rulesYaml.value = ''
}
}
async function previewRules(): Promise<void> {
if (rulesYaml.value.trim() === '') {
rulesError.value = '请先选择规则包文件'
return
}
rulesPreviewing.value = true
rulesError.value = null
rulesApplyResult.value = null
try {
const result = await rulesApi.preview(rulesYaml.value)
rulesMock.value = rulesMock.value || result.mock
rulesPreview.value = result.data
} catch (caught) {
rulesPreview.value = null
rulesError.value = caught instanceof Error ? caught.message : '预览失败'
} finally {
rulesPreviewing.value = false
}
}
function requestRulesApply(): void {
if (rulesPreview.value === null) return
confirmRulesApply.value = true
}
async function applyRules(): Promise<void> {
confirmRulesApply.value = false
rulesApplying.value = true
rulesError.value = null
try {
const result = await rulesApi.apply(rulesYaml.value)
rulesMock.value = rulesMock.value || result.mock
rulesApplyResult.value = result.data
rulesPreview.value = result.data
toast.success(
'规则包已导入',
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
)
} catch (caught) {
rulesError.value = caught instanceof Error ? caught.message : '导入失败'
} finally {
rulesApplying.value = false
}
}
</script>
<template>
<div>
<PageHeader title="导出" subtitle="结果导出 / 赛事项目包 / 规则包(devdocs/07 第 5 节)">
<template #actions>
<MockBadge v-if="data.isMock" />
</template>
</PageHeader>
<div class="mx-tabs export-tabs">
<button
v-for="tab in TABS"
:key="tab.key"
class="mx-tab"
:class="{ active: activeTab === tab.key }"
type="button"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- 结果导出 -->
<section v-if="activeTab === 'results'" class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">结果导出(xlsx)</span>
<MockBadge v-if="resultsMock" />
</div>
<div class="mx-card-body">
<p class="hint">
表头、合并单元格与字体都已按打印模板预设,导出即可直接打印。总表与全部导出由服务端合成一个多 sheet 工作簿。
</p>
<div class="export-grid">
<article v-for="option in EXPORT_OPTIONS" :key="option.key" class="export-item">
<div class="export-item-head">
<span class="export-icon"><MxIcon :name="option.icon" :size="18" /></span>
<strong>{{ option.title }}</strong>
</div>
<p class="export-desc">{{ option.desc }}</p>
<div v-if="option.key === 'event'" class="mx-input-wrap">
<select v-model="selectedEventId" class="mx-input">
<option :value="null" disabled>选择小项…</option>
<option v-for="item in eventOptions" :key="item.id" :value="item.id">{{ item.label }}</option>
</select>
</div>
<div v-else-if="option.key === 'team'" class="mx-input-wrap">
<select v-model="selectedTeamId" class="mx-input">
<option :value="null" disabled>选择单位…</option>
<option v-for="team in data.teams" :key="team.id" :value="team.id">
{{ team.name }}({{ team.code }})
</option>
</select>
</div>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="pendingExport !== null"
@click="download(option.key)"
>
<MxIcon name="export" :size="15" />
{{ pendingExport === option.key ? '导出中…' : '下载' }}
</button>
</article>
</div>
<div v-if="data.events.length === 0" class="mx-empty">
<div class="mx-empty-title">还没有小项</div>
<div class="mx-empty-desc">先在基础数据里建立大项 / 小项,分项成绩表才有内容可导</div>
</div>
</div>
</section>
<!-- 赛事项目包 -->
<div v-else-if="activeTab === 'project'" class="project-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出项目包</span>
<MockBadge v-if="projectMock" />
</div>
<div class="mx-card-body">
<p class="hint">
包内含 <code class="mono">meta.yaml</code> + <code class="mono">event.db</code> +
<code class="mono">uploads/</code> + <code class="mono">rules.yaml</code>(字体可选)。
整包拷走即可在另一台机器复现整场赛事。
</p>
<button class="mx-btn mx-btn-primary full" type="button" :disabled="projectExporting" @click="exportProject">
<MxIcon name="export" :size="15" /> {{ projectExporting ? '打包中…' : '导出 zip' }}
</button>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">导入项目包</span></div>
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">导入赛事包会替换整库数据,只有管理员可操作。</p>
<div class="file-row">
<input
ref="projectFileInput"
class="file-input"
type="file"
accept=".zip,application/zip"
@change="pickProjectFile"
/>
<button class="mx-btn mx-btn-secondary" type="button" @click="projectFileInput?.click()">
<MxIcon name="database" :size="15" /> 选择 .zip
</button>
<span class="file-name mono">{{ projectFile?.name ?? '未选择文件' }}</span>
</div>
<div class="form-grid">
<div class="field">
<label class="mx-label" for="project-rename">重命名(可选)</label>
<div class="mx-input-wrap">
<input
id="project-rename"
v-model="projectRename"
class="mx-input"
placeholder="留空则用包内项目名"
/>
</div>
</div>
<div class="field switch-field">
<span class="mx-label">覆盖同名项目</span>
<span class="switch-row">
<label class="mx-switch">
<input v-model="projectOverwrite" type="checkbox" />
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
</label>
<span class="switch-text">{{ projectOverwrite ? '覆盖(危险)' : '不覆盖,重名时失败' }}</span>
</span>
</div>
</div>
<p v-if="projectError !== null" class="notice error">{{ projectError }}</p>
<button
class="mx-btn mx-btn-primary full"
type="button"
:disabled="!isAdmin || projectImporting || projectFile === null"
@click="requestProjectImport"
>
{{ projectImporting ? '导入中…' : '导入' }}
</button>
<div v-if="projectResult !== null" class="result-box">
<div class="result-title"><MxIcon name="check" :size="15" /> 导入完成</div>
<dl class="result-list">
<div><dt>项目名</dt><dd>{{ projectResult.name }}</dd></div>
<div><dt>落盘目录</dt><dd class="mono">{{ projectResult.dir }}</dd></div>
<div><dt>数据库</dt><dd class="mono">{{ projectResult.eventDB }}</dd></div>
</dl>
<p class="hint">如需立即切换到这个赛事,请到「系统 · 赛事信息」确认配置后重启服务端。</p>
</div>
</div>
</section>
</div>
<!-- 规则包 -->
<div v-else class="rules-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">导出规则包</span>
<MockBadge v-if="rulesMock" />
</div>
<div class="mx-card-body">
<p class="hint">
导出为 YAML,含积分模板、并列规则、破纪录加分、计分通道与项目覆盖,可直接给另一场赛事复用。
</p>
<button class="mx-btn mx-btn-primary full" type="button" :disabled="rulesExporting" @click="exportRules">
<MxIcon name="export" :size="15" /> {{ rulesExporting ? '导出中…' : '导出 rules.yaml' }}
</button>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head"><span class="mx-card-title">导入规则包</span></div>
<div class="mx-card-body">
<p v-if="!isAdmin" class="notice">导入规则包会全量重算积分,只有管理员可操作。</p>
<div class="file-row">
<input
ref="rulesFileInput"
class="file-input"
type="file"
accept=".yaml,.yml,text/yaml"
@change="pickRulesFile"
/>
<button class="mx-btn mx-btn-secondary" type="button" @click="rulesFileInput?.click()">
<MxIcon name="database" :size="15" /> 选择 YAML
</button>
<span class="file-name mono">{{ rulesFileName === '' ? '未选择文件' : rulesFileName }}</span>
</div>
<p class="hint">导入分两步:先 <code class="mono">?preview=1</code> 预览差异,确认后再落地。</p>
<p v-if="rulesError !== null" class="notice error">{{ rulesError }}</p>
<button
class="mx-btn mx-btn-secondary full"
type="button"
:disabled="!isAdmin || rulesPreviewing || rulesYaml === ''"
@click="previewRules"
>
{{ rulesPreviewing ? '比对中…' : '预览差异' }}
</button>
<div v-if="rulesPreview !== null" class="preview-box">
<div class="preview-head">
<span class="mx-badge" :class="rulesPreview.compatible ? 'mx-badge-success' : 'mx-badge-error'">
{{ rulesPreview.compatible ? '格式兼容' : '格式不兼容,禁止导入' }}
</span>
<span class="preview-meta">影响 {{ rulesPreview.affectedEventCount }} 个小项</span>
</div>
<div v-if="rulesPreview.rows.length === 0" class="mx-empty">
<div class="mx-empty-title">没有差异</div>
<div class="mx-empty-desc">当前规则与该规则包一致,无需导入</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>类型</th>
<th>对象</th>
<th>变化</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rulesPreview.rows" :key="`${row.kind}-${row.target}-${index}`">
<td>{{ row.kind }}</td>
<td class="mono">{{ row.target }}</td>
<td>
<span class="mx-badge" :class="changeTone(row.change)">
{{ CHANGE_LABEL[row.change] ?? row.change }}
</span>
</td>
<td class="detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div>
<p v-if="rulesPreview.unknownEvents.length > 0" class="notice warn">
以下项目编码在当前赛事库中不存在,导入时会被跳过:{{ rulesPreview.unknownEvents.join('、') }}
</p>
<div v-if="rulesApplyResult !== null" class="result-box">
<div class="result-title"><MxIcon name="check" :size="15" /> 已全量重算</div>
<p class="hint">
规则已落库,服务端重算了
<strong class="mono">{{ rulesApplyResult.recalculatedLedgerRows }}</strong>
条积分流水;大屏会随实时推送自动刷新。
</p>
</div>
<button
class="mx-btn mx-btn-danger full"
type="button"
:disabled="!isAdmin || !rulesPreview.compatible || rulesApplying"
@click="requestRulesApply"
>
{{ rulesApplying ? '导入中…' : '确认导入并全量重算' }}
</button>
</div>
</div>
</section>
</div>
<ConfirmDialog
:open="confirmProjectImport"
tone="danger"
title="覆盖同名赛事项目?"
:message="`将用「${projectFile?.name ?? ''}」覆盖同名赛事目录,旧目录会先改名保留但不会出现在列表里。`"
:details="['覆盖后如需回退,需要手工把旧目录改回来', '导入期间请勿停止服务端进程']"
confirm-text="覆盖导入"
:pending="projectImporting"
@confirm="runProjectImport"
@cancel="confirmProjectImport = false"
/>
<ConfirmDialog
:open="confirmRulesApply"
tone="danger"
title="导入规则包并全量重算积分?"
:message="'规则一旦落地,全部已发布成绩会按新分值重算,积分榜与大屏会立即变化。'"
:details="[
`影响 ${rulesPreview?.affectedEventCount ?? 0} 个小项`,
`将产生 ${rulesPreview?.rows.length ?? 0} 处规则改动`,
'重算完成后不可一键撤销,纠错需重新导入旧规则包',
]"
confirm-text="导入并重算"
:pending="rulesApplying"
@confirm="applyRules"
@cancel="confirmRulesApply = false"
/>
<p class="footer-note">当前数据更新于 {{ formatDateTime(data.lastRefreshAt) }}</p>
</div>
</template>
<style scoped>
.export-tabs {
margin-bottom: 14px;
}
.hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
}
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.export-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.export-item {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
border: 1px solid var(--mx-border);
}
.export-item-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.export-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--mx-r-xs);
background: var(--mx-primary-glow);
color: var(--mx-primary);
}
.export-desc {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
min-height: 38px;
}
.full {
width: 100%;
}
.project-grid,
.rules-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 16px;
align-items: start;
}
.file-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.file-input {
display: none;
}
.file-name {
font-size: 12px;
color: var(--mx-t3);
word-break: break-all;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
}
.switch-field {
justify-content: flex-end;
}
.switch-row {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
}
.switch-text {
font-size: 12px;
color: var(--mx-t3);
}
.notice {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.notice.warn {
color: var(--mx-amber);
background: rgba(251, 191, 36, 0.08);
}
.result-box,
.preview-box {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
border: 1px solid var(--mx-border);
}
.result-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
color: var(--mx-green);
}
.result-list {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.result-list div {
display: flex;
gap: 10px;
}
.result-list dt {
color: var(--mx-t3);
min-width: 64px;
}
.result-list dd {
color: var(--mx-t2);
word-break: break-all;
}
.preview-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.preview-meta {
font-size: 12px;
color: var(--mx-t3);
}
.detail {
font-size: 12px;
color: var(--mx-t3);
max-width: 260px;
}
.footer-note {
margin-top: 16px;
font-size: 12px;
color: var(--mx-t3);
}
@media (max-width: 1279px) {
.project-grid,
.rules-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1023px) {
.export-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
+314
View File
@@ -0,0 +1,314 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { formatDateTime, type AuditLog } from '@/shared'
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 { adminApi, mockMode } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
/**
* 审计日志(devdocs/08 第 3 节)。
*
* 审计日志只读、不可删;所有写操作都记录操作人、时间、IP、request_id 与 before/after。
* 服务端的 /api/audit-logs 只支持 entityType / userId 过滤,没有 action 参数,
* 所以这里一次取最近 200 条再在前端按动作与实体筛选——宁可把窗口写清楚,也不假装能全库筛。
*/
const PAGE_SIZE = 20
const WINDOW_SIZE = 200
/** 08 第 3 节点名的关键动作,单独标出来便于复盘。 */
const CRITICAL_ACTIONS = ['publish', 'unpublish', 'void', 'adjustment', 'user']
const auth = useAuthStore()
const toast = useToastStore()
const rows = ref<AuditLog[]>([])
const loading = ref(false)
const loadError = ref<string | null>(null)
const usingMock = ref(false)
const page = ref(1)
const actionFilter = ref('')
const entityFilter = ref('')
const expandedId = ref<number | null>(null)
const actionOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.action))
return [...set].sort((a, b) => a.localeCompare(b))
})
const entityOptions = computed(() => {
const set = new Set(rows.value.map((row) => row.entityType))
return [...set].sort((a, b) => a.localeCompare(b))
})
const filtered = computed(() =>
rows.value.filter(
(row) =>
(actionFilter.value === '' || row.action === actionFilter.value) &&
(entityFilter.value === '' || row.entityType === entityFilter.value),
),
)
const pageCount = computed(() => Math.max(1, Math.ceil(filtered.value.length / PAGE_SIZE)))
const pageRows = computed(() => {
const start = (page.value - 1) * PAGE_SIZE
return filtered.value.slice(start, start + PAGE_SIZE)
})
const operatorLabel = (row: AuditLog): string => {
if (row.userId === null) return '系统'
if (auth.user !== null && auth.user.id === row.userId) return `${auth.user.displayName}(我)`
return `用户 #${row.userId}`
}
const isCritical = (action: string): boolean =>
CRITICAL_ACTIONS.some((keyword) => action.toLowerCase().includes(keyword))
function resetFilters(): void {
actionFilter.value = ''
entityFilter.value = ''
page.value = 1
}
function onFilterChange(): void {
// 筛选变化后停留在旧页码会看到空白页,直接回到第一页。
page.value = 1
}
async function load(): Promise<void> {
loading.value = true
loadError.value = null
try {
const result = await adminApi.auditLogs({ page: 1, size: WINDOW_SIZE })
rows.value = result.items
// adminApi 内部静默降级,只能从全局标记反推这批数据是不是示例。
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)
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
})
</script>
<template>
<div>
<PageHeader title="日志" subtitle="审计日志:谁在什么时候改了什么(只读,不可删除)">
<template #actions>
<MockBadge v-if="usingMock" />
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">审计日志</span>
<span class="mx-badge mx-badge-neutral">{{ filtered.length }} 条</span>
</div>
<div class="mx-card-body">
<div class="filter-row">
<div class="filter">
<label class="mx-label" for="log-action">动作</label>
<div class="mx-input-wrap">
<select id="log-action" v-model="actionFilter" class="mx-input" @change="onFilterChange">
<option value="">全部动作</option>
<option v-for="action in actionOptions" :key="action" :value="action">{{ action }}</option>
</select>
</div>
</div>
<div class="filter">
<label class="mx-label" for="log-entity">实体</label>
<div class="mx-input-wrap">
<select id="log-entity" v-model="entityFilter" class="mx-input" @change="onFilterChange">
<option value="">全部实体</option>
<option v-for="entity in entityOptions" :key="entity" :value="entity">{{ entity }}</option>
</select>
</div>
</div>
<button class="mx-btn mx-btn-ghost clear" type="button" @click="resetFilters">清除筛选</button>
</div>
<p class="hint">
服务端 <code class="mono">/api/audit-logs</code> 只支持按实体与操作人过滤,暂不支持 action 参数,
因此筛选与分页作用于最近 {{ WINDOW_SIZE }} 条记录。
</p>
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在加载审计日志…</div>
<div v-else-if="rows.length === 0" class="mx-empty">
<div class="mx-empty-title">还没有审计记录</div>
<div class="mx-empty-desc">发布成绩、修改设置、管理用户等写操作都会留痕</div>
</div>
<div v-else-if="filtered.length === 0" class="mx-empty">
<div class="mx-empty-title">没有匹配的记录</div>
<div class="mx-empty-desc">换个动作或实体试试,或点「清除筛选」</div>
</div>
<template v-else>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>时间</th>
<th>操作人</th>
<th>IP</th>
<th>动作</th>
<th>实体</th>
<th>请求 ID</th>
<th class="right">详情</th>
</tr>
</thead>
<tbody>
<template v-for="row in pageRows" :key="row.id">
<tr
class="log-row"
:class="{ expanded: expandedId === row.id }"
@click="expandedId = expandedId === row.id ? null : row.id"
>
<td class="mono muted">{{ formatDateTime(row.createdAt) }}</td>
<td>{{ operatorLabel(row) }}</td>
<td class="mono muted">{{ row.ip ?? '—' }}</td>
<td>
<span class="action mono">{{ row.action }}</span>
<span v-if="isCritical(row.action)" class="mx-tag mx-tag-warning mx-tag-sm">关键</span>
</td>
<td class="mono">
{{ row.entityType }}<span v-if="row.entityId !== null"> #{{ row.entityId }}</span>
</td>
<td class="mono muted req">{{ row.requestId ?? '—' }}</td>
<td class="right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button">
{{ expandedId === row.id ? '收起' : '对比' }}
</button>
</td>
</tr>
<tr v-if="expandedId === row.id" class="detail-row">
<td colspan="7">
<JsonDiff :before="row.beforeJson" :after="row.afterJson" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<span class="mx-page-info">第 {{ page }} / {{ pageCount }} 页</span>
<button
class="mx-page-btn"
type="button"
:disabled="page <= 1"
@click="page -= 1"
>
上一页
</button>
<button
class="mx-page-btn"
type="button"
:disabled="page >= pageCount"
@click="page += 1"
>
下一页
</button>
</div>
</template>
</div>
</section>
</div>
</template>
<style scoped>
.mx-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.filter {
display: flex;
flex-direction: column;
min-width: 200px;
}
.clear {
margin-bottom: 2px;
}
.hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
}
.notice {
font-size: 12px;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
color: var(--mx-t3);
}
.notice.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
.state-block {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
padding: 18px 0;
}
.log-row {
cursor: pointer;
}
.log-row.expanded {
background: var(--mx-primary-glow);
}
.right {
text-align: right;
}
.muted {
color: var(--mx-t3);
font-size: 12px;
}
.req {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
font-size: 12px;
}
.detail-row td {
padding: 0 12px 12px;
}
.pager {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
color: var(--mx-t3);
}
</style>
+649
View File
@@ -0,0 +1,649 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatDateTime,
formatPoints,
type Event,
type RecordEntry,
type RecordLevel,
type RulePack,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 疑似破纪录:由「已发布成绩 vs 当前纪录」实时推导,接口不返回该列表。 */
interface SuspectedBreak {
key: string
eventId: number
eventName: string
eventCode: string
categoryName: string
entryId: number
teamName: string
athleteName: string
resultText: string
resultValue: number
level: RecordLevel
recordText: string
bonusPoints: number
}
interface RecordGroup {
eventId: number
eventName: string
eventCode: string
categoryLabel: string
records: RecordEntry[]
}
const LEVEL_LABELS: Record<RecordLevel, string> = {
meet: '赛会',
city: '市级',
province: '省级',
national: '全国',
}
const LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const records = ref<RecordEntry[]>([])
const recordsLoading = ref(false)
const recordsError = ref<string | null>(null)
const categoryLabels = ref<Record<number, string>>({})
const suspected = ref<SuspectedBreak[]>([])
const suspectedLoading = ref(false)
const suspectedError = ref<string | null>(null)
const rulePack = ref<RulePack | null>(null)
const ruleError = ref<string | null>(null)
const rulesLoading = ref(false)
const search = ref('')
const levelFilter = ref<RecordLevel | 'all'>('all')
const approveTarget = ref<SuspectedBreak | null>(null)
const approving = ref(false)
const rejectTarget = ref<SuspectedBreak | null>(null)
/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */
const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false)
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档'))
const filteredRecords = computed(() => {
const keyword = search.value.trim()
return records.value.filter((record) => {
if (levelFilter.value !== 'all' && record.level !== levelFilter.value) return false
if (keyword === '') return true
const event = data.eventById(record.eventId)
const label = categoryLabels.value[record.eventId] ?? ''
return (
(event?.name ?? '').includes(keyword) ||
(event?.code ?? '').includes(keyword) ||
label.includes(keyword) ||
(record.holderName ?? '').includes(keyword)
)
})
})
const recordGroups = computed<RecordGroup[]>(() => {
const map = new Map<number, RecordEntry[]>()
for (const record of filteredRecords.value) {
const list = map.get(record.eventId)
if (list === undefined) map.set(record.eventId, [record])
else list.push(record)
}
return [...map.entries()]
.map(([eventId, list]) => {
const event = data.eventById(eventId)
return {
eventId,
eventName: event?.name ?? `项目 ${eventId}`,
eventCode: event?.code ?? '',
categoryLabel: categoryLabels.value[eventId] ?? '未绑定组别',
records: [...list].sort((a, b) => levelRank(a.level) - levelRank(b.level)),
}
})
.sort((a, b) => a.eventName.localeCompare(b.eventName))
})
const pendingBreaks = computed(() =>
suspected.value.filter(
(item) => !rejectedKeys.value.includes(item.key) && !approvedKeys.value.includes(item.key),
),
)
function levelRank(level: RecordLevel): number {
const index = LEVEL_ORDER.indexOf(level)
return index < 0 ? 0 : index
}
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
}
function teamLabel(teamId: number): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
}
/** 只有「已发布 + 有效成绩」才可能破纪录;草稿永远不参与判断。 */
function beats(compare: Event['compare'], value: number, recordValue: number): boolean {
if (compare === 'asc') return value < recordValue
if (compare === 'desc') return value > recordValue
return false
}
async function loadRules(): Promise<void> {
rulesLoading.value = true
try {
rulePack.value = await adminApi.client.rules.get()
ruleError.value = null
} catch (caught) {
rulePack.value = null
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
} finally {
rulesLoading.value = false
}
}
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
const labels: Record<number, string> = {}
await Promise.all(
eventIds.map(async (eventId) => {
const cached = data.eventById(eventId)?.categories
if (cached !== undefined && cached.length > 0) {
labels[eventId] = cached.map((category) => category.name).join('、')
return
}
try {
const detail = await adminApi.eventDetail(eventId)
labels[eventId] = (detail.categories ?? []).map((category) => category.name).join('、')
} catch {
labels[eventId] = ''
}
}),
)
categoryLabels.value = labels
}
async function loadRecords(): Promise<void> {
recordsLoading.value = true
recordsError.value = null
try {
records.value = await adminApi.records()
const eventIds = [...new Set(records.value.map((record) => record.eventId))]
await loadCategoryLabels(eventIds)
} catch (caught) {
recordsError.value = caught instanceof Error ? caught.message : '加载纪录库失败'
} finally {
recordsLoading.value = false
}
}
async function loadSuspected(): Promise<void> {
if (!data.loaded) return
suspectedLoading.value = true
suspectedError.value = null
try {
const candidates = data.events.filter(
(event) => event.recordEligible && records.value.some((record) => record.eventId === event.id),
)
const found = await Promise.all(
candidates.map(async (event) => {
const entries = await adminApi.entries(event.id)
const eventRecords = records.value.filter((record) => record.eventId === event.id)
const list: SuspectedBreak[] = []
for (const entry of entries) {
const result = entry.result
if (result === null || result.state !== 'published' || result.status !== 'ok') continue
const value = result.resultValue
if (value === null) continue
let best: RecordEntry | null = null
for (const record of eventRecords) {
if (!beats(event.compare, value, record.resultValue)) continue
if (best === null || levelRank(record.level) > levelRank(best.level)) best = record
}
if (best === null) continue
list.push({
key: `${event.id}-${entry.id}-${best.level}`,
eventId: event.id,
eventName: event.name,
eventCode: event.code,
categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—',
entryId: entry.id,
teamName: teamLabel(entry.teamId),
athleteName: entry.athleteName ?? entry.memberNames?.join('、') ?? '—',
resultText: result.resultText,
resultValue: value,
level: best.level,
recordText: best.resultText,
bonusPoints: bonusFor(best.level),
})
}
return list
}),
)
suspected.value = found.flat().sort((a, b) => levelRank(b.level) - levelRank(a.level))
} catch (caught) {
suspectedError.value = caught instanceof Error ? caught.message : '推导疑似破纪录失败'
} finally {
suspectedLoading.value = false
}
}
function bonusFor(level: RecordLevel): number {
const definition = rulePack.value?.recordBonus.levels.find((item) => item.code === level)
return definition?.points ?? 0
}
function requestApprove(item: SuspectedBreak): void {
if (!recordBonusEnabled.value) {
toast.error('本期破纪录加分已关闭', '规则包 recordBonus.enabled = false,确认后不会生成加分流水')
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
approveTarget.value = item
}
async function confirmApprove(): Promise<void> {
const item = approveTarget.value
if (item === null) return
approving.value = true
try {
const created = await adminApi.client.records.createBreak({
eventId: item.eventId,
entryId: item.entryId,
level: item.level,
resultValue: item.resultValue,
resultText: item.resultText,
})
await adminApi.client.records.approveBreak(created.id)
approvedKeys.value = [...approvedKeys.value, item.key]
approveTarget.value = null
toast.success('已确认破纪录', `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`)
await Promise.all([loadRecords(), data.refreshStandings()])
} catch (caught) {
if (isFallback(caught)) {
// 后端未就绪:只做本地标记,绝不假装写成功。
approvedKeys.value = [...approvedKeys.value, item.key]
approveTarget.value = null
toast.warning('已本地登记(示例数据)', '服务端未就绪,加分流水未真正生成')
} else {
toast.error('确认失败', caught instanceof Error ? caught.message : null)
}
} finally {
approving.value = false
}
}
function confirmReject(): void {
const item = rejectTarget.value
if (item === null) return
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现')
}
async function refreshAll(): Promise<void> {
await Promise.all([loadRecords(), loadRules()])
await loadSuspected()
}
onMounted(() => {
void refreshAll()
})
// 项目列表由布局层异步加载,加载完成后才能推导疑似破纪录。
watch(
() => data.loaded,
(loaded) => {
if (loaded) void loadSuspected()
},
)
</script>
<template>
<div class="records-page">
<PageHeader title="破纪录" subtitle="纪录库 + 疑似破纪录确认;本期加分功能默认关闭">
<template #actions>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="recordsLoading || suspectedLoading" @click="refreshAll">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<div class="mx-card notice-card">
<div class="mx-card-body notice-body">
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
<div>
<div class="notice-title">
{{ recordBonusEnabled ? '破纪录加分已启用' : '本期破纪录加分已关闭' }}
<span class="mx-badge" :class="recordBonusEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
recordBonus.enabled = {{ recordBonusEnabled ? 'true' : 'false' }}
</span>
</div>
<p class="notice-desc">
<template v-if="recordBonusEnabled">
确认破纪录会生成加分流水并更新纪录库;加分档位 {{ bonusMode }},与成绩积分相互独立(不随权重翻倍)。
</template>
<template v-else>
规则包 `recordBonus.enabled = false`(02 第 3 节)。疑似破纪录仍会列出,但「确认加分」不会生成加分流水;
以后开一个开关即可启用,档位与分值已预置。
</template>
</p>
<p v-if="ruleError !== null" class="notice-error">
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
</p>
</div>
</div>
</div>
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">疑似破纪录</span>
<span class="mx-badge mx-badge-neutral">{{ pendingBreaks.length }} 条待确认</span>
</div>
<div class="mx-card-body">
<div v-if="suspectedLoading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在比对已发布成绩…</div>
</div>
<div v-else-if="suspectedError !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ suspectedError }}</span>
</div>
<div v-else-if="pendingBreaks.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
<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>
<th>档位</th>
<th class="right">加分</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pendingBreaks" :key="item.key">
<td>
<span class="event-name">{{ item.eventName }}</span>
<small class="mono event-code">{{ item.eventCode }}</small>
</td>
<td>{{ item.categoryName }}</td>
<td>
<span class="team-name">{{ item.teamName }}</span>
<small class="athlete">{{ item.athleteName }}</small>
</td>
<td class="mono highlight">{{ item.resultText }}</td>
<td class="mono">{{ item.recordText }}</td>
<td><span class="mx-tag mx-tag-primary">{{ LEVEL_LABELS[item.level] }}</span></td>
<td class="right mono points">+{{ formatPoints(item.bonusPoints) }}</td>
<td class="right actions">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!recordBonusEnabled || !canWrite || approving"
@click="requestApprove(item)"
>
确认加分
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="rejectTarget = item">拒绝</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!recordBonusEnabled && pendingBreaks.length > 0" class="hint">
「确认加分」在 recordBonus 关闭时不可用,避免生成无规则的积分流水。
</p>
</div>
</section>
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">纪录库</span>
<div class="head-tools">
<div class="mx-tabs">
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
<button
v-for="level in LEVEL_ORDER"
:key="level"
class="mx-tab"
:class="{ active: levelFilter === level }"
type="button"
@click="levelFilter = level"
>
{{ LEVEL_LABELS[level] }}
</button>
</div>
<div class="mx-input-wrap search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索小项 / 保持者" />
</div>
</div>
</div>
<div class="mx-card-body">
<div v-if="recordsLoading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载纪录库…</div>
</div>
<div v-else-if="recordsError !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ recordsError }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="loadRecords">重试</button>
</div>
<div v-else-if="recordGroups.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
<div class="mx-empty-title">没有匹配的纪录</div>
<div class="mx-empty-desc">按小项 + 组别维护纪录库</div>
</div>
<div v-else class="record-groups">
<div v-for="group in recordGroups" :key="group.eventId" class="record-group">
<div class="record-group-head">
<span class="event-name">{{ group.eventName }}</span>
<small class="mono event-code">{{ group.eventCode }}</small>
<span class="mx-tag">{{ group.categoryLabel }}</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>档位</th>
<th>成绩</th>
<th>保持者</th>
<th>单位</th>
<th>达成时间</th>
<th>来源</th>
<th class="center">当前</th>
</tr>
</thead>
<tbody>
<tr v-for="record in group.records" :key="record.id">
<td><span class="mx-tag" :class="record.isCurrent ? 'mx-tag-primary' : ''">{{ LEVEL_LABELS[record.level] }}</span></td>
<td class="mono highlight">{{ record.resultText }}</td>
<td>{{ record.holderName ?? '—' }}</td>
<td>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td>
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
<td>{{ record.source === 'current' ? '本届' : '历史' }}</td>
<td class="center">
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
<span v-else class="muted">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<ConfirmDialog
:open="approveTarget !== null"
tone="warning"
title="确认破纪录并加分?"
:message="
approveTarget === null
? ''
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
"
:details="[
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
`加分模式:${bonusMode};破纪录加分与成绩积分相互独立,不随权重翻倍`,
]"
confirm-text="确认并加分"
:pending="approving"
@confirm="confirmApprove"
@cancel="approveTarget = null"
/>
<ConfirmDialog
:open="rejectTarget !== null"
tone="danger"
title="拒绝这条疑似破纪录?"
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。"
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
confirm-text="确认拒绝"
@confirm="confirmReject"
@cancel="rejectTarget = null"
/>
</div>
</template>
<style scoped>
.notice-card {
margin-top: 16px;
}
.notice-body {
display: flex;
gap: 12px;
align-items: flex-start;
}
.notice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.12);
color: var(--mx-amber);
flex-shrink: 0;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
margin-bottom: 6px;
flex-wrap: wrap;
}
.notice-desc {
font-size: 13px;
color: var(--mx-t2);
line-height: 1.7;
}
.notice-error {
margin-top: 6px;
font-size: 12px;
color: var(--mx-red);
}
.head-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
width: 220px;
}
.event-name {
font-weight: 600;
}
.event-code {
margin-left: 8px;
font-size: 11px;
color: var(--mx-t3);
}
.team-name {
display: block;
font-weight: 600;
}
.athlete {
font-size: 11px;
color: var(--mx-t3);
}
.highlight {
font-weight: 700;
color: var(--mx-primary);
}
.points {
color: var(--mx-green);
font-weight: 800;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
.muted {
color: var(--mx-t3);
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--mx-t3);
}
.record-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.record-group-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(248, 113, 113, 0.08);
color: var(--mx-red);
font-size: 13px;
}
</style>
+729
View File
@@ -0,0 +1,729 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatPoints,
type EntryWithResult,
type Event,
type EventCategory,
type ResultStatus,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 一条草稿成绩:展示与对比都只用这里面的字段。 */
interface DraftRow {
entryId: number
teamId: number
teamName: string
teamShort: string
athleteName: string
memberNames: string[] | null
resultText: string
rank: number | null
status: ResultStatus
isTie: boolean
points: number
}
/** 一个「小项 + 组别」的草稿组;发布以它为事务边界(07 第 3 节)。 */
interface DraftGroup {
key: string
event: Event
eventName: string
eventCode: string
sportName: string
category: EventCategory | null
categoryName: string
rows: DraftRow[]
deltas: TeamDelta[]
affectedTeams: number
maxDelta: number
}
interface TeamDelta {
teamId: number
name: string
delta: number
}
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const groups = ref<DraftGroup[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const selectedKey = ref<string | null>(null)
const selectedEntryId = ref<number | null>(null)
const compareMode = ref<'group' | 'row'>('group')
const publishTarget = ref<DraftGroup | null>(null)
const publishing = ref(false)
const returnTarget = ref<DraftGroup | null>(null)
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
const returnedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const filteredGroups = computed(() => {
const keyword = search.value.trim()
if (keyword === '') return groups.value
return groups.value.filter(
(group) =>
group.eventName.includes(keyword) ||
group.categoryName.includes(keyword) ||
group.rows.some((row) => row.teamName.includes(keyword) || row.athleteName.includes(keyword)),
)
})
const selectedGroup = computed<DraftGroup | null>(
() => groups.value.find((group) => group.key === selectedKey.value) ?? null,
)
const selectedRow = computed<DraftRow | null>(() => {
const group = selectedGroup.value
if (group === null || selectedEntryId.value === null) return null
return group.rows.find((row) => row.entryId === selectedEntryId.value) ?? null
})
/** 逐条对比:这一条自己的得分(个人项目按系数折算到单位)。 */
const rowImpact = computed<TeamDelta[]>(() => {
const row = selectedRow.value
if (row === null) return []
const individual = selectedGroup.value?.event.eventType === 'individual'
const delta = round(row.points * (individual ? weight.value : 1))
if (delta === 0) return []
return [{ teamId: row.teamId, name: row.teamShort || row.teamName, delta }]
})
const impactRows = computed<TeamDelta[]>(() => {
if (compareMode.value === 'row') return rowImpact.value
return selectedGroup.value?.deltas ?? []
})
const impactAffected = computed(() => impactRows.value.length)
const impactMax = computed(() => impactRows.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
const publishedCount = computed(() => groups.value.reduce((sum, group) => sum + group.rows.length, 0))
const returnSummary = computed(() =>
returnTarget.value === null
? ''
: `将退回「${returnTarget.value.eventName} · ${returnTarget.value.categoryName}」的 ${returnTarget.value.rows.length} 条草稿,录入员可修改后重新提交。`,
)
function round(value: number): number {
return Number(value.toFixed(2))
}
function teamLabel(teamId: number, fallback: string): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? fallback
}
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
}
function sportNameOf(event: Event): string {
return data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'
}
function buildGroup(event: Event, category: EventCategory | null, entries: EntryWithResult[]): DraftGroup | null {
const scoped = entries.filter((entry) => category === null || entry.categoryId === category.id)
const drafts = scoped.filter((entry) => entry.result?.state === 'draft')
if (drafts.length === 0) return null
const scorable: ScorableRow[] = scoped.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
resultValue: entry.result?.resultValue ?? null,
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
}))
const context: ScoreContext = {
event,
category,
channels: event.channels ?? [],
profiles: data.profiles,
weight: weight.value,
}
const score = computeEventScore(scorable, context)
const individual = event.eventType === 'individual'
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
const published = new Map<number, number>()
for (const entry of scoped) {
if (entry.result?.state !== 'published') continue
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1)
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
}
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
const deltas: TeamDelta[] = []
for (const teamId of teamIds) {
const delta = round((score.teamPoints.get(teamId) ?? 0) - (published.get(teamId) ?? 0))
if (delta === 0) continue
deltas.push({ teamId, name: teamLabel(teamId, `单位 ${teamId}`), delta })
}
deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
const rows: DraftRow[] = drafts.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
teamShort: entry.shortName ?? '',
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
resultText: entry.result?.resultText ?? '',
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
points: round(score.rowPoints.get(entry.id) ?? 0),
}))
return {
key: `${event.id}-${category?.id ?? 0}`,
event,
eventName: event.name,
eventCode: event.code,
sportName: sportNameOf(event),
category,
categoryName: category?.name ?? '全部组别',
rows,
deltas,
affectedTeams: deltas.length,
maxDelta: deltas.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0),
}
}
/** 找出有草稿的小项:优先用结果接口的 state 过滤,接口未就绪时退回逐项探测。 */
async function draftEventIds(): Promise<number[]> {
try {
const page = await adminApi.client.results.list({ state: 'draft', size: 500 })
return [...new Set(page.items.map((item) => item.eventId))]
} catch (caught) {
if (!isFallback(caught)) throw caught
const found: number[] = []
await Promise.all(
data.events.map(async (event) => {
// adminApi.entries 走统一降级逻辑:后端未就绪时读示例数据。
const entries = await adminApi.entries(event.id)
if (entries.some((entry) => entry.result?.state === 'draft')) found.push(event.id)
}),
)
return found
}
}
async function load(): Promise<void> {
if (!data.loaded) return
loading.value = true
error.value = null
try {
const eventIds = await draftEventIds()
const loaded = await Promise.all(
eventIds.map(async (eventId) => {
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
const categories = event.categories ?? []
const scoped: Array<EventCategory | null> = categories.length > 0 ? categories : [null]
return scoped
.map((category) => buildGroup(event, category, entries))
.filter((group): group is DraftGroup => group !== null)
}),
)
groups.value = loaded.flat().sort((a, b) => a.eventCode.localeCompare(b.eventCode))
if (selectedKey.value !== null && !groups.value.some((group) => group.key === selectedKey.value)) {
selectedKey.value = null
}
if (selectedKey.value === null && groups.value.length > 0) {
selectedKey.value = groups.value[0]?.key ?? null
}
} catch (caught) {
error.value = caught instanceof Error ? caught.message : '加载草稿失败'
} finally {
loading.value = false
}
}
function selectGroup(group: DraftGroup): void {
selectedKey.value = group.key
selectedEntryId.value = null
}
function isReturned(group: DraftGroup): boolean {
return returnedKeys.value.includes(group.key)
}
function requestPublish(group: DraftGroup): void {
if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
publishTarget.value = group
}
async function confirmPublish(): Promise<void> {
const group = publishTarget.value
if (group === null) return
publishing.value = true
try {
await adminApi.publish({ eventId: group.event.id, categoryId: group.category?.id })
publishTarget.value = null
await load()
await data.refreshStandings()
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
} finally {
publishing.value = false
}
}
function confirmReturn(): void {
const group = returnTarget.value
if (group === null) return
if (!returnedKeys.value.includes(group.key)) {
returnedKeys.value = [...returnedKeys.value, group.key]
}
returnTarget.value = null
toast.warning('已标记退回', '当前接口未提供「退回」动作,本次仅在本地标记,不影响服务端数据')
}
function undoReturn(group: DraftGroup): void {
returnedKeys.value = returnedKeys.value.filter((key) => key !== group.key)
toast.info('已取消退回标记')
}
function statusLabel(status: ResultStatus): string {
if (status === 'ok') return '有效'
if (status === 'dns') return 'DNS'
if (status === 'dq') return 'DQ'
if (status === 'dnf') return 'DNF'
return '退赛'
}
onMounted(() => {
void load()
})
watch(
() => data.loaded,
(loaded) => {
if (loaded) void load()
},
)
</script>
<template>
<div class="review-page">
<PageHeader title="成绩审核" subtitle="草稿按「小项 + 组别」分组,发布是整组事务;草稿态绝不上屏">
<template #actions>
<span class="mx-badge mx-badge-warning">{{ publishedCount }} 条草稿待审</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="error !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ error }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="load">重试</button>
</div>
<div class="review-grid">
<aside class="mx-card group-panel">
<div class="mx-card-head">
<span class="mx-card-title">待审草稿</span>
<span class="mx-badge mx-badge-neutral">{{ filteredGroups.length }} 组</span>
</div>
<div class="mx-card-body group-body">
<div class="mx-input-wrap search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索小项 / 组别 / 单位" />
</div>
<div v-if="loading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载草稿…</div>
</div>
<div v-else-if="filteredGroups.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="review" :size="26" /></div>
<div class="mx-empty-title">没有待审草稿</div>
<div class="mx-empty-desc">录入页保存的草稿会出现在这里</div>
</div>
<div v-else class="group-list">
<button
v-for="group in filteredGroups"
:key="group.key"
class="group-item"
:class="{ active: group.key === selectedKey }"
type="button"
@click="selectGroup(group)"
>
<span class="group-main">
<span class="group-name">{{ group.eventName }}</span>
<span class="group-meta">{{ group.sportName }} · {{ group.categoryName }}</span>
</span>
<span class="group-side">
<span class="mx-badge mx-badge-warning">{{ group.rows.length }} 条</span>
<span v-if="isReturned(group)" class="mx-badge mx-badge-neutral">已退回</span>
</span>
</button>
</div>
</div>
</aside>
<section class="review-main">
<div v-if="selectedGroup === null" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="review" :size="28" /></div>
<div class="mx-empty-title">选择左侧一组草稿</div>
<div class="mx-empty-desc">对比发布影响后再决定发布或退回</div>
</div>
</div>
</div>
<template v-else>
<div class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
{{ selectedGroup.eventName }}
<small class="head-sub">{{ selectedGroup.sportName }} · {{ selectedGroup.categoryName }}</small>
</span>
<div class="head-actions">
<span v-if="isReturned(selectedGroup)" class="mx-badge mx-badge-neutral">已退回待修改</span>
<button
v-if="isReturned(selectedGroup)"
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
@click="undoReturn(selectedGroup)"
>
取消退回
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite"
@click="returnTarget = selectedGroup"
>
<MxIcon name="logout" :size="14" /> 退回
</button>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing"
@click="requestPublish(selectedGroup)"
>
<MxIcon name="push" :size="14" /> 发布整组
</button>
</div>
</div>
<div class="mx-card-body">
<div class="compare-bar">
<div class="mx-tabs mx-tabs-pill">
<button
class="mx-tab"
:class="{ active: compareMode === 'group' }"
type="button"
@click="compareMode = 'group'"
>
整组对比
</button>
<button
class="mx-tab"
:class="{ active: compareMode === 'row' }"
type="button"
:disabled="selectedRow === null"
@click="compareMode = 'row'"
>
逐条对比
</button>
</div>
<p class="impact-line">
本次将影响 <strong>{{ impactAffected }}</strong> 个单位,最大分差
<strong class="mono">{{ formatPoints(impactMax) }}</strong> 分
</p>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>单位</th>
<th>运动员 / 队</th>
<th>成绩</th>
<th class="center">名次</th>
<th class="center">状态</th>
<th class="right">积分</th>
<th class="right">对比</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in selectedGroup.rows"
:key="row.entryId"
:class="{ selected: row.entryId === selectedEntryId }"
@click="selectedEntryId = row.entryId"
>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td class="mono">{{ row.resultText || '—' }}</td>
<td class="center mono">{{ row.rank ?? '—' }}</td>
<td class="center">{{ statusLabel(row.status) }}</td>
<td class="right mono points">{{ formatPoints(row.points) }}</td>
<td class="right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click.stop="selectedEntryId = row.entryId; compareMode = 'row'">
看该条
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
发布影响({{ compareMode === 'row' ? '逐条' : '整组' }})
</span>
<span class="mx-badge mx-badge-info">{{ impactAffected }} 个单位</span>
</div>
<div class="mx-card-body">
<ul v-if="impactRows.length > 0" class="impact-list">
<li v-for="item in impactRows" :key="item.teamId" class="impact-item">
<span class="impact-name">{{ item.name }}</span>
<span class="impact-delta mono" :class="item.delta > 0 ? 'up' : 'down'">
{{ item.delta > 0 ? '+' : '' }}{{ formatPoints(item.delta) }}
</span>
</li>
</ul>
<p v-else class="impact-empty">
{{ compareMode === 'row' ? '选中一行查看它单独的得分影响' : '本次发布不会改变任何单位的分数' }}
</p>
</div>
</div>
</template>
</section>
</div>
<ConfirmDialog
:open="publishTarget !== null"
tone="warning"
title="确认发布该组成绩?"
:message="
publishTarget === null
? ''
: `本次将影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
"
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
confirm-text="确认发布"
:pending="publishing"
@confirm="confirmPublish"
@cancel="publishTarget = null"
/>
<ConfirmDialog
:open="returnTarget !== null"
tone="danger"
title="确认退回这组草稿?"
:message="returnSummary"
:details="['服务端接口清单里没有「退回」动作,本次只在管理端做本地标记', '成绩数据不变,录入员修改后重新保存草稿即可']"
confirm-text="标记退回"
@confirm="confirmReturn"
@cancel="returnTarget = null"
/>
</div>
</template>
<style scoped>
.review-grid {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
align-items: start;
margin-top: 16px;
}
.group-panel {
position: sticky;
top: 0;
max-height: calc(100dvh - 140px);
display: flex;
flex-direction: column;
}
.group-body {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.search {
width: 100%;
}
.group-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 10px;
border-radius: var(--mx-r-sm);
text-align: left;
cursor: pointer;
color: var(--mx-t2);
transition: all var(--mx-d1) var(--mx-ease);
}
.group-item:hover {
background: var(--mx-s-hover);
color: var(--mx-text);
}
.group-item.active {
background: var(--mx-primary-glow);
color: var(--mx-primary);
}
.group-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.group-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-meta {
font-size: 11px;
color: var(--mx-t3);
}
.group-side {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
flex-shrink: 0;
}
.review-main {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.head-sub {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--mx-t3);
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.compare-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.impact-line {
font-size: 13px;
color: var(--mx-t2);
}
.impact-line strong {
color: var(--mx-primary);
font-size: 15px;
}
.team-cell {
font-weight: 600;
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.points {
color: var(--mx-green);
font-weight: 700;
}
tr.selected {
background: var(--mx-primary-glow);
}
.impact-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.impact-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
font-size: 13px;
}
.impact-delta {
font-weight: 800;
}
.impact-delta.up {
color: var(--mx-green);
}
.impact-delta.down {
color: var(--mx-red);
}
.impact-empty {
font-size: 13px;
color: var(--mx-t3);
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(248, 113, 113, 0.08);
color: var(--mx-red);
font-size: 13px;
margin-top: 14px;
}
@media (max-width: 1279px) {
.review-grid {
grid-template-columns: 1fr;
}
.group-panel {
position: static;
max-height: none;
}
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import UsersPanel from '../components/system/UsersPanel.vue'
import BackupPanel from '../components/system/BackupPanel.vue'
import EventInfoPanel from '../components/system/EventInfoPanel.vue'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
/**
* 系统页(devdocs/07 第 1 节)。
*
* 三块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间,
* 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。
*/
type TabKey = 'users' | 'backup' | 'event'
const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [
{ key: 'users', label: '用户管理' },
{ key: 'backup', label: '备份' },
{ key: 'event', label: '赛事信息' },
]
const auth = useAuthStore()
const data = useEventStore()
const activeTab = ref<TabKey>('users')
const isAdmin = computed(() => auth.user?.role === 'admin')
</script>
<template>
<div>
<PageHeader title="系统" subtitle="用户管理 / 备份恢复 / 赛事信息(devdocs/08 第 2、6 节)" />
<div class="system-toolbar">
<div class="mx-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>
<span class="mx-badge" :class="isAdmin ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ isAdmin ? `当前身份:${auth.displayName}` : `只读访客 · ${auth.displayName}` }}
</span>
</div>
<p v-if="!isAdmin" class="role-note">
系统配置类接口对 Guest 一律 403(devdocs/08 第 2 节),因此这里的写操作按钮全部禁用;
登录管理员账号后即可操作。
</p>
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
<UsersPanel v-if="activeTab === 'users'" />
<BackupPanel v-else-if="activeTab === 'backup'" />
<EventInfoPanel v-else />
</div>
</template>
<style scoped>
.system-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.role-note {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.7;
padding: 8px 10px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
margin-bottom: 14px;
}
.role-note.error {
color: var(--mx-error);
background: rgba(248, 113, 113, 0.1);
}
</style>