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 例。
This commit is contained in:
AskaEth committed 2026-09-12 01:27:09 +08:00
1 parent 6841cf49aa
commit ed2c0b3f3c
35 files changed
+4003 -115

No files matched your search

+544 -32
View File
@@ -5,6 +5,7 @@ import {
formatDateTime,
formatPoints,
type Event,
type RecordBreak,
type RecordEntry,
type RecordLevel,
type RulePack,
@@ -13,7 +14,19 @@ import {
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
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'
@@ -44,15 +57,6 @@ interface RecordGroup {
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()
@@ -73,10 +77,39 @@ 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')
/**
@@ -129,14 +162,24 @@ const recordGroups = computed<RecordGroup[]>(() => {
.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),
(item) =>
!rejectedKeys.value.includes(item.key) &&
!approvedKeys.value.includes(item.key) &&
!handledKeys.value.has(item.key),
),
)
/** 档位序号:排序与筛选下拉共用(登记卡片与纪录库都要按同一顺序排)。 */
function levelRank(level: RecordLevel): number {
const index = LEVEL_ORDER.indexOf(level)
const index = RECORD_LEVEL_ORDER.indexOf(level)
return index < 0 ? 0 : index
}
@@ -154,6 +197,20 @@ function teamLabel(teamId: number): string {
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
@@ -225,6 +282,42 @@ async function loadRecords(): Promise<void> {
}
}
/**
* 读破纪录登记(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
@@ -250,7 +343,7 @@ async function loadSuspected(): Promise<void> {
}
if (best === null) continue
list.push({
key: `${event.id}-${entry.id}-${best.level}`,
key: breakKey(event.id, entry.id, best.level),
eventId: event.id,
eventName: event.name,
eventCode: event.code,
@@ -315,10 +408,10 @@ async function confirmApprove(): Promise<void> {
toast.success(
'已确认破纪录',
item.bonusPoints === null
? `${LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准`
: `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`,
? `${RECORD_LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准`
: `+${formatPoints(item.bonusPoints)} 分(${RECORD_LEVEL_LABELS[item.level]})`,
)
await Promise.all([loadRecords(), data.refreshStandings()])
await Promise.all([loadRecords(), data.refreshStandings(), loadBreaks()])
} catch (caught) {
if (isFallback(caught)) {
// 后端未就绪:只做本地标记,绝不假装写成功。
@@ -333,16 +426,187 @@ async function confirmApprove(): Promise<void> {
}
}
function confirmReject(): void {
/** 「忽略」确认框正文:示例数据模式下写不进去,必须如实说明只在本机生效。 */
const rejectMessage = computed(() =>
data.isMock
? '示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。'
: '会在服务端登记为一条破纪录记录并立即驳回(原因写「现场判定不是破纪录」),刷新后不会再出现在待确认列表里。',
)
/**
* 「忽略」疑似破纪录:在服务端登记一条 pending 再驳回,原因是固定的「现场判定不是破纪录」。
*
* 以前只在本地数组里藏起来,刷新就回来——现场看到的「已忽略」和服务端真实状态不一致。
* 现在只有真的写不进去(示例数据 / 未连接服务端)才退回本机标记,并如实说明仅本机有效。
*/
async function confirmReject(): Promise<void> {
const item = rejectTarget.value
if (item === null) return
rejectedKeys.value = [...rejectedKeys.value, item.key]
rejectTarget.value = null
toast.info('已拒绝该条', '接口未提供拒绝动作,仅在本地隐藏,刷新后会重新出现')
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()])
await Promise.all([loadRecords(), loadRules(), loadBreaks()])
await loadSuspected()
}
@@ -460,7 +724,7 @@ watch(
</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><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>
@@ -495,21 +759,105 @@ watch(
</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 LEVEL_ORDER"
v-for="level in RECORD_LEVEL_ORDER"
:key="level"
class="mx-tab"
:class="{ active: levelFilter === level }"
type="button"
@click="levelFilter = level"
>
{{ LEVEL_LABELS[level] }}
{{ RECORD_LEVEL_LABELS[level] }}
</button>
</div>
<div class="mx-input-wrap search">
@@ -551,20 +899,37 @@ watch(
<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' : ''">{{ LEVEL_LABELS[record.level] }}</span></td>
<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>{{ record.holderTeamId === null ? '—' : teamLabel(record.holderTeamId) }}</td>
<td>{{ holderTeamLabel(record.holderTeamId) }}</td>
<td class="mono">{{ formatDateTime(record.achievedAt) }}</td>
<td>{{ record.source === 'current' ? '本届' : '历史' }}</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>
@@ -582,8 +947,8 @@ watch(
approveTarget === null
? ''
: approveTarget.bonusPoints === null
? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。`
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
? `将登记「${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 ?? ''})`,
@@ -599,12 +964,126 @@ watch(
:open="rejectTarget !== null"
tone="danger"
title="拒绝这条疑似破纪录?"
message="拒绝只在本地隐藏该条,不会写入服务端;接口清单里没有拒绝动作。"
: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>
@@ -704,6 +1183,39 @@ watch(
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;