fix(server): 赛事包导出传入项目名,修复 GET /api/export/project.zip 恒 500
This commit is contained in:
1 parent
72462792ec
commit
5ae55b8057
36 files changed
+2672
-108
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -1,16 +1,35 @@
|
|||||||
'use strict'
|
'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 =
|
const ICON_PNG_BASE64 =
|
||||||
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABUklEQVR42u2XwUdEURTG5z9JSholJY2SklJSf0+0iKFFtIhoES0iWkSLiBaRDKNSKU2GjCRGEkkSSZsvb3GfnHPnzTm382Y1P36b9x33fKv37stkmiiYLf1AovnimatvhPjvxdMXX7AwaPnU6ScsVS2fLH4gDUXLJwrvSNO6BcaP3pCmicvHDl/RCGsWGD14QSP0Lh/Zf4aVFN8MKzC89wQrKb4ZVmBotworKb4ZVmBw5xFWUnwzrEBu+wFWUnwzrMDA1j00UqSZkxXo36xAI0WaOVmBvo07aKRIMycr0LtehkaKNHOyAj1rt9BIkWZO79uwe/UGUinSLLLmt6Br5Rp/pVhkkYlfxOzyJZwUi6zufaBz6RxOikUmuhV1LJ4hkuKeh2aqe2F7/oQdFD1zarOgmzE9qG2hGKvJgv8L6EGt84VYTWZWoGXuOFaTNX9qk/gFLmHiXP3t+GEAAAAASUVORK5CYII='
|
'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() {
|
function createTrayIcon() {
|
||||||
const image = nativeImage.createFromDataURL(`data:image/png;base64,${ICON_PNG_BASE64}`)
|
const file = resolveIconPath()
|
||||||
// Windows 上托盘按 16px 显示,缩放交给系统会更模糊,这里直接给 16px
|
if (file) {
|
||||||
return image.resize({ width: 16, height: 16 })
|
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 }
|
module.exports = { createTrayIcon }
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<!-- 窗口是无边框的,标题栏和最小化/最大化/关闭三键都在这条上自绘 -->
|
<!-- 窗口是无边框的,标题栏和最小化/最大化/关闭三键都在这条上自绘 -->
|
||||||
<header id="titlebar" class="topbar">
|
<header id="titlebar" class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-mark"></span>
|
<img class="brand-mark" src="scoreboard.png" alt="" width="30" height="30" />
|
||||||
<div>
|
<div>
|
||||||
<h1>Scoreboard Console</h1>
|
<h1>Scoreboard Console</h1>
|
||||||
<p class="brand-sub">赛事积分榜 · 桌面宿主</p>
|
<p class="brand-sub">赛事积分榜 · 桌面宿主</p>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
@@ -51,10 +51,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 8px;
|
width: 30px;
|
||||||
height: 22px;
|
height: 30px;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
background: linear-gradient(180deg, var(--accent), #1b6bb8);
|
object-fit: cover;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@@ -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 "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 "dist\scoreboard.exe" "%CONSOLE_OUT%\scoreboard.exe" >nul || exit /b 1
|
||||||
copy /y "config.yaml" "%CONSOLE_OUT%\config.yaml" >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.
|
||||||
echo === 构建完成 ===
|
echo === 构建完成 ===
|
||||||
|
|||||||
@@ -90,8 +90,16 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
|||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return nil, internal(err)
|
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")
|
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)
|
return nil, internal(err)
|
||||||
}
|
}
|
||||||
defer func() { _ = os.Remove(dst) }()
|
defer func() { _ = os.Remove(dst) }()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/scoreboard.png" />
|
||||||
<title>管理面板</title>
|
<title>管理面板</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/scoreboard.png" />
|
||||||
<title>积分大屏</title>
|
<title>积分大屏</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/scoreboard.png" />
|
||||||
<title>赛事积分榜系统</title>
|
<title>赛事积分榜系统</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
@@ -37,7 +37,7 @@ function handleDoubleClick(event: MouseEvent): void {
|
|||||||
<template>
|
<template>
|
||||||
<header v-if="visible" class="desktop-titlebar" @dblclick="handleDoubleClick">
|
<header v-if="visible" class="desktop-titlebar" @dblclick="handleDoubleClick">
|
||||||
<div class="desktop-titlebar-brand">
|
<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-title">积分榜管理</span>
|
||||||
<span class="desktop-titlebar-sub">服务端管理面板</span>
|
<span class="desktop-titlebar-sub">服务端管理面板</span>
|
||||||
</div>
|
</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[]>
|
profiles: Ref<ScoringProfile[]>
|
||||||
athletes: Ref<Athlete[]>
|
athletes: Ref<Athlete[]>
|
||||||
loading: Ref<boolean>
|
loading: Ref<boolean>
|
||||||
|
loaded: Ref<boolean>
|
||||||
error: Ref<string | null>
|
error: Ref<string | null>
|
||||||
selectedEventId: Ref<number | null>
|
selectedEventId: Ref<number | null>
|
||||||
isMock: ComputedRef<boolean>
|
isMock: ComputedRef<boolean>
|
||||||
@@ -280,6 +281,7 @@ export function useBaseData(): BaseDataController {
|
|||||||
profiles,
|
profiles,
|
||||||
athletes,
|
athletes,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
error,
|
error,
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
isMock: computed(() => baseDataFallback.value || mockMode.value),
|
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>
|
||||||
@@ -141,12 +141,8 @@ export interface DiffEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sameRow(a: RevisionSnapshotRow, b: RevisionSnapshotRow): boolean {
|
function sameRow(a: RevisionSnapshotRow, b: RevisionSnapshotRow): boolean {
|
||||||
return (
|
// 只比成绩本身;draft / published 的状态切换由动作标签表达,否则每次发布都会「全行变化」。
|
||||||
a.resultText === b.resultText &&
|
return a.resultText === b.resultText && a.rank === b.rank && a.status === b.status
|
||||||
a.rank === b.rank &&
|
|
||||||
a.status === b.status &&
|
|
||||||
a.state === b.state
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前快照 vs 父快照;父为空时全部视为新增(首次发布)。 */
|
/** 当前快照 vs 父快照;父为空时全部视为新增(首次发布)。 */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Revision, RevisionSnapshotRow } from './types'
|
|||||||
*
|
*
|
||||||
* 只用于把「时间轴 + 分支 + diff」走通,界面必须显式标注「示例数据」;
|
* 只用于把「时间轴 + 分支 + diff」走通,界面必须显式标注「示例数据」;
|
||||||
* entryId 按示例数据的约定生成(eventId * 1000 + teamId * 10 + 1),单位名由全局数据补齐。
|
* entryId 按示例数据的约定生成(eventId * 1000 + teamId * 10 + 1),单位名由全局数据补齐。
|
||||||
|
* 草稿快照的 points 一律为 null:撤销发布后流水已作废,不能还算作有效积分。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function iso(hoursAgo: number): string {
|
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 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]
|
const POINTS_101 = [10, 8, 6, 3, 2, 1, 0]
|
||||||
|
|
||||||
|
/** order 是「名次顺序」的单位 id,用来演示编辑后名次与积分的变化。 */
|
||||||
function snapshot101(state: string, order: readonly number[]): RevisionSnapshotRow[] {
|
function snapshot101(state: string, order: readonly number[]): RevisionSnapshotRow[] {
|
||||||
// order 是「名次顺序」的单位 id;用于演示编辑后名次与积分的变化。
|
const published = state === 'published'
|
||||||
return order.map((teamId, index) =>
|
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,
|
parentId: 7,
|
||||||
eventId: 103,
|
eventId: 103,
|
||||||
action: 'publish',
|
action: 'publish',
|
||||||
snapshot: [
|
snapshot: snapshot103('published', true),
|
||||||
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'),
|
|
||||||
],
|
|
||||||
ledgerIds: [801, 802, 803, 804, 805],
|
ledgerIds: [801, 802, 803, 804, 805],
|
||||||
actorId: 2,
|
actorId: 2,
|
||||||
actorName: '张裁判',
|
actorName: '张裁判',
|
||||||
message: '更正第二名成绩后重新发布',
|
message: '更正第二名成绩后重新发布',
|
||||||
createdAt: iso(12),
|
createdAt: iso(16),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
parentId: 6,
|
parentId: 6,
|
||||||
eventId: 103,
|
eventId: 103,
|
||||||
action: 'edit',
|
action: 'unpublish',
|
||||||
snapshot: [
|
snapshot: snapshot103('draft', false),
|
||||||
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'),
|
|
||||||
],
|
|
||||||
ledgerIds: [],
|
ledgerIds: [],
|
||||||
actorId: 2,
|
actorId: 2,
|
||||||
actorName: '张裁判',
|
actorName: '张裁判',
|
||||||
message: '第 2 名成绩由 2:14.30 更正为 2:13.90',
|
message: '第 2 名成绩存疑,先撤销发布',
|
||||||
createdAt: iso(14),
|
createdAt: iso(18),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
parentId: null,
|
parentId: null,
|
||||||
eventId: 103,
|
eventId: 103,
|
||||||
action: 'publish',
|
action: 'publish',
|
||||||
snapshot: [
|
snapshot: snapshot103('published', false),
|
||||||
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'),
|
|
||||||
],
|
|
||||||
ledgerIds: [601, 602, 603, 604, 605],
|
ledgerIds: [601, 602, 603, 604, 605],
|
||||||
actorId: 1,
|
actorId: 1,
|
||||||
actorName: '李管理员',
|
actorName: '李管理员',
|
||||||
@@ -116,26 +133,26 @@ export function createMockRevisions(): Revision[] {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
parentId: 4,
|
parentId: 2,
|
||||||
eventId: 101,
|
eventId: 101,
|
||||||
action: 'publish',
|
action: 'publish',
|
||||||
snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]),
|
snapshot: snapshot101('published', [1, 2, 4, 3, 5, 6, 7]),
|
||||||
ledgerIds: [501, 502, 503, 504, 505, 506, 507],
|
ledgerIds: [501, 502, 503, 504, 505, 506, 507],
|
||||||
actorId: 2,
|
actorId: 2,
|
||||||
actorName: '张裁判',
|
actorName: '张裁判',
|
||||||
message: '修正后重新发布',
|
message: '从撤销点重新发布(第三、四名已更正)',
|
||||||
createdAt: iso(22),
|
createdAt: iso(22),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
parentId: 2,
|
parentId: 3,
|
||||||
eventId: 101,
|
eventId: 101,
|
||||||
action: 'edit',
|
action: 'unpublish',
|
||||||
snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]),
|
snapshot: snapshot101('draft', [1, 2, 3, 4, 5, 6, 7]),
|
||||||
ledgerIds: [],
|
ledgerIds: [],
|
||||||
actorId: 2,
|
actorId: 2,
|
||||||
actorName: '张裁判',
|
actorName: '张裁判',
|
||||||
message: '第三、四名成绩录入对调,等待重新发布',
|
message: '第三、四名成绩录入对调,撤销发布',
|
||||||
createdAt: iso(24),
|
createdAt: iso(24),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -143,7 +160,7 @@ export function createMockRevisions(): Revision[] {
|
|||||||
parentId: 2,
|
parentId: 2,
|
||||||
eventId: 101,
|
eventId: 101,
|
||||||
action: 'publish',
|
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],
|
ledgerIds: [301, 302, 303, 304, 305, 306, 307],
|
||||||
actorId: 1,
|
actorId: 1,
|
||||||
actorName: '李管理员',
|
actorName: '李管理员',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const ICON_PATHS = {
|
|||||||
logout: 'M15 4h4v16h-4M11 8l-4 4 4 4M7 12h9',
|
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',
|
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',
|
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
|
} as const
|
||||||
|
|
||||||
export type IconName = keyof typeof ICON_PATHS
|
export type IconName = keyof typeof ICON_PATHS
|
||||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { formatDateTime } from '@/shared'
|
import { formatDateTime } from '@/shared'
|
||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import MockBadge from './MockBadge.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 { useAuthStore } from '../../stores/auth'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ async function load(): Promise<void> {
|
|||||||
rows.value = result.data
|
rows.value = result.data
|
||||||
usingMock.value = result.mock
|
usingMock.value = result.mock
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
loadError.value = caught instanceof Error ? caught.message : '加载备份列表失败'
|
loadError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -49,12 +49,24 @@ async function createBackup(): Promise<void> {
|
|||||||
toast.success('已生成备份', result.data.name)
|
toast.success('已生成备份', result.data.name)
|
||||||
await load()
|
await load()
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
toast.error('备份失败', caught instanceof Error ? caught.message : null)
|
toast.error('备份失败', describeError(caught))
|
||||||
} finally {
|
} finally {
|
||||||
backingUp.value = false
|
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 {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes <= 0) return '—'
|
if (bytes <= 0) return '—'
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -93,7 +105,7 @@ onMounted(() => {
|
|||||||
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
<p v-if="loadError !== null" class="notice error">{{ loadError }}</p>
|
||||||
<p v-if="latest !== null" class="latest">
|
<p v-if="latest !== null" class="latest">
|
||||||
最近一次备份:{{ formatDateTime(latest.createdAt) }}
|
最近一次备份:{{ formatDateTime(latest.createdAt) }}
|
||||||
<span class="muted">({{ latest.reason === 'manual' ? '手动' : '自动' }})</span>
|
<span class="muted">({{ reasonLabel(latest.reason) }})</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="loading" class="state-block"><span class="mx-activity-dot"></span> 正在读取备份目录…</div>
|
<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">
|
<tr v-for="row in rows" :key="row.name">
|
||||||
<td class="mono name">{{ row.name }}</td>
|
<td class="mono name">{{ row.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mx-badge" :class="row.reason === 'manual' ? 'mx-badge-info' : 'mx-badge-neutral'">
|
<span class="mx-badge" :class="row.reason === 'event' ? 'mx-badge-info' : 'mx-badge-neutral'">
|
||||||
{{ row.reason === 'manual' ? '手动' : '自动' }}
|
{{ reasonLabel(row.reason) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="right mono">{{ formatSize(row.size) }}</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 type { EventSettings } from '@/shared'
|
||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import MockBadge from './MockBadge.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 { useAuthStore } from '../../stores/auth'
|
||||||
import { useEventStore } from '../../stores/event'
|
import { useEventStore } from '../../stores/event'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
@@ -84,7 +84,7 @@ async function save(): Promise<void> {
|
|||||||
form.value = clone(result.data)
|
form.value = clone(result.data)
|
||||||
toast.success('赛事信息已保存', result.mock ? '示例数据:改动仅保留在本地' : null)
|
toast.success('赛事信息已保存', result.mock ? '示例数据:改动仅保留在本地' : null)
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
saveError.value = caught instanceof Error ? caught.message : '保存失败'
|
saveError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { formatDateTime } from '@/shared'
|
|||||||
import MxIcon from '../MxIcon.vue'
|
import MxIcon from '../MxIcon.vue'
|
||||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
import MockBadge from './MockBadge.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 { useAuthStore } from '../../stores/auth'
|
||||||
import { useToastStore } from '../../stores/toast'
|
import { useToastStore } from '../../stores/toast'
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ async function load(): Promise<void> {
|
|||||||
rows.value = result.data
|
rows.value = result.data
|
||||||
usingMock.value = result.mock
|
usingMock.value = result.mock
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
loadError.value = caught instanceof Error ? caught.message : '加载用户列表失败'
|
loadError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ async function commitSave(): Promise<void> {
|
|||||||
editorOpen.value = false
|
editorOpen.value = false
|
||||||
await load()
|
await load()
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
formError.value = caught instanceof Error ? caught.message : '保存失败'
|
formError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ async function resetPassword(): Promise<void> {
|
|||||||
revealed.value = { username: target.username, password: result.data.password }
|
revealed.value = { username: target.username, password: result.data.password }
|
||||||
await load()
|
await load()
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
toast.error('重置密码失败', caught instanceof Error ? caught.message : null)
|
toast.error('重置密码失败', describeError(caught))
|
||||||
} finally {
|
} finally {
|
||||||
resetting.value = false
|
resetting.value = false
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ onMounted(() => {
|
|||||||
placeholder="至少 8 位,首次登录强制修改"
|
placeholder="至少 8 位,首次登录强制修改"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field switch-field">
|
<div v-if="editingId !== null" class="field switch-field">
|
||||||
<span class="mx-label">启用</span>
|
<span class="mx-label">启用</span>
|
||||||
<span class="switch-row">
|
<span class="switch-row">
|
||||||
<label class="mx-switch">
|
<label class="mx-switch">
|
||||||
@@ -281,6 +281,9 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
角色只有「管理员 / 录入员」两种;未登录访客的只读身份由系统默认提供,不需要建账号。
|
||||||
|
</p>
|
||||||
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
|
<p v-if="formError !== null" class="notice error">{{ formError }}</p>
|
||||||
|
|
||||||
<div class="editor-foot">
|
<div class="editor-foot">
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ export async function withMock<T>(real: () => Promise<T>, mock: () => T | Promis
|
|||||||
return (await withMockInfo(real, mock)).data
|
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 未覆盖的路径)
|
// 底层 HTTP(补齐 ApiClient 未覆盖的路径)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -206,14 +219,6 @@ async function requestJson<T>(path: string, options: RawOptions = {}): Promise<T
|
|||||||
return unwrap<T>(body, response.status)
|
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> {
|
async function requestBlob(path: string, options: RawOptions = {}): Promise<Blob> {
|
||||||
const response = await send(path, options)
|
const response = await send(path, options)
|
||||||
if (!response.ok) throw toApiError(await readBody(response), response.status)
|
if (!response.ok) throw toApiError(await readBody(response), response.status)
|
||||||
@@ -257,10 +262,13 @@ export interface AdminUserInput {
|
|||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可落库的角色只有 recorder / admin:guest 是「未登录」的默认身份,不是一条用户记录
|
||||||
|
* (服务端 userRole() 会直接拒绝 guest,这里不给出这个选项)。
|
||||||
|
*/
|
||||||
export const USER_ROLES: ReadonlyArray<{ value: string; label: string }> = [
|
export const USER_ROLES: ReadonlyArray<{ value: string; label: string }> = [
|
||||||
{ value: 'admin', label: '管理员' },
|
{ value: 'admin', label: '管理员' },
|
||||||
{ value: 'recorder', label: '录入员' },
|
{ value: 'recorder', label: '录入员' },
|
||||||
{ value: 'guest', label: '只读访客' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function toAdminUser(value: unknown): AdminUser | null {
|
function toAdminUser(value: unknown): AdminUser | null {
|
||||||
@@ -311,9 +319,9 @@ const mockUserRows: AdminUser[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
username: 'lookup',
|
username: 'recorder02',
|
||||||
displayName: '只读账号',
|
displayName: '录入员二(已停用)',
|
||||||
role: 'guest',
|
role: 'recorder',
|
||||||
active: false,
|
active: false,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
lastLoginAt: null,
|
lastLoginAt: null,
|
||||||
@@ -329,7 +337,8 @@ function mockUsers() {
|
|||||||
username: input.username,
|
username: input.username,
|
||||||
displayName: input.displayName === '' ? input.username : input.displayName,
|
displayName: input.displayName === '' ? input.username : input.displayName,
|
||||||
role: input.role,
|
role: input.role,
|
||||||
active: input.active,
|
// 与真实服务端一致:新建出来的账号一定是启用状态。
|
||||||
|
active: true,
|
||||||
mustChangePassword: input.password !== '',
|
mustChangePassword: input.password !== '',
|
||||||
lastLoginAt: null,
|
lastLoginAt: null,
|
||||||
}
|
}
|
||||||
@@ -379,7 +388,16 @@ export const usersApi = {
|
|||||||
async create(input: AdminUserInput): Promise<MockAware<AdminUser>> {
|
async create(input: AdminUserInput): Promise<MockAware<AdminUser>> {
|
||||||
return withMockInfo(
|
return withMockInfo(
|
||||||
async () => {
|
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)
|
const created = toAdminUser(body)
|
||||||
if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body)
|
if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body)
|
||||||
return created
|
return created
|
||||||
@@ -425,14 +443,20 @@ export interface BackupItem {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两种来源要兼容:
|
||||||
|
* - GET /api/export/backups 返回 export.BackupInfo(name / size / reason 齐全);
|
||||||
|
* - POST /api/backup 只返回 { path, createdAt },缺的字段从路径里补。
|
||||||
|
*/
|
||||||
function toBackupItem(value: unknown): BackupItem | null {
|
function toBackupItem(value: unknown): BackupItem | null {
|
||||||
if (!isRecord(value)) return null
|
if (!isRecord(value)) return null
|
||||||
const name = readString(value, 'name')
|
const path = readString(value, 'path')
|
||||||
const createdAt = readString(value, 'createdAt')
|
const createdAt = readString(value, 'createdAt')
|
||||||
|
const name = readString(value, 'name') ?? (path === null ? null : (path.split(/[\\/]/).pop() ?? null))
|
||||||
if (name === null || createdAt === null) return null
|
if (name === null || createdAt === null) return null
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: readString(value, 'path') ?? name,
|
path: path ?? name,
|
||||||
size: readNumber(value, 'size') ?? 0,
|
size: readNumber(value, 'size') ?? 0,
|
||||||
reason: readString(value, 'reason') ?? '',
|
reason: readString(value, 'reason') ?? '',
|
||||||
createdAt,
|
createdAt,
|
||||||
@@ -455,6 +479,8 @@ export const backupsApi = {
|
|||||||
return withMockInfo(
|
return withMockInfo(
|
||||||
async () => {
|
async () => {
|
||||||
const body = await requestJson<unknown>('/export/backups')
|
const body = await requestJson<unknown>('/export/backups')
|
||||||
|
// 备份目录为空时服务端把 nil 切片序列化成 null,这不是错误,是「还没有备份」。
|
||||||
|
if (body === null) return []
|
||||||
if (!Array.isArray(body)) throw new ApiError('PARSE_FAILED', '备份列表响应格式不符', 0, body)
|
if (!Array.isArray(body)) throw new ApiError('PARSE_FAILED', '备份列表响应格式不符', 0, body)
|
||||||
return body.map(toBackupItem).filter((row): row is BackupItem => row !== null)
|
return body.map(toBackupItem).filter((row): row is BackupItem => row !== null)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const NAV: NavGroup[] = [
|
|||||||
{ section: '系统', items: [
|
{ section: '系统', items: [
|
||||||
{ name: 'system', label: '系统设置', icon: 'system' },
|
{ name: 'system', label: '系统设置', icon: 'system' },
|
||||||
{ name: 'password', label: '修改密码', icon: 'lock' },
|
{ name: 'password', label: '修改密码', icon: 'lock' },
|
||||||
|
{ name: 'about', label: '关于', icon: 'info' },
|
||||||
] },
|
] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ onMounted(async () => {
|
|||||||
<div class="mx-app admin-shell">
|
<div class="mx-app admin-shell">
|
||||||
<aside class="mx-sidebar" :class="{ open: sidebarOpen }">
|
<aside class="mx-sidebar" :class="{ open: sidebarOpen }">
|
||||||
<div class="mx-logo brand">
|
<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>
|
<span class="mx-logo-text">积分榜管理</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import AthletesSection from '../components/baseData/AthletesSection.vue'
|
||||||
|
import CategoriesSection from '../components/baseData/CategoriesSection.vue'
|
||||||
|
import ChannelsSection from '../components/baseData/ChannelsSection.vue'
|
||||||
|
import ImportSection from '../components/baseData/ImportSection.vue'
|
||||||
|
import ProfilesSection from '../components/baseData/ProfilesSection.vue'
|
||||||
|
import SportsEventsSection from '../components/baseData/SportsEventsSection.vue'
|
||||||
|
import TeamsSection from '../components/baseData/TeamsSection.vue'
|
||||||
|
import { BASE_DATA_TABS, type BaseDataTabKey } from '../components/baseData/tabs'
|
||||||
|
import { useBaseData } from '../components/baseData/useBaseData'
|
||||||
|
import { useToastStore } from '../stores/toast'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础数据页(07 第 1 节)。
|
||||||
|
*
|
||||||
|
* 七个分区共用一份数据状态(见 useBaseData);分区之间通过 `goTab` 跳转,
|
||||||
|
* 例如「大项 / 小项」里点「配置组别」直接切到组别分区。
|
||||||
|
*/
|
||||||
|
const data = useBaseData()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const activeTab = ref<BaseDataTabKey>('teams')
|
||||||
|
|
||||||
|
function setTab(tab: BaseDataTabKey): void {
|
||||||
|
activeTab.value = tab
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await data.load(true)
|
||||||
|
const selected = data.selectedEventId.value
|
||||||
|
if (selected !== null) await data.loadEventDetail(selected, true)
|
||||||
|
toast.success('基础数据已刷新')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void data.load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader title="基础数据" subtitle="单位 / 大项 / 小项 / 组别 / 计分通道 / 积分模板 / 运动员 / 报名导入">
|
||||||
|
<template #actions>
|
||||||
|
<span v-if="data.isMock.value" class="mx-badge mx-badge-warning">示例数据</span>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="data.loading.value" @click="refresh">
|
||||||
|
<MxIcon name="refresh" :size="15" /> {{ data.loading.value ? '刷新中…' : '刷新' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div v-if="data.isMock.value" class="mock-note">
|
||||||
|
<MxIcon name="warning" :size="15" />
|
||||||
|
<span>{{ data.mockLabel.value }},本页改动只写入内存示例数据,不会影响真实赛事。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="data.error.value" class="error-note">
|
||||||
|
<span class="mx-badge mx-badge-error">加载失败</span>
|
||||||
|
<span>{{ data.error.value }}</span>
|
||||||
|
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="refresh">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-tabs page-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in BASE_DATA_TABS"
|
||||||
|
:key="tab.key"
|
||||||
|
class="mx-tab"
|
||||||
|
:class="{ active: activeTab === tab.key }"
|
||||||
|
type="button"
|
||||||
|
@click="setTab(tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!data.loaded.value && data.loading.value" class="mx-card">
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="mx-empty bd-empty">
|
||||||
|
<span class="mx-spinner" />
|
||||||
|
<div class="mx-empty-title">正在加载基础数据…</div>
|
||||||
|
<div class="mx-empty-desc">单位 / 项目 / 模板 / 运动员</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<TeamsSection v-if="activeTab === 'teams'" />
|
||||||
|
<SportsEventsSection v-else-if="activeTab === 'events'" @go-tab="setTab" />
|
||||||
|
<CategoriesSection v-else-if="activeTab === 'categories'" @go-tab="setTab" />
|
||||||
|
<ChannelsSection v-else-if="activeTab === 'channels'" @go-tab="setTab" />
|
||||||
|
<ProfilesSection v-else-if="activeTab === 'profiles'" />
|
||||||
|
<AthletesSection v-else-if="activeTab === 'athletes'" />
|
||||||
|
<ImportSection v-else />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 页面级共享布局类(分区组件 scoped 样式里也会用到)。 */
|
||||||
|
@import '../components/baseData/baseData.css';
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mock-note,
|
||||||
|
.error-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mock-note {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
color: var(--mx-amber);
|
||||||
|
}
|
||||||
|
.error-note {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.error-note .mx-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.page-tabs {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue'
|
|||||||
import MockBadge from '../components/system/MockBadge.vue'
|
import MockBadge from '../components/system/MockBadge.vue'
|
||||||
import type { IconName } from '../components/icons'
|
import type { IconName } from '../components/icons'
|
||||||
import {
|
import {
|
||||||
|
describeError,
|
||||||
projectApi,
|
projectApi,
|
||||||
resultsExportApi,
|
resultsExportApi,
|
||||||
rulesApi,
|
rulesApi,
|
||||||
@@ -137,7 +138,7 @@ async function download(kind: ResultExportKind): Promise<void> {
|
|||||||
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
||||||
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
|
toast.success(`已导出 ${result.data.filename}`, result.mock ? '示例数据:内容为占位文本' : null)
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
toast.error('导出失败', caught instanceof Error ? caught.message : null)
|
toast.error('导出失败', describeError(caught))
|
||||||
} finally {
|
} finally {
|
||||||
pendingExport.value = null
|
pendingExport.value = null
|
||||||
}
|
}
|
||||||
@@ -152,7 +153,7 @@ async function exportProject(): Promise<void> {
|
|||||||
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
||||||
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
|
toast.success('赛事项目包已导出', result.mock ? '示例数据:内容为占位文本' : '整包拷走即可在另一台机器复现')
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
projectError.value = caught instanceof Error ? caught.message : '导出失败'
|
projectError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
projectExporting.value = false
|
projectExporting.value = false
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ async function runProjectImport(): Promise<void> {
|
|||||||
projectFile.value = null
|
projectFile.value = null
|
||||||
if (projectFileInput.value !== null) projectFileInput.value.value = ''
|
if (projectFileInput.value !== null) projectFileInput.value.value = ''
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
projectError.value = caught instanceof Error ? caught.message : '导入失败'
|
projectError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
projectImporting.value = false
|
projectImporting.value = false
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ async function exportRules(): Promise<void> {
|
|||||||
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
adminApi.client.exports.save(result.data.blob, result.data.filename)
|
||||||
toast.success('规则包已导出', result.data.filename)
|
toast.success('规则包已导出', result.data.filename)
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
rulesError.value = caught instanceof Error ? caught.message : '导出失败'
|
rulesError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
rulesExporting.value = false
|
rulesExporting.value = false
|
||||||
}
|
}
|
||||||
@@ -268,7 +269,7 @@ async function previewRules(): Promise<void> {
|
|||||||
rulesPreview.value = result.data
|
rulesPreview.value = result.data
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
rulesPreview.value = null
|
rulesPreview.value = null
|
||||||
rulesError.value = caught instanceof Error ? caught.message : '预览失败'
|
rulesError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
rulesPreviewing.value = false
|
rulesPreviewing.value = false
|
||||||
}
|
}
|
||||||
@@ -293,7 +294,7 @@ async function applyRules(): Promise<void> {
|
|||||||
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
|
`已全量重算 ${result.data.recalculatedLedgerRows} 条积分流水`,
|
||||||
)
|
)
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
rulesError.value = caught instanceof Error ? caught.message : '导入失败'
|
rulesError.value = describeError(caught)
|
||||||
} finally {
|
} finally {
|
||||||
rulesApplying.value = false
|
rulesApplying.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
type EntryWithResult,
|
||||||
|
type Event,
|
||||||
|
type EventCategory,
|
||||||
|
type ResultStatus,
|
||||||
|
type ScoreChannel,
|
||||||
|
} from '@/shared'
|
||||||
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
|
import RevisionGraph from '../components/history/RevisionGraph.vue'
|
||||||
|
import RevisionDetail from '../components/history/RevisionDetail.vue'
|
||||||
|
import { buildGraph, describeRow, diffSnapshots, laneColor } from '../components/history/graph'
|
||||||
|
import { fetchRevisions } from '../components/history/revisionsSource'
|
||||||
|
import { createMockRevisions } from '../components/history/mockRevisions'
|
||||||
|
import { ACTION_LABELS, type Revision, type RevisionDiffRow, type TeamImpact } from '../components/history/types'
|
||||||
|
import { adminApi } from '../api/client'
|
||||||
|
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||||
|
import { useEventStore } from '../stores/event'
|
||||||
|
|
||||||
|
type TimeFilter = 'all' | '24h' | '7d'
|
||||||
|
|
||||||
|
/** 计算 diff / 影响面所需的小项上下文,按 eventId 缓存。 */
|
||||||
|
interface EventContext {
|
||||||
|
event: Event
|
||||||
|
categories: EventCategory[]
|
||||||
|
channels: ScoreChannel[]
|
||||||
|
entryById: Map<number, EntryWithResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = useEventStore()
|
||||||
|
|
||||||
|
const revisions = ref<Revision[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const usingMock = ref(false)
|
||||||
|
const mockReason = ref<string | null>(null)
|
||||||
|
const eventFilter = ref<number | 'all'>('all')
|
||||||
|
const timeFilter = ref<TimeFilter>('all')
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const contexts = ref<Record<number, EventContext>>({})
|
||||||
|
|
||||||
|
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
|
||||||
|
|
||||||
|
const visibleRevisions = computed(() => {
|
||||||
|
const threshold = timeThreshold(timeFilter.value)
|
||||||
|
const list = threshold === null ? revisions.value : revisions.value.filter((item) => item.createdAt >= threshold)
|
||||||
|
return [...list].sort((a, b) => (a.createdAt === b.createdAt ? b.id - a.id : a.createdAt < b.createdAt ? 1 : -1))
|
||||||
|
})
|
||||||
|
|
||||||
|
const layout = computed(() => buildGraph(visibleRevisions.value))
|
||||||
|
const selectedRevision = computed<Revision | null>(
|
||||||
|
() => visibleRevisions.value.find((item) => item.id === selectedId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const parentRevision = computed<Revision | null>(() => {
|
||||||
|
const revision = selectedRevision.value
|
||||||
|
if (revision === null || revision.parentId === null) return null
|
||||||
|
return revisions.value.find((item) => item.id === revision.parentId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedContext = computed<EventContext | null>(() => {
|
||||||
|
const revision = selectedRevision.value
|
||||||
|
if (revision === null) return null
|
||||||
|
return contexts.value[revision.eventId] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedEventName = computed(() => {
|
||||||
|
const revision = selectedRevision.value
|
||||||
|
if (revision === null) return '—'
|
||||||
|
return data.eventById(revision.eventId)?.name ?? selectedContext.value?.event.name ?? `项目 ${revision.eventId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const actorLabel = computed(() => {
|
||||||
|
const revision = selectedRevision.value
|
||||||
|
if (revision === null) return '—'
|
||||||
|
if (revision.actorName !== null && revision.actorName !== '') return revision.actorName
|
||||||
|
return revision.actorId === null ? '系统' : `用户 #${revision.actorId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const impacts = computed<TeamImpact[]>(() => {
|
||||||
|
const revision = selectedRevision.value
|
||||||
|
if (revision === null) return []
|
||||||
|
const context = selectedContext.value
|
||||||
|
const current = teamTotals(revision, context)
|
||||||
|
const previous = parentRevision.value === null ? new Map<number, number>() : teamTotals(parentRevision.value, context)
|
||||||
|
const ids = new Set<number>([...current.keys(), ...previous.keys()])
|
||||||
|
const list: TeamImpact[] = []
|
||||||
|
for (const teamId of ids) {
|
||||||
|
const delta = round((current.get(teamId) ?? 0) - (previous.get(teamId) ?? 0))
|
||||||
|
if (delta === 0) continue
|
||||||
|
list.push({ teamId, label: teamLabel(teamId), delta })
|
||||||
|
}
|
||||||
|
return list.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||||||
|
})
|
||||||
|
|
||||||
|
const diffRows = computed<RevisionDiffRow[]>(() => {
|
||||||
|
const revision = selectedRevision.value
|
||||||
|
if (revision === null) return []
|
||||||
|
const context = selectedContext.value
|
||||||
|
const individual = context?.event.eventType === 'individual'
|
||||||
|
const factor = individual ? weight.value : 1
|
||||||
|
return diffSnapshots(revision.snapshot, parentRevision.value?.snapshot ?? null).map((entry) => {
|
||||||
|
const info = context?.entryById.get(entry.entryId)
|
||||||
|
const teamId = entry.after?.teamId ?? entry.before?.teamId ?? info?.teamId ?? null
|
||||||
|
const beforePoints = entry.before?.points ?? null
|
||||||
|
const afterPoints = entry.after?.points ?? null
|
||||||
|
const pointsDelta = round(((afterPoints ?? 0) - (beforePoints ?? 0)) * factor)
|
||||||
|
return {
|
||||||
|
entryId: entry.entryId,
|
||||||
|
kind: entry.kind,
|
||||||
|
teamLabel: teamId === null ? '—' : teamLabel(teamId),
|
||||||
|
athleteLabel: info?.athleteName ?? info?.memberNames?.join('、') ?? '',
|
||||||
|
beforeText: describeRow(entry.before),
|
||||||
|
afterText: describeRow(entry.after),
|
||||||
|
beforePoints,
|
||||||
|
afterPoints,
|
||||||
|
pointsDelta,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventOptions = computed(() =>
|
||||||
|
data.events.map((event) => ({ id: event.id, name: event.name })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function round(value: number): number {
|
||||||
|
return Number(value.toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeThreshold(filter: TimeFilter): string | null {
|
||||||
|
if (filter === 'all') return null
|
||||||
|
const hours = filter === '24h' ? 24 : 24 * 7
|
||||||
|
return new Date(Date.now() - hours * 3_600_000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamLabel(teamId: number): string {
|
||||||
|
const team = data.teamById(teamId)
|
||||||
|
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: string | null): ResultStatus {
|
||||||
|
if (value === 'dq' || value === 'dns' || value === 'dnf' || value === 'withdrawn') return value
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快照带 points 时用服务端真值;缺 points 时用录入页同一套纯函数重算。 */
|
||||||
|
function teamTotals(revision: Revision, context: EventContext | null): Map<number, number> {
|
||||||
|
const individual = context?.event.eventType === 'individual'
|
||||||
|
const factor = individual ? weight.value : 1
|
||||||
|
// 只有已发布成绩才产生积分流水;草稿行即使带分值也不计入。
|
||||||
|
const publishedRows = revision.snapshot.filter((row) => row.state !== 'draft')
|
||||||
|
const hasPoints = publishedRows.length > 0 && publishedRows.every((row) => row.points !== null)
|
||||||
|
|
||||||
|
if (context === null || hasPoints) {
|
||||||
|
const totals = new Map<number, number>()
|
||||||
|
for (const row of publishedRows) {
|
||||||
|
if (row.points === null) continue
|
||||||
|
const teamId = row.teamId ?? context?.entryById.get(row.entryId)?.teamId ?? null
|
||||||
|
if (teamId === null) continue
|
||||||
|
totals.set(teamId, (totals.get(teamId) ?? 0) + row.points * factor)
|
||||||
|
}
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
const scorable: ScorableRow[] = []
|
||||||
|
for (const row of publishedRows) {
|
||||||
|
const teamId = row.teamId ?? context.entryById.get(row.entryId)?.teamId
|
||||||
|
if (teamId === undefined) continue
|
||||||
|
scorable.push({
|
||||||
|
entryId: row.entryId,
|
||||||
|
teamId,
|
||||||
|
resultValue: row.resultValue,
|
||||||
|
rank: row.rank,
|
||||||
|
status: normalizeStatus(row.status),
|
||||||
|
isTie: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const category = context.categories.length === 1 ? (context.categories[0] ?? null) : null
|
||||||
|
return computeEventScore(scorable, {
|
||||||
|
event: context.event,
|
||||||
|
category,
|
||||||
|
channels: context.channels,
|
||||||
|
profiles: data.profiles,
|
||||||
|
weight: weight.value,
|
||||||
|
}).teamPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureContext(eventId: number): Promise<void> {
|
||||||
|
if (contexts.value[eventId] !== undefined) return
|
||||||
|
try {
|
||||||
|
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
|
||||||
|
contexts.value = {
|
||||||
|
...contexts.value,
|
||||||
|
[eventId]: {
|
||||||
|
event,
|
||||||
|
categories: event.categories ?? [],
|
||||||
|
channels: event.channels ?? [],
|
||||||
|
entryById: new Map(entries.map((entry) => [entry.id, entry])),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 上下文拉不到只影响单位名与重算,快照本身的 diff 仍然可读。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
const eventId = eventFilter.value === 'all' ? undefined : eventFilter.value
|
||||||
|
try {
|
||||||
|
revisions.value = await fetchRevisions(eventId === undefined ? {} : { eventId })
|
||||||
|
usingMock.value = false
|
||||||
|
mockReason.value = null
|
||||||
|
} catch (caught) {
|
||||||
|
const all = createMockRevisions()
|
||||||
|
revisions.value = eventId === undefined ? all : all.filter((item) => item.eventId === eventId)
|
||||||
|
usingMock.value = true
|
||||||
|
mockReason.value = caught instanceof Error ? caught.message : '变更历史接口不可用'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(eventFilter, () => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
visibleRevisions,
|
||||||
|
(list) => {
|
||||||
|
if (list.length === 0) {
|
||||||
|
selectedId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!list.some((item) => item.id === selectedId.value)) selectedId.value = list[0]?.id ?? null
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedRevision,
|
||||||
|
(revision) => {
|
||||||
|
if (revision !== null) void ensureContext(revision.eventId)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="history-page">
|
||||||
|
<PageHeader title="变更历史" subtitle="仿 Git Graph 的操作图谱,只读;纠错走「撤销发布 → 改 → 重新发布」">
|
||||||
|
<template #actions>
|
||||||
|
<span class="mx-badge mx-badge-neutral"><MxIcon name="lock" :size="13" /> 只读</span>
|
||||||
|
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
|
||||||
|
<MxIcon name="refresh" :size="15" /> 刷新
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div v-if="usingMock" class="mock-banner">
|
||||||
|
<MxIcon name="warning" :size="15" />
|
||||||
|
<span>示例数据:{{ mockReason ?? '/api/revisions 暂不可用' }}</span>
|
||||||
|
<span class="mock-hint">接口就绪后自动切换为真实数据</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<label class="filter">
|
||||||
|
<span>小项</span>
|
||||||
|
<select v-model="eventFilter" class="mx-cell-input">
|
||||||
|
<option value="all">全部小项</option>
|
||||||
|
<option v-for="event in eventOptions" :key="event.id" :value="event.id">{{ event.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="mx-tabs">
|
||||||
|
<button class="mx-tab" :class="{ active: timeFilter === 'all' }" type="button" @click="timeFilter = 'all'">全部时间</button>
|
||||||
|
<button class="mx-tab" :class="{ active: timeFilter === '24h' }" type="button" @click="timeFilter = '24h'">近 24 小时</button>
|
||||||
|
<button class="mx-tab" :class="{ active: timeFilter === '7d' }" type="button" @click="timeFilter = '7d'">近 7 天</button>
|
||||||
|
</div>
|
||||||
|
<span class="filter-count">{{ visibleRevisions.length }} 个节点 · {{ layout.laneCount }} 条泳道</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mx-card">
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="mx-empty">
|
||||||
|
<div class="mx-spinner mx-spinner-sm"></div>
|
||||||
|
<div class="mx-empty-title">正在加载变更历史…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="visibleRevisions.length === 0" class="mx-card">
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<div class="mx-empty">
|
||||||
|
<div class="mx-empty-icon"><MxIcon name="history" :size="28" /></div>
|
||||||
|
<div class="mx-empty-title">没有符合条件的修订</div>
|
||||||
|
<div class="mx-empty-desc">发布、撤销发布、编辑成绩都会生成节点</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="history-grid">
|
||||||
|
<section class="mx-card graph-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">时间轴</span>
|
||||||
|
<span class="mx-badge mx-badge-info">最新在上</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-scroll">
|
||||||
|
<RevisionGraph
|
||||||
|
:rows="layout.rows"
|
||||||
|
:width="layout.width"
|
||||||
|
:selected-id="selectedId"
|
||||||
|
@select="selectedId = $event"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="node" :class="{ selected: row.revision.id === selectedId }">
|
||||||
|
<span class="node-line">
|
||||||
|
<span class="mx-tag" :class="`tag-${row.revision.action}`">{{ ACTION_LABELS[row.revision.action] }}</span>
|
||||||
|
<span class="node-event">{{ data.eventById(row.revision.eventId)?.name ?? `项目 ${row.revision.eventId}` }}</span>
|
||||||
|
<span v-if="row.isBranch" class="mx-tag mx-tag-warning">分支</span>
|
||||||
|
</span>
|
||||||
|
<span class="node-meta">
|
||||||
|
<span :style="{ color: laneColor(row.lane) }" class="mono">#{{ row.revision.id }}</span>
|
||||||
|
<span>{{ row.revision.actorName ?? (row.revision.actorId === null ? '系统' : `用户 #${row.revision.actorId}`) }}</span>
|
||||||
|
<span class="mono">{{ formatDateTime(row.revision.createdAt) }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="row.revision.message" class="node-message">{{ row.revision.message }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</RevisionGraph>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="mx-card detail-card">
|
||||||
|
<div class="mx-card-head">
|
||||||
|
<span class="mx-card-title">节点详情</span>
|
||||||
|
<span v-if="selectedRevision" class="mx-badge mx-badge-neutral mono">#{{ selectedRevision.id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mx-card-body">
|
||||||
|
<RevisionDetail
|
||||||
|
v-if="selectedRevision"
|
||||||
|
:revision="selectedRevision"
|
||||||
|
:parent="parentRevision"
|
||||||
|
:event-name="selectedEventName"
|
||||||
|
:actor-label="actorLabel"
|
||||||
|
:diff="diffRows"
|
||||||
|
:impacts="impacts"
|
||||||
|
/>
|
||||||
|
<div v-else class="mx-empty">
|
||||||
|
<div class="mx-empty-title">选择左侧一个节点</div>
|
||||||
|
<div class="mx-empty-desc">查看操作人、影响单位与成绩 diff</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="foot-hint">
|
||||||
|
<MxIcon name="lock" :size="13" />
|
||||||
|
本页只读,不提供一键回滚:纠错请到成绩录入页「撤销发布 → 修改 → 重新发布」,每步都会留审计。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mock-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--mx-r-sm);
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
color: var(--mx-amber);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.mock-hint {
|
||||||
|
color: var(--mx-t3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.filter .mx-cell-input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.filter-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.history-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 420px;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.graph-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.graph-scroll {
|
||||||
|
max-height: calc(100dvh - 260px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.detail-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
.node-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.node-event {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.node-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
.node-message {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tag-publish {
|
||||||
|
background: rgba(52, 211, 153, 0.12);
|
||||||
|
color: var(--mx-green);
|
||||||
|
}
|
||||||
|
.tag-unpublish {
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
color: var(--mx-red);
|
||||||
|
}
|
||||||
|
.tag-edit {
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
color: var(--mx-amber);
|
||||||
|
}
|
||||||
|
.foot-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mx-t3);
|
||||||
|
}
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.history-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.detail-card {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,7 +41,7 @@ function asGuest(): void {
|
|||||||
<div class="mx-card login-card">
|
<div class="mx-card login-card">
|
||||||
<div class="mx-card-body">
|
<div class="mx-card-body">
|
||||||
<div class="login-brand">
|
<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>
|
<div>
|
||||||
<h1>积分榜管理面板</h1>
|
<h1>积分榜管理面板</h1>
|
||||||
<p>2026 职工运动会 · 成绩录入与实时积分</p>
|
<p>2026 职工运动会 · 成绩录入与实时积分</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import MxIcon from '../components/MxIcon.vue'
|
|||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import MockBadge from '../components/system/MockBadge.vue'
|
import MockBadge from '../components/system/MockBadge.vue'
|
||||||
import JsonDiff from '../components/system/JsonDiff.vue'
|
import JsonDiff from '../components/system/JsonDiff.vue'
|
||||||
|
import { describeError } from '../components/system/systemApi'
|
||||||
import { adminApi, mockMode } from '../api/client'
|
import { adminApi, mockMode } from '../api/client'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
@@ -90,8 +91,8 @@ async function load(): Promise<void> {
|
|||||||
usingMock.value = mockMode.value
|
usingMock.value = mockMode.value
|
||||||
if (page.value > pageCount.value) page.value = 1
|
if (page.value > pageCount.value) page.value = 1
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
loadError.value = caught instanceof Error ? caught.message : '加载审计日志失败'
|
loadError.value = describeError(caught)
|
||||||
toast.error('加载审计日志失败', caught instanceof Error ? caught.message : null)
|
toast.error('加载审计日志失败', describeError(caught))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-17
@@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-rou
|
|||||||
import AdminLayout from './layouts/AdminLayout.vue'
|
import AdminLayout from './layouts/AdminLayout.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路由表。占位页统一走 PlaceholderPage,避免为每个未实现页面各写一个空组件。
|
* 路由表。页面已全部落地,不再使用 PlaceholderPage。
|
||||||
*/
|
*/
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -31,20 +31,20 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'review',
|
path: 'review',
|
||||||
name: 'review',
|
name: 'review',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/ReviewPage.vue'),
|
||||||
meta: { title: '成绩审核', note: '草稿列表 → 对比 → 发布 / 退回' },
|
meta: { title: '成绩审核' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'records',
|
path: 'records',
|
||||||
name: 'records',
|
name: 'records',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/RecordsPage.vue'),
|
||||||
meta: { title: '破纪录', note: '疑似破纪录列表 → 确认 → 加分' },
|
meta: { title: '破纪录' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'adjustments',
|
path: 'adjustments',
|
||||||
name: 'adjustments',
|
name: 'adjustments',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/AdjustmentsPage.vue'),
|
||||||
meta: { title: '积分调整', note: '人工加减分(必填理由),本期规则关闭' },
|
meta: { title: '积分调整' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'standings',
|
path: 'standings',
|
||||||
@@ -61,32 +61,32 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'base-data',
|
path: 'base-data',
|
||||||
name: 'base-data',
|
name: 'base-data',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/BaseDataPage.vue'),
|
||||||
meta: { title: '基础数据', note: '单位 / 大项 / 小项 / 积分模板 / 运动员 / 报名导入' },
|
meta: { title: '基础数据' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'export',
|
path: 'export',
|
||||||
name: 'export',
|
name: 'export',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/ExportPage.vue'),
|
||||||
meta: { title: '导出', note: '积分榜 / 明细 / 成绩册 / 赛事项目包' },
|
meta: { title: '导出' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'history',
|
path: 'history',
|
||||||
name: 'history',
|
name: 'history',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/HistoryPage.vue'),
|
||||||
meta: { title: '变更历史', note: '仿 Git Graph 的操作图谱(只读)' },
|
meta: { title: '变更历史' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
name: 'logs',
|
name: 'logs',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/LogsPage.vue'),
|
||||||
meta: { title: '日志', note: '审计日志:谁在什么时候改了什么' },
|
meta: { title: '日志' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system',
|
path: 'system',
|
||||||
name: 'system',
|
name: 'system',
|
||||||
component: () => import('./pages/PlaceholderPage.vue'),
|
component: () => import('./pages/SystemPage.vue'),
|
||||||
meta: { title: '系统', note: '用户管理 / 备份恢复 / 赛事信息' },
|
meta: { title: '系统设置' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'password',
|
path: 'password',
|
||||||
@@ -94,6 +94,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('./pages/ChangePasswordPage.vue'),
|
component: () => import('./pages/ChangePasswordPage.vue'),
|
||||||
meta: { title: '修改密码' },
|
meta: { title: '修改密码' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about',
|
||||||
|
name: 'about',
|
||||||
|
component: () => import('./pages/AboutPage.vue'),
|
||||||
|
meta: { title: '关于' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } },
|
{ path: '/:pathMatch(.*)*', redirect: { name: 'dashboard' } },
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
@import './fonts.css';
|
@import './fonts.css';
|
||||||
|
|
||||||
|
/* 应用标识(Scoreboard 自身的品牌图标,不是赛事会标) */
|
||||||
|
.app-icon {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */
|
/* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b1e3a;
|
--bg: #0b1e3a;
|
||||||
|
|||||||
Reference in new issue
Block a user