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