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

+649
View File
@@ -0,0 +1,649 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatDateTime,
formatPoints,
type Event,
type RecordEntry,
type RecordLevel,
type RulePack,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
/** 疑似破纪录:由「已发布成绩 vs 当前纪录」实时推导,接口不返回该列表。 */
interface SuspectedBreak {
key: string
eventId: number
eventName: string
eventCode: string
categoryName: string
entryId: number
teamName: string
athleteName: string
resultText: string
resultValue: number
level: RecordLevel
recordText: string
bonusPoints: number
}
interface RecordGroup {
eventId: number
eventName: string
eventCode: string
categoryLabel: string
records: RecordEntry[]
}
const LEVEL_LABELS: Record<RecordLevel, string> = {
meet: '赛会',
city: '市级',
province: '省级',
national: '全国',
}
const LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
const records = ref<RecordEntry[]>([])
const recordsLoading = ref(false)
const recordsError = ref<string | null>(null)
const categoryLabels = ref<Record<number, string>>({})
const suspected = ref<SuspectedBreak[]>([])
const suspectedLoading = ref(false)
const suspectedError = ref<string | null>(null)
const rulePack = ref<RulePack | null>(null)
const ruleError = ref<string | null>(null)
const rulesLoading = ref(false)
const search = ref('')
const levelFilter = ref<RecordLevel | 'all'>('all')
const approveTarget = ref<SuspectedBreak | null>(null)
const approving = ref(false)
const rejectTarget = ref<SuspectedBreak | null>(null)
/** 拒绝没有服务端接口,只能在本地标记;列表刷新后会重新出现。 */
const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? false)
const bonusMode = computed(() => (rulePack.value?.recordBonus.mode === 'sum' ? '多档累加' : '取最高档'))
const filteredRecords = computed(() => {
const keyword = search.value.trim()
return records.value.filter((record) => {
if (levelFilter.value !== 'all' && record.level !== levelFilter.value) return false
if (keyword === '') return true
const event = data.eventById(record.eventId)
const label = categoryLabels.value[record.eventId] ?? ''
return (
(event?.name ?? '').includes(keyword) ||
(event?.code ?? '').includes(keyword) ||
label.includes(keyword) ||
(record.holderName ?? '').includes(keyword)
)
})
})
const recordGroups = computed<RecordGroup[]>(() => {
const map = new Map<number, RecordEntry[]>()
for (const record of filteredRecords.value) {
const list = map.get(record.eventId)
if (list === undefined) map.set(record.eventId, [record])
else list.push(record)
}
return [...map.entries()]
.map(([eventId, list]) => {
const event = data.eventById(eventId)
return {
eventId,
eventName: event?.name ?? `项目 ${eventId}`,
eventCode: event?.code ?? '',
categoryLabel: categoryLabels.value[eventId] ?? '未绑定组别',
records: [...list].sort((a, b) => levelRank(a.level) - levelRank(b.level)),
}
})
.sort((a, b) => a.eventName.localeCompare(b.eventName))
})
const pendingBreaks = computed(() =>
suspected.value.filter(
(item) => !rejectedKeys.value.includes(item.key) && !approvedKeys.value.includes(item.key),
),
)
function levelRank(level: RecordLevel): number {
const index = LEVEL_ORDER.indexOf(level)
return index < 0 ? 0 : index
}
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
}
function teamLabel(teamId: number): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
}
/** 只有「已发布 + 有效成绩」才可能破纪录;草稿永远不参与判断。 */
function beats(compare: Event['compare'], value: number, recordValue: number): boolean {
if (compare === 'asc') return value < recordValue
if (compare === 'desc') return value > recordValue
return false
}
async function loadRules(): Promise<void> {
rulesLoading.value = true
try {
rulePack.value = await adminApi.client.rules.get()
ruleError.value = null
} catch (caught) {
rulePack.value = null
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
} finally {
rulesLoading.value = false
}
}
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
const labels: Record<number, string> = {}
await Promise.all(
eventIds.map(async (eventId) => {
const cached = data.eventById(eventId)?.categories
if (cached !== undefined && cached.length > 0) {
labels[eventId] = cached.map((category) => category.name).join('、')
return
}
try {
const detail = await adminApi.eventDetail(eventId)
labels[eventId] = (detail.categories ?? []).map((category) => category.name).join('、')
} catch {
labels[eventId] = ''
}
}),
)
categoryLabels.value = labels
}
async function loadRecords(): Promise<void> {
recordsLoading.value = true
recordsError.value = null
try {
records.value = await adminApi.records()
const eventIds = [...new Set(records.value.map((record) => record.eventId))]
await loadCategoryLabels(eventIds)
} catch (caught) {
recordsError.value = caught instanceof Error ? caught.message : '加载纪录库失败'
} finally {
recordsLoading.value = false
}
}
async function loadSuspected(): Promise<void> {
if (!data.loaded) return
suspectedLoading.value = true
suspectedError.value = null
try {
const candidates = data.events.filter(
(event) => event.recordEligible && records.value.some((record) => record.eventId === event.id),
)
const found = await Promise.all(
candidates.map(async (event) => {
const entries = await adminApi.entries(event.id)
const eventRecords = records.value.filter((record) => record.eventId === event.id)
const list: SuspectedBreak[] = []
for (const entry of entries) {
const result = entry.result
if (result === null || result.state !== 'published' || result.status !== 'ok') continue
const value = result.resultValue
if (value === null) continue
let best: RecordEntry | null = null
for (const record of eventRecords) {
if (!beats(event.compare, value, record.resultValue)) continue
if (best === null || levelRank(record.level) > levelRank(best.level)) best = record
}
if (best === null) continue
list.push({
key: `${event.id}-${entry.id}-${best.level}`,
eventId: event.id,
eventName: event.name,
eventCode: event.code,
categoryName: entry.categoryName ?? categoryLabels.value[event.id] ?? '—',
entryId: entry.id,
teamName: teamLabel(entry.teamId),
athleteName: entry.athleteName ?? entry.memberNames?.join('、') ?? '—',
resultText: result.resultText,
resultValue: value,
level: best.level,
recordText: best.resultText,
bonusPoints: bonusFor(best.level),
})
}
return list
}),
)
suspected.value = found.flat().sort((a, b) => levelRank(b.level) - levelRank(a.level))
} catch (caught) {
suspectedError.value = caught instanceof Error ? caught.message : '推导疑似破纪录失败'
} finally {
suspectedLoading.value = false
}
}
function bonusFor(level: RecordLevel): number {
const definition = rulePack.value?.recordBonus.levels.find((item) => item.code === level)
return definition?.points ?? 0
}
function requestApprove(item: SuspectedBreak): void {
if (!recordBonusEnabled.value) {
toast.error('本期破纪录加分已关闭', '规则包 recordBonus.enabled = false,确认后不会生成加分流水')
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
approveTarget.value = item
}
async function confirmApprove(): Promise<void> {
const item = approveTarget.value
if (item === null) return
approving.value = true
try {
const created = await adminApi.client.records.createBreak({
eventId: item.eventId,
entryId: item.entryId,
level: item.level,
resultValue: item.resultValue,
resultText: item.resultText,
})
await adminApi.client.records.approveBreak(created.id)
approvedKeys.value = [...approvedKeys.value, item.key]
approveTarget.value = null
toast.success('已确认破纪录', `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`)
await Promise.all([loadRecords(), data.refreshStandings()])
} catch (caught) {
if (isFallback(caught)) {
// 后端未就绪:只做本地标记,绝不假装写成功。
approvedKeys.value = [...approvedKeys.value, item.key]
approveTarget.value = null
toast.warning('已本地登记(示例数据)', '服务端未就绪,加分流水未真正生成')
} else {
toast.error('确认失败', caught instanceof Error ? caught.message : null)
}
} finally {
approving.value = false
}
}
function confirmReject(): void {
const item = rejectTarget.value
if (item === null) return
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现')
}
async function refreshAll(): Promise<void> {
await Promise.all([loadRecords(), loadRules()])
await loadSuspected()
}
onMounted(() => {
void refreshAll()
})
// 项目列表由布局层异步加载,加载完成后才能推导疑似破纪录。
watch(
() => data.loaded,
(loaded) => {
if (loaded) void loadSuspected()
},
)
</script>
<template>
<div class="records-page">
<PageHeader title="破纪录" subtitle="纪录库 + 疑似破纪录确认;本期加分功能默认关闭">
<template #actions>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="recordsLoading || suspectedLoading" @click="refreshAll">
<MxIcon name="refresh" :size="15" /> 刷新
</button>
</template>
</PageHeader>
<div class="mx-card notice-card">
<div class="mx-card-body notice-body">
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
<div>
<div class="notice-title">
{{ recordBonusEnabled ? '破纪录加分已启用' : '本期破纪录加分已关闭' }}
<span class="mx-badge" :class="recordBonusEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
recordBonus.enabled = {{ recordBonusEnabled ? 'true' : 'false' }}
</span>
</div>
<p class="notice-desc">
<template v-if="recordBonusEnabled">
确认破纪录会生成加分流水并更新纪录库;加分档位 {{ bonusMode }},与成绩积分相互独立(不随权重翻倍)。
</template>
<template v-else>
规则包 `recordBonus.enabled = false`(02 第 3 节)。疑似破纪录仍会列出,但「确认加分」不会生成加分流水;
以后开一个开关即可启用,档位与分值已预置。
</template>
</p>
<p v-if="ruleError !== null" class="notice-error">
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
</p>
</div>
</div>
</div>
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">疑似破纪录</span>
<span class="mx-badge mx-badge-neutral">{{ pendingBreaks.length }} 条待确认</span>
</div>
<div class="mx-card-body">
<div v-if="suspectedLoading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在比对已发布成绩…</div>
</div>
<div v-else-if="suspectedError !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ suspectedError }}</span>
</div>
<div v-else-if="pendingBreaks.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
<div class="mx-empty-title">暂无疑似破纪录</div>
<div class="mx-empty-desc">已发布成绩优于当前纪录时会自动出现在这里</div>
</div>
<div v-else class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>小项</th>
<th>组别</th>
<th>单位 / 运动员</th>
<th>成绩</th>
<th>原纪录</th>
<th>档位</th>
<th class="right">加分</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pendingBreaks" :key="item.key">
<td>
<span class="event-name">{{ item.eventName }}</span>
<small class="mono event-code">{{ item.eventCode }}</small>
</td>
<td>{{ item.categoryName }}</td>
<td>
<span class="team-name">{{ item.teamName }}</span>
<small class="athlete">{{ item.athleteName }}</small>
</td>
<td class="mono highlight">{{ item.resultText }}</td>
<td class="mono">{{ item.recordText }}</td>
<td><span class="mx-tag mx-tag-primary">{{ LEVEL_LABELS[item.level] }}</span></td>
<td class="right mono points">+{{ formatPoints(item.bonusPoints) }}</td>
<td class="right actions">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!recordBonusEnabled || !canWrite || approving"
@click="requestApprove(item)"
>
确认加分
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="rejectTarget = item">拒绝</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="!recordBonusEnabled && pendingBreaks.length > 0" class="hint">
「确认加分」在 recordBonus 关闭时不可用,避免生成无规则的积分流水。
</p>
</div>
</section>
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">纪录库</span>
<div class="head-tools">
<div class="mx-tabs">
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
<button
v-for="level in LEVEL_ORDER"
:key="level"
class="mx-tab"
:class="{ active: levelFilter === level }"
type="button"
@click="levelFilter = level"
>
{{ LEVEL_LABELS[level] }}
</button>
</div>
<div class="mx-input-wrap search">
<span class="mx-input-icon"><MxIcon name="search" :size="15" /></span>
<input v-model="search" class="mx-input" placeholder="搜索小项 / 保持者" />
</div>
</div>
</div>
<div class="mx-card-body">
<div v-if="recordsLoading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载纪录库…</div>
</div>
<div v-else-if="recordsError !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ recordsError }}</span>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="loadRecords">重试</button>
</div>
<div v-else-if="recordGroups.length === 0" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="record" :size="26" /></div>
<div class="mx-empty-title">没有匹配的纪录</div>
<div class="mx-empty-desc">按小项 + 组别维护纪录库</div>
</div>
<div v-else class="record-groups">
<div v-for="group in recordGroups" :key="group.eventId" class="record-group">
<div class="record-group-head">
<span class="event-name">{{ group.eventName }}</span>
<small class="mono event-code">{{ group.eventCode }}</small>
<span class="mx-tag">{{ group.categoryLabel }}</span>
</div>
<div class="mx-table-wrap">
<table class="mx-table mx-table-compact">
<thead>
<tr>
<th>档位</th>
<th>成绩</th>
<th>保持者</th>
<th>单位</th>
<th>达成时间</th>
<th>来源</th>
<th class="center">当前</th>
</tr>
</thead>
<tbody>
<tr v-for="record in group.records" :key="record.id">
<td><span class="mx-tag" :class="record.isCurrent ? 'mx-tag-primary' : ''">{{ LEVEL_LABELS[record.level] }}</span></td>
<td class="mono highlight">{{ record.resultText }}</td>
<td>{{ record.holderName ?? '—' }}</td>
<td>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td>
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
<td>{{ record.source === 'current' ? '本届' : '历史' }}</td>
<td class="center">
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
<span v-else class="muted">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<ConfirmDialog
:open="approveTarget !== null"
tone="warning"
title="确认破纪录并加分?"
:message="
approveTarget === null
? ''
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
"
:details="[
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
`加分模式:${bonusMode};破纪录加分与成绩积分相互独立,不随权重翻倍`,
]"
confirm-text="确认并加分"
:pending="approving"
@confirm="confirmApprove"
@cancel="approveTarget = null"
/>
<ConfirmDialog
:open="rejectTarget !== null"
tone="danger"
title="拒绝这条疑似破纪录?"
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。"
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
confirm-text="确认拒绝"
@confirm="confirmReject"
@cancel="rejectTarget = null"
/>
</div>
</template>
<style scoped>
.notice-card {
margin-top: 16px;
}
.notice-body {
display: flex;
gap: 12px;
align-items: flex-start;
}
.notice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--mx-r-sm);
background: rgba(251, 191, 36, 0.12);
color: var(--mx-amber);
flex-shrink: 0;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
margin-bottom: 6px;
flex-wrap: wrap;
}
.notice-desc {
font-size: 13px;
color: var(--mx-t2);
line-height: 1.7;
}
.notice-error {
margin-top: 6px;
font-size: 12px;
color: var(--mx-red);
}
.head-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
width: 220px;
}
.event-name {
font-weight: 600;
}
.event-code {
margin-left: 8px;
font-size: 11px;
color: var(--mx-t3);
}
.team-name {
display: block;
font-weight: 600;
}
.athlete {
font-size: 11px;
color: var(--mx-t3);
}
.highlight {
font-weight: 700;
color: var(--mx-primary);
}
.points {
color: var(--mx-green);
font-weight: 800;
}
.actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
.muted {
color: var(--mx-t3);
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--mx-t3);
}
.record-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.record-group-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--mx-r-sm);
background: rgba(248, 113, 113, 0.08);
color: var(--mx-red);
font-size: 13px;
}
</style>