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

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

No files matched your search

+729
View File
@@ -0,0 +1,729 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatPoints,
type EntryWithResult,
type Event,
type EventCategory,
type ResultStatus,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 一条草稿成绩:展示与对比都只用这里面的字段。 */
interface DraftRow {
entryId: number
teamId: number
teamName: string
teamShort: string
athleteName: string
memberNames: string[] | null
resultText: string
rank: number | null
status: ResultStatus
isTie: boolean
points: number
}
/** 一个「小项 + 组别」的草稿组;发布以它为事务边界(07 第 3 节)。 */
interface DraftGroup {
key: string
event: Event
eventName: string
eventCode: string
sportName: string
category: EventCategory | null
categoryName: string
rows: DraftRow[]
deltas: TeamDelta[]
affectedTeams: number
maxDelta: number
}
interface TeamDelta {
teamId: number
name: string
delta: number
}
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const groups = ref<DraftGroup[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const selectedKey = ref<string | null>(null)
const selectedEntryId = ref<number | null>(null)
const compareMode = ref<'group' | 'row'>('group')
const publishTarget = ref<DraftGroup | null>(null)
const publishing = ref(false)
const returnTarget = ref<DraftGroup | null>(null)
/** 退回没有服务端接口(05 §7 未定义),只能本地标记,界面必须说清楚这一点。 */
const returnedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const filteredGroups = computed(() => {
const keyword = search.value.trim()
if (keyword === '') return groups.value
return groups.value.filter(
(group) =>
group.eventName.includes(keyword) ||
group.categoryName.includes(keyword) ||
group.rows.some((row) => row.teamName.includes(keyword) || row.athleteName.includes(keyword)),
)
})
const selectedGroup = computed<DraftGroup | null>(
() => groups.value.find((group) => group.key === selectedKey.value) ?? null,
)
const selectedRow = computed<DraftRow | null>(() => {
const group = selectedGroup.value
if (group === null || selectedEntryId.value === null) return null
return group.rows.find((row) => row.entryId === selectedEntryId.value) ?? null
})
/** 逐条对比:这一条自己的得分(个人项目按系数折算到单位)。 */
const rowImpact = computed<TeamDelta[]>(() => {
const row = selectedRow.value
if (row === null) return []
const individual = selectedGroup.value?.event.eventType === 'individual'
const delta = round(row.points * (individual ? weight.value : 1))
if (delta === 0) return []
return [{ teamId: row.teamId, name: row.teamShort || row.teamName, delta }]
})
const impactRows = computed<TeamDelta[]>(() => {
if (compareMode.value === 'row') return rowImpact.value
return selectedGroup.value?.deltas ?? []
})
const impactAffected = computed(() => impactRows.value.length)
const impactMax = computed(() => impactRows.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
const publishedCount = computed(() => groups.value.reduce((sum, group) => sum + group.rows.length, 0))
const returnSummary = computed(() =>
returnTarget.value === null
? ''
: `将退回「${returnTarget.value.eventName} · ${returnTarget.value.categoryName}」的 ${returnTarget.value.rows.length} 条草稿,录入员可修改后重新提交。`,
)
function round(value: number): number {
return Number(value.toFixed(2))
}
function teamLabel(teamId: number, fallback: string): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? fallback
}
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
}
function sportNameOf(event: Event): string {
return data.sports.find((sport) => sport.id === event.sportId)?.name ?? '未分组'
}
function buildGroup(event: Event, category: EventCategory | null, entries: EntryWithResult[]): DraftGroup | null {
const scoped = entries.filter((entry) => category === null || entry.categoryId === category.id)
const drafts = scoped.filter((entry) => entry.result?.state === 'draft')
if (drafts.length === 0) return null
const scorable: ScorableRow[] = scoped.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
resultValue: entry.result?.resultValue ?? null,
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
}))
const context: ScoreContext = {
event,
category,
channels: event.channels ?? [],
profiles: data.profiles,
weight: weight.value,
}
const score = computeEventScore(scorable, context)
const individual = event.eventType === 'individual'
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
const published = new Map<number, number>()
for (const entry of scoped) {
if (entry.result?.state !== 'published') continue
const points = (entry.result.points ?? 0) * (individual ? weight.value : 1)
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
}
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
const deltas: TeamDelta[] = []
for (const teamId of teamIds) {
const delta = round((score.teamPoints.get(teamId) ?? 0) - (published.get(teamId) ?? 0))
if (delta === 0) continue
deltas.push({ teamId, name: teamLabel(teamId, `单位 ${teamId}`), delta })
}
deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
const rows: DraftRow[] = drafts.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
teamName: entry.teamName ?? teamLabel(entry.teamId, '未知单位'),
teamShort: entry.shortName ?? '',
athleteName: entry.athleteName ?? '',
memberNames: entry.memberNames,
resultText: entry.result?.resultText ?? '',
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
isTie: entry.result?.isTie ?? false,
points: round(score.rowPoints.get(entry.id) ?? 0),
}))
return {
key: `${event.id}-${category?.id ?? 0}`,
event,
eventName: event.name,
eventCode: event.code,
sportName: sportNameOf(event),
category,
categoryName: category?.name ?? '全部组别',
rows,
deltas,
affectedTeams: deltas.length,
maxDelta: deltas.reduce((max, item) => Math.max(max, Math.abs(item.delta)), 0),
}
}
/** 找出有草稿的小项:优先用结果接口的 state 过滤,接口未就绪时退回逐项探测。 */
async function draftEventIds(): Promise<number[]> {
try {
const page = await adminApi.client.results.list({ state: 'draft', size: 500 })
return [...new Set(page.items.map((item) => item.eventId))]
} catch (caught) {
if (!isFallback(caught)) throw caught
const found: number[] = []
await Promise.all(
data.events.map(async (event) => {
// adminApi.entries 走统一降级逻辑:后端未就绪时读示例数据。
const entries = await adminApi.entries(event.id)
if (entries.some((entry) => entry.result?.state === 'draft')) found.push(event.id)
}),
)
return found
}
}
async function load(): Promise<void> {
if (!data.loaded) return
loading.value = true
error.value = null
try {
const eventIds = await draftEventIds()
const loaded = await Promise.all(
eventIds.map(async (eventId) => {
const [event, entries] = await Promise.all([adminApi.eventDetail(eventId), adminApi.entries(eventId)])
const categories = event.categories ?? []
const scoped: Array<EventCategory | null> = categories.length > 0 ? categories : [null]
return scoped
.map((category) => buildGroup(event, category, entries))
.filter((group): group is DraftGroup => group !== null)
}),
)
groups.value = loaded.flat().sort((a, b) => a.eventCode.localeCompare(b.eventCode))
if (selectedKey.value !== null && !groups.value.some((group) => group.key === selectedKey.value)) {
selectedKey.value = null
}
if (selectedKey.value === null && groups.value.length > 0) {
selectedKey.value = groups.value[0]?.key ?? null
}
} catch (caught) {
error.value = caught instanceof Error ? caught.message : '加载草稿失败'
} finally {
loading.value = false
}
}
function selectGroup(group: DraftGroup): void {
selectedKey.value = group.key
selectedEntryId.value = null
}
function isReturned(group: DraftGroup): boolean {
return returnedKeys.value.includes(group.key)
}
function requestPublish(group: DraftGroup): void {
if (!canWrite.value) {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
publishTarget.value = group
}
async function confirmPublish(): Promise<void> {
const group = publishTarget.value
if (group === null) return
publishing.value = true
try {
await adminApi.publish({ eventId: group.event.id, categoryId: group.category?.id })
publishTarget.value = null
await load()
await data.refreshStandings()
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
} finally {
publishing.value = false
}
}
function confirmReturn(): void {
const group = returnTarget.value
if (group === null) return
if (!returnedKeys.value.includes(group.key)) {
returnedKeys.value = [...returnedKeys.value, group.key]
}
returnTarget.value = null
toast.warning('已标记退回', '当前接口未提供「退回」动作,本次仅在本地标记,不影响服务端数据')
}
function undoReturn(group: DraftGroup): void {
returnedKeys.value = returnedKeys.value.filter((key) => key !== group.key)
toast.info('已取消退回标记')
}
function statusLabel(status: ResultStatus): string {
if (status === 'ok') return '有效'
if (status === 'dns') return 'DNS'
if (status === 'dq') return 'DQ'
if (status === 'dnf') return 'DNF'
return '退赛'
}
onMounted(() => {
void load()
})
watch(
() => data.loaded,
(loaded) => {
if (loaded) void load()
},
)
</script>
<template>
<div class="review-page">
<PageHeader title="成绩审核" subtitle="草稿按「小项 + 组别」分组,发布是整组事务;草稿态绝不上屏">
<template #actions>
<span class="mx-badge mx-badge-warning">{{ publishedCount }} 条草稿待审</span>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<div v-if="error !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ error }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="load">重试</button>
</div>
<div class="review-grid">
<aside class="mx-card group-panel">
<div class="mx-card-head">
<span class="mx-card-title">待审草稿</span>
<span class="mx-badge mx-badge-neutral">{{ filteredGroups.length }} 组</span>
</div>
<div class="mx-card-body group-body">
<div class="mx-input-wrap search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索小项 / 组别 / 单位" />
</div>
<div v-if="loading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载草稿…</div>
</div>
<div v-else-if="filteredGroups.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="review" :size="26" /></div>
<div class="mx-empty-title">没有待审草稿</div>
<div class="mx-empty-desc">录入页保存的草稿会出现在这里</div>
</div>
<div v-else class="group-list">
<button
v-for="group in filteredGroups"
:key="group.key"
class="group-item"
:class="{ active: group.key === selectedKey }"
type="button"
@click="selectGroup(group)"
>
<span class="group-main">
<span class="group-name">{{ group.eventName }}</span>
<span class="group-meta">{{ group.sportName }} · {{ group.categoryName }}</span>
</span>
<span class="group-side">
<span class="mx-badge mx-badge-warning">{{ group.rows.length }} 条</span>
<span v-if="isReturned(group)" class="mx-badge mx-badge-neutral">已退回</span>
</span>
</button>
</div>
</div>
</aside>
<section class="review-main">
<div v-if="selectedGroup === null" class="mx-card">
<div class="mx-card-body">
<div class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="review" :size="28" /></div>
<div class="mx-empty-title">选择左侧一组草稿</div>
<div class="mx-empty-desc">对比发布影响后再决定发布或退回</div>
</div>
</div>
</div>
<template v-else>
<div class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
{{ selectedGroup.eventName }}
<small class="head-sub">{{ selectedGroup.sportName }} · {{ selectedGroup.categoryName }}</small>
</span>
<div class="head-actions">
<span v-if="isReturned(selectedGroup)" class="mx-badge mx-badge-neutral">已退回待修改</span>
<button
v-if="isReturned(selectedGroup)"
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
@click="undoReturn(selectedGroup)"
>
取消退回
</button>
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite"
@click="returnTarget = selectedGroup"
>
<MxIcon name="logout" :size="14" /> 退回
</button>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing"
@click="requestPublish(selectedGroup)"
>
<MxIcon name="push" :size="14" /> 发布整组
</button>
</div>
</div>
<div class="mx-card-body">
<div class="compare-bar">
<div class="mx-tabs mx-tabs-pill">
<button
class="mx-tab"
:class="{ active: compareMode === 'group' }"
type="button"
@click="compareMode = 'group'"
>
整组对比
</button>
<button
class="mx-tab"
:class="{ active: compareMode === 'row' }"
type="button"
:disabled="selectedRow === null"
@click="compareMode = 'row'"
>
逐条对比
</button>
</div>
<p class="impact-line">
本次将影响 <strong>{{ impactAffected }}</strong> 个单位,最大分差
<strong class="mono">{{ formatPoints(impactMax) }}</strong> 分
</p>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>单位</th>
<th>运动员 / 队</th>
<th>成绩</th>
<th class="center">名次</th>
<th class="center">状态</th>
<th class="right">积分</th>
<th class="right">对比</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in selectedGroup.rows"
:key="row.entryId"
:class="{ selected: row.entryId === selectedEntryId }"
@click="selectedEntryId = row.entryId"
>
<td>
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td class="mono">{{ row.resultText || '—' }}</td>
<td class="center mono">{{ row.rank ?? '—' }}</td>
<td class="center">{{ statusLabel(row.status) }}</td>
<td class="right mono points">{{ formatPoints(row.points) }}</td>
<td class="right">
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click.stop="selectedEntryId = row.entryId; compareMode = 'row'">
看该条
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">
发布影响({{ compareMode === 'row' ? '逐条' : '整组' }})
</span>
<span class="mx-badge mx-badge-info">{{ impactAffected }} 个单位</span>
</div>
<div class="mx-card-body">
<ul v-if="impactRows.length > 0" class="impact-list">
<li v-for="item in impactRows" :key="item.teamId" class="impact-item">
<span class="impact-name">{{ item.name }}</span>
<span class="impact-delta mono" :class="item.delta > 0 ? 'up' : 'down'">
{{ item.delta > 0 ? '+' : '' }}{{ formatPoints(item.delta) }}
</span>
</li>
</ul>
<p v-else class="impact-empty">
{{ compareMode === 'row' ? '选中一行查看它单独的得分影响' : '本次发布不会改变任何单位的分数' }}
</p>
</div>
</div>
</template>
</section>
</div>
<ConfirmDialog
:open="publishTarget !== null"
tone="warning"
title="确认发布该组成绩?"
:message="
publishTarget === null
? ''
: `本次将影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。`
"
:details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)"
confirm-text="确认发布"
:pending="publishing"
@confirm="confirmPublish"
@cancel="publishTarget = null"
/>
<ConfirmDialog
:open="returnTarget !== null"
tone="danger"
title="确认退回这组草稿?"
:message="returnSummary"
:details="['服务端接口清单里没有「退回」动作,本次只在管理端做本地标记', '成绩数据不变,录入员修改后重新保存草稿即可']"
confirm-text="标记退回"
@confirm="confirmReturn"
@cancel="returnTarget = null"
/>
</div>
</template>
<style scoped>
.review-grid {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 16px;
align-items: start;
margin-top: 16px;
}
.group-panel {
position: sticky;
top: 0;
max-height: calc(100dvh - 140px);
display: flex;
flex-direction: column;
}
.group-body {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.search {
width: 100%;
}
.group-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 10px;
border-radius: var(--mx-r-sm);
text-align: left;
cursor: pointer;
color: var(--mx-t2);
transition: all var(--mx-d1) var(--mx-ease);
}
.group-item:hover {
background: var(--mx-s-hover);
color: var(--mx-text);
}
.group-item.active {
background: var(--mx-primary-glow);
color: var(--mx-primary);
}
.group-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.group-name {
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-meta {
font-size: 11px;
color: var(--mx-t3);
}
.group-side {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
flex-shrink: 0;
}
.review-main {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.head-sub {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--mx-t3);
}
.head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.compare-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.impact-line {
font-size: 13px;
color: var(--mx-t2);
}
.impact-line strong {
color: var(--mx-primary);
font-size: 15px;
}
.team-cell {
font-weight: 600;
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.points {
color: var(--mx-green);
font-weight: 700;
}
tr.selected {
background: var(--mx-primary-glow);
}
.impact-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.impact-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: var(--mx-r-xs);
background: var(--mx-s3);
font-size: 13px;
}
.impact-delta {
font-weight: 800;
}
.impact-delta.up {
color: var(--mx-green);
}
.impact-delta.down {
color: var(--mx-red);
}
.impact-empty {
font-size: 13px;
color: var(--mx-t3);
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(248, 113, 113, 0.08);
color: var(--mx-red);
font-size: 13px;
margin-top: 14px;
}
@media (max-width: 1279px) {
.review-grid {
grid-template-columns: 1fr;
}
.group-panel {
position: static;
max-height: none;
}
}
</style>