Files
Scoreboard/web/src/admin/pages/RecordsPage.vue
T
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

1237 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatDateTime,
formatPoints,
type Event,
type RecordBreak,
type RecordEntry,
type RecordLevel,
type RulePack,
type RuleSwitches,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi, sampleDataBlockedNotice } from '../api/client'
import {
IGNORE_BREAK_REASON,
RECORD_LEVEL_LABELS,
RECORD_LEVEL_OPTIONS,
RECORD_LEVEL_ORDER,
breakKey,
breakNote,
breakBonusText,
handledBreakKeys,
recordSourceLabel,
} from './recordBreaks'
import { emptyRecordForm, planRecordInput, type RecordFormState } from './recordForm'
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
/** null = 规则包读不到,分值未知,以服务端为准。 */
bonusPoints: number | null
}
interface RecordGroup {
eventId: number
eventName: string
eventCode: string
categoryLabel: string
records: RecordEntry[]
}
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 ruleSwitches = ref<RuleSwitches | 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 rejecting = ref(false)
/**
* 兜底用的本地标记。
*
* 服务端登记成功后靠「已处理登记」抑制(handledKeys),这两个数组只在接口写不进去时
* (示例数据 / 后端旧版)用,并且提示里会如实说明「仅本机有效,刷新后重新出现」。
*/
const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([])
/** 已批准的破纪录登记(GET /api/record-breaks?state=approved)。 */
const approvedBreaks = ref<RecordBreak[]>([])
const breaksLoading = ref(false)
const breaksError = ref<string | null>(null)
/** 接口读不到(示例数据 / 后端旧版)时卡片显示空态说明,绝不让整页报错。 */
const breaksUnavailable = ref(false)
/** 读不到是因为权限(guest):文案与「接口不可用」不同,别把权限说成后端太旧。 */
const breaksForbidden = ref(false)
/** 已处理登记(approved + rejected)的键,用来抑制前端推导出的疑似列表。 */
const handledKeys = ref<Set<string>>(new Set())
const revokeTarget = ref<RecordBreak | null>(null)
const revoking = ref(false)
/** 新增历史纪录表单(P2 C11)。 */
const createOpen = ref(false)
const createForm = ref<RecordFormState>(emptyRecordForm())
const createError = ref('')
const creating = ref(false)
/** 删除纪录确认。 */
const deleteTarget = ref<RecordEntry | null>(null)
const deleting = ref(false)
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const isAdmin = computed(() => auth.user?.role === 'admin')
/**
* 破纪录加分开关:true / false / null(未知)。
*
* 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。
* 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
*/
const recordBonusEnabled = computed<boolean | null>(
() => ruleSwitches.value?.recordBonusEnabled ?? rulePack.value?.recordBonus.enabled ?? null,
)
const bonusMode = computed(() =>
rulePack.value === null ? '—' : 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))
})
/**
* 待确认的疑似破纪录。
*
* 三层过滤:推导出的疑似条目,减去本机兜底标记(接口写不进去时用的),
* 再减去服务端已登记(approved / rejected)的键——最后这层才是「驳回以后刷新不再出现」的关键。
*/
const pendingBreaks = computed(() =>
suspected.value.filter(
(item) =>
!rejectedKeys.value.includes(item.key) &&
!approvedKeys.value.includes(item.key) &&
!handledKeys.value.has(item.key),
),
)
/** 档位序号:排序与筛选下拉共用(登记卡片与纪录库都要按同一顺序排)。 */
function levelRank(level: RecordLevel): number {
const index = RECORD_LEVEL_ORDER.indexOf(level)
return index < 0 ? 0 : index
}
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
function isFallback(error: unknown): boolean {
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
}
function isForbidden(error: unknown): boolean {
return error instanceof ApiError && (error.status === 403 || error.code === 'FORBIDDEN')
}
function teamLabel(teamId: number): string {
const team = data.teamById(teamId)
return team?.shortName ?? team?.name ?? `单位 ${teamId}`
}
/** 破纪录登记只带 eventId,卡片里要显示小项名与代码;查不到时给出可定位的占位。 */
function eventNameOf(eventId: number): string {
return data.eventById(eventId)?.name ?? `项目 ${eventId}`
}
function eventCodeOf(eventId: number): string {
return data.eventById(eventId)?.code ?? ''
}
/** 保持人单位:登记的是单位 id,可能在单位列表里查不到(已停用 / 已删)。 */
function holderTeamLabel(teamId: number | null): string {
return teamId === null ? '—' : teamLabel(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 {
// /api/rules/export 是 admin-only:录入员跳过它,改用下面的只读开关端点,
// 否则页面会常驻一条红色「读取规则包失败:无权访问该资源」。
if (isAdmin.value) {
rulePack.value = await adminApi.client.rules.get()
ruleError.value = null
} else {
rulePack.value = null
ruleError.value = null
}
} catch (caught) {
rulePack.value = null
ruleError.value = isForbidden(caught)
? null
: caught instanceof Error
? caught.message
: '无法读取规则包'
} finally {
rulesLoading.value = false
}
// 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。
try {
ruleSwitches.value = await adminApi.client.rules.switches()
} catch {
ruleSwitches.value = null
}
}
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
}
}
/**
* 读破纪录登记(approved + rejected 两态)。
*
* 两态分开取:approved 是「已批准的破纪录」卡片的数据源,两者合起来才能算出
* 「已经处理过」的键去抑制前端推导出的疑似列表(驳回过的条目刷新后不该再冒出来)。
*
* 读不到时(示例数据 / 后端旧版 404)不让整页报错,只把卡片置为空态说明;
* 这时「忽略」退回本机标记,并在提示里如实说明刷新后会重新出现。
*/
async function loadBreaks(): Promise<void> {
breaksLoading.value = true
const [approved, rejected] = await Promise.allSettled([
adminApi.recordBreaks('approved'),
adminApi.recordBreaks('rejected'),
])
const failures = [approved, rejected].filter((result) => result.status === 'rejected')
const failure = failures[0]
breaksUnavailable.value = failure !== undefined
// GET /api/record-breaks 要求 recorder 及以上:guest 必然 403,那是权限边界而不是后端太旧。
breaksForbidden.value = failures.some((item) => isForbidden(item.reason))
breaksError.value =
failure !== undefined
? failure.reason instanceof Error
? failure.reason.message
: '读取破纪录登记失败'
: null
// 部分成功也照收:能抑制多少算多少,读不到的那一态由上面的空态说明交代。
const loaded: RecordBreak[] = [
...(approved.status === 'fulfilled' ? approved.value : []),
...(rejected.status === 'fulfilled' ? rejected.value : []),
]
approvedBreaks.value = approved.status === 'fulfilled' ? approved.value : []
handledKeys.value = handledBreakKeys(loaded)
breaksLoading.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: breakKey(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 | null {
if (rulePack.value === null) return null
const definition = rulePack.value.recordBonus.levels.find((item) => item.code === level)
return definition?.points ?? null
}
function requestApprove(item: SuspectedBreak): void {
if (recordBonusEnabled.value === false) {
toast.error('本期破纪录加分已关闭', '规则包 recordBonus.enabled = false,确认后不会生成加分流水')
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (recordBonusEnabled.value === null) {
toast.info('加分开关状态未知', '当前角色读不到规则包,将由服务端校验后决定是否加分')
}
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(
'已确认破纪录',
item.bonusPoints === null
? `${RECORD_LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准`
: `+${formatPoints(item.bonusPoints)} 分(${RECORD_LEVEL_LABELS[item.level]})`,
)
await Promise.all([loadRecords(), data.refreshStandings(), loadBreaks()])
} 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
}
}
/** 「忽略」确认框正文:示例数据模式下写不进去,必须如实说明只在本机生效。 */
const rejectMessage = computed(() =>
data.isMock
? '示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。'
: '会在服务端登记为一条破纪录记录并立即驳回(原因写「现场判定不是破纪录」),刷新后不会再出现在待确认列表里。',
)
/**
* 「忽略」疑似破纪录:在服务端登记一条 pending 再驳回,原因是固定的「现场判定不是破纪录」。
*
* 以前只在本地数组里藏起来,刷新就回来——现场看到的「已忽略」和服务端真实状态不一致。
* 现在只有真的写不进去(示例数据 / 未连接服务端)才退回本机标记,并如实说明仅本机有效。
*/
async function confirmReject(): Promise<void> {
const item = rejectTarget.value
if (item === null || rejecting.value) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
rejecting.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.rejectBreak(created.id, IGNORE_BREAK_REASON)
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.success('已拒绝该条疑似破纪录', '已在服务端登记并驳回,刷新后不会再出现')
await loadBreaks()
} catch (caught) {
if (isFallback(caught)) {
// 后端未就绪:只能在本机隐藏,绝不假装写成功;提示里必须说清刷新后会回来。
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.warning('已在本机忽略(未连接服务端)', '没有写入服务端,刷新页面后该条会重新出现')
} else {
toast.error('拒绝失败', caught instanceof Error ? caught.message : null)
}
} finally {
rejecting.value = false
}
}
// ── 已批准的破纪录:作废(撤回加分 + 恢复上一条纪录) ────────────────────────
/** 示例数据模式统一拦截写操作;返回 true 表示已拦下。 */
function blockSampleData(action: string): boolean {
if (!data.isMock) return false
const notice = sampleDataBlockedNotice(action)
toast.error(notice.title, notice.detail)
return true
}
/** 点「作废」:先过权限与示例数据,再弹必填原因的确认框。 */
function requestRevoke(item: RecordBreak): void {
if (blockSampleData('作废破纪录')) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
revokeTarget.value = item
}
async function confirmRevoke(reason: string): Promise<void> {
const item = revokeTarget.value
if (item === null || revoking.value) return
revoking.value = true
try {
await adminApi.rejectBreak(item.id, reason)
revokeTarget.value = null
// 撤回加分与「恢复上一条纪录」都在服务端同一事务里做完:纪录库与榜单必须重拉。
await Promise.all([loadRecords(), data.refreshStandings()])
await Promise.all([loadBreaks(), loadSuspected()])
toast.success(
'加分已撤回,该级别纪录已恢复上一条',
`已作废 ${RECORD_LEVEL_LABELS[item.level]}破纪录登记(${breakBonusText(item)})`,
)
} catch (caught) {
toast.error('作废破纪录失败', caught instanceof Error ? caught.message : null)
} finally {
revoking.value = false
}
}
function cancelRevoke(): void {
if (revoking.value) return
revokeTarget.value = null
}
// ── 纪录库:新增历史纪录 / 删除 ──────────────────────────────────────────────
/** 打开新增表单:每次都用空表单,别把上一次的输入带进来。 */
function openCreate(): void {
if (blockSampleData('新增历史纪录')) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
createForm.value = emptyRecordForm(data.events[0]?.id ?? null)
createError.value = ''
createOpen.value = true
}
function closeCreate(): void {
if (creating.value) return
createOpen.value = false
}
/** 保持下拉受控:小项 / 单位允许留空(null),空值不能变成字符串 "null"。 */
function formEventId(event: globalThis.Event): number | null {
const raw = (event.target as HTMLSelectElement).value
return raw === '' ? null : Number(raw)
}
function formTeamId(event: globalThis.Event): number | null {
const raw = (event.target as HTMLSelectElement).value
return raw === '' ? null : Number(raw)
}
async function submitCreate(): Promise<void> {
if (creating.value) return
const plan = planRecordInput(createForm.value)
if (!plan.ok) {
createError.value = plan.error
return
}
createError.value = ''
creating.value = true
try {
await adminApi.createRecord(plan.input)
createOpen.value = false
// 新纪录可能比现有纪录更优 → 疑似破纪录的推导会变,两件事一起重拉。
await loadRecords()
await loadSuspected()
toast.success('已新增历史纪录', '来源记为「历史」')
} catch (caught) {
toast.error('新增历史纪录失败', caught instanceof Error ? caught.message : null)
} finally {
creating.value = false
}
}
/** 点「删除」:先过权限与示例数据,再弹确认(说清「当前纪录会自动恢复上一条」)。 */
function requestDelete(record: RecordEntry): void {
if (blockSampleData('删除纪录')) return
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
deleteTarget.value = record
}
async function confirmDelete(): Promise<void> {
const record = deleteTarget.value
if (record === null || deleting.value) return
deleting.value = true
try {
await adminApi.removeRecord(record.id)
deleteTarget.value = null
await loadRecords()
await loadSuspected()
toast.success('已删除该条纪录', '若它是当前纪录,服务端已恢复上一条')
} catch (caught) {
toast.error('删除纪录失败', caught instanceof Error ? caught.message : null)
} finally {
deleting.value = false
}
}
function cancelDelete(): void {
if (deleting.value) return
deleteTarget.value = null
}
async function refreshAll(): Promise<void> {
await Promise.all([loadRecords(), loadRules(), loadBreaks()])
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 === null
? '破纪录加分开关未知'
: recordBonusEnabled
? '破纪录加分已启用'
: '本期破纪录加分已关闭'
}}
<span
class="mx-badge"
:class="
recordBonusEnabled === true
? 'mx-badge-success'
: recordBonusEnabled === false
? 'mx-badge-warning'
: 'mx-badge-neutral'
"
>
recordBonus.enabled = {{ recordBonusEnabled === null ? '未知' : recordBonusEnabled ? 'true' : 'false' }}
</span>
</div>
<p class="notice-desc">
<template v-if="recordBonusEnabled === true">
确认破纪录会生成加分流水并更新纪录库;加分档位 {{ bonusMode }},与成绩积分相互独立(不随权重翻倍)。
</template>
<template v-else-if="recordBonusEnabled === false">
规则包 `recordBonus.enabled = false`(02 第 3 节)。疑似破纪录仍会列出,但「确认加分」不会生成加分流水;
以后开一个开关即可启用,档位与分值已预置。
</template>
<template v-else>
当前角色读不到规则包(/api/rules/export 仅管理员可读),无法判断开关状态,不再按「关闭」显示;
疑似破纪录仍会列出,「确认加分」提交后由服务端校验决定是否生成加分流水。
</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">{{ RECORD_LEVEL_LABELS[item.level] }}</span></td>
<td class="right mono points">
{{ item.bonusPoints === null ? '—' : `+${formatPoints(item.bonusPoints)}` }}
</td>
<td class="right actions">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="recordBonusEnabled === false || !canWrite || approving"
@click="requestApprove(item)"
>
确认加分
</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite"
@click="rejectTarget = item"
>
拒绝
</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="recordBonusEnabled === false && pendingBreaks.length > 0" class="hint">
「确认加分」在 recordBonus 关闭时不可用,避免生成无规则的积分流水。
</p>
<p v-else-if="recordBonusEnabled === null && pendingBreaks.length > 0" class="hint">
当前角色读不到规则包,加分开关状态未知;「确认加分」可提交,由服务端校验决定。
</p>
</div>
</section>
<!--
已批准的破纪录(A41):加分一旦批出去就要能撤回。
数据来自服务端登记表,不是前端推导——所以作废后刷新页面也仍然生效。
-->
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">已批准的破纪录</span>
<span class="mx-badge mx-badge-neutral">{{ approvedBreaks.length }} 条</span>
</div>
<div class="mx-card-body">
<div v-if="breaksLoading" class="mx-empty">
<div class="mx-spinner mx-spinner-sm"></div>
<div class="mx-empty-title">正在加载已批准的破纪录…</div>
</div>
<!-- 接口读不到时只让这张卡片显示空态说明,页面其余部分照常可用。 -->
<div v-else-if="breaksUnavailable" class="mx-empty">
<div class="mx-empty-icon"><MxIcon name="warning" :size="26" /></div>
<div class="mx-empty-title">{{ breaksForbidden ? '访客只读:看不到破纪录登记' : '读不到破纪录登记' }}</div>
<div v-if="breaksForbidden" class="mx-empty-desc">
破纪录登记(含待审核的登记与驳回理由)要求录入员及以上权限;登录后即可查看与作废。
</div>
<div v-else class="mx-empty-desc">
{{ breaksError ?? '接口不可用' }};示例数据模式或后端版本较旧时没有这个接口,
已批准的破纪录无法在这里作废,请在服务端升级后再试。
</div>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="loadBreaks">重试</button>
</div>
<div v-else-if="approvedBreaks.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 class="right">加分</th>
<th>批准时间</th>
<th>说明</th>
<th class="right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in approvedBreaks" :key="item.id">
<td>
<span class="event-name">{{ eventNameOf(item.eventId) }}</span>
<small class="mono event-code">{{ eventCodeOf(item.eventId) }}</small>
</td>
<td><span class="mx-tag mx-tag-primary">{{ RECORD_LEVEL_LABELS[item.level] }}</span></td>
<td class="mono highlight">{{ item.resultText }}</td>
<td class="right mono points">{{ breakBonusText(item) }}</td>
<td class="mono">{{ formatDateTime(item.approvedAt) }}</td>
<!-- note 是新增字段,旧服务端不下发:缺字段时整格留白,不显示 undefined。 -->
<td class="note">{{ breakNote(item) ?? '—' }}</td>
<td class="right actions">
<button
class="mx-btn mx-btn-danger mx-btn-sm"
type="button"
:disabled="!canWrite || revoking"
title="作废后撤回该笔加分,并恢复该级别的上一条纪录"
@click="requestRevoke(item)"
>
作废
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="mx-card mx-section">
<div class="mx-card-head">
<span class="mx-card-title">纪录库</span>
<div class="head-tools">
<button
class="mx-btn mx-btn-primary mx-btn-sm"
type="button"
:disabled="!canWrite"
:title="canWrite ? '登记一条历史纪录(来源记为「历史」)' : '访客只读,无法新增'"
@click="openCreate"
>
<MxIcon name="entry" :size="14" /> 新增历史纪录
</button>
<div class="mx-tabs">
<button class="mx-tab" :class="{ active: levelFilter === 'all' }" type="button" @click="levelFilter = 'all'">全部</button>
<button
v-for="level in RECORD_LEVEL_ORDER"
:key="level"
class="mx-tab"
:class="{ active: levelFilter === level }"
type="button"
@click="levelFilter = level"
>
{{ RECORD_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>
<th class="right">操作</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' : ''">{{ RECORD_LEVEL_LABELS[record.level] }}</span></td>
<td class="mono highlight">{{ record.resultText }}</td>
<td>{{ record.holderName ?? '—' }}</td>
<td>{{ holderTeamLabel(record.holderTeamId) }}</td>
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
<!-- 来源:历史 = 老资料登记进来的,本届 = 本场赛事产生的;缺字段显示 —,不冒充「历史」。 -->
<td>
<span class="mx-badge" :class="record.source === 'history' ? 'mx-badge-neutral' : 'mx-badge-info'">
{{ recordSourceLabel(record.source) }}
</span>
</td>
<td class="center">
<span v-if="record.isCurrent" class="mx-badge mx-badge-success">当前</span>
<span v-else class="muted">—</span>
</td>
<td class="right">
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite || deleting"
title="删除该条纪录"
@click="requestDelete(record)"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<ConfirmDialog
:open="approveTarget !== null"
tone="warning"
title="确认破纪录并加分?"
:message="
approveTarget === null
? ''
: approveTarget.bonusPoints === null
? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${RECORD_LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。`
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${RECORD_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="rejectMessage"
:details="rejectTarget === null ? [] : [`成绩:${rejectTarget.resultText}`, `小项:${rejectTarget.eventName}`]"
confirm-text="确认拒绝"
:pending="rejecting"
@confirm="confirmReject"
@cancel="rejectTarget = null"
/>
<!--
作废已批准的破纪录:批准时加过分,作废要把那笔加分撤掉并恢复上一条纪录,
所以原因必填(写审计),后果写在弹窗里。
-->
<ConfirmDialog
:open="revokeTarget !== null"
tone="danger"
title="确认作废这条破纪录登记?"
message="作废后将撤回该笔加分,并恢复该级别的上一条纪录;大屏与此处纪录库会立即更新。"
:details="
revokeTarget === null
? []
: [
`小项:${eventNameOf(revokeTarget.eventId)}`,
`成绩:${revokeTarget.resultText}(${RECORD_LEVEL_LABELS[revokeTarget.level]})`,
`撤回加分:${breakBonusText(revokeTarget)}`,
]
"
confirm-text="确认作废"
:reason-required="true"
reason-label="作废原因"
:pending="revoking"
@confirm="confirmRevoke"
@cancel="cancelRevoke"
/>
<ConfirmDialog
:open="deleteTarget !== null"
tone="danger"
title="确认删除这条纪录?"
:message="
deleteTarget === null
? ''
: `将删除「${eventNameOf(deleteTarget.eventId)}」的${RECORD_LEVEL_LABELS[deleteTarget.level]}纪录 ${deleteTarget.resultText};若它是当前纪录,会恢复上一条。`
"
:details="['删除只影响纪录库,不会改动任何已发布的成绩与积分']"
confirm-text="确认删除"
:pending="deleting"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
<!-- 新增历史纪录表单:成绩值 / 成绩文本至少一个,其余可留空(老资料常常只有一部分)。 -->
<div v-if="createOpen" class="mx-modal-overlay open" @click.self="closeCreate">
<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" :disabled="creating" @click="closeCreate">
<MxIcon name="close" :size="16" />
</button>
</div>
<div class="mx-modal-body create-body">
<p class="create-hint">
登记的是<strong>历史资料</strong>(秩序册 / 上届成绩),来源固定记为「历史」,与本届成绩无关。
</p>
<label class="create-field">
<span>小项</span>
<select class="mx-input" :value="createForm.eventId ?? ''" @change="createForm.eventId = formEventId($event)">
<option value="">请选择小项</option>
<option v-for="event in data.events" :key="event.id" :value="event.id">
{{ event.name }}
</option>
</select>
</label>
<label class="create-field">
<span>级别</span>
<select v-model="createForm.level" class="mx-input">
<option v-for="option in RECORD_LEVEL_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<div class="create-row">
<label class="create-field">
<span>成绩值</span>
<input v-model="createForm.resultValue" class="mx-input mono" placeholder="例 11.2(秒 / 米 / 次)" />
</label>
<label class="create-field">
<span>成绩文本</span>
<input v-model="createForm.resultText" class="mx-input mono" placeholder="例 11.20 或 1:06.33" />
</label>
</div>
<p class="create-hint">成绩值与成绩文本至少填一个;比较与破纪录判断以成绩值为准。</p>
<div class="create-row">
<label class="create-field">
<span>保持人姓名</span>
<input v-model="createForm.holderName" class="mx-input" placeholder="可留空" />
</label>
<label class="create-field">
<span>保持单位</span>
<select class="mx-input" :value="createForm.holderTeamId ?? ''" @change="createForm.holderTeamId = formTeamId($event)">
<option value="">不指定</option>
<option v-for="team in data.teams" :key="team.id" :value="team.id">
{{ team.shortName ?? team.name }}
</option>
</select>
</label>
</div>
<label class="create-field">
<span>创造日期</span>
<input v-model="createForm.achievedAt" class="mx-input mono" type="date" />
</label>
<p v-if="createError !== ''" class="create-error">{{ createError }}</p>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="creating" @click="closeCreate">取消</button>
<button class="mx-btn mx-btn-primary" type="button" :disabled="creating" @click="submitCreate">
{{ creating ? '保存中…' : '保存纪录' }}
</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;
}
.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;
}
.create-body {
display: flex;
flex-direction: column;
gap: 10px;
}
.create-row {
display: flex;
gap: 10px;
}
.create-row .create-field {
flex: 1;
}
.create-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--mx-t2);
}
.create-hint {
font-size: 12px;
color: var(--mx-t3);
line-height: 1.6;
}
.create-error {
font-size: 12px;
color: var(--mx-red);
}
.note {
max-width: 220px;
font-size: 12px;
color: var(--mx-t3);
}
.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>