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:
1 parent
6841cf49aa
commit
ed2c0b3f3c
35 files changed
+4003
-115
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user