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

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

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+25 -6
View File
@@ -1,16 +1,35 @@
'use strict'
const { nativeImage } = require('electron')
const fs = require('fs')
const path = require('path')
const { app, nativeImage } = require('electron')
// 托盘图标以 base64 内嵌:现场部署包只有 exe 和几个文件,
// 少一个需要跟着走的资源文件就少一处「文件丢了图标不显示」的故障点。
// 托盘图标:优先用部署包里的 scoreboard.png(换图标不用重新打包),
// 找不到时回落到内嵌 base64,保证任何情况下托盘都有图标。
const ICON_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABUklEQVR42u2XwUdEURTG5z9JSholJY2SklJSf0+0iKFFtIhoES0iWkSLiBaRDKNSKU2GjCRGEkkSSZsvb3GfnHPnzTm382Y1P36b9x33fKv37stkmiiYLf1AovnimatvhPjvxdMXX7AwaPnU6ScsVS2fLH4gDUXLJwrvSNO6BcaP3pCmicvHDl/RCGsWGD14QSP0Lh/Zf4aVFN8MKzC89wQrKb4ZVmBotworKb4ZVmBw5xFWUnwzrEBu+wFWUnwzrMDA1j00UqSZkxXo36xAI0WaOVmBvo07aKRIMycr0LtehkaKNHOyAj1rt9BIkWZO79uwe/UGUinSLLLmt6Br5Rp/pVhkkYlfxOzyJZwUi6zufaBz6RxOikUmuhV1LJ4hkuKeh2aqe2F7/oQdFD1zarOgmzE9qG2hGKvJgv8L6EGt84VYTWZWoGXuOFaTNX9qk/gFLmHiXP3t+GEAAAAASUVORK5CYII='
// 候选顺序:exe 同级(部署包)→ 源码目录(开发态)→ resources 目录
function resolveIconPath() {
const candidates = [
path.join(path.dirname(app.getPath('exe')), 'scoreboard.png'),
path.join(__dirname, '..', 'scoreboard.png'),
path.join(process.resourcesPath || '', 'scoreboard.png'),
]
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null
}
function createTrayIcon() {
const image = nativeImage.createFromDataURL(`data:image/png;base64,${ICON_PNG_BASE64}`)
// Windows 上托盘按 16px 显示,缩放交给系统会更模糊,这里直接给 16px
return image.resize({ width: 16, height: 16 })
const file = resolveIconPath()
if (file) {
const image = nativeImage.createFromPath(file)
if (!image.isEmpty()) {
// Windows 托盘按 16px 显示,缩放交给系统会更模糊,这里直接给 16px
return image.resize({ width: 16, height: 16 })
}
}
const fallback = nativeImage.createFromDataURL(`data:image/png;base64,${ICON_PNG_BASE64}`)
return fallback.resize({ width: 16, height: 16 })
}
module.exports = { createTrayIcon }
+1 -1
View File
@@ -13,7 +13,7 @@
<!-- 窗口是无边框的,标题栏和最小化/最大化/关闭三键都在这条上自绘 -->
<header id="titlebar" class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<img class="brand-mark" src="scoreboard.png" alt="" width="30" height="30" />
<div>
<h1>Scoreboard Console</h1>
<p class="brand-sub">赛事积分榜 · 桌面宿主</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+5 -4
View File
@@ -51,10 +51,11 @@ body {
}
.brand-mark {
width: 8px;
height: 22px;
border-radius: 4px;
background: linear-gradient(180deg, var(--accent), #1b6bb8);
width: 30px;
height: 30px;
border-radius: 8px;
object-fit: cover;
flex: none;
}
h1 {
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+2
View File
@@ -53,6 +53,8 @@ REM config.json must sit next to the exe: the console resolves it from the exe d
copy /y "console\config.json" "%CONSOLE_OUT%\config.json" >nul || exit /b 1
copy /y "dist\scoreboard.exe" "%CONSOLE_OUT%\scoreboard.exe" >nul || exit /b 1
copy /y "config.yaml" "%CONSOLE_OUT%\config.yaml" >nul || exit /b 1
REM app icon: the console loads it from the exe directory for tray / window icon
copy /y "console\scoreboard.png" "%CONSOLE_OUT%\scoreboard.png" >nul || exit /b 1
echo.
echo === 构建完成 ===
+9 -1
View File
@@ -90,8 +90,16 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
// 项目名取赛事设置里的 event.name;为空会触发 SafeProjectName 校验失败,回退到固定名
name := "scoreboard-project"
var raw string
if err := s.ev.QueryRowContext(r.Context(),
`SELECT value FROM settings WHERE key = 'event.name'`).Scan(&raw); err == nil && raw != "" {
name = raw
}
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{}); err != nil {
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{Name: name}); err != nil {
return nil, internal(err)
}
defer func() { _ = os.Remove(dst) }()
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/scoreboard.png" />
<title>管理面板</title>
</head>
<body>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/scoreboard.png" />
<title>积分大屏</title>
</head>
<body>
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/scoreboard.png" />
<title>赛事积分榜系统</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+1 -1
View File
@@ -37,7 +37,7 @@ function handleDoubleClick(event: MouseEvent): void {
<template>
<header v-if="visible" class="desktop-titlebar" @dblclick="handleDoubleClick">
<div class="desktop-titlebar-brand">
<span class="desktop-titlebar-mark" aria-hidden="true"></span>
<img class="desktop-titlebar-mark app-icon" src="/scoreboard.png" alt="" width="20" height="20" />
<span class="desktop-titlebar-title">积分榜管理</span>
<span class="desktop-titlebar-sub">服务端管理面板</span>
</div>
@@ -0,0 +1,297 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Athlete, AthleteInput, Gender } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormDialog from './FormDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
interface AthleteForm {
name: string
gender: '' | Gender
teamId: string
employeeNo: string
active: boolean
}
type AthleteField = keyof AthleteForm
const search = ref('')
const teamFilter = ref<string>('all')
const statusFilter = ref<'all' | 'active' | 'inactive'>('all')
const dialogOpen = ref(false)
const editingId = ref<number | null>(null)
const form = ref<AthleteForm>(emptyForm())
const errors = ref<Partial<Record<AthleteField, string>>>({})
const formError = ref<string | null>(null)
const pending = ref(false)
const confirmOpen = ref(false)
const confirmTarget = ref<Athlete | null>(null)
const confirmPending = ref(false)
function emptyForm(): AthleteForm {
return { name: '', gender: '', teamId: '', employeeNo: '', active: true }
}
const filtered = computed(() => {
const keyword = search.value.trim().toLowerCase()
return data.athletes.value.filter((athlete) => {
if (teamFilter.value !== 'all' && String(athlete.teamId) !== teamFilter.value) return false
if (statusFilter.value === 'active' && !athlete.active) return false
if (statusFilter.value === 'inactive' && athlete.active) return false
if (keyword === '') return true
const team = data.teamById(athlete.teamId)
return (
athlete.name.toLowerCase().includes(keyword) ||
(athlete.employeeNo ?? '').toLowerCase().includes(keyword) ||
(team?.name ?? '').toLowerCase().includes(keyword)
)
})
})
const activeCount = computed(() => data.athletes.value.filter((athlete) => athlete.active).length)
function openCreate(): void {
editingId.value = null
form.value = emptyForm()
form.value.teamId = data.teams.value[0] === undefined ? '' : String(data.teams.value[0].id)
errors.value = {}
formError.value = null
dialogOpen.value = true
}
function openEdit(athlete: Athlete): void {
editingId.value = athlete.id
form.value = {
name: athlete.name,
gender: athlete.gender ?? '',
teamId: String(athlete.teamId),
employeeNo: athlete.employeeNo ?? '',
active: athlete.active,
}
errors.value = {}
formError.value = null
dialogOpen.value = true
}
function validate(): boolean {
const next: Partial<Record<AthleteField, string>> = {}
if (form.value.name.trim() === '') next.name = '姓名必填'
if (form.value.teamId === '' || data.teamById(Number(form.value.teamId)) === undefined) {
next.teamId = '必须选择一个参赛单位'
}
errors.value = next
return Object.keys(next).length === 0
}
async function submit(): Promise<void> {
if (!validate()) return
pending.value = true
formError.value = null
const input: AthleteInput = {
name: form.value.name.trim(),
gender: form.value.gender === '' ? null : form.value.gender,
teamId: Number(form.value.teamId),
employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(),
active: form.value.active,
}
try {
await data.saveAthlete(input, editingId.value)
toast.success(editingId.value === null ? '运动员已新增' : '运动员已保存')
dialogOpen.value = false
} catch (caught) {
formError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
pending.value = false
}
}
function askToggle(athlete: Athlete): void {
if (athlete.active) {
confirmTarget.value = athlete
confirmOpen.value = true
return
}
void toggleActive(athlete)
}
async function toggleActive(athlete: Athlete): Promise<void> {
confirmPending.value = true
try {
await data.saveAthlete(
{
name: athlete.name,
gender: athlete.gender,
teamId: athlete.teamId,
employeeNo: athlete.employeeNo,
active: !athlete.active,
},
athlete.id,
)
toast.success(athlete.active ? `已停用「${athlete.name}」` : `已启用「${athlete.name}」`)
confirmOpen.value = false
} catch (caught) {
toast.error('操作失败', caught instanceof Error ? caught.message : null)
} finally {
confirmPending.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">运动员</span>
<span class="mx-badge mx-badge-neutral">{{ activeCount }} / {{ data.athletes.value.length }} 启用</span>
</div>
<div class="mx-card-body">
<div class="bd-toolbar">
<div class="bd-toolbar-left">
<div class="mx-input-wrap bd-search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索姓名 / 工号" />
</div>
<select v-model="teamFilter" class="mx-cell-input bd-select">
<option value="all">全部单位</option>
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">{{ team.name }}</option>
</select>
<select v-model="statusFilter" class="mx-cell-input bd-select">
<option value="all">全部状态</option>
<option value="active">仅启用</option>
<option value="inactive">仅停用</option>
</select>
</div>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-primary" type="button" :disabled="!data.canWrite.value || data.teams.value.length === 0" @click="openCreate">
<MxIcon name="entry" :size="15" /> 新增运动员
</button>
</div>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>姓名</th>
<th class="bd-cell-mini">性别</th>
<th>所属单位</th>
<th>工号 / 学号</th>
<th>状态</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="data.loading.value && data.athletes.value.length === 0">
<td colspan="6">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
</div>
</td>
</tr>
<template v-else>
<tr v-for="athlete in filtered" :key="athlete.id">
<td>{{ athlete.name }}</td>
<td>{{ athlete.gender ?? '—' }}</td>
<td>{{ data.teamById(athlete.teamId)?.name ?? '未知单位' }}</td>
<td class="bd-mono">{{ athlete.employeeNo ?? '—' }}</td>
<td>
<span class="mx-badge" :class="athlete.active ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ athlete.active ? '启用' : '已停用' }}
</span>
</td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="openEdit(athlete)">
编辑
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
:class="{ 'danger-text': athlete.active }"
type="button"
:disabled="!data.canWrite.value"
@click="askToggle(athlete)"
>
{{ athlete.active ? '停用' : '启用' }}
</button>
</div>
</td>
</tr>
<tr v-if="filtered.length === 0">
<td colspan="6">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">{{ data.athletes.value.length === 0 ? '还没有运动员' : '没有匹配的运动员' }}</div>
<div class="mx-empty-desc">也可以直接通过「报名导入」批量导入</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p class="bd-note">停用为软删除:历史报名与成绩保留,新报名不再可选该运动员。</p>
</div>
<FormDialog
:open="dialogOpen"
:title="editingId === null ? '新增运动员' : '编辑运动员'"
subtitle="个人项目的报名项会关联到运动员;团体项目可留空"
:pending="pending"
:error="formError"
@submit="submit"
@cancel="dialogOpen = false"
>
<div class="bd-form-grid">
<FormField label="姓名" required :error="errors.name">
<input v-model="form.name" class="mx-cell-input" placeholder="例如 张伟" />
</FormField>
<FormField label="性别">
<select v-model="form.gender" class="mx-cell-input">
<option value="">未设置</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</FormField>
<FormField label="所属单位" required :error="errors.teamId">
<select v-model="form.teamId" class="mx-cell-input">
<option value="" disabled>请选择单位</option>
<option v-for="team in data.teams.value" :key="team.id" :value="String(team.id)">
{{ team.name }}{{ team.active ? '' : '(已停用)' }}
</option>
</select>
</FormField>
<FormField label="工号 / 学号" hint="可留空,用于对账">
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
</FormField>
<FormField label="状态" wide>
<label class="bd-check"><input v-model="form.active" type="checkbox" /> 启用</label>
</FormField>
</div>
</FormDialog>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
title="停用该运动员?"
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该运动员;历史报名与成绩保留。`"
confirm-text="停用"
:pending="confirmPending"
@confirm="confirmTarget && toggleActive(confirmTarget)"
@cancel="confirmOpen = false"
/>
</section>
</template>
<style scoped>
.danger-text {
color: var(--mx-red);
}
</style>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { EventCategory, EventCategoryInput, Gender } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
const data = useBaseData()
const toast = useToastStore()
interface CategoryRow {
key: number
name: string
multiplier: string
gender: '' | Gender
ageMin: string
ageMax: string
sortOrder: string
}
interface RowError {
name?: string
multiplier?: string
age?: string
}
let nextKey = 1
const rows = ref<CategoryRow[]>([])
const original = ref<EventCategory[]>([])
const errors = ref<Record<number, RowError>>({})
const saving = ref(false)
const confirmOpen = ref(false)
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
const removedNames = computed(() => {
const current = new Set(rows.value.map((row) => row.name.trim()).filter((name) => name !== ''))
return original.value.filter((category) => !current.has(category.name)).map((category) => category.name)
})
watch(
[() => data.selectedEventId.value, () => data.categoriesOf(data.selectedEventId.value)],
() => {
const list = data.categoriesOf(data.selectedEventId.value)
original.value = list
rows.value = list.map((category) => toRow(category))
errors.value = {}
},
{ immediate: true },
)
function toRow(category: EventCategory): CategoryRow {
return {
key: (nextKey += 1),
name: category.name,
multiplier: String(category.multiplier),
gender: category.gender ?? '',
ageMin: category.ageMin === null ? '' : String(category.ageMin),
ageMax: category.ageMax === null ? '' : String(category.ageMax),
sortOrder: String(category.sortOrder),
}
}
function addRow(): void {
rows.value = [
...rows.value,
{
key: (nextKey += 1),
name: '',
multiplier: '1',
gender: '',
ageMin: '',
ageMax: '',
sortOrder: String(rows.value.length + 1),
},
]
}
function removeRow(key: number): void {
rows.value = rows.value.filter((row) => row.key !== key)
}
function moveRow(index: number, delta: number): void {
const target = index + delta
if (target < 0 || target >= rows.value.length) return
const next = [...rows.value]
const current = next[index]
const swap = next[target]
if (current === undefined || swap === undefined) return
next[index] = swap
next[target] = current
rows.value = next
}
function validate(): boolean {
const next: Record<number, RowError> = {}
const seen = new Set<string>()
rows.value.forEach((row) => {
const rowError: RowError = {}
const name = row.name.trim()
if (name === '') rowError.name = '名称必填'
else if (seen.has(name)) rowError.name = '组别名称重复'
else seen.add(name)
const multiplier = Number(row.multiplier)
if (!Number.isFinite(multiplier) || multiplier <= 0 || multiplier > 100) {
rowError.multiplier = '权重为 0–100 的正数'
}
const ageMin = row.ageMin.trim() === '' ? null : Number(row.ageMin)
const ageMax = row.ageMax.trim() === '' ? null : Number(row.ageMax)
if (ageMin !== null && (!Number.isInteger(ageMin) || ageMin < 0 || ageMin > 120)) rowError.age = '年龄为 0–120 的整数'
else if (ageMax !== null && (!Number.isInteger(ageMax) || ageMax < 0 || ageMax > 120)) rowError.age = '年龄为 0–120 的整数'
else if (ageMin !== null && ageMax !== null && ageMin > ageMax) rowError.age = '起始年龄不能大于结束年龄'
if (Object.keys(rowError).length > 0) next[row.key] = rowError
})
errors.value = next
return Object.keys(next).length === 0
}
function buildInputs(): EventCategoryInput[] {
return rows.value.map((row, index) => ({
name: row.name.trim(),
multiplier: Number(row.multiplier),
gender: row.gender === '' ? null : row.gender,
ageMin: row.ageMin.trim() === '' ? null : Number(row.ageMin),
ageMax: row.ageMax.trim() === '' ? null : Number(row.ageMax),
sortOrder: Number(row.sortOrder) || index + 1,
}))
}
function requestSave(): void {
if (data.selectedEventId.value === null) return
if (!validate()) {
toast.error('组别校验未通过', '请先修正标红的字段')
return
}
if (removedNames.value.length > 0) {
confirmOpen.value = true
return
}
void save()
}
async function save(): Promise<void> {
const eventId = data.selectedEventId.value
if (eventId === null) return
saving.value = true
try {
await data.saveCategories(eventId, buildInputs())
toast.success('组别已保存')
confirmOpen.value = false
} catch (caught) {
toast.error('保存组别失败', caught instanceof Error ? caught.message : null)
} finally {
saving.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
组别{{ selectedEvent === null ? '' : ` · ${selectedEvent.name}` }}
</span>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value || selectedEvent === null" @click="addRow">
<MxIcon name="entry" :size="14" /> 新增组别
</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || selectedEvent === null || saving"
@click="requestSave"
>
{{ saving ? '保存中…' : '保存全部' }}
</button>
</div>
</div>
<div class="mx-card-body">
<div v-if="selectedEvent === null" class="mx-empty bd-empty">
<div class="mx-empty-title">请先选择一个小项</div>
<div class="mx-empty-desc">组别挂在具体小项下,名次只在同一组别内有效</div>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="emit('goTab', 'events')">去选小项</button>
</div>
<template v-else>
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
保存会整体替换该小项的组别配置(PUT /api/events/{{ selectedEvent.id }}/categories)。
若该项目已有报名数据,服务端会拒绝替换。
</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>名称</th>
<th class="bd-cell-narrow">权重</th>
<th class="bd-cell-narrow">性别</th>
<th class="bd-cell-narrow">年龄下限</th>
<th class="bd-cell-narrow">年龄上限</th>
<th class="bd-cell-mini">排序</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.key">
<td>
<input
v-model="row.name"
class="mx-cell-input"
:class="{ 'is-invalid': errors[row.key]?.name }"
placeholder="例如 男子组"
/>
<span v-if="errors[row.key]?.name" class="row-error">{{ errors[row.key]?.name }}</span>
</td>
<td>
<input v-model="row.multiplier" class="mx-cell-input bd-mono" :class="{ 'is-invalid': errors[row.key]?.multiplier }" type="number" min="0.1" step="0.1" />
</td>
<td>
<select v-model="row.gender" class="mx-cell-input">
<option value="">不限</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</td>
<td>
<input v-model="row.ageMin" class="mx-cell-input bd-mono" :class="{ 'is-invalid': errors[row.key]?.age }" type="number" min="0" max="120" placeholder="—" />
</td>
<td>
<input v-model="row.ageMax" class="mx-cell-input bd-mono" :class="{ 'is-invalid': errors[row.key]?.age }" type="number" min="0" max="120" placeholder="—" />
<span v-if="errors[row.key]?.age" class="row-error">{{ errors[row.key]?.age }}</span>
</td>
<td>
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
</td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="index === 0" @click="moveRow(index, -1)">上移</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="index === rows.length - 1" @click="moveRow(index, 1)">下移</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm danger-text" type="button" :disabled="!data.canWrite.value" @click="removeRow(row.key)">移除</button>
</div>
</td>
</tr>
<tr v-if="rows.length === 0">
<td colspan="7">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">该小项还没有组别</div>
<div class="mx-empty-desc">至少需要一个组别,报名项与名次都挂在组别上</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p class="bd-note">组别权重会乘在通道得分上;性别与年龄仅用于展示与筛选。</p>
</template>
</div>
<ConfirmDialog
:open="confirmOpen"
tone="danger"
title="保存将移除组别?"
:message="`本次保存会删除 ${removedNames.length} 个组别,并整体替换该小项的组别配置。`"
:details="removedNames"
confirm-text="确认保存"
:pending="saving"
@confirm="save"
@cancel="confirmOpen = false"
/>
</section>
</template>
<style scoped>
.danger-text {
color: var(--mx-red);
}
.row-error {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--mx-red);
}
</style>
@@ -0,0 +1,300 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
import type { BaseDataTabKey } from './tabs'
const emit = defineEmits<{ goTab: [tab: BaseDataTabKey] }>()
const data = useBaseData()
const toast = useToastStore()
const SCOPE_OPTIONS: Array<{ value: ChannelScope; label: string }> = [
{ value: 'athlete', label: '个人名次' },
{ value: 'team', label: '团体名次' },
]
const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
{ value: 'result', label: '按成绩自动排名' },
{ value: 'manual', label: '手动录名次' },
{ value: 'aggregate', label: '按团体汇总(预留)' },
]
interface ChannelRow {
key: number
scope: ChannelScope
basis: ChannelBasis
profileId: string
sortOrder: string
}
interface RowError {
scope?: string
profile?: string
}
let nextKey = 1
const rows = ref<ChannelRow[]>([])
const original = ref<ScoreChannel[]>([])
const errors = ref<Record<number, RowError>>({})
const saving = ref(false)
const confirmOpen = ref(false)
const selectedEvent = computed(() => data.eventById(data.selectedEventId.value) ?? null)
const removedKeys = computed(() => {
const current = new Set(rows.value.map((row) => `${row.scope}:${row.basis}`))
return original.value
.filter((channel) => !current.has(`${channel.scope}:${channel.basis}`))
.map((channel) => `${scopeLabel(channel.scope)} / ${basisLabel(channel.basis)}`)
})
watch(
[() => data.selectedEventId.value, () => data.channelsOf(data.selectedEventId.value)],
() => {
const list = data.channelsOf(data.selectedEventId.value)
original.value = list
rows.value = list.map((channel) => toRow(channel))
errors.value = {}
},
{ immediate: true },
)
function scopeLabel(scope: ChannelScope): string {
return SCOPE_OPTIONS.find((option) => option.value === scope)?.label ?? scope
}
function basisLabel(basis: ChannelBasis): string {
return BASIS_OPTIONS.find((option) => option.value === basis)?.label ?? basis
}
function toRow(channel: ScoreChannel): ChannelRow {
return {
key: (nextKey += 1),
scope: channel.scope,
basis: channel.basis,
profileId: String(channel.profileId),
sortOrder: String(channel.sortOrder),
}
}
function addRow(): void {
rows.value = [
...rows.value,
{
key: (nextKey += 1),
scope: 'athlete',
basis: 'result',
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
sortOrder: String(rows.value.length + 1),
},
]
}
function removeRow(key: number): void {
rows.value = rows.value.filter((row) => row.key !== key)
}
function profileSummary(profileId: string): string {
const profile = data.profileById(profileId === '' ? null : Number(profileId))
if (profile === undefined) return '—'
const rules = [...profile.rules]
.sort((a, b) => a.rank - b.rank)
.map((rule) => `${rule.rank}:${rule.points}`)
.join(' / ')
return `录取前 ${profile.awardRanks ?? rules.length} 名 · ${rules || '无明细'}`
}
function validate(): boolean {
const next: Record<number, RowError> = {}
const seen = new Set<string>()
rows.value.forEach((row) => {
const rowError: RowError = {}
const key = `${row.scope}:${row.basis}`
if (seen.has(key)) rowError.scope = '同一「名次范围 + 名次来源」只能配置一个通道'
else seen.add(key)
if (row.profileId === '' || data.profileById(Number(row.profileId)) === undefined) {
rowError.profile = '必须选择一个积分模板'
}
if (Object.keys(rowError).length > 0) next[row.key] = rowError
})
errors.value = next
return Object.keys(next).length === 0
}
function buildInputs(): ScoreChannelInput[] {
return rows.value.map((row, index) => ({
scope: row.scope,
basis: row.basis,
profileId: Number(row.profileId),
sortOrder: Number(row.sortOrder) || index + 1,
}))
}
function requestSave(): void {
if (data.selectedEventId.value === null) return
if (!validate()) {
toast.error('计分通道校验未通过', '请先修正标红的字段')
return
}
if (removedKeys.value.length > 0) {
confirmOpen.value = true
return
}
void save()
}
async function save(): Promise<void> {
const eventId = data.selectedEventId.value
if (eventId === null) return
saving.value = true
try {
await data.saveChannels(eventId, buildInputs())
toast.success('计分通道已保存')
confirmOpen.value = false
} catch (caught) {
toast.error('保存计分通道失败', caught instanceof Error ? caught.message : null)
} finally {
saving.value = false
}
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
计分通道{{ selectedEvent === null ? '' : ` · ${selectedEvent.name}` }}
</span>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value || selectedEvent === null" @click="addRow">
<MxIcon name="entry" :size="14" /> 新增通道
</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || selectedEvent === null || saving"
@click="requestSave"
>
{{ saving ? '保存中…' : '保存全部' }}
</button>
</div>
</div>
<div class="mx-card-body">
<div v-if="selectedEvent === null" class="mx-empty bd-empty">
<div class="mx-empty-title">请先选择一个小项</div>
<div class="mx-empty-desc">通道 = 排谁的名次 + 名次从哪来 + 用哪张积分模板</div>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="emit('goTab', 'events')">去选小项</button>
</div>
<template v-else>
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
保存会整体替换该小项的计分通道(PUT /api/events/{{ selectedEvent.id }}/channels)。
同一「名次范围 + 名次来源」只能有一个通道;一个项目可配 0–N 个通道。
</span>
</div>
<div v-if="!selectedEvent.scored" class="bd-note scored-off">
<MxIcon name="warning" :size="14" />
该小项标记为「不计分」,通道配置会保存但不参与积分计算。
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="bd-select">名次范围</th>
<th class="bd-select">名次来源</th>
<th>积分模板</th>
<th class="bd-cell-mini">排序</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.key">
<td>
<select v-model="row.scope" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
<option v-for="option in SCOPE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</td>
<td>
<select v-model="row.basis" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.scope }">
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
</td>
<td>
<select v-model="row.profileId" class="mx-cell-input" :class="{ 'is-invalid': errors[row.key]?.profile }">
<option value="" disabled>请选择积分模板</option>
<option v-for="profile in data.profiles.value" :key="profile.id" :value="String(profile.id)">{{ profile.name }}</option>
</select>
<span class="profile-summary bd-note">{{ profileSummary(row.profileId) }}</span>
<span v-if="errors[row.key]?.profile" class="row-error">{{ errors[row.key]?.profile }}</span>
</td>
<td>
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
</td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm danger-text" type="button" :disabled="!data.canWrite.value" @click="removeRow(row.key)">
移除
</button>
</div>
</td>
</tr>
<tr v-if="rows.length === 0">
<td colspan="5">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">该小项没有计分通道</div>
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<ConfirmDialog
:open="confirmOpen"
tone="danger"
title="保存将移除计分通道?"
:message="`本次保存会删除 ${removedKeys.length} 个计分通道,并整体替换该小项的通道配置。`"
:details="removedKeys"
confirm-text="确认保存"
:pending="saving"
@confirm="save"
@cancel="confirmOpen = false"
/>
</section>
</template>
<style scoped>
.danger-text {
color: var(--mx-red);
}
.row-error {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--mx-red);
}
.profile-summary {
display: block;
margin-top: 4px;
}
.scored-off {
display: flex;
align-items: center;
gap: 6px;
color: var(--mx-amber);
margin-bottom: 10px;
}
</style>
@@ -0,0 +1,286 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { EntryImportResult } from '@/shared'
import MxIcon from '../MxIcon.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
/** 与服务端 handlers_config.go 的 importColumns 一致;缺任何一列服务端会直接拒绝。 */
const REQUIRED_COLUMNS = ['event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names']
const TEMPLATE_CSV = [
REQUIRED_COLUMNS.join(','),
'M100,男子组,F1,张伟,101,,registered,',
'BB,混合组,F2,,,种子1,registered,第一分公司代表队',
].join('\n')
const MAX_SIZE = 16 * 1024 * 1024
const fileInput = ref<HTMLInputElement | null>(null)
const picked = ref<File | null>(null)
const dragging = ref(false)
const importing = ref(false)
const result = ref<EntryImportResult | null>(null)
const error = ref<string | null>(null)
const fileLabel = computed(() => (picked.value === null ? '' : `${picked.value.name} · ${formatSize(picked.value.size)}`))
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function chooseFile(): void {
fileInput.value?.click()
}
function acceptFile(file: File): void {
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
if (extension !== '.csv' && extension !== '.xlsx') {
error.value = '只支持 .csv 或 .xlsx 报名表'
return
}
if (file.size > MAX_SIZE) {
error.value = '文件超过 16MB,服务端会拒绝解析'
return
}
picked.value = file
result.value = null
error.value = null
}
function onPick(event: Event): void {
const input = event.target
if (!(input instanceof HTMLInputElement)) return
const file = input.files?.[0]
if (file !== undefined) acceptFile(file)
// 清空 value,否则同一个文件再次选择不会触发 change。
input.value = ''
}
function onDrop(event: DragEvent): void {
dragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file !== undefined) acceptFile(file)
}
async function runImport(): Promise<void> {
if (picked.value === null) return
importing.value = true
error.value = null
result.value = null
try {
result.value = await data.importEntries(picked.value)
toast.success('导入完成', `新增 ${result.value.created} · 更新 ${result.value.updated} · 跳过 ${result.value.skipped}`)
} catch (caught) {
error.value = caught instanceof Error ? caught.message : '导入失败'
} finally {
importing.value = false
}
}
function downloadTemplate(): void {
// 带 BOM,Excel 打开才不会把中文表头识别成乱码。
const blob = new Blob([`\uFEFF${TEMPLATE_CSV}\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = 'entries-template.csv'
anchor.rel = 'noopener'
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
</script>
<template>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">报名导入</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="downloadTemplate">
<MxIcon name="export" :size="14" /> 下载 CSV 模板
</button>
</div>
<div class="mx-card-body">
<div class="bd-warn">
<MxIcon name="warning" :size="14" />
<span>
表头必须包含:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。
服务端按 event_code、team_code 与组别名称匹配;匹配不到的行会跳过并回显行号。
</span>
</div>
<div
class="drop-zone"
:class="{ dragging }"
@click="chooseFile"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
>
<MxIcon name="export" :size="26" />
<div class="drop-title">点击选择,或把 CSV / XLSX 拖到这里</div>
<div class="bd-note">单个文件不超过 16MB;xlsx 由服务端解析第一个工作表</div>
<input ref="fileInput" class="file-input" type="file" accept=".csv,.xlsx" @change="onPick" />
</div>
<div v-if="picked !== null" class="picked">
<span class="bd-mono">{{ fileLabel }}</span>
<div class="bd-toolbar-right">
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" :disabled="importing" @click="picked = null">移除</button>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || importing"
@click="runImport"
>
{{ importing ? '导入中…' : '开始导入' }}
</button>
</div>
</div>
<div v-if="!data.canWrite.value" class="bd-note readonly-hint">
<MxIcon name="lock" :size="14" />
当前不可写入(未登录或服务端未连接),导入按钮已停用。
</div>
<div v-if="error !== null" class="mx-badge mx-badge-error import-error">{{ error }}</div>
<div v-if="result !== null" class="result">
<div class="result-cards">
<div class="result-card">
<span class="result-label">新增</span>
<strong class="bd-mono">{{ result.created }}</strong>
</div>
<div class="result-card">
<span class="result-label">更新</span>
<strong class="bd-mono">{{ result.updated }}</strong>
</div>
<div class="result-card">
<span class="result-label">跳过</span>
<strong class="bd-mono">{{ result.skipped }}</strong>
</div>
</div>
<div v-if="result.errors.length > 0" class="errors">
<div class="errors-head">
<span class="mx-badge mx-badge-warning">{{ result.errors.length }} 行有问题</span>
<span class="bd-note">这些行未导入,修正后可重新上传</span>
</div>
<ul class="error-list">
<li v-for="(line, index) in result.errors" :key="index">{{ line }}</li>
</ul>
</div>
<p v-else class="bd-note">全部行导入成功,没有错误行。</p>
</div>
</div>
</section>
</template>
<style scoped>
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 20px;
border: 1px dashed var(--mx-border);
border-radius: var(--mx-r);
background: var(--mx-s2);
color: var(--mx-t2);
text-align: center;
cursor: pointer;
transition: all var(--mx-d2) var(--mx-ease);
}
.drop-zone:hover,
.drop-zone.dragging {
border-color: var(--mx-primary);
background: var(--mx-primary-glow);
color: var(--mx-primary);
}
.drop-title {
font-size: 14px;
font-weight: 600;
}
.file-input {
display: none;
}
.picked {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 14px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
font-size: 13px;
}
.readonly-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
color: var(--mx-amber);
}
.import-error {
display: block;
margin-top: 14px;
padding: 10px 12px;
line-height: 1.5;
}
.result {
margin-top: 18px;
}
.result-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.result-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
}
.result-label {
font-size: 12px;
color: var(--mx-t3);
}
.result-card strong {
font-size: 22px;
color: var(--mx-primary);
}
.errors-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.error-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 260px;
overflow-y: auto;
padding: 10px 12px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
font-size: 12px;
color: var(--mx-t2);
font-family: var(--mx-mono);
}
@media (max-width: 767px) {
.result-cards {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,412 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { RecordBonusMode, ScoringProfile, ScoringProfileInput, TieMode } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import FormField from './FormField.vue'
import { useBaseData } from './useBaseData'
import { useToastStore } from '../../stores/toast'
const data = useBaseData()
const toast = useToastStore()
const CODE_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
const TIE_MODE_OPTIONS: Array<{ value: TieMode; label: string }> = [
{ value: 'skip', label: '跳位(并列后空出后续名次)' },
{ value: 'average', label: '均分(并列者取平均分)' },
{ value: 'higher', label: '取高(并列者都按较高名次)' },
]
const BONUS_MODE_OPTIONS: Array<{ value: RecordBonusMode; label: string }> = [
{ value: 'max', label: '取最高一次' },
{ value: 'sum', label: '累加全部' },
]
interface RuleRow {
key: number
rank: string
points: string
}
interface ProfileForm {
code: string
name: string
tieMode: TieMode
recordBonusMode: RecordBonusMode
awardRanks: string
lastPlaceZero: boolean
rules: RuleRow[]
}
type FormFieldKey = 'code' | 'name' | 'awardRanks' | 'rules'
let nextKey = 1
const selectedId = ref<number | null>(null)
const creating = ref(false)
const form = ref<ProfileForm>(emptyForm())
const errors = ref<Partial<Record<FormFieldKey, string>>>({})
const formError = ref<string | null>(null)
const pending = ref(false)
const confirmOpen = ref(false)
const removedRules = ref<string[]>([])
const selectedProfile = computed(() => data.profileById(selectedId.value) ?? null)
const isEditing = computed(() => creating.value || selectedProfile.value !== null)
watch(
() => data.profiles.value,
(list) => {
if (creating.value) return
const current = list.find((profile) => profile.id === selectedId.value)
if (current === undefined) {
const first = list[0]
if (first === undefined) {
selectedId.value = null
form.value = emptyForm()
} else {
loadProfile(first)
}
return
}
if (form.value.code === '' && form.value.name === '') loadProfile(current)
},
{ immediate: true },
)
function emptyForm(): ProfileForm {
return {
code: '',
name: '',
tieMode: 'skip',
recordBonusMode: 'max',
awardRanks: '6',
lastPlaceZero: false,
rules: [
{ key: (nextKey += 1), rank: '1', points: '10' },
{ key: (nextKey += 1), rank: '2', points: '8' },
{ key: (nextKey += 1), rank: '3', points: '6' },
],
}
}
function loadProfile(profile: ScoringProfile): void {
creating.value = false
selectedId.value = profile.id
form.value = {
code: profile.code,
name: profile.name,
tieMode: profile.tieMode,
recordBonusMode: profile.recordBonusMode,
awardRanks: String(profile.awardRanks ?? profile.rules.length),
lastPlaceZero: profile.lastPlaceZero ?? false,
rules: [...profile.rules]
.sort((a, b) => a.rank - b.rank)
.map((rule) => ({ key: (nextKey += 1), rank: String(rule.rank), points: String(rule.points) })),
}
errors.value = {}
formError.value = null
}
function startCreate(): void {
creating.value = true
selectedId.value = null
form.value = emptyForm()
errors.value = {}
formError.value = null
}
function addRule(): void {
const nextRank = form.value.rules.length + 1
form.value.rules = [
...form.value.rules,
{ key: (nextKey += 1), rank: String(nextRank), points: '0' },
]
}
function removeRule(key: number): void {
form.value.rules = form.value.rules.filter((rule) => rule.key !== key)
}
function validate(): boolean {
const next: Partial<Record<FormFieldKey, string>> = {}
const code = form.value.code.trim()
if (code === '') next.code = '代码必填'
else if (!CODE_PATTERN.test(code)) next.code = '只能用字母、数字、下划线或短横线'
else if (data.profiles.value.some((profile) => profile.id !== selectedId.value && profile.code.toLowerCase() === code.toLowerCase())) {
next.code = '代码已存在,必须唯一'
}
if (form.value.name.trim() === '') next.name = '名称必填'
const awardRanks = Number(form.value.awardRanks)
if (!Number.isInteger(awardRanks) || awardRanks < 0 || awardRanks > 100) next.awardRanks = '录取名次为 0–100 的整数'
if (form.value.rules.length === 0) {
next.rules = '至少配置一条名次分值'
} else {
const ranks = new Set<number>()
for (const rule of form.value.rules) {
const rank = Number(rule.rank)
const points = Number(rule.points)
if (!Number.isInteger(rank) || rank < 1 || rank > 1000) {
next.rules = '名次为 1–1000 的整数'
break
}
if (ranks.has(rank)) {
next.rules = `名次 ${rank} 重复`
break
}
ranks.add(rank)
if (!Number.isFinite(points) || points < 0 || points > 10000) {
next.rules = '分值为 0–10000 的数'
break
}
}
}
errors.value = next
return Object.keys(next).length === 0
}
function buildInput(): ScoringProfileInput {
return {
code: form.value.code.trim(),
name: form.value.name.trim(),
tieMode: form.value.tieMode,
recordBonusMode: form.value.recordBonusMode,
awardRanks: Number(form.value.awardRanks),
lastPlaceZero: form.value.lastPlaceZero,
rules: [...form.value.rules]
.map((rule) => ({ rank: Number(rule.rank), points: Number(rule.points) }))
.sort((a, b) => a.rank - b.rank),
}
}
function requestSave(): void {
if (!validate()) {
toast.error('积分模板校验未通过', '请先修正标红的字段')
return
}
const currentRanks = new Set(form.value.rules.map((rule) => Number(rule.rank)))
removedRules.value =
selectedProfile.value === null
? []
: selectedProfile.value.rules
.filter((rule) => !currentRanks.has(rule.rank))
.map((rule) => `第 ${rule.rank} 名(${rule.points} 分)`)
if (removedRules.value.length > 0) {
confirmOpen.value = true
return
}
void save()
}
async function save(): Promise<void> {
pending.value = true
formError.value = null
try {
const saved = await data.saveProfile(buildInput(), selectedId.value)
toast.success(creating.value ? '积分模板已新增' : '积分模板已保存')
creating.value = false
confirmOpen.value = false
loadProfile(saved)
} catch (caught) {
formError.value = caught instanceof Error ? caught.message : '保存失败'
} finally {
pending.value = false
}
}
const totalTop = computed(() => {
const first = form.value.rules.find((rule) => Number(rule.rank) === 1)
return first === undefined ? null : Number(first.points)
})
</script>
<template>
<div class="bd-split">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">积分模板</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="startCreate">
<MxIcon name="entry" :size="14" /> 新增
</button>
</div>
<div class="mx-card-body">
<div v-if="data.loading.value && data.profiles.value.length === 0" class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">加载中…</div>
</div>
<div v-else-if="data.profiles.value.length === 0 && !creating" class="mx-empty bd-empty">
<div class="mx-empty-title">还没有积分模板</div>
<div class="mx-empty-desc">模板定义「第几名得几分」,由计分通道引用</div>
</div>
<div v-else class="bd-list">
<button
v-if="creating"
class="bd-list-item active"
type="button"
>
<span class="bd-list-main">
<span>新模板</span>
<small>未保存</small>
</span>
</button>
<button
v-for="profile in data.profiles.value"
:key="profile.id"
class="bd-list-item"
:class="{ active: !creating && profile.id === selectedId }"
type="button"
@click="loadProfile(profile)"
>
<span class="bd-list-main">
<span>{{ profile.name }}</span>
<small>{{ profile.code }} · 录取前 {{ profile.awardRanks ?? profile.rules.length }} 名 · {{ profile.rules.length }} 条明细</small>
</span>
</button>
</div>
</div>
</section>
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">{{ creating ? '新建积分模板' : (selectedProfile?.name ?? '积分模板明细') }}</span>
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!data.canWrite.value || !isEditing || pending"
@click="requestSave"
>
{{ pending ? '保存中…' : '保存模板' }}
</button>
</div>
<div class="mx-card-body">
<div v-if="!isEditing" class="mx-empty bd-empty">
<div class="mx-empty-title">选择左侧模板查看明细</div>
<div class="mx-empty-desc">或点「新增」创建一张新模板</div>
</div>
<template v-else>
<div v-if="formError" class="mx-badge mx-badge-error form-error">{{ formError }}</div>
<div class="bd-form-grid">
<FormField label="代码" required :error="errors.code" hint="规则包跨赛事引用用代码,不用 id">
<input v-model="form.code" class="mx-cell-input" placeholder="例如 individual" />
</FormField>
<FormField label="名称" required :error="errors.name">
<input v-model="form.name" class="mx-cell-input" placeholder="例如 个人项目前六名" />
</FormField>
<FormField label="并列处理" hint="多人同名次时如何分配后续名次">
<select v-model="form.tieMode" class="mx-cell-input">
<option v-for="option in TIE_MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="破纪录加分" hint="一个项目多次破纪录时的取值方式">
<select v-model="form.recordBonusMode" class="mx-cell-input">
<option v-for="option in BONUS_MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</FormField>
<FormField label="录取名次" :error="errors.awardRanks" hint="超过该名次不计分">
<input v-model="form.awardRanks" class="mx-cell-input" type="number" min="0" max="100" />
</FormField>
<FormField label="末位归零" hint="最后一名是否计 0 分">
<label class="bd-check"><input v-model="form.lastPlaceZero" type="checkbox" /> 最后一名记 0 分</label>
</FormField>
</div>
<div class="rules-head">
<span class="mx-label">名次 → 分值</span>
<div class="rules-meta">
<span v-if="totalTop !== null" class="bd-note">第一名 {{ totalTop }} 分</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" :disabled="!data.canWrite.value" @click="addRule">
<MxIcon name="entry" :size="14" /> 增加一行
</button>
</div>
</div>
<span v-if="errors.rules" class="row-error">{{ errors.rules }}</span>
<div class="mx-table-wrap rules-table">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th class="bd-cell-narrow">名次</th>
<th class="bd-cell-narrow">分值</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in form.rules" :key="rule.key">
<td><input v-model="rule.rank" class="mx-cell-input bd-mono" type="number" min="1" max="1000" /></td>
<td><input v-model="rule.points" class="mx-cell-input bd-mono" type="number" min="0" step="0.5" /></td>
<td>
<div class="bd-actions">
<button class="mx-btn mx-btn-ghost mx-btn-sm danger-text" type="button" :disabled="!data.canWrite.value" @click="removeRule(rule.key)">
移除
</button>
</div>
</td>
</tr>
<tr v-if="form.rules.length === 0">
<td colspan="3">
<div class="mx-empty bd-empty">
<div class="mx-empty-title">没有分值明细</div>
<div class="mx-empty-desc">至少配置一条,否则通道无法算分</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</section>
<ConfirmDialog
:open="confirmOpen"
tone="warning"
title="保存将移除分值明细?"
:message="`本次保存会删除 ${removedRules.length} 条名次分值,之后的计分将不再包含这些名次。`"
:details="[...removedRules, '已发布的成绩不会自动重算,需要重新发布才生效']"
confirm-text="确认保存"
:pending="pending"
@confirm="save"
@cancel="confirmOpen = false"
/>
</div>
</template>
<style scoped>
.danger-text {
color: var(--mx-red);
}
.form-error {
display: block;
margin-bottom: 14px;
padding: 10px 12px;
line-height: 1.5;
}
.rules-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 18px 0 8px;
flex-wrap: wrap;
}
.rules-meta {
display: flex;
align-items: center;
gap: 12px;
}
.row-error {
display: block;
margin-bottom: 8px;
font-size: 12px;
color: var(--mx-red);
}
.rules-table {
max-width: 420px;
}
</style>
@@ -242,6 +242,7 @@ export interface BaseDataController {
profiles: Ref<ScoringProfile[]>
athletes: Ref<Athlete[]>
loading: Ref<boolean>
loaded: Ref<boolean>
error: Ref<string | null>
selectedEventId: Ref<number | null>
isMock: ComputedRef<boolean>
@@ -280,6 +281,7 @@ export function useBaseData(): BaseDataController {
profiles,
athletes,
loading,
loaded,
error,
selectedEventId,
isMock: computed(() => baseDataFallback.value || mockMode.value),
@@ -0,0 +1,253 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatDateTime, formatPoints } from '@/shared'
import MxIcon from '../MxIcon.vue'
import { ACTION_LABELS, type Revision, type RevisionDiffRow, type TeamImpact } from './types'
/**
* 右侧节点详情:操作人 / 时间 / 动作 / 项目 / 影响单位与分数增减 + diff。
* 全部数据由页面算好后传入,组件只负责展示,避免在这里再拉一次接口。
*/
const props = defineProps<{
revision: Revision
parent: Revision | null
eventName: string
actorLabel: string
diff: RevisionDiffRow[]
impacts: TeamImpact[]
}>()
const affectedTeams = computed(() => props.impacts.length)
const maxDelta = computed(() => props.impacts.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0))
const KIND_LABELS: Record<RevisionDiffRow['kind'], string> = {
added: '新增',
removed: '移除',
changed: '修改',
}
function kindTone(kind: RevisionDiffRow['kind']): string {
if (kind === 'added') return 'mx-tag-success'
if (kind === 'removed') return 'mx-tag-error'
return 'mx-tag-warning'
}
</script>
<template>
<div class="revision-detail">
<div class="detail-head">
<div class="detail-title">
<span class="mx-tag mx-tag-primary">{{ ACTION_LABELS[revision.action] }}</span>
<span class="event-name">{{ eventName }}</span>
<span class="mx-badge mx-badge-neutral mono">#{{ revision.id }}</span>
</div>
<p v-if="revision.message" class="detail-message">{{ revision.message }}</p>
</div>
<dl class="meta-grid">
<div>
<dt>操作人</dt>
<dd>{{ actorLabel }}</dd>
</div>
<div>
<dt>时间</dt>
<dd class="mono">{{ formatDateTime(revision.createdAt) }}</dd>
</div>
<div>
<dt>动作</dt>
<dd>{{ ACTION_LABELS[revision.action] }}</dd>
</div>
<div>
<dt>项目</dt>
<dd>{{ eventName }}</dd>
</div>
<div>
<dt>父节点</dt>
<dd class="mono">{{ parent === null ? '无(主线起点)' : `#${parent.id}` }}</dd>
</div>
<div>
<dt>积分流水</dt>
<dd class="mono">{{ revision.ledgerIds.length }} 条</dd>
</div>
</dl>
<div class="impact-summary">
<MxIcon name="adjust" :size="15" />
本次将影响 <strong>{{ affectedTeams }}</strong> 个单位,最大分差
<strong class="mono">{{ formatPoints(maxDelta) }}</strong> 分
</div>
<ul v-if="impacts.length > 0" class="impact-list">
<li v-for="item in impacts" :key="item.teamId" class="impact-item">
<span class="impact-name">{{ item.label }}</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">该次操作没有改变任何单位的积分</p>
<div class="diff-head">
<span class="section-title">成绩 diff</span>
<span class="mx-badge mx-badge-neutral">{{ diff.length }} 处变化</span>
</div>
<div v-if="diff.length === 0" class="mx-empty">
<div class="mx-empty-title">与父节点相比没有成绩变化</div>
<div class="mx-empty-desc">首次发布时会把全部成绩标记为新增</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>变化</th>
<th>单位 / 运动员</th>
<th>变更前</th>
<th>变更后</th>
<th class="right">积分</th>
</tr>
</thead>
<tbody>
<tr v-for="item in diff" :key="`${item.kind}-${item.entryId}`">
<td><span class="mx-tag" :class="kindTone(item.kind)">{{ KIND_LABELS[item.kind] }}</span></td>
<td>
<span class="team-name">{{ item.teamLabel }}</span>
<small class="athlete">{{ item.athleteLabel }}</small>
</td>
<td class="mono before">{{ item.beforeText }}</td>
<td class="mono after">{{ item.afterText }}</td>
<td class="right mono" :class="item.pointsDelta > 0 ? 'up' : item.pointsDelta < 0 ? 'down' : 'flat'">
{{ item.pointsDelta === 0 ? '—' : `${item.pointsDelta > 0 ? '+' : ''}${formatPoints(item.pointsDelta)}` }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.revision-detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.event-name {
font-size: 15px;
font-weight: 700;
}
.detail-message {
font-size: 13px;
color: var(--mx-t2);
line-height: 1.6;
padding: 8px 12px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
}
.meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
padding: 12px 14px;
border-radius: var(--mx-r-sm);
background: var(--mx-s2);
}
.meta-grid dt {
font-size: 11px;
color: var(--mx-t3);
margin-bottom: 2px;
}
.meta-grid dd {
font-size: 13px;
font-weight: 600;
}
.impact-summary {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--mx-t2);
}
.impact-summary strong {
color: var(--mx-primary);
}
.impact-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.impact-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 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: 12px;
color: var(--mx-t3);
}
.diff-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 4px;
}
.section-title {
font-size: 13px;
font-weight: 700;
}
.team-name {
font-weight: 600;
}
.athlete {
margin-left: 8px;
font-size: 11px;
color: var(--mx-t3);
}
.before {
color: var(--mx-t3);
text-decoration: line-through;
}
.after {
color: var(--mx-primary);
font-weight: 700;
}
.right {
text-align: right;
}
.up {
color: var(--mx-green);
font-weight: 700;
}
.down {
color: var(--mx-red);
font-weight: 700;
}
.flat {
color: var(--mx-t3);
}
</style>
+2 -6
View File
@@ -141,12 +141,8 @@ export interface DiffEntry {
}
function sameRow(a: RevisionSnapshotRow, b: RevisionSnapshotRow): boolean {
return (
a.resultText === b.resultText &&
a.rank === b.rank &&
a.status === b.status &&
a.state === b.state
)
// 只比成绩本身;draft / published 的状态切换由动作标签表达,否则每次发布都会「全行变化」。
return a.resultText === b.resultText && a.rank === b.rank && a.status === b.status
}
/** 当前快照 vs 父快照;父为空时全部视为新增(首次发布)。 */
@@ -5,6 +5,7 @@ import type { Revision, RevisionSnapshotRow } from './types'
*
* 只用于把「时间轴 + 分支 + diff」走通,界面必须显式标注「示例数据」;
* entryId 按示例数据的约定生成(eventId * 1000 + teamId * 10 + 1),单位名由全局数据补齐。
* 草稿快照的 points 一律为 null:撤销发布后流水已作废,不能还算作有效积分。
*/
function iso(hoursAgo: number): string {
@@ -35,10 +36,44 @@ function row(
const TIMES_101 = ['11.58', '11.92', '12.01', '12.20', '12.35', '12.48', '12.60']
const POINTS_101 = [10, 8, 6, 3, 2, 1, 0]
/** order 是「名次顺序」的单位 id,用来演示编辑后名次与积分的变化。 */
function snapshot101(state: string, order: readonly number[]): RevisionSnapshotRow[] {
// order 是「名次顺序」的单位 id;用于演示编辑后名次与积分的变化。
const published = state === 'published'
return order.map((teamId, index) =>
row(101, teamId, index + 1, TIMES_101[index] ?? '—', Number(TIMES_101[index] ?? 0), POINTS_101[index] ?? 0, state),
row(
101,
teamId,
index + 1,
TIMES_101[index] ?? '—',
Number(TIMES_101[index] ?? 0),
published ? (POINTS_101[index] ?? 0) : null,
state,
),
)
}
const TIMES_103 = ['2:14.30', '2:16.05', '2:18.77', '2:20.10', '2:22.44']
const POINTS_103 = [10, 8, 6, 3, 2]
function parseTime(text: string): number {
const [minutes, seconds] = text.split(':')
return Number(minutes ?? 0) * 60 + Number(seconds ?? 0)
}
function snapshot103(state: string, corrected: boolean): RevisionSnapshotRow[] {
const published = state === 'published'
const teams = [2, 1, 4, 3, 5]
const times = corrected ? ['2:13.90', ...TIMES_103.slice(1)] : TIMES_103
return teams.map((teamId, index) =>
row(
103,
teamId,
index + 1,
times[index] ?? '—',
parseTime(times[index] ?? '0'),
published ? (POINTS_103[index] ?? 0) : null,
state,
),
)
}
@@ -65,49 +100,31 @@ export function createMockRevisions(): Revision[] {
parentId: 7,
eventId: 103,
action: 'publish',
snapshot: [
row(103, 2, 1, '2:13.90', 133.9, 10, 'published'),
row(103, 1, 2, '2:16.05', 136.05, 8, 'published'),
row(103, 4, 3, '2:18.77', 138.77, 6, 'published'),
row(103, 3, 4, '2:20.10', 140.1, 3, 'published'),
row(103, 5, 5, '2:22.44', 142.44, 2, 'published'),
],
snapshot: snapshot103('published', true),
ledgerIds: [801, 802, 803, 804, 805],
actorId: 2,
actorName: '张裁判',
message: '更正第二名成绩后重新发布',
createdAt: iso(12),
createdAt: iso(16),
},
{
id: 7,
parentId: 6,
eventId: 103,
action: 'edit',
snapshot: [
row(103, 2, 1, '2:13.90', 133.9, null, 'draft'),
row(103, 1, 2, '2:16.05', 136.05, null, 'draft'),
row(103, 4, 3, '2:18.77', 138.77, null, 'draft'),
row(103, 3, 4, '2:20.10', 140.1, null, 'draft'),
row(103, 5, 5, '2:22.44', 142.44, null, 'draft'),
],
action: 'unpublish',
snapshot: snapshot103('draft', false),
ledgerIds: [],
actorId: 2,
actorName: '张裁判',
message: '第 2 名成绩由 2:14.30 更正为 2:13.90',
createdAt: iso(14),
message: '第 2 名成绩存疑,先撤销发布',
createdAt: iso(18),
},
{
id: 6,
parentId: null,
eventId: 103,
action: 'publish',
snapshot: [
row(103, 2, 1, '2:14.30', 134.3, 10, 'published'),
row(103, 1, 2, '2:16.05', 136.05, 8, 'published'),
row(103, 4, 3, '2:18.77', 138.77, 6, 'published'),
row(103, 3, 4, '2:20.10', 140.1, 3, 'published'),
row(103, 5, 5, '2:22.44', 142.44, 2, 'published'),
],
snapshot: snapshot103('published', false),
ledgerIds: [601, 602, 603, 604, 605],
actorId: 1,
actorName: '李管理员',
@@ -116,26 +133,26 @@ export function createMockRevisions(): Revision[] {
},
{
id: 5,
parentId: 4,
parentId: 2,
eventId: 101,
action: 'publish',
snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]),
ledgerIds: [501, 502, 503, 504, 505, 506, 507],
actorId: 2,
actorName: '张裁判',
message: '修正后重新发布',
message: '从撤销点重新发布(第三、四名已更正)',
createdAt: iso(22),
},
{
id: 4,
parentId: 2,
parentId: 3,
eventId: 101,
action: 'edit',
action: 'unpublish',
snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]),
ledgerIds: [],
actorId: 2,
actorName: '张裁判',
message: '第三、四名成绩录入对调,等待重新发布',
message: '第三、四名成绩录入对调,撤销发布',
createdAt: iso(24),
},
{
@@ -143,7 +160,7 @@ export function createMockRevisions(): Revision[] {
parentId: 2,
eventId: 101,
action: 'publish',
snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]),
snapshot: snapshot101('published', [1, 2, 3, 4, 5, 6, 7]),
ledgerIds: [301, 302, 303, 304, 305, 306, 307],
actorId: 1,
actorName: '李管理员',
+1
View File
@@ -30,6 +30,7 @@ export const ICON_PATHS = {
logout: 'M15 4h4v16h-4M11 8l-4 4 4 4M7 12h9',
user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-8 8a8 8 0 0 1 16 0',
keyboard: 'M3 6h18v12H3zM7 10h.01M11 10h.01M15 10h.01M7 14h10',
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-13v.5M12 12v5',
} as const
export type IconName = keyof typeof ICON_PATHS
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { formatDateTime } from '@/shared'
import MxIcon from '../MxIcon.vue'
import MockBadge from './MockBadge.vue'
import { backupsApi, type BackupItem } from './systemApi'
import { backupsApi, describeError, type BackupItem } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -35,7 +35,7 @@ async function load(): Promise<void> {
rows.value = result.data
usingMock.value = result.mock
} catch (caught) {
loadError.value = caught instanceof Error ? caught.message : '加载备份列表失败'
loadError.value = describeError(caught)
} finally {
loading.value = false
}
@@ -49,12 +49,24 @@ async function createBackup(): Promise<void> {
toast.success('已生成备份', result.data.name)
await load()
} catch (caught) {
toast.error('备份失败', caught instanceof Error ? caught.message : null)
toast.error('备份失败', describeError(caught))
} finally {
backingUp.value = false
}
}
/** 服务端 reason 取值来自文件名前缀:event(赛事库)/ system(系统库),手动备份落在赛事库上。 */
const REASON_LABEL: Record<string, string> = {
event: '赛事库',
system: '系统库',
manual: '手动',
auto: '自动',
}
function reasonLabel(reason: string): string {
return REASON_LABEL[reason] ?? (reason === '' ? '—' : reason)
}
function formatSize(bytes: number): string {
if (bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
@@ -93,7 +105,7 @@ onMounted(() => {
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
<p v-if="latest !== null" class="latest">
最近一次备份:{{ formatDateTime(latest.createdAt) }}
<span class="muted">({{ latest.reason === 'manual' ? '手动' : '自动' }})</span>
<span class="muted">({{ reasonLabel(latest.reason) }})</span>
</p>
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在读取备份目录…</div>
@@ -117,8 +129,8 @@ onMounted(() => {
<tr v-for="row in rows" :key="row.name">
<td class="mono name">{{ row.name }}</td>
<td>
<span class="mx-badge" :class="row.reason === 'manual' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ row.reason === 'manual' ? '手动' : '自动' }}
<span class="mx-badge" :class="row.reason === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
{{ reasonLabel(row.reason) }}
</span>
</td>
<td class="right mono">{{ formatSize(row.size) }}</td>
@@ -3,7 +3,7 @@ import { computed, ref, watch } from 'vue'
import type { EventSettings } from '@/shared'
import MxIcon from '../MxIcon.vue'
import MockBadge from './MockBadge.vue'
import { settingsApi, type EventSettingsPatch } from './systemApi'
import { describeError, settingsApi, type EventSettingsPatch } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useEventStore } from '../../stores/event'
import { useToastStore } from '../../stores/toast'
@@ -84,7 +84,7 @@ async function save(): Promise<void> {
form.value = clone(result.data)
toast.success('赛事信息已保存', result.mock ? '示例数据:改动仅保留在本地' : null)
} catch (caught) {
saveError.value = caught instanceof Error ? caught.message : '保存失败'
saveError.value = describeError(caught)
} finally {
saving.value = false
}
@@ -4,7 +4,7 @@ import { formatDateTime } from '@/shared'
import MxIcon from '../MxIcon.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import MockBadge from './MockBadge.vue'
import { USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
import { describeError, USER_ROLES, usersApi, type AdminUser, type AdminUserInput } from './systemApi'
import { useAuthStore } from '../../stores/auth'
import { useToastStore } from '../../stores/toast'
@@ -46,7 +46,7 @@ async function load(): Promise<void> {
rows.value = result.data
usingMock.value = result.mock
} catch (caught) {
loadError.value = caught instanceof Error ? caught.message : '加载用户列表失败'
loadError.value = describeError(caught)
} finally {
loading.value = false
}
@@ -115,7 +115,7 @@ async function commitSave(): Promise<void> {
editorOpen.value = false
await load()
} catch (caught) {
formError.value = caught instanceof Error ? caught.message : '保存失败'
formError.value = describeError(caught)
} finally {
saving.value = false
}
@@ -133,7 +133,7 @@ async function resetPassword(): Promise<void> {
revealed.value = { username: target.username, password: result.data.password }
await load()
} catch (caught) {
toast.error('重置密码失败', caught instanceof Error ? caught.message : null)
toast.error('重置密码失败', describeError(caught))
} finally {
resetting.value = false
}
@@ -269,7 +269,7 @@ onMounted(() => {
placeholder="至少 8 位,首次登录强制修改"
/>
</div>
<div class="field switch-field">
<div v-if="editingId !== null" class="field switch-field">
<span class="mx-label">启用</span>
<span class="switch-row">
<label class="mx-switch">
@@ -281,6 +281,9 @@ onMounted(() => {
</div>
</div>
<p class="notice">
角色只有「管理员 / 录入员」两种;未登录访客的只读身份由系统默认提供,不需要建账号。
</p>
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
<div class="editor-foot">
+42 -16
View File
@@ -74,6 +74,19 @@ export async function withMock<T>(real: () => Promise<T>, mock: () => T | Promis
return (await withMockInfo(real, mock)).data
}
/**
* 服务端只回一句「服务端异常」,真正的线索在 error.detail 里(例如「赛事项目名非法: 名称为空」)。
* 展示时把 detail 一并带上,现场才不用去翻服务端日志。
*/
export function describeError(error: unknown): string {
if (error instanceof ApiError) {
const detail =
typeof error.detail === 'string' && error.detail.trim() !== '' ? `:${error.detail}` : ''
return `${error.message}${detail}`
}
return error instanceof Error ? error.message : '未知错误'
}
// ---------------------------------------------------------------------------
// 底层 HTTP(补齐 ApiClient 未覆盖的路径)
// ---------------------------------------------------------------------------
@@ -206,14 +219,6 @@ async function requestJson<T>(path: string, options: RawOptions = {}): Promise<T
return unwrap<T>(body, response.status)
}
async function requestVoid(path: string, options: RawOptions = {}): Promise<void> {
const response = await send(path, options)
const body = await readBody(response)
if (!response.ok) throw toApiError(body, response.status)
if (body === null || body === undefined) return
unwrap<unknown>(body, response.status)
}
async function requestBlob(path: string, options: RawOptions = {}): Promise<Blob> {
const response = await send(path, options)
if (!response.ok) throw toApiError(await readBody(response), response.status)
@@ -257,10 +262,13 @@ export interface AdminUserInput {
password: string
}
/**
* 可落库的角色只有 recorder / admin:guest 是「未登录」的默认身份,不是一条用户记录
* (服务端 userRole() 会直接拒绝 guest,这里不给出这个选项)。
*/
export const USER_ROLES: ReadonlyArray<{ value: string; label: string }> = [
{ value: 'admin', label: '管理员' },
{ value: 'recorder', label: '录入员' },
{ value: 'guest', label: '只读访客' },
]
function toAdminUser(value: unknown): AdminUser | null {
@@ -311,9 +319,9 @@ const mockUserRows: AdminUser[] = [
},
{
id: 3,
username: 'lookup',
displayName: '只读账号',
role: 'guest',
username: 'recorder02',
displayName: '录入员二(已停用)',
role: 'recorder',
active: false,
mustChangePassword: true,
lastLoginAt: null,
@@ -329,7 +337,8 @@ function mockUsers() {
username: input.username,
displayName: input.displayName === '' ? input.username : input.displayName,
role: input.role,
active: input.active,
// 与真实服务端一致:新建出来的账号一定是启用状态。
active: true,
mustChangePassword: input.password !== '',
lastLoginAt: null,
}
@@ -379,7 +388,16 @@ export const usersApi = {
async create(input: AdminUserInput): Promise<MockAware<AdminUser>> {
return withMockInfo(
async () => {
const body = await requestJson<unknown>('/users', { method: 'POST', json: input })
const body = await requestJson<unknown>('/users', {
method: 'POST',
// 服务端新建固定启用,不接受 active;多传字段虽被忽略,但少传更不容易误解契约。
json: {
username: input.username,
displayName: input.displayName,
role: input.role,
password: input.password,
},
})
const created = toAdminUser(body)
if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body)
return created
@@ -425,14 +443,20 @@ export interface BackupItem {
createdAt: string
}
/**
* 两种来源要兼容:
* - GET /api/export/backups 返回 export.BackupInfo(name / size / reason 齐全);
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补。
*/
function toBackupItem(value: unknown): BackupItem | null {
if (!isRecord(value)) return null
const name = readString(value, 'name')
const path = readString(value, 'path')
const createdAt = readString(value, 'createdAt')
const name = readString(value, 'name') ?? (path === null ? null : (path.split(/[\\/]/).pop() ?? null))
if (name === null || createdAt === null) return null
return {
name,
path: readString(value, 'path') ?? name,
path: path ?? name,
size: readNumber(value, 'size') ?? 0,
reason: readString(value, 'reason') ?? '',
createdAt,
@@ -455,6 +479,8 @@ export const backupsApi = {
return withMockInfo(
async () => {
const body = await requestJson<unknown>('/export/backups')
// 备份目录为空时服务端把 nil 切片序列化成 null,这不是错误,是「还没有备份」。
if (body === null) return []
if (!Array.isArray(body)) throw new ApiError('PARSE_FAILED', '备份列表响应格式不符', 0, body)
return body.map(toBackupItem).filter((row): row is BackupItem => row !== null)
},
+2 -1
View File
@@ -50,6 +50,7 @@ const NAV: NavGroup[] = [
{ section: '系统', items: [
{ name: 'system', label: '系统设置', icon: 'system' },
{ name: 'password', label: '修改密码', icon: 'lock' },
{ name: 'about', label: '关于', icon: 'info' },
] },
]
@@ -104,7 +105,7 @@ onMounted(async () => {
<div class="mx-app admin-shell">
<aside class="mx-sidebar" :class="{ open: sidebarOpen }">
<div class="mx-logo brand">
<span class="mx-logo-icon"><MxIcon name="standings" :size="18" /></span>
<img class="app-icon" src="/scoreboard.png" alt="" width="28" height="28" />
<span class="mx-logo-text">积分榜管理</span>
</div>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import AthletesSection from '../components/baseData/AthletesSection.vue'
import CategoriesSection from '../components/baseData/CategoriesSection.vue'
import ChannelsSection from '../components/baseData/ChannelsSection.vue'
import ImportSection from '../components/baseData/ImportSection.vue'
import ProfilesSection from '../components/baseData/ProfilesSection.vue'
import SportsEventsSection from '../components/baseData/SportsEventsSection.vue'
import TeamsSection from '../components/baseData/TeamsSection.vue'
import { BASE_DATA_TABS, type BaseDataTabKey } from '../components/baseData/tabs'
import { useBaseData } from '../components/baseData/useBaseData'
import { useToastStore } from '../stores/toast'
/**
* 基础数据页(07 第 1 节)。
*
* 七个分区共用一份数据状态(见 useBaseData);分区之间通过 `goTab` 跳转,
* 例如「大项 / 小项」里点「配置组别」直接切到组别分区。
*/
const data = useBaseData()
const toast = useToastStore()
const activeTab = ref<BaseDataTabKey>('teams')
function setTab(tab: BaseDataTabKey): void {
activeTab.value = tab
}
async function refresh(): Promise<void> {
await data.load(true)
const selected = data.selectedEventId.value
if (selected !== null) await data.loadEventDetail(selected, true)
toast.success('基础数据已刷新')
}
onMounted(() => {
void data.load()
})
</script>
<template>
<div>
<PageHeader title="基础数据" subtitle="单位 / 大项 / 小项 / 组别 / 计分通道 / 积分模板 / 运动员 / 报名导入">
<template #actions>
<span v-if="data.isMock.value" class="mx-badge mx-badge-warning">示例数据</span>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="data.loading.value" @click="refresh">
<MxIcon name="refresh" :size="15" /> {{ data.loading.value ? '刷新中…' : '刷新' }}
</button>
</template>
</PageHeader>
<div v-if="data.isMock.value" class="mock-note">
<MxIcon name="warning" :size="15" />
<span>{{ data.mockLabel.value }},本页改动只写入内存示例数据,不会影响真实赛事。</span>
</div>
<div v-if="data.error.value" class="error-note">
<span class="mx-badge mx-badge-error">加载失败</span>
<span>{{ data.error.value }}</span>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="refresh">重试</button>
</div>
<div class="mx-tabs page-tabs">
<button
v-for="tab in BASE_DATA_TABS"
:key="tab.key"
class="mx-tab"
:class="{ active: activeTab === tab.key }"
type="button"
@click="setTab(tab.key)"
>
{{ tab.label }}
</button>
</div>
<div v-if="!data.loaded.value && data.loading.value" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty bd-empty">
<span class="mx-spinner" />
<div class="mx-empty-title">正在加载基础数据…</div>
<div class="mx-empty-desc">单位 / 项目 / 模板 / 运动员</div>
</div>
</div>
</div>
<template v-else>
<TeamsSection v-if="activeTab === 'teams'" />
<SportsEventsSection v-else-if="activeTab === 'events'" @go-tab="setTab" />
<CategoriesSection v-else-if="activeTab === 'categories'" @go-tab="setTab" />
<ChannelsSection v-else-if="activeTab === 'channels'" @go-tab="setTab" />
<ProfilesSection v-else-if="activeTab === 'profiles'" />
<AthletesSection v-else-if="activeTab === 'athletes'" />
<ImportSection v-else />
</template>
</div>
</template>
<style>
/* 页面级共享布局类(分区组件 scoped 样式里也会用到)。 */
@import '../components/baseData/baseData.css';
</style>
<style scoped>
.mock-note,
.error-note {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-bottom: 14px;
border-radius: var(--mx-r-sm);
font-size: 13px;
}
.mock-note {
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
}
.error-note {
background: rgba(248, 113, 113, 0.1);
color: var(--mx-red);
}
.error-note .mx-btn {
margin-left: auto;
}
.page-tabs {
margin-bottom: 16px;
flex-wrap: wrap;
}
</style>
+7 -6
View File
@@ -7,6 +7,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
import MockBadge from '../components/system/MockBadge.vue'
import type { IconName } from '../components/icons'
import {
describeError,
projectApi,
resultsExportApi,
rulesApi,
@@ -137,7 +138,7 @@ async function download(kind: ResultExportKind): Promise<void> {
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
} catch (caught) {
toast.error('导出失败', caught instanceof Error ? caught.message : null)
toast.error('导出失败', describeError(caught))
} finally {
pendingExport.value = null
}
@@ -152,7 +153,7 @@ async function exportProject(): Promise<void> {
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
} catch (caught) {
projectError.value = caught instanceof Error ? caught.message : '导出失败'
projectError.value = describeError(caught)
} finally {
projectExporting.value = false
}
@@ -206,7 +207,7 @@ async function runProjectImport(): Promise<void> {
projectFile.value = null
if (projectFileInput.value !== null) projectFileInput.value.value = ''
} catch (caught) {
projectError.value = caught instanceof Error ? caught.message : '导入失败'
projectError.value = describeError(caught)
} finally {
projectImporting.value = false
}
@@ -221,7 +222,7 @@ async function exportRules(): Promise<void> {
adminApi.client.exports.save(result.data.blob, result.data.filename)
toast.success('规则包已导出', result.data.filename)
} catch (caught) {
rulesError.value = caught instanceof Error ? caught.message : '导出失败'
rulesError.value = describeError(caught)
} finally {
rulesExporting.value = false
}
@@ -268,7 +269,7 @@ async function previewRules(): Promise<void> {
rulesPreview.value = result.data
} catch (caught) {
rulesPreview.value = null
rulesError.value = caught instanceof Error ? caught.message : '预览失败'
rulesError.value = describeError(caught)
} finally {
rulesPreviewing.value = false
}
@@ -293,7 +294,7 @@ async function applyRules(): Promise<void> {
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
)
} catch (caught) {
rulesError.value = caught instanceof Error ? caught.message : '导入失败'
rulesError.value = describeError(caught)
} finally {
rulesApplying.value = false
}
+483
View File
@@ -0,0 +1,483 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
formatDateTime,
type EntryWithResult,
type Event,
type EventCategory,
type ResultStatus,
type ScoreChannel,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import RevisionGraph from '../components/history/RevisionGraph.vue'
import RevisionDetail from '../components/history/RevisionDetail.vue'
import { buildGraph, describeRow, diffSnapshots, laneColor } from '../components/history/graph'
import { fetchRevisions } from '../components/history/revisionsSource'
import { createMockRevisions } from '../components/history/mockRevisions'
import { ACTION_LABELS, type Revision, type RevisionDiffRow, type TeamImpact } from '../components/history/types'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { useEventStore } from '../stores/event'
type TimeFilter = 'all' | '24h' | '7d'
/** 计算 diff / 影响面所需的小项上下文,按 eventId 缓存。 */
interface EventContext {
event: Event
categories: EventCategory[]
channels: ScoreChannel[]
entryById: Map<number, EntryWithResult>
}
const data = useEventStore()
const revisions = ref<Revision[]>([])
const loading = ref(false)
const usingMock = ref(false)
const mockReason = ref<string | null>(null)
const eventFilter = ref<number | 'all'>('all')
const timeFilter = ref<TimeFilter>('all')
const selectedId = ref<number | null>(null)
const contexts = ref<Record<number, EventContext>>({})
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const visibleRevisions = computed(() => {
const threshold = timeThreshold(timeFilter.value)
const list = threshold === null ? revisions.value : revisions.value.filter((item) => item.createdAt >= threshold)
return [...list].sort((a, b) => (a.createdAt === b.createdAt ? b.id - a.id : a.createdAt < b.createdAt ? 1 : -1))
})
const layout = computed(() => buildGraph(visibleRevisions.value))
const selectedRevision = computed<Revision | null>(
() => visibleRevisions.value.find((item) => item.id === selectedId.value) ?? null,
)
const parentRevision = computed<Revision | null>(() => {
const revision = selectedRevision.value
if (revision === null || revision.parentId === null) return null
return revisions.value.find((item) => item.id === revision.parentId) ?? null
})
const selectedContext = computed<EventContext | null>(() => {
const revision = selectedRevision.value
if (revision === null) return null
return contexts.value[revision.eventId] ?? null
})
const selectedEventName = computed(() => {
const revision = selectedRevision.value
if (revision === null) return '—'
return data.eventById(revision.eventId)?.name ?? selectedContext.value?.event.name ?? `项目 ${revision.eventId}`
})
const actorLabel = computed(() => {
const revision = selectedRevision.value
if (revision === null) return '—'
if (revision.actorName !== null && revision.actorName !== '') return revision.actorName
return revision.actorId === null ? '系统' : `用户 #${revision.actorId}`
})
const impacts = computed<TeamImpact[]>(() => {
const revision = selectedRevision.value
if (revision === null) return []
const context = selectedContext.value
const current = teamTotals(revision, context)
const previous = parentRevision.value === null ? new Map<number, number>() : teamTotals(parentRevision.value, context)
const ids = new Set<number>([...current.keys(), ...previous.keys()])
const list: TeamImpact[] = []
for (const teamId of ids) {
const delta = round((current.get(teamId) ?? 0) - (previous.get(teamId) ?? 0))
if (delta === 0) continue
list.push({ teamId, label: teamLabel(teamId), delta })
}
return list.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
})
const diffRows = computed<RevisionDiffRow[]>(() => {
const revision = selectedRevision.value
if (revision === null) return []
const context = selectedContext.value
const individual = context?.event.eventType === 'individual'
const factor = individual ? weight.value : 1
return diffSnapshots(revision.snapshot, parentRevision.value?.snapshot ?? null).map((entry) => {
const info = context?.entryById.get(entry.entryId)
const teamId = entry.after?.teamId ?? entry.before?.teamId ?? info?.teamId ?? null
const beforePoints = entry.before?.points ?? null
const afterPoints = entry.after?.points ?? null
const pointsDelta = round(((afterPoints ?? 0) - (beforePoints ?? 0)) * factor)
return {
entryId: entry.entryId,
kind: entry.kind,
teamLabel: teamId === null ? '—' : teamLabel(teamId),
athleteLabel: info?.athleteName ?? info?.memberNames?.join('、') ?? '',
beforeText: describeRow(entry.before),
afterText: describeRow(entry.after),
beforePoints,
afterPoints,
pointsDelta,
}
})
})
const eventOptions = computed(() =>
data.events.map((event) => ({ id: event.id, name: event.name })).sort((a, b) => a.name.localeCompare(b.name)),
)
function round(value: number): number {
return Number(value.toFixed(2))
}
function timeThreshold(filter: TimeFilter): string | null {
if (filter === 'all') return null
const hours = filter === '24h' ? 24 : 24 * 7
return new Date(Date.now() - hours * 3_600_000).toISOString()
}
function teamLabel(teamId: number): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
}
function normalizeStatus(value: string | null): ResultStatus {
if (value === 'dq' || value === 'dns' || value === 'dnf' || value === 'withdrawn') return value
return 'ok'
}
/** 快照带 points 时用服务端真值;缺 points 时用录入页同一套纯函数重算。 */
function teamTotals(revision: Revision, context: EventContext | null): Map<number, number> {
const individual = context?.event.eventType === 'individual'
const factor = individual ? weight.value : 1
// 只有已发布成绩才产生积分流水;草稿行即使带分值也不计入。
const publishedRows = revision.snapshot.filter((row) => row.state !== 'draft')
const hasPoints = publishedRows.length > 0 && publishedRows.every((row) => row.points !== null)
if (context === null || hasPoints) {
const totals = new Map<number, number>()
for (const row of publishedRows) {
if (row.points === null) continue
const teamId = row.teamId ?? context?.entryById.get(row.entryId)?.teamId ?? null
if (teamId === null) continue
totals.set(teamId, (totals.get(teamId) ?? 0) + row.points * factor)
}
return totals
}
const scorable: ScorableRow[] = []
for (const row of publishedRows) {
const teamId = row.teamId ?? context.entryById.get(row.entryId)?.teamId
if (teamId === undefined) continue
scorable.push({
entryId: row.entryId,
teamId,
resultValue: row.resultValue,
rank: row.rank,
status: normalizeStatus(row.status),
isTie: false,
})
}
const category = context.categories.length === 1 ? (context.categories[0] ?? null) : null
return computeEventScore(scorable, {
event: context.event,
category,
channels: context.channels,
profiles: data.profiles,
weight: weight.value,
}).teamPoints
}
async function ensureContext(eventId: number): Promise<void> {
if (contexts.value[eventId] !== undefined) return
try {
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
contexts.value = {
...contexts.value,
[eventId]: {
event,
categories: event.categories ?? [],
channels: event.channels ?? [],
entryById: new Map(entries.map((entry) => [entry.id, entry])),
},
}
} catch {
// 上下文拉不到只影响单位名与重算,快照本身的 diff 仍然可读。
}
}
async function load(): Promise<void> {
loading.value = true
const eventId = eventFilter.value === 'all' ? undefined : eventFilter.value
try {
revisions.value = await fetchRevisions(eventId === undefined ? {} : { eventId })
usingMock.value = false
mockReason.value = null
} catch (caught) {
const all = createMockRevisions()
revisions.value = eventId === undefined ? all : all.filter((item) => item.eventId === eventId)
usingMock.value = true
mockReason.value = caught instanceof Error ? caught.message : '变更历史接口不可用'
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
})
watch(eventFilter, () => {
void load()
})
watch(
visibleRevisions,
(list) => {
if (list.length === 0) {
selectedId.value = null
return
}
if (!list.some((item) => item.id === selectedId.value)) selectedId.value = list[0]?.id ?? null
},
{ immediate: true },
)
watch(
selectedRevision,
(revision) => {
if (revision !== null) void ensureContext(revision.eventId)
},
{ immediate: true },
)
</script>
<template>
<div class="history-page">
<PageHeader title="变更历史" subtitle="仿 Git Graph 的操作图谱,只读;纠错走「撤销发布 → 改 → 重新发布」">
<template #actions>
<span class="mx-badge mx-badge-neutral"><MxIcon name="lock" :size="13" /> 只读</span>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<div v-if="usingMock" class="mock-banner">
<MxIcon name="warning" :size="15" />
<span>示例数据:{{ mockReason ?? '/api/revisions 暂不可用' }}</span>
<span class="mock-hint">接口就绪后自动切换为真实数据</span>
</div>
<div class="filter-bar">
<label class="filter">
<span>小项</span>
<select v-model="eventFilter" class="mx-cell-input">
<option value="all">全部小项</option>
<option v-for="event in eventOptions" :key="event.id" :value="event.id">{{ event.name }}</option>
</select>
</label>
<div class="mx-tabs">
<button class="mx-tab" :class="{ active: timeFilter === 'all' }" type="button" @click="timeFilter = 'all'">全部时间</button>
<button class="mx-tab" :class="{ active: timeFilter === '24h' }" type="button" @click="timeFilter = '24h'">近 24 小时</button>
<button class="mx-tab" :class="{ active: timeFilter === '7d' }" type="button" @click="timeFilter = '7d'">近 7 天</button>
</div>
<span class="filter-count">{{ visibleRevisions.length }} 个节点 · {{ layout.laneCount }} 条泳道</span>
</div>
<div v-if="loading" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载变更历史…</div>
</div>
</div>
</div>
<div v-else-if="visibleRevisions.length === 0" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="history" :size="28" /></div>
<div class="mx-empty-title">没有符合条件的修订</div>
<div class="mx-empty-desc">发布、撤销发布、编辑成绩都会生成节点</div>
</div>
</div>
</div>
<div v-else class="history-grid">
<section class="mx-card graph-card">
<div class="mx-card-head">
<span class="mx-card-title">时间轴</span>
<span class="mx-badge mx-badge-info">最新在上</span>
</div>
<div class="graph-scroll">
<RevisionGraph
:rows="layout.rows"
:width="layout.width"
:selected-id="selectedId"
@select="selectedId = $event"
>
<template #default="{ row }">
<span class="node" :class="{ selected: row.revision.id === selectedId }">
<span class="node-line">
<span class="mx-tag" :class="`tag-${row.revision.action}`">{{ ACTION_LABELS[row.revision.action] }}</span>
<span class="node-event">{{ data.eventById(row.revision.eventId)?.name ?? `项目 ${row.revision.eventId}` }}</span>
<span v-if="row.isBranch" class="mx-tag mx-tag-warning">分支</span>
</span>
<span class="node-meta">
<span :style="{ color: laneColor(row.lane) }" class="mono">#{{ row.revision.id }}</span>
<span>{{ row.revision.actorName ?? (row.revision.actorId === null ? '系统' : `用户 #${row.revision.actorId}`) }}</span>
<span class="mono">{{ formatDateTime(row.revision.createdAt) }}</span>
</span>
<span v-if="row.revision.message" class="node-message">{{ row.revision.message }}</span>
</span>
</template>
</RevisionGraph>
</div>
</section>
<aside class="mx-card detail-card">
<div class="mx-card-head">
<span class="mx-card-title">节点详情</span>
<span v-if="selectedRevision" class="mx-badge mx-badge-neutral mono">#{{ selectedRevision.id }}</span>
</div>
<div class="mx-card-body">
<RevisionDetail
v-if="selectedRevision"
:revision="selectedRevision"
:parent="parentRevision"
:event-name="selectedEventName"
:actor-label="actorLabel"
:diff="diffRows"
:impacts="impacts"
/>
<div v-else class="mx-empty">
<div class="mx-empty-title">选择左侧一个节点</div>
<div class="mx-empty-desc">查看操作人、影响单位与成绩 diff</div>
</div>
</div>
</aside>
</div>
<p class="foot-hint">
<MxIcon name="lock" :size="13" />
本页只读,不提供一键回滚:纠错请到成绩录入页「撤销发布 → 修改 → 重新发布」,每步都会留审计。
</p>
</div>
</template>
<style scoped>
.mock-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.1);
color: var(--mx-amber);
font-size: 13px;
margin-top: 14px;
}
.mock-hint {
color: var(--mx-t3);
font-size: 12px;
}
.filter-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin: 14px 0;
}
.filter {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t3);
}
.filter .mx-cell-input {
width: 200px;
}
.filter-count {
font-size: 12px;
color: var(--mx-t3);
margin-left: auto;
}
.history-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 16px;
align-items: start;
}
.graph-card {
min-width: 0;
}
.graph-scroll {
max-height: calc(100dvh - 260px);
overflow: auto;
padding: 8px;
}
.detail-card {
position: sticky;
top: 0;
}
.node {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding-right: 10px;
}
.node-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.node-event {
font-size: 13px;
font-weight: 600;
}
.node-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: var(--mx-t3);
}
.node-message {
font-size: 11px;
color: var(--mx-t3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-publish {
background: rgba(52, 211, 153, 0.12);
color: var(--mx-green);
}
.tag-unpublish {
background: rgba(248, 113, 113, 0.12);
color: var(--mx-red);
}
.tag-edit {
background: rgba(251, 191, 36, 0.12);
color: var(--mx-amber);
}
.foot-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 18px;
font-size: 12px;
color: var(--mx-t3);
}
@media (max-width: 1279px) {
.history-grid {
grid-template-columns: 1fr;
}
.detail-card {
position: static;
}
}
</style>
+1 -1
View File
@@ -41,7 +41,7 @@ function asGuest(): void {
<div class="mx-card login-card">
<div class="mx-card-body">
<div class="login-brand">
<span class="mx-logo-icon"><MxIcon name="standings" :size="20" /></span>
<img class="app-icon" src="/scoreboard.png" alt="" width="36" height="36" />
<div>
<h1>积分榜管理面板</h1>
<p>2026 职工运动会 · 成绩录入与实时积分</p>
+3 -2
View File
@@ -5,6 +5,7 @@ import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import MockBadge from '../components/system/MockBadge.vue'
import JsonDiff from '../components/system/JsonDiff.vue'
import { describeError } from '../components/system/systemApi'
import { adminApi, mockMode } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
@@ -90,8 +91,8 @@ async function load(): Promise<void> {
usingMock.value = mockMode.value
if (page.value > pageCount.value) page.value = 1
} catch (caught) {
loadError.value = caught instanceof Error ? caught.message : '加载审计日志失败'
toast.error('加载审计日志失败', caught instanceof Error ? caught.message : null)
loadError.value = describeError(caught)
toast.error('加载审计日志失败', describeError(caught))
} finally {
loading.value = false
}
+23 -17
View File
@@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-rou
import AdminLayout from './layouts/AdminLayout.vue'
/**
* 路由表。占位页统一走 PlaceholderPage,避免为每个未实现页面各写一个空组件。
* 路由表。页面已全部落地,不再使用 PlaceholderPage。
*/
const routes: RouteRecordRaw[] = [
{
@@ -31,20 +31,20 @@ const routes: RouteRecordRaw[] = [
{
path: 'review',
name: 'review',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '成绩审核', note: '草稿列表 → 对比 → 发布 / 退回' },
component: () => import('./pages/ReviewPage.vue'),
meta: { title: '成绩审核' },
},
{
path: 'records',
name: 'records',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '破纪录', note: '疑似破纪录列表 → 确认 → 加分' },
component: () => import('./pages/RecordsPage.vue'),
meta: { title: '破纪录' },
},
{
path: 'adjustments',
name: 'adjustments',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '积分调整', note: '人工加减分(必填理由),本期规则关闭' },
component: () => import('./pages/AdjustmentsPage.vue'),
meta: { title: '积分调整' },
},
{
path: 'standings',
@@ -61,32 +61,32 @@ const routes: RouteRecordRaw[] = [
{
path: 'base-data',
name: 'base-data',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '基础数据', note: '单位 / 大项 / 小项 / 积分模板 / 运动员 / 报名导入' },
component: () => import('./pages/BaseDataPage.vue'),
meta: { title: '基础数据' },
},
{
path: 'export',
name: 'export',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '导出', note: '积分榜 / 明细 / 成绩册 / 赛事项目包' },
component: () => import('./pages/ExportPage.vue'),
meta: { title: '导出' },
},
{
path: 'history',
name: 'history',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '变更历史', note: '仿 Git Graph 的操作图谱(只读)' },
component: () => import('./pages/HistoryPage.vue'),
meta: { title: '变更历史' },
},
{
path: 'logs',
name: 'logs',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '日志', note: '审计日志:谁在什么时候改了什么' },
component: () => import('./pages/LogsPage.vue'),
meta: { title: '日志' },
},
{
path: 'system',
name: 'system',
component: () => import('./pages/PlaceholderPage.vue'),
meta: { title: '系统', note: '用户管理 / 备份恢复 / 赛事信息' },
component: () => import('./pages/SystemPage.vue'),
meta: { title: '系统设置' },
},
{
path: 'password',
@@ -94,6 +94,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('./pages/ChangePasswordPage.vue'),
meta: { title: '修改密码' },
},
{
path: 'about',
name: 'about',
component: () => import('./pages/AboutPage.vue'),
meta: { title: '关于' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } },
+8
View File
@@ -1,5 +1,13 @@
@import './fonts.css';
/* 应用标识(Scoreboard 自身的品牌图标,不是赛事会标) */
.app-icon {
display: block;
border-radius: 8px;
object-fit: cover;
flex: none;
}
/* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */
:root {
--bg: #0b1e3a;