## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `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 例。
1237 lines
46 KiB
Vue
1237 lines
46 KiB
Vue
<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>
|