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

+47
View File
@@ -17,7 +17,11 @@ import type {
PageQuery,
PointAdjustment,
PublishPayload,
RecordBreak,
RecordBreakState,
RecordEntry,
RecordEntryInput,
ReopenResultPayload,
Result,
SaveDraftPayload,
ScoringProfile,
@@ -27,6 +31,7 @@ import type {
TeamBreakdown,
TeamRanksInput,
TeamRanksView,
VoidResultPayload,
} from '@/shared'
import { mockDb } from './mock'
@@ -219,6 +224,18 @@ export const adminApi = {
unpublish: (payload: PublishPayload): Promise<Result[]> =>
callWrite(() => client.results.unpublish(payload)),
/**
* 作废一条已发布的成绩(外科手术式,影响面远小于整组撤销发布)。
*
* 写操作不降级:示例数据模式下没有后端可落库,页面会先用 data.isMock 拦下。
*/
voidResult: (id: number, payload: VoidResultPayload): Promise<Result> =>
callWrite(() => client.results.void(id, payload)),
/** 把一条已作废的成绩退回草稿,改完再发布本组;同样是不降级的写操作。 */
reopenResult: (id: number, payload: ReopenResultPayload): Promise<Result> =>
callWrite(() => client.results.reopen(id, payload)),
standings: (): Promise<StandingRow[]> =>
call(() => client.standings.teams(), () => mockDb.standings()),
@@ -244,6 +261,36 @@ export const adminApi = {
records: (): Promise<RecordEntry[]> =>
call(() => client.records.list(), () => mockDb.records()),
/**
* 破纪录登记列表(GET /api/record-breaks?state=...)。
*
* 刻意**不走示例数据降级**:示例数据里根本没有登记表,回一个空数组会让页面把
* 「读不到」显示成「没有已批准的破纪录」。读不到时原样抛出,页面据此显示空态说明。
*/
recordBreaks: (state: RecordBreakState): Promise<RecordBreak[]> =>
client.records.listBreaks(state),
/** 驳回 / 作废一条登记;原本 approved 的会连加分一起撤掉(服务端行为)。 */
rejectBreak: (id: number, reason: string): Promise<RecordBreak> =>
callWrite(() => client.records.rejectBreak(id, reason)),
/** 登记一条历史纪录;服务端固定 source = history。 */
createRecord: (input: RecordEntryInput): Promise<RecordEntry> =>
callWrite(() => client.records.createRecord(input)),
/** 删除一条纪录;删的若是当前纪录,服务端恢复上一条。 */
removeRecord: (id: number): Promise<void> => callWrite(() => client.records.removeRecord(id)),
/**
* 「草稿待发布数」:分页只取 1 条,要的是 Page.total 这个总数。
*
* 同样不降级:读不到时页面显示 `—`,不用 0 冒充「一条草稿都没有」。
*/
draftResultCount: async (): Promise<number> => {
const page = await client.results.list({ state: 'draft', page: 1, size: 1 })
return page.total
},
adjustments: (query: PageQuery = {}): Promise<Page<PointAdjustment>> =>
call(() => client.adjustments.list(query), () => mockDb.adjustments()),
+70 -3
View File
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import MxIcon from './MxIcon.vue'
/**
* 危险操作二次确认(07 第 7 节)。
* 只负责「确认」这一件事,loading 态由调用方通过 `pending` 传入,避免弹窗自己管请求。
*
* `reasonRequired` 打开时,弹窗里多出一个必填原因输入框(作废成绩 / 退回草稿 / 作废破纪录登记),
* 原因随 confirm 事件一起 emit 给调用方,由调用方写进请求体(服务端再写审计)。
* 原因留空时**不 emit confirm**:这类操作都改榜单,宁可多拦一次,也不能带着空原因落库。
*/
const props = withDefaults(
defineProps<{
@@ -16,6 +20,10 @@ const props = withDefaults(
cancelText?: string
tone?: 'info' | 'warning' | 'danger'
pending?: boolean
/** true 时要求填写原因,并在 confirm 事件里带出。 */
reasonRequired?: boolean
reasonLabel?: string
reasonPlaceholder?: string
}>(),
{
details: () => [],
@@ -23,10 +31,29 @@ const props = withDefaults(
cancelText: '取消',
tone: 'warning',
pending: false,
reasonRequired: false,
reasonLabel: '原因',
reasonPlaceholder: '必填,会写入审计日志',
},
)
const emit = defineEmits<{ confirm: []; cancel: [] }>()
const emit = defineEmits<{ confirm: [reason: string]; cancel: [] }>()
/** 原因输入内容;每次弹窗打开都清空,避免把上一次的原因带进这次操作。 */
const reason = ref('')
/** 只有点过确认键(或按键提交)之后才亮红字,避免刚打开就一片报错。 */
const reasonTouched = ref(false)
const reasonMissing = computed(() => props.reasonRequired && reason.value.trim() === '')
watch(
() => props.open,
(open) => {
if (!open) return
reason.value = ''
reasonTouched.value = false
},
)
/**
* 关闭弹窗的唯一出口:ESC、遮罩、右上角关闭键都走这里。
@@ -40,6 +67,16 @@ function requestCancel(): void {
emit('cancel')
}
/** 确认键:先过原因必填,再 emit。 */
function requestConfirm(): void {
if (props.pending) return
if (reasonMissing.value) {
reasonTouched.value = true
return
}
emit('confirm', reason.value.trim())
}
function onKeydown(event: KeyboardEvent): void {
if (!props.open) return
if (event.key === 'Escape') requestCancel()
@@ -66,6 +103,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
<ul v-if="details.length > 0" class="confirm-details">
<li v-for="(line, index) in details" :key="index">{{ line }}</li>
</ul>
<!--
原因输入:pending 时用 readonly 而不是 disabled——请求已经在服务端跑了,
这时候改文字不会被提交(弹窗本身也锁死了,关不掉),但不该把已填的内容抹成灰。
-->
<label v-if="reasonRequired" class="confirm-reason">
<span class="confirm-reason-label">{{ reasonLabel }}</span>
<textarea
v-model="reason"
class="mx-textarea"
rows="3"
:readonly="pending"
:placeholder="reasonPlaceholder"
></textarea>
<span v-if="reasonTouched && reasonMissing" class="confirm-reason-error">原因必填</span>
</label>
</div>
<div class="mx-modal-foot">
<button class="mx-btn mx-btn-secondary" type="button" :disabled="pending" @click="requestCancel">
@@ -76,7 +128,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
:class="tone === 'danger' ? 'mx-btn-danger' : 'mx-btn-primary'"
type="button"
:disabled="pending"
@click="emit('confirm')"
@click="requestConfirm"
>
{{ pending ? '处理中…' : confirmText }}
</button>
@@ -105,4 +157,19 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
content: '·';
color: var(--mx-primary);
}
.confirm-reason {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.confirm-reason-label {
font-size: 12px;
font-weight: 600;
color: var(--mx-t2);
}
.confirm-reason-error {
font-size: 12px;
color: var(--mx-red);
}
</style>
+145 -5
View File
@@ -2,9 +2,18 @@
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { formatPoints, type RecordEntry } from '@/shared'
import type { DisplayInstance } from '@/shared/api'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import { adminApi } from '../api/client'
import {
adminLinkLabel,
countText,
displayCountText,
displayCounts,
scheduleProgress,
todayEvents,
} from './dashboard'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
@@ -17,6 +26,38 @@ const pendingEvents = computed(() => data.events.filter((event) => event.status
const topTeams = computed(() => data.standings.slice(0, 3))
const leader = computed(() => data.standings[0] ?? null)
/** 今天的小项与进度(devdocs/07 §7「当前赛程」卡片)。 */
const today = computed(() => todayEvents(data.events))
const todayProgress = computed(() => scheduleProgress(today.value))
const todayIds = computed(() => new Set(today.value.map((event) => event.id)))
/**
* 进度说明。
*
* 项目列表还没加载完时不能显示「今天没有排期」——那会把「还没读到」说成一条结论;
* 加载完成且确实没有排期时才是真的空。
*/
const todayHint = computed(() => {
if (!data.loaded) return '数据加载中…'
return todayProgress.value.text ?? '今天没有排期'
})
/**
* 待办:草稿待发布数、疑似破纪录待确认数。
*
* 两个数都来自各自的接口,读不到时为 null → 显示 `—`:卡片不能把「读不到」说成「0 条」。
*/
const draftCount = ref<number | null>(null)
const pendingBreakCount = ref<number | null>(null)
/**
* 大屏实例:`null` 表示数据源不可用(示例数据 / 接口失败)。
*
* adminApi.displayInstances 在连不上服务端时会退化成空数组,单看它分不清
* 「没有大屏」与「读不到」;示例数据模式(isMock)一律按读不到处理,显示 `—`。
*/
const displayInstances = ref<DisplayInstance[] | null>(null)
const displayTotal = computed(() => displayCountText(displayCounts(displayInstances.value)))
const connectionText = computed(() => {
if (data.isMock) return '示例数据'
if (data.realtimeStatus === 'online') return '实时在线'
@@ -24,12 +65,43 @@ const connectionText = computed(() => {
return '连接中断'
})
onMounted(async () => {
/** 管理端连接文案:没有「管理端在线数」接口,如实显示本机连接状态。 */
const adminLink = computed(() => adminLinkLabel(data.isMock, data.realtimeStatus))
/** 待确认数读不到时的说明:访客是权限问题,其余按接口不可用说。 */
const pendingBreakHint = computed(() => {
if (pendingBreakCount.value === null) return auth.readOnly ? '访客只读' : '接口不可用'
return pendingBreakCount.value > 0 ? '去「破纪录」页确认' : '没有待确认'
})
async function loadCards(): Promise<void> {
try {
records.value = await adminApi.records()
} catch {
records.value = []
}
// 每个数据源各自兜底:任何一个接口挂了都只让自己的卡片显示 —,不让整页崩。
try {
draftCount.value = await adminApi.draftResultCount()
} catch {
draftCount.value = null
}
try {
// GET /api/record-breaks 要求 recorder 及以上:访客必然 403,不必发这一枪。
pendingBreakCount.value = auth.readOnly ? null : (await adminApi.recordBreaks('pending')).length
} catch {
pendingBreakCount.value = null
}
try {
// GET /api/display/instances 要求 recorder 及以上:访客不必发这一枪(403 没有更多信息)。
displayInstances.value = data.isMock || auth.readOnly ? null : await adminApi.displayInstances()
} catch {
displayInstances.value = null
}
}
onMounted(() => {
void loadCards()
})
</script>
@@ -70,11 +142,47 @@ onMounted(async () => {
</div>
</section>
<!--
07 §7 承诺的另外三张卡片:今天的小项与进度、待办的两类数量、大屏在线数。
任何一个数据源读不到,只让那一张显示 —,不影响其它卡片。
-->
<section class="mx-stat-grid mx-section">
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="display" :size="14" /></span>今天的小项</div>
<div class="mx-stat-value">{{ !data.loaded || todayProgress.text === null ? '—' : todayProgress.total }}</div>
<span class="mx-stat-delta" :class="todayProgress.text === null ? '' : 'up'">
{{ todayHint }}
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="record" :size="14" /></span>草稿待发布</div>
<div class="mx-stat-value">{{ countText(draftCount) }}</div>
<span class="mx-stat-delta" :class="draftCount !== null && draftCount > 0 ? 'down' : 'up'">
{{ draftCount === null ? '接口不可用' : draftCount > 0 ? '已保存未发布' : '没有待发布草稿' }}
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="record" :size="14" /></span>待确认破纪录</div>
<div class="mx-stat-value">{{ countText(pendingBreakCount) }}</div>
<span class="mx-stat-delta" :class="pendingBreakCount !== null && pendingBreakCount > 0 ? 'down' : 'up'">
{{ pendingBreakHint }}
</span>
</div>
<div class="mx-stat">
<div class="mx-stat-label"><span class="mx-stat-icon"><MxIcon name="display" :size="14" /></span>大屏在线数</div>
<div class="mx-stat-value">{{ displayTotal }}</div>
<!-- 没有「管理端在线数」这种接口:这里显示本机管理端的连接状态,不编造数字。 -->
<span class="mx-stat-delta up">管理端:{{ adminLink }}</span>
</div>
</section>
<div class="dash-grid">
<section class="mx-card">
<div class="mx-card-head">
<span class="mx-card-title">当前赛程</span>
<span class="mx-badge mx-badge-neutral">{{ data.events.length }} 个小项</span>
<span class="mx-badge mx-badge-neutral">
{{ data.events.length }} 个小项 · 今天 {{ todayProgress.total }} 个
</span>
</div>
<div class="mx-card-body no-pad">
<div class="mx-table-wrap">
@@ -89,7 +197,10 @@ onMounted(async () => {
</thead>
<tbody>
<tr v-for="event in data.events.slice(0, 8)" :key="event.id">
<td>{{ event.name }}</td>
<td>
{{ event.name }}
<span v-if="todayIds.has(event.id)" class="mx-badge mx-badge-info today-tag">今天</span>
</td>
<td class="mono">{{ event.code }}</td>
<td>
<span
@@ -137,9 +248,24 @@ onMounted(async () => {
<li v-if="topTeams.length === 0" class="top-empty">暂无已发布成绩</li>
</ol>
<div v-if="pendingEvents.length > 0" class="todo">
<div v-if="pendingEvents.length > 0 || draftCount !== null || pendingBreakCount !== null" class="todo">
<div class="todo-title"><MxIcon name="warning" :size="14" /> 待办</div>
<p>
<ul class="todo-list">
<li>
草稿待发布:
<strong>{{ countText(draftCount) }}</strong>
{{ draftCount === null ? '(读不到,接口不可用)' : '条成绩草稿还没发布' }}
</li>
<li>
疑似破纪录待确认:
<strong>{{ countText(pendingBreakCount) }}</strong>
{{ pendingBreakCount === null ? '(读不到,接口不可用)' : '条' }}
<RouterLink v-if="pendingBreakCount !== null && pendingBreakCount > 0" class="mx-btn mx-btn-ghost mx-btn-sm" :to="{ name: 'records' }">
去确认
</RouterLink>
</li>
</ul>
<p v-if="pendingEvents.length > 0">
还有 {{ pendingEvents.length }} 个项目未录入:
{{ pendingEvents.map((event) => event.name).join('、') }}
</p>
@@ -237,6 +363,20 @@ onMounted(async () => {
margin-bottom: 10px;
line-height: 1.6;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
line-height: 1.6;
}
.todo-list strong {
color: var(--mx-t1);
}
.today-tag {
margin-left: 6px;
font-size: 10px;
}
.foot-hint {
margin-top: 20px;
font-size: 12px;
+174 -4
View File
@@ -48,6 +48,15 @@ import {
type EntryBatchPlan,
type EntryBatchRow,
} from './entryBatch'
import {
RESULT_ACTION_CONFIRM,
RESULT_ACTION_SUCCESS,
RESULT_ROW_ACTION_LABEL,
RESULT_ROW_ACTION_TITLE,
RESULT_ROW_ACTION_WORD,
resultActionTarget,
type ResultActionTarget,
} from './entryResultActions'
import {
DEFAULT_HEAT_SIZE,
MAX_HEAT,
@@ -60,7 +69,7 @@ import {
type HeatAllocation,
type HeatFilter,
} from './entryHeat'
import { adminApi } from '../api/client'
import { adminApi, sampleDataBlockedNotice } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
import { settlementApi, type SettlementGroup, type SettlementStatus } from '../components/system/systemApi'
@@ -83,6 +92,8 @@ interface EditableRow {
heat: number | null
/** 报名项状态:与成绩状态(status)无关,退赛 / 取消资格看这一列。 */
entryStatus: EntryStatus
/** 成绩 id(服务端按它定位作废 / 退回;草稿行也有,未报名时才是 null)。 */
resultId: number | null
resultText: string
rank: number | null
status: ResultStatus
@@ -453,6 +464,34 @@ function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published'
}
/**
* 每行的行操作(作废本条 / 重新录入):判定在 entryResultActions 的纯函数里。
*
* 与 judgements 同口径——按当前可见行算,筛选后看不见的行不会残留按钮。
*/
const rowActions = computed<Map<number, ResultActionTarget>>(() => {
const map = new Map<number, ResultActionTarget>()
for (const row of visibleRows.value) {
const target = resultActionTarget(row)
if (target !== null) map.set(row.entryId, target)
}
return map
})
function rowAction(row: EditableRow): ResultActionTarget | null {
return rowActions.value.get(row.entryId) ?? null
}
function rowActionLabel(row: EditableRow): string {
const target = rowAction(row)
return target === null ? '' : RESULT_ROW_ACTION_LABEL[target.action]
}
function rowActionTitle(row: EditableRow): string {
const target = rowAction(row)
return target === null ? '' : RESULT_ROW_ACTION_TITLE[target.action]
}
/**
* 每一行的发布判定:✅已填 / ⚠️未录 / 🚫有错。
*
@@ -505,7 +544,12 @@ const focusedHint = computed<ResultCellHint | null>(() => {
/** 失焦后由既有的红框 + title 承载原因;合法成绩不写 title,避免无意义悬浮。 */
function resultTitle(row: EditableRow): string {
if (rowLocked(row)) return '已发布,请先撤销发布'
if (rowLocked(row)) {
// 已作废的行同样是「锁定」的,但原因是作废而不是发布:提示要指向真正的出路。
return row.status === 'withdrawn'
? '本条已作废:不计入名次与积分,用「重新录入」退回草稿后才能修改'
: '已发布,请先撤销发布'
}
const hint = resultHintOf(row)
return hint !== null && hint.level !== 'ok' ? hint.message : ''
}
@@ -681,6 +725,7 @@ function toRow(entry: EntryWithResult): EditableRow {
memberNames: entry.memberNames,
heat: entry.heat,
entryStatus: entry.status,
resultId: result?.id ?? null,
resultText: result?.resultText ?? '',
rank: result?.rank ?? null,
status: result?.status ?? 'ok',
@@ -1350,6 +1395,69 @@ const BATCH_CONFIRM_TEXT: Record<EntryBatchAction, string> = {
remove: '确认移出',
}
// ── 单条作废 / 重新录入(A16:外科手术式改一条,不必整组撤销发布) ──────────────
/** 待确认的行操作;非 null 时确认框打开。 */
const resultAction = ref<ResultActionTarget | null>(null)
const resultActionBusy = ref(false)
/** 确认框文案随动作变(作废 / 退回草稿的后果不同)。 */
const resultActionCopy = computed(() => RESULT_ACTION_CONFIRM[resultAction.value?.action ?? 'void'])
/**
* 点行操作按钮:先做前置检查(示例数据 / 只读 / 未保存修改),再弹确认框。
*
* 与批量操作同一口径——它会重算并刷新整表,未保存的编辑必须先落草稿,
* 否则刚填的名次会被服务端返回的口径覆盖掉。
*/
function requestResultAction(row: EditableRow): void {
const target = rowAction(row)
if (target === null) return
if (data.isMock) {
// 示例数据模式下写接口没有后端可落库,提示成功等于谎报(与发布 / 批量操作同口径)。
const notice = sampleDataBlockedNotice(RESULT_ROW_ACTION_WORD[target.action])
toast.error(notice.title, notice.detail)
return
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (dirty.value) {
toast.error('有未保存的修改', '作废 / 退回会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
return
}
resultAction.value = target
}
/** 确认框回传的原因;空原因由 ConfirmDialog 拦下,这里拿到的一定是填好的。 */
async function confirmResultAction(reason: string): Promise<void> {
const target = resultAction.value
if (target === null || resultActionBusy.value) return
resultActionBusy.value = true
try {
if (target.action === 'void') {
await adminApi.voidResult(target.resultId, { reason })
} else {
await adminApi.reopenResult(target.resultId, { reason })
}
resultAction.value = null
// 名次 / 积分 / 大屏都由服务端重算:整表 + 榜单一起重拉,不在本地行上打补丁。
await reloadAfterSettlementChange()
const success = RESULT_ACTION_SUCCESS[target.action]
toast.success(success.title, success.detail)
} catch (caught) {
toast.error(target.action === 'void' ? '作废失败' : '退回草稿失败', caught instanceof Error ? caught.message : null)
} finally {
resultActionBusy.value = false
}
}
function cancelResultAction(): void {
if (resultActionBusy.value) return
resultAction.value = null
}
/** 一键均分:先亮明「动哪些人、怎么切、编到第几批」,确认后才提交。 */
function requestSplit(): void {
const plan = splitPlan.value
@@ -1810,6 +1918,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<th>状态</th>
<th class="center">积分(自动)</th>
<th>备注</th>
<th class="center action-col">操作</th>
</tr>
</thead>
<tbody>
@@ -1827,8 +1936,17 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
<input v-model="selectedEntryIds" type="checkbox" :value="row.entryId" />
</td>
<td class="center">
<!--
已作废是独立状态:它仍是 published(所以输入框照样锁着),
但成绩不计入名次与积分,徽标必须先说清这件事,别只显示「已发布」。
-->
<span
v-if="row.resultState === 'published'"
v-if="row.resultState === 'published' && row.status === 'withdrawn'"
class="mx-badge mx-badge-error"
title="本条已作废:不计入名次与积分,可用「重新录入」退回草稿"
>已作废</span>
<span
v-else-if="row.resultState === 'published'"
class="mx-badge mx-badge-success"
title="已发布上屏"
>已发布</span>
@@ -1937,9 +2055,27 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
/>
</td>
<!--
行操作区:只对已发布的行出现(草稿行本来就能直接改)。
guest / 断线只读时按钮保留但置灰,让访客看得到「这里能作废」而不是一片空白。
-->
<td class="center action-cell">
<button
v-if="rowAction(row) !== null"
class="mx-btn mx-btn-sm"
:class="rowAction(row)?.action === 'reopen' ? 'mx-btn-secondary' : 'mx-btn-danger'"
type="button"
:disabled="!canWrite || resultActionBusy"
:title="rowActionTitle(row)"
@click="requestResultAction(row)"
>
{{ rowActionLabel(row) }}
</button>
<span v-else class="muted">—</span>
</td>
</tr>
<tr v-if="!loading && visibleRows.length === 0">
<td colspan="11">
<td colspan="12">
<div class="mx-empty">
<div class="mx-empty-title">
{{ categoryRows.length === 0 ? '该组还没有报名项' : '该批次没有报名项' }}
@@ -2099,6 +2235,28 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
@cancel="unpublishDialog = false"
/>
<!--
单条作废 / 重新录入:原因必填(服务端写审计),后果写在弹窗里。
与「撤销发布」分开:这是外科手术式改一条,不动同组其它已发布成绩。
-->
<ConfirmDialog
:open="resultAction !== null"
tone="danger"
:title="resultActionCopy.title"
:message="resultActionCopy.message"
:details="
resultAction === null
? []
: [`${resultAction.resultText === '' ? '(空成绩)' : resultAction.resultText}`, '同组其它人的名次与积分会一起重算并推送大屏']
"
:confirm-text="resultActionCopy.confirmText"
:reason-required="true"
:reason-label="resultActionCopy.reasonLabel"
:pending="resultActionBusy"
@confirm="confirmResultAction"
@cancel="cancelResultAction"
/>
<ConfirmDialog
:open="settleDialog"
tone="warning"
@@ -2426,6 +2584,18 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
.team-cell {
font-weight: 600;
}
/* 行操作区:窄列、不换行,按钮别把整张表撑宽。 */
.action-col,
.action-cell {
width: 96px;
white-space: nowrap;
}
.action-cell .mx-btn {
width: 100%;
}
.action-cell .muted {
color: var(--mx-t3);
}
/*
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
+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;
+97
View File
@@ -0,0 +1,97 @@
import type { Event, RealtimeStatus } from '@/shared'
/**
* 仪表盘卡片的纯逻辑(P2 B08)。
*
* `devdocs/07 §7` 承诺了「今天的小项 / 待办数 / 连接状态」三类卡片,之前实现里都没有。
* 补齐时最容易出错的是「今天是哪一天」——服务端存的是带时区的时间串,
* 一律按本地时区取当天(现场看的日历就是本机日历),所以口径抽在这里单独测。
*/
/** 本地时区的当天,`YYYY-MM-DD`。 */
export function localDayKey(at: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, '0')
return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
}
/**
* 排期时间属于本地时区的哪一天;读不出日期时返回 null(不猜)。
*
* 纯日期(`2026-09-12`)没有时区含义,直接取日期部分——交给 `new Date()` 会按 UTC 午夜解析,
* 在东八区以外的机器上会整体错一天,把「今天的小项」算成昨天或明天。
* 带时间的串(`...T09:00` 或带 Z / 偏移量)交给 Date 解析后再取本地日历日:
* 带偏移量的时间串本来就是「某个绝对时刻」,显示给现场看必须是本机日历上的那一天。
*/
export function scheduleDayKey(scheduledAt: string | null | undefined): string | null {
const text = (scheduledAt ?? '').trim()
if (text === '') return null
const bare = /^(\d{4}-\d{2}-\d{2})$/.exec(text)
if (bare !== null) return bare[1] ?? null
const at = new Date(text)
if (Number.isNaN(at.getTime())) return null
return localDayKey(at)
}
/** 指定某天(`YYYY-MM-DD`)计划进行的小项;scheduledAt 为空的排期不算任何一天。 */
export function eventsOnDay(events: readonly Event[], day: string): Event[] {
return events.filter((event) => scheduleDayKey(event.scheduledAt) === day)
}
export function todayEvents(events: readonly Event[], now: Date = new Date()): Event[] {
return eventsOnDay(events, localDayKey(now))
}
export interface ScheduleProgress {
total: number
published: number
/** 可展示的进度文案;total 为 0 时给 null,由页面显示「今天没有排期」。 */
text: string | null
}
export function scheduleProgress(events: readonly Event[]): ScheduleProgress {
const published = events.filter((event) => event.status === 'published').length
return {
total: events.length,
published,
text: events.length === 0 ? null : `已发布 ${published} / 共 ${events.length}`,
}
}
export interface DisplayCounts {
online: number
total: number
}
/**
* 大屏在线数 / 总数。
*
* `instances` 为 null 表示数据源不可用(示例数据 / 接口失败):这时返回 null,
* 由页面显示 `—`,绝不能用 0 / 0 冒充「一台大屏都没连」。
*/
export function displayCounts(instances: readonly { online: boolean }[] | null): DisplayCounts | null {
if (instances === null) return null
return { online: instances.filter((item) => item.online).length, total: instances.length }
}
/** 数据源不可用时统一显示 `—`,不要用 0 冒充「没有」。 */
export function countText(value: number | null): string {
return value === null ? '—' : String(value)
}
export function displayCountText(counts: DisplayCounts | null): string {
return counts === null ? '—' : `${counts.online} / ${counts.total}`
}
/**
* 管理端的连接状态文案。
*
* 接口里没有「管理端在线数」这种统计(`GET /api/display/instances` 只登记大屏实例),
* 所以这里如实显示**本机管理端**的实时连接状态,不编一个数出来。
*/
export function adminLinkLabel(isMock: boolean, status: RealtimeStatus): string {
if (isMock) return '示例数据'
if (status === 'online') return '本机在线'
if (status === 'connecting') return '连接中'
if (status === 'idle') return '未连接'
return '连接中断'
}
+96
View File
@@ -0,0 +1,96 @@
import type { ResultState, ResultStatus } from '@/shared'
/**
* 成绩录入页「行操作区」的纯逻辑(P2 A16)。
*
* 抽出来的原因与 entryGuards 一致:`.vue` 组件跑不进 `node --test`,而这里恰好有两处
* 会真出错的地方——「哪一行显示哪个按钮」的状态判定,以及「动的是哪一条成绩」的 id 口径。
*
* 注意 id 口径:作废 / 退回的接口是 `POST /api/results/{id}/...`,id 是**成绩 id**,
* 不是报名项 id。两者在页面上挨着,拿错就会把别人的成绩作废掉,所以这里单独强调。
*/
/** 行操作区能对一条已发布成绩做的事。 */
export type ResultRowAction = 'void' | 'reopen'
export interface ResultRowState {
resultState: ResultState | null
status: ResultStatus
}
/**
* 这一行此刻该显示哪个操作;null 表示不显示(草稿行本来就能直接改,不需要作废入口)。
*
* 口径来自服务端 void 的实际行为:只把 `results.status` 改成 withdrawn,`state` 仍是 published,
* 所以「已发布 + withdrawn」= 已作废(显示「重新录入」),「已发布 + 其它状态」= 正常已发布(显示「作废本条」)。
*/
export function resultRowAction(row: ResultRowState): ResultRowAction | null {
if (row.resultState !== 'published') return null
return row.status === 'withdrawn' ? 'reopen' : 'void'
}
export interface ResultActionRow extends ResultRowState {
entryId: number
/** 成绩 id;草稿行也可能有(未发布的成绩同样有 id),缺了就没法定位接口目标。 */
resultId: number | null
resultText: string
}
export interface ResultActionTarget {
action: ResultRowAction
/** 服务端按成绩 id 定位,绝不是 entryId。 */
resultId: number
entryId: number
resultText: string
}
/** 把一行收窄成一次可提交的操作;判定不成立(草稿行 / 没有成绩 id)时为 null。 */
export function resultActionTarget(row: ResultActionRow): ResultActionTarget | null {
const action = resultRowAction(row)
if (action === null || row.resultId === null) return null
return { action, resultId: row.resultId, entryId: row.entryId, resultText: row.resultText }
}
export const RESULT_ROW_ACTION_LABEL: Record<ResultRowAction, string> = {
void: '作废本条',
reopen: '重新录入',
}
export const RESULT_ROW_ACTION_TITLE: Record<ResultRowAction, string> = {
void: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新',
reopen: '退回草稿后可继续修改,改完再发布本组',
}
/** 示例数据模式下拦下写操作时的动作名(拼进「示例数据模式不能X」)。 */
export const RESULT_ROW_ACTION_WORD: Record<ResultRowAction, string> = {
void: '作废成绩',
reopen: '退回草稿',
}
/** 确认框文案:后果必须写在弹窗里,不能只让操作员凭经验猜(07 §8)。 */
export const RESULT_ACTION_CONFIRM: Record<ResultRowAction, { title: string; message: string; confirmText: string; reasonLabel: string }> = {
void: {
title: '确认作废本条成绩?',
message: '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
confirmText: '确认作废',
reasonLabel: '作废原因',
},
reopen: {
title: '确认重新录入本条成绩?',
message: '该条成绩将退回草稿(可编辑),不再计入名次与积分;改完请再发布本组。',
confirmText: '退回草稿',
reasonLabel: '退回原因',
},
}
/** 成功提示:作废与退回的后续动作不同,提示要各说各的。 */
export const RESULT_ACTION_SUCCESS: Record<ResultRowAction, { title: string; detail: string }> = {
void: {
title: '已作废该条成绩,名次与积分已重算',
detail: '大屏已更新;需要改回来时用同一行的「重新录入」退回草稿',
},
reopen: {
title: '已退回草稿,该行可以继续修改',
detail: '改完记得再点一次「发布本批」,名次与积分会按新成绩重算',
},
}
+81
View File
@@ -0,0 +1,81 @@
import { formatPoints } from '@/shared'
import type { RecordBreak, RecordLevel, RecordSource } from '@/shared'
/**
* 破纪录登记(record-breaks)与纪录库(records)的纯逻辑(P2 A41 / C11)。
*
* 抽出来的原因:`.vue` 跑不进 `node --test`,而这里有两处会真出错的地方——
* 「已处理」的登记要靠键去抑制前端推导出来的疑似列表(两边键算法必须一模一样),
* 以及「来源」列缺字段时不能把旧包显示成「历史」。
*/
export const RECORD_LEVEL_LABELS: Record<RecordLevel, string> = {
meet: '赛会',
city: '市级',
province: '省级',
national: '全国',
}
/** 档位从低到高;列表与筛选都按它排序。 */
export const RECORD_LEVEL_ORDER: RecordLevel[] = ['meet', 'city', 'province', 'national']
export const RECORD_LEVEL_OPTIONS: Array<{ value: RecordLevel; label: string }> = RECORD_LEVEL_ORDER.map(
(level) => ({ value: level, label: RECORD_LEVEL_LABELS[level] }),
)
/**
* 纪录来源文案。
*
* 缺字段(旧包 / 示例数据 / 老服务端)时显示 `—` 而不是「历史」:
* 「历史」是一条真实结论,而这里只是读不到,两者不能混。
*/
export function recordSourceLabel(source: RecordSource | null | undefined): string {
if (source === 'current') return '本届'
if (source === 'history') return '历史'
return '—'
}
/**
* 疑似破纪录与登记行共用的键。
*
* 录入侧由「已发布成绩 vs 当前纪录」推导出疑似条目,服务端侧返回已处理的登记;
* 两侧必须用同一个函数算键,否则「已经驳回的那条」刷新后会重新冒出来。
*/
export function breakKey(eventId: number, entryId: number, level: RecordLevel): string {
return `${eventId}-${entryId}-${level}`
}
/** 已处理(approved / rejected)的登记键;pending 的不抑制——它本来就该留在待确认里。 */
export function handledBreakKeys(breaks: readonly RecordBreak[]): Set<string> {
const keys = new Set<string>()
for (const item of breaks) {
if (item.state === 'pending') continue
keys.add(breakKey(item.eventId, item.entryId, item.level))
}
return keys
}
/** 登记说明(驳回原因等);旧服务端不下发 note 时返回 null,界面据此不渲染那一行。 */
export function breakNote(item: { note?: string | null }): string | null {
const text = (item.note ?? '').trim()
return text === '' ? null : text
}
/**
* 加分展示。
*
* `bonusPoints` 缺失(旧服务端 / 示例数据)时给 `—`:直接拼 `+${formatPoints(undefined)}`
* 会渲染成「+—」这种既不是数字也不是占位的怪东西。
*/
export function breakBonusText(item: { bonusPoints?: number | null }): string {
const value = item.bonusPoints
return typeof value === 'number' && Number.isFinite(value) ? `+${formatPoints(value)}` : '—'
}
/**
* 「忽略」在服务端登记的固定原因。
*
* 现场判定不是破纪录不是「错误」而是一次判定,把它写进 note 之后,
* 谁在什么时候否掉了哪一条在审计里看得见,而不是只留在某个浏览器的内存里。
*/
export const IGNORE_BREAK_REASON = '现场判定不是破纪录'
+68
View File
@@ -0,0 +1,68 @@
import type { RecordEntryInput, RecordLevel } from '@/shared'
/**
* 「新增历史纪录」表单的纯逻辑(P2 C11)。
*
* 之前 `records.source='history'` 没有任何写入路径,这一档永远是空的;
* 接上 `POST /api/records` 之后,录入的是**手抄的历史资料**,所以两个值字段都允许只填一个:
* 老秩序册上常常只有「12.34 秒」这种文本,也可能只有换算后的数值。
* 校验与请求体的拼装放这里,组件只负责把输入框接上去。
*/
export interface RecordFormState {
eventId: number | null
level: RecordLevel
/** 成绩值输入框的原始文本;空串 = 没填。 */
resultValue: string
resultText: string
holderName: string
holderTeamId: number | null
/** 创造日期;空串 = 没填(历史资料常常记不清具体日期)。 */
achievedAt: string
}
export type RecordFormPlan = { ok: true; input: RecordEntryInput } | { ok: false; error: string }
/** 表单初始值:级别默认赛会纪录(录入历史资料时最常用),其余留空。 */
export function emptyRecordForm(eventId: number | null = null): RecordFormState {
return {
eventId,
level: 'meet',
resultValue: '',
resultText: '',
holderName: '',
holderTeamId: null,
achievedAt: '',
}
}
export function planRecordInput(form: RecordFormState): RecordFormPlan {
if (form.eventId === null) return { ok: false, error: '请选择小项' }
const valueText = form.resultValue.trim()
const text = form.resultText.trim()
let resultValue: number | null = null
if (valueText !== '') {
const parsed = Number(valueText)
if (!Number.isFinite(parsed)) return { ok: false, error: '成绩值要填数字;只填成绩文本也可以' }
resultValue = parsed
}
if (resultValue === null && text === '') {
return { ok: false, error: '成绩值或成绩文本至少填一个' }
}
return {
ok: true,
input: {
eventId: form.eventId,
level: form.level,
resultValue,
resultText: text === '' ? null : text,
holderName: form.holderName.trim() === '' ? null : form.holderName.trim(),
holderTeamId: form.holderTeamId,
achievedAt: form.achievedAt.trim() === '' ? null : form.achievedAt.trim(),
// 前端不猜 source:服务端对新登记的纪录固定写 history,这里显式带上只为与响应体同构。
source: 'history',
},
}
}
+178
View File
@@ -0,0 +1,178 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { Event } from '@/shared'
import {
adminLinkLabel,
countText,
displayCountText,
displayCounts,
eventsOnDay,
localDayKey,
scheduleDayKey,
scheduleProgress,
todayEvents,
} from '../pages/dashboard'
/**
* 仪表盘补齐 07 §7 承诺的卡片(P2 B08)。
*
* 承诺里有「今天计划进行的小项 + 进度」「待办(草稿待发布数 / 疑似破纪录待确认数)」
* 「连接状态(大屏在线数 / 总数)」,实现里一张都没有。补卡片时最容易踩的是
* 「今天是哪一天」与「读不到时不显示 0」这两件事,所以口径抽在这里测。
*/
const DASH_SOURCE = readFileSync(new URL('../pages/DashboardPage.vue', import.meta.url), 'utf8')
function event(overrides: Partial<Event>): Event {
return {
id: 1,
sportId: 1,
code: 'M100',
name: '男子 100 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
recordEligible: true,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
// ── 今天是哪一天 ────────────────────────────────────────────────────────────
test('当天用本地时区的 YYYY-MM-DD', () => {
// 本地时间 2026-09-12 09:05 → 就是 2026-09-12,与时区无关。
assert.equal(localDayKey(new Date(2026, 8, 12, 9, 5)), '2026-09-12')
assert.equal(localDayKey(new Date(2026, 0, 3, 23, 59)), '2026-01-03')
})
test('纯日期排期直接取日期部分(不能按 UTC 解析后倒推一天)', () => {
assert.equal(scheduleDayKey('2026-09-12'), '2026-09-12')
})
test('带时间的排期按本地日历日归属', () => {
assert.equal(scheduleDayKey('2026-09-12T09:00'), '2026-09-12')
// 绝对时刻按本机时区落到哪一天就算哪一天(现场看的是本机日历)。
const utc = '2026-09-12T23:30:00Z'
assert.equal(scheduleDayKey(utc), localDayKey(new Date(utc)))
})
test('空值与读不出来的时间串不算任何一天', () => {
assert.equal(scheduleDayKey(null), null)
assert.equal(scheduleDayKey(undefined), null)
assert.equal(scheduleDayKey(' '), null)
assert.equal(scheduleDayKey('待定'), null)
})
// ── 今天的小项与进度 ────────────────────────────────────────────────────────
test('只挑出当天排期的小项', () => {
const events = [
event({ id: 1, scheduledAt: '2026-09-12' }),
event({ id: 2, scheduledAt: '2026-09-12T14:00' }),
event({ id: 3, scheduledAt: '2026-09-13' }),
event({ id: 4, scheduledAt: null }),
]
assert.deepEqual(
eventsOnDay(events, '2026-09-12').map((item) => item.id),
[1, 2],
)
assert.deepEqual(
todayEvents(events, new Date(2026, 8, 12, 8, 0)).map((item) => item.id),
[1, 2],
)
})
test('今天没有小项时返回空数组、进度文案为 null(页面显示空态,不崩)', () => {
assert.deepEqual(todayEvents([], new Date(2026, 8, 12)), [])
assert.deepEqual(eventsOnDay([event({ scheduledAt: null })], '2026-09-12'), [])
const progress = scheduleProgress([])
assert.deepEqual(progress, { total: 0, published: 0, text: null })
})
test('进度按已发布 / 总数统计', () => {
const progress = scheduleProgress([
event({ id: 1, status: 'published' }),
event({ id: 2, status: 'pending' }),
event({ id: 3, status: 'published' }),
])
assert.deepEqual(progress, { total: 3, published: 2, text: '已发布 2 / 共 3' })
})
// ── 连接状态 ────────────────────────────────────────────────────────────────
test('大屏在线数 / 总数按 online 统计', () => {
assert.deepEqual(
displayCounts([{ online: true }, { online: false }, { online: true }]),
{ online: 2, total: 3 },
)
})
test('数据源不可用时给 null(页面显示 —),不能用 0 / 0 冒充', () => {
assert.equal(displayCounts(null), null)
assert.equal(displayCountText(displayCounts(null)), '—')
assert.equal(displayCountText(displayCounts([{ online: true }])), '1 / 1')
assert.equal(displayCountText(displayCounts([])), '0 / 0')
assert.equal(countText(null), '—')
assert.equal(countText(0), '0')
})
test('管理端只显示本机连接状态:接口里没有「管理端在线数」这种统计', () => {
assert.equal(adminLinkLabel(true, 'online'), '示例数据')
assert.equal(adminLinkLabel(false, 'online'), '本机在线')
assert.equal(adminLinkLabel(false, 'connecting'), '连接中')
assert.equal(adminLinkLabel(false, 'offline'), '连接中断')
assert.equal(adminLinkLabel(false, 'idle'), '未连接')
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('三张新卡片都在,旧的卡片一个没删', () => {
for (const label of ['今天的小项', '草稿待发布', '待确认破纪录', '大屏在线数']) {
assert.match(DASH_SOURCE, new RegExp(`</span>${label}</div>`), `缺少卡片:${label}`)
}
for (const label of ['领先单位', '已发布项目', '纪录条目', '实时连接']) {
assert.match(DASH_SOURCE, new RegExp(`</span>${label}</div>`), `旧卡片被删了:${label}`)
}
assert.match(DASH_SOURCE, /实时积分 Top 3/)
})
test('每个数据源各自兜底:读不到只让那一张显示 —,不整页崩', () => {
const load = DASH_SOURCE.slice(DASH_SOURCE.indexOf('async function loadCards('))
// 三个新数据源各有一个 try/catch,且失败时写 null 而不是 0。
assert.match(load, /draftCount\.value = null/)
assert.match(load, /pendingBreakCount\.value = null/)
assert.match(load, /displayInstances\.value = null/)
assert.match(DASH_SOURCE, /countText\(draftCount\)/)
assert.match(DASH_SOURCE, /countText\(pendingBreakCount\)/)
assert.match(DASH_SOURCE, /displayTotal/)
})
test('待确认破纪录数走 GET /api/record-breaks?state=pending', () => {
assert.match(DASH_SOURCE, /adminApi\.recordBreaks\('pending'\)\)\.length/)
// 访客必然 403:不发这一枪,卡片如实说明是权限而不是接口不可用。
assert.match(DASH_SOURCE, /auth\.readOnly \? null : \(await adminApi\.recordBreaks\('pending'\)\)\.length/)
assert.match(DASH_SOURCE, /pendingBreakCount\.value === null\) return auth\.readOnly \? '访客只读' : '接口不可用'/)
})
test('草稿待发布数走分页接口的 total', () => {
assert.match(DASH_SOURCE, /await adminApi\.draftResultCount\(\)/)
})
test('今天的小项用纯函数判定,模板不做日期字符串比较', () => {
assert.match(DASH_SOURCE, /todayEvents\(data\.events\)/)
assert.match(DASH_SOURCE, /todayProgress\.text === null \? '—' : todayProgress\.total/)
assert.match(DASH_SOURCE, /今天没有排期/)
const page = DASH_SOURCE.slice(DASH_SOURCE.indexOf('<template>'))
assert.doesNotMatch(page, /scheduledAt/, '模板里不该直接比较排期字符串')
})
@@ -0,0 +1,188 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
RESULT_ACTION_CONFIRM,
RESULT_ACTION_SUCCESS,
RESULT_ROW_ACTION_LABEL,
RESULT_ROW_ACTION_TITLE,
RESULT_ROW_ACTION_WORD,
resultActionTarget,
resultRowAction,
} from '../pages/entryResultActions'
/**
* 成绩录入页「作废 / 重新录入」(P2 A16)。
*
* 后端早有 `POST /api/results/{id}/void`,管理端却没有任何入口:现场改一个人填错的成绩,
* 只能「整组撤销发布 → 改 → 重发」,同组其它人的名次与积分会被整体回退再重算。
*
* 项目没有组件测试工具(无 @vue/test-utils / jsdom),判定逻辑抽在 entryResultActions 里直接测,
* 界面接线按仓库既有做法读源码断言。
*/
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8')
function section(source: string, start: string, end: string): string {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
return source.slice(from, to)
}
// ── 哪一行显示哪个按钮 ──────────────────────────────────────────────────────
test('已发布且未作废的行显示「作废本条」', () => {
assert.equal(resultRowAction({ resultState: 'published', status: 'ok' }), 'void')
assert.equal(resultRowAction({ resultState: 'published', status: 'dq' }), 'void')
assert.equal(resultRowAction({ resultState: 'published', status: 'dns' }), 'void')
})
test('已作废(published + withdrawn)的行显示「重新录入」', () => {
// 服务端 void 只改 status、不动 state,所以「已作废」等于「已发布 + withdrawn」。
assert.equal(resultRowAction({ resultState: 'published', status: 'withdrawn' }), 'reopen')
})
test('草稿行(含退回草稿后的行)不显示任何行操作:本来就是可编辑的', () => {
assert.equal(resultRowAction({ resultState: 'draft', status: 'withdrawn' }), null)
assert.equal(resultRowAction({ resultState: 'draft', status: 'ok' }), null)
assert.equal(resultRowAction({ resultState: null, status: 'ok' }), null)
})
test('按钮文案逐字:作废本条 / 重新录入', () => {
assert.equal(RESULT_ROW_ACTION_LABEL.void, '作废本条')
assert.equal(RESULT_ROW_ACTION_LABEL.reopen, '重新录入')
assert.match(RESULT_ROW_ACTION_TITLE.void, /不再计入名次与积分/)
assert.match(RESULT_ROW_ACTION_WORD.void, /作废/)
})
// ── 动的是哪一条成绩 ────────────────────────────────────────────────────────
test('操作目标是成绩 id(接口按它定位),不是报名项 id', () => {
const target = resultActionTarget({
entryId: 7001,
resultId: 9001,
resultState: 'published',
status: 'ok',
resultText: '11.58',
})
assert.deepEqual(target, {
action: 'void',
resultId: 9001,
entryId: 7001,
resultText: '11.58',
})
})
test('没有成绩 id 或不是已发布行时,不产生可提交的目标', () => {
const base = { entryId: 7001, resultState: 'published' as const, status: 'ok' as const, resultText: '11.58' }
assert.equal(resultActionTarget({ ...base, resultId: null }), null)
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }), null)
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('确认框文案写清了后果,且作废原因必填', () => {
assert.equal(RESULT_ACTION_CONFIRM.void.title, '确认作废本条成绩?')
assert.equal(
RESULT_ACTION_CONFIRM.void.message,
'作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
)
assert.equal(RESULT_ACTION_CONFIRM.reopen.title, '确认重新录入本条成绩?')
assert.match(RESULT_ACTION_CONFIRM.reopen.message, /退回草稿(可编辑)/)
assert.match(ENTRY_SOURCE, /:reason-required="true"/)
assert.match(ENTRY_SOURCE, /@confirm="confirmResultAction"/)
})
test('作废调用 void、退回草稿调用 reopen,提交的都是成绩 id', () => {
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
assert.match(confirm, /await adminApi\.voidResult\(target\.resultId, \{ reason \}\)/)
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
assert.doesNotMatch(confirm, /(voidResult|reopenResult)\(target\.entryId/)
})
test('作废 / 退回成功后刷新本页数据与榜单', () => {
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
// 名次与积分由服务端重算:必须整表重拉 + 刷新榜单,不能只在本地行上打补丁。
assert.match(confirm, /await reloadAfterSettlementChange\(\)/)
assert.ok(
confirm.indexOf('reloadAfterSettlementChange') < confirm.indexOf('toast.success'),
'刷新要发生在成功提示之前,否则提示先说成功、页面还没变',
)
const reload = section(ENTRY_SOURCE, 'async function reloadAfterSettlementChange(', 'async function confirmSettle(')
assert.match(reload, /await reload\(\)/)
assert.match(reload, /data\.refreshStandings\(\)/)
})
test('成功后给 toast,失败时原样报错', () => {
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
assert.match(confirm, /const success = RESULT_ACTION_SUCCESS\[target\.action\]/)
assert.match(confirm, /toast\.success\(success\.title, success\.detail\)/)
assert.match(confirm, /toast\.error\(target\.action === 'void' \? '作废失败' : '退回草稿失败'/)
assert.match(RESULT_ACTION_SUCCESS.void.title, /名次与积分已重算/)
})
test('示例数据模式在发请求前拦下,提示与既有口径一致', () => {
const request = section(ENTRY_SOURCE, 'function requestResultAction(', 'async function confirmResultAction(')
assert.match(request, /if \(data\.isMock\) \{/)
assert.match(request, /sampleDataBlockedNotice\(RESULT_ROW_ACTION_WORD\[target\.action\]\)/)
assert.ok(
request.indexOf('data.isMock') < request.indexOf('resultAction.value = target'),
'isMock 判断要发生在打开确认框之前,示例数据下不弹一个点了也没用的框',
)
})
test('访客只读时按钮保留但置灰(与既有 data.canWrite 同一口径)', () => {
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
assert.match(cell, /\{\{ rowActionLabel\(row\) \}\}/)
assert.match(cell, /@click="requestResultAction\(row\)"/)
})
test('行能不能改只看服务端返回的 state:退回草稿后必须重新可编辑', () => {
// 退回草稿(reopen)由服务端把 state 改回 draft;页面只有按 state 判定锁,行才会重新可编辑。
assert.match(
ENTRY_SOURCE,
/function rowLocked\(row: EditableRow\): boolean \{\n\s*return row\.resultState === 'published'\n\}/,
)
assert.match(ENTRY_SOURCE, /resultState: result\.state \?\? null|resultState: result\?\.state \?\? null/)
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| rowLocked\(row\)"/)
})
test('已作废的行有独立徽标,不会被显示成「已发布」', () => {
assert.match(
ENTRY_SOURCE,
/v-if="row\.resultState === 'published' && row\.status === 'withdrawn'"/,
)
assert.match(ENTRY_SOURCE, />已作废<\/span>/)
})
test('表格多了一列「操作」,空状态行的 colspan 跟着改', () => {
assert.match(ENTRY_SOURCE, /<th class="center action-col">操作<\/th>/)
assert.match(ENTRY_SOURCE, /<td colspan="12">/)
assert.doesNotMatch(ENTRY_SOURCE, /<td colspan="11">/)
})
// ── ConfirmDialog 的必填原因 ────────────────────────────────────────────────
test('原因为空时不提交(emit confirm),只亮出提示', () => {
const submit = section(DIALOG_SOURCE, 'function requestConfirm(', 'function onKeydown(')
assert.match(submit, /if \(reasonMissing\.value\) \{/)
assert.match(submit, /reasonTouched\.value = true/)
assert.ok(
submit.indexOf('reasonMissing.value') < submit.indexOf("emit('confirm'"),
'必填判断要在 emit 之前',
)
assert.match(submit, /emit\('confirm', reason\.value\.trim\(\)\)/)
})
test('每次打开弹窗都清空上一次的原因', () => {
assert.match(DIALOG_SOURCE, /watch\(\n\s*\(\) => props\.open,/)
assert.match(DIALOG_SOURCE, /reason\.value = ''\n\s*reasonTouched\.value = false/)
})
test('没有必填原因的老用法不受影响:reasonRequired 默认 false', () => {
assert.match(DIALOG_SOURCE, /reasonRequired: false/)
assert.match(DIALOG_SOURCE, /<label v-if="reasonRequired" class="confirm-reason">/)
})
@@ -0,0 +1,206 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 新增接口的契约(P2 A16 / A41 / C11):
* 作废成绩、把作废的成绩退回草稿、破纪录登记列表 / 驳回、历史纪录的登记与删除。
*
* 这些路径都由服务端契约冻结,前端只负责「发对地址、传对字段、别自作主张降级」。
* 尤其是 recordBreaks:它**不能**在连不上服务端时退化成空数组——
* 那会把「读不到」显示成「没有已批准的破纪录」,现场据此以为加分从来没批过。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 构造时就绑定了当时的 fetch。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function sentBody(): Record<string, unknown> {
return JSON.parse(String(lastRequest.body)) as Record<string, unknown>
}
const { ApiError, adminApi } = await import('../api/client')
const BREAK = {
id: 12,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'approved',
approvedBy: 1,
approvedAt: '2026-09-12T09:00:00Z',
note: null,
}
// ── 成绩:作废 / 退回草稿 ───────────────────────────────────────────────────
test('作废一条成绩:POST /api/results/{id}/void,原因随请求体发出', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 9001, status: 'withdrawn', state: 'published' } }))
const result = await adminApi.voidResult(9001, { reason: '成绩抄错' })
assert.equal(lastRequest.url, '/api/results/9001/void')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '成绩抄错')
// 幂等键由客户端注入,服务端靠它做重试去重。
assert.equal(typeof sentBody().requestId, 'string')
assert.equal(result.status, 'withdrawn')
})
test('退回草稿:POST /api/results/{id}/reopen,后端的业务错误原样抛出', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 9001, status: 'ok', state: 'draft' } }))
const result = await adminApi.reopenResult(9001, { reason: '改回来' })
assert.equal(lastRequest.url, '/api/results/9001/reopen')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '改回来')
assert.equal(result.state, 'draft')
// 「这条没被作废」是服务端的状态判断,不能让前端吞成成功。
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'STATE_INVALID', message: '该成绩未作废,无需重新录入' } }, 409),
)
await assert.rejects(
() => adminApi.reopenResult(9001, { reason: '改回来' }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'STATE_INVALID')
assert.equal(error.message, '该成绩未作废,无需重新录入')
return true
},
)
})
// ── 破纪录登记 ──────────────────────────────────────────────────────────────
test('登记列表按 state 过滤,字段(含新增的 note)原样透传', async () => {
respondWith(() => jsonResponse({ ok: true, data: [BREAK, { ...BREAK, id: 11, note: '误判' }] }))
const list = await adminApi.recordBreaks('approved')
assert.equal(lastRequest.url, '/api/record-breaks?state=approved')
assert.equal(lastRequest.method, 'GET')
assert.equal(list.length, 2)
assert.equal(list[0]?.bonusPoints, 5)
assert.equal(list[0]?.note, null)
assert.equal(list[1]?.note, '误判')
})
test('登记列表读不到时抛错,不退化成空数组', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => adminApi.recordBreaks('approved'),
(error: unknown) => error instanceof ApiError && error.code === 'NETWORK',
)
})
test('驳回 / 作废登记:POST /api/record-breaks/{id}/reject,原因必填', async () => {
respondWith(() => jsonResponse({ ok: true, data: { ...BREAK, state: 'rejected', note: '现场判定不是破纪录' } }))
const updated = await adminApi.rejectBreak(12, '现场判定不是破纪录')
assert.equal(lastRequest.url, '/api/record-breaks/12/reject')
assert.equal(lastRequest.method, 'POST')
assert.equal(sentBody().reason, '现场判定不是破纪录')
assert.equal(updated.state, 'rejected')
assert.equal(updated.note, '现场判定不是破纪录')
})
// ── 纪录库:新增历史纪录 / 删除 ─────────────────────────────────────────────
test('新增历史纪录:POST /api/records,返回体里的 source 原样带回', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { id: 3, eventId: 101, level: 'meet', resultText: '11.20', source: 'history', isCurrent: false },
}),
)
const created = await adminApi.createRecord({
eventId: 101,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
holderName: '张三',
holderTeamId: 1,
achievedAt: '2025-09-01',
source: 'history',
})
assert.equal(lastRequest.url, '/api/records')
assert.equal(lastRequest.method, 'POST')
const body = sentBody()
assert.equal(body.eventId, 101)
assert.equal(body.level, 'meet')
assert.equal(body.resultValue, 11.2)
assert.equal(body.holderName, '张三')
assert.equal(body.holderTeamId, 1)
assert.equal(body.achievedAt, '2025-09-01')
assert.equal(created.source, 'history')
})
test('删除纪录:DELETE /api/records/{id},空响应也算成功', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.removeRecord(3)
assert.equal(lastRequest.url, '/api/records/3')
assert.equal(lastRequest.method, 'DELETE')
assert.equal(lastRequest.body, undefined)
})
test('删不存在 / 已被别人删掉的纪录时 404 原文原样抛出', async () => {
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '纪录不存在' } }, 404))
await assert.rejects(
() => adminApi.removeRecord(99),
(error: unknown) => error instanceof ApiError && error.status === 404 && error.message === '纪录不存在',
)
})
// ── 仪表盘的两个计数 ────────────────────────────────────────────────────────
test('草稿待发布数取分页接口的 total,不拉全量列表', async () => {
respondWith(() => jsonResponse({ ok: true, data: { items: [], total: 17, page: 1, size: 1 } }))
const total = await adminApi.draftResultCount()
assert.equal(lastRequest.url, '/api/results?state=draft&page=1&size=1')
assert.equal(total, 17)
})
test('计数接口读不到时抛错,由卡片显示 —(不用 0 冒充)', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(() => adminApi.draftResultCount())
})
+196
View File
@@ -0,0 +1,196 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { RecordBreak } from '@/shared'
import {
IGNORE_BREAK_REASON,
breakBonusText,
breakKey,
breakNote,
handledBreakKeys,
RECORD_LEVEL_LABELS,
RECORD_LEVEL_OPTIONS,
recordSourceLabel,
} from '../pages/recordBreaks'
/**
* 破纪录的驳回 / 作废(P2 A41)。
*
* 以前只有「登记 + 批准」两条路:误判的纪录在系统内无法纠正,「忽略」还只存在浏览器内存里
* (刷新就回来)。现在驳回与作废都落服务端登记表,「已处理」的键用来抑制前端推导出来的疑似列表。
*
* 项目没有组件测试工具,纯逻辑直接测,界面接线读源码断言。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
function section(source: string, start: string, end: string): string {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
return source.slice(from, to)
}
function makeBreak(overrides: Partial<RecordBreak>): RecordBreak {
return {
id: 1,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'pending',
approvedBy: null,
approvedAt: null,
...overrides,
}
}
// ── 已处理登记的键(抑制前端推导出的疑似列表) ───────────────────────────────
test('疑似条目的键与服务端登记行的键必须能对上(对不上就抑制不掉)', () => {
assert.equal(breakKey(101, 7001, 'meet'), '101-7001-meet')
})
test('approved / rejected 的登记会被抑制,pending 的不会', () => {
const keys = handledBreakKeys([
makeBreak({ id: 1, state: 'approved' }),
makeBreak({ id: 2, state: 'rejected', entryId: 7002 }),
makeBreak({ id: 3, state: 'pending', entryId: 7003 }),
])
assert.equal(keys.has(breakKey(101, 7001, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7002, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7003, 'meet')), false)
})
test('同一报名项的不同档位是两条独立的登记', () => {
const keys = handledBreakKeys([makeBreak({ state: 'approved', level: 'province' })])
assert.equal(keys.has(breakKey(101, 7001, 'province')), true)
assert.equal(keys.has(breakKey(101, 7001, 'meet')), false)
})
test('待确认列表按三层过滤(本机标记 + 服务端已处理)', () => {
const pending = section(RECORDS_SOURCE, 'const pendingBreaks = computed(', 'function levelRank(')
assert.match(pending, /!rejectedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!approvedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!handledKeys\.value\.has\(item\.key\)/)
})
test('推导疑似条目时用的就是 breakKey,两边不会各算一套', () => {
assert.match(RECORDS_SOURCE, /key: breakKey\(event\.id, entry\.id, best\.level\)/)
})
// ── 字段容错 ────────────────────────────────────────────────────────────────
test('来源:history / current 各有文案,缺字段显示 —(不冒充「历史」)', () => {
assert.equal(recordSourceLabel('history'), '历史')
assert.equal(recordSourceLabel('current'), '本届')
assert.equal(recordSourceLabel(null), '—')
assert.equal(recordSourceLabel(undefined), '—')
})
test('登记说明缺字段时不渲染', () => {
assert.equal(breakNote({ note: '现场判定不是破纪录' }), '现场判定不是破纪录')
assert.equal(breakNote({ note: ' 驳回 ' }), '驳回')
assert.equal(breakNote({ note: '' }), null)
assert.equal(breakNote({ note: null }), null)
assert.equal(breakNote({}), null)
})
test('加分缺字段时显示 —,不渲染成「+—」', () => {
assert.equal(breakBonusText({ bonusPoints: 5 }), '+5')
assert.equal(breakBonusText({ bonusPoints: 2.5 }), '+2.5')
assert.equal(breakBonusText({ bonusPoints: null }), '—')
assert.equal(breakBonusText({}), '—')
})
test('四个档位的文案与下拉选项齐全', () => {
assert.deepEqual(RECORD_LEVEL_OPTIONS.map((option) => option.value), [
'meet',
'city',
'province',
'national',
])
assert.equal(RECORD_LEVEL_LABELS.meet, '赛会')
assert.equal(RECORD_LEVEL_LABELS.national, '全国')
})
// ── 已批准的破纪录卡片 ──────────────────────────────────────────────────────
test('卡片数据来自 GET /api/record-breaks?state=approved', () => {
const loadBreaks = section(RECORDS_SOURCE, 'async function loadBreaks(', 'async function loadSuspected(')
assert.match(loadBreaks, /adminApi\.recordBreaks\('approved'\)/)
assert.match(loadBreaks, /adminApi\.recordBreaks\('rejected'\)/)
// 两态分开取、任一失败都只影响这张卡片:用 allSettled,不用 Promise.all(一个挂全挂)。
assert.match(loadBreaks, /Promise\.allSettled\(\[/)
assert.match(loadBreaks, /breaksUnavailable\.value = failure !== undefined/)
assert.match(RECORDS_SOURCE, /<span class="mx-card-title">已批准的破纪录<\/span>/)
})
test('接口读不到时卡片显示空态说明,不让整页报错', () => {
assert.match(RECORDS_SOURCE, /v-else-if="breaksUnavailable"/)
assert.match(RECORDS_SOURCE, /读不到破纪录登记/)
assert.match(RECORDS_SOURCE, /示例数据模式或后端版本较旧时没有这个接口/)
// 空态在「没有已批准的破纪录」之前判断:读不到不能被说成「没有」。
assert.ok(
RECORDS_SOURCE.indexOf('v-else-if="breaksUnavailable"') <
RECORDS_SOURCE.indexOf('approvedBreaks.length === 0'),
)
})
test('访客 403 与后端太旧分开说:权限问题不能显示成「接口不可用」', () => {
assert.match(RECORDS_SOURCE, /breaksForbidden\.value = failures\.some\(\(item\) => isForbidden\(item\.reason\)\)/)
assert.match(RECORDS_SOURCE, /访客只读:看不到破纪录登记/)
assert.match(RECORDS_SOURCE, /要求录入员及以上权限/)
})
test('每条登记一个「作废」按钮,调用 reject 并带上必填原因', () => {
assert.match(RECORDS_SOURCE, />\s*作废\s*<\/button>/)
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await adminApi\.rejectBreak\(item\.id, reason\)/)
assert.match(RECORDS_SOURCE, /:reason-required="true"/)
assert.match(RECORDS_SOURCE, /@confirm="confirmRevoke"/)
})
test('作废成功后刷新纪录库与榜单,并提示加分已撤回', () => {
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await Promise\.all\(\[loadRecords\(\), data\.refreshStandings\(\)\]\)/)
assert.match(revoke, /'加分已撤回,该级别纪录已恢复上一条'/)
assert.ok(
revoke.indexOf('rejectBreak') < revoke.indexOf('loadRecords()'),
'先作废再刷新,顺序反了会把旧数据当新数据',
)
})
test('访客只读时「作废」按钮置灰', () => {
const card = section(RECORDS_SOURCE, '<span class="mx-card-title">已批准的破纪录</span>', '</section>')
assert.match(card, /:disabled="!canWrite \|\| revoking"/)
})
// ── 「忽略」改成持久 ────────────────────────────────────────────────────────
test('忽略先登记再驳回,原因是固定的「现场判定不是破纪录」', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /adminApi\.client\.records\.createBreak\(/)
assert.match(reject, /await adminApi\.rejectBreak\(created\.id, IGNORE_BREAK_REASON\)/)
assert.equal(IGNORE_BREAK_REASON, '现场判定不是破纪录')
assert.ok(
reject.indexOf('createBreak') < reject.indexOf('rejectBreak(created.id'),
'必须先拿到 pending 行才能驳回',
)
})
test('写不进服务端时才退回本机标记,并如实说明刷新后会回来', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /toast\.warning\('已在本机忽略(未连接服务端)', '没有写入服务端,刷新页面后该条会重新出现'\)/)
// 不能出现「只在本地隐藏」这种旧说法:现在默认是落服务端的。
assert.doesNotMatch(RECORDS_SOURCE, /接口未提供拒绝动作/)
})
test('忽略确认框的正文按模式说清后果(示例数据下不能承诺「不会再出现」)', () => {
const message = section(RECORDS_SOURCE, 'const rejectMessage = computed(', 'async function confirmReject(')
assert.match(message, /示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。/)
assert.match(message, /会在服务端登记为一条破纪录记录并立即驳回/)
assert.match(RECORDS_SOURCE, /:message="rejectMessage"/)
})
+139
View File
@@ -0,0 +1,139 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { emptyRecordForm, planRecordInput, type RecordFormState } from '../pages/recordForm'
/**
* 纪录库的历史纪录录入与删除(P2 C11)。
*
* `records.source='history'` 以前没有任何写入路径,所以「来源」这一列恒为「本届」——
* 界面上承诺的「历史 / 本届」实际只有一半。现在接上 POST /api/records(固定写 history)
* 与 DELETE /api/records/{id}(删当前纪录时服务端恢复上一条)。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
function section(source: string, start: string, end: string): string {
const from = source.indexOf(start)
const to = source.indexOf(end, from)
assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`)
return source.slice(from, to)
}
function form(overrides: Partial<RecordFormState>): RecordFormState {
return { ...emptyRecordForm(101), ...overrides }
}
// ── 表单口径 ────────────────────────────────────────────────────────────────
test('没选小项时不提交', () => {
const plan = planRecordInput(form({ eventId: null, resultText: '11.20' }))
assert.equal(plan.ok, false)
assert.equal(plan.ok === false ? plan.error : '', '请选择小项')
})
test('成绩值与成绩文本至少填一个(历史资料常常只有手抄文本)', () => {
const empty = planRecordInput(form({ resultValue: '', resultText: ' ' }))
assert.equal(empty.ok, false)
assert.equal(empty.ok === false ? empty.error : '', '成绩值或成绩文本至少填一个')
const textOnly = planRecordInput(form({ resultText: '11.20' }))
assert.equal(textOnly.ok, true)
assert.equal(textOnly.ok === true ? textOnly.input.resultText : '', '11.20')
assert.equal(textOnly.ok === true ? textOnly.input.resultValue : 1, null)
const valueOnly = planRecordInput(form({ resultValue: '11.2' }))
assert.equal(valueOnly.ok, true)
assert.equal(valueOnly.ok === true ? valueOnly.input.resultValue : 0, 11.2)
assert.equal(valueOnly.ok === true ? valueOnly.input.resultText : 'x', null)
})
test('成绩值写错时明确报错,不静默丢成空值', () => {
const plan = planRecordInput(form({ resultValue: '11.2秒' }))
assert.equal(plan.ok, false)
assert.match(plan.ok === false ? plan.error : '', /数字/)
})
test('来源固定 history;空字符串归一成 null(服务端不该存空串)', () => {
const plan = planRecordInput(
form({ resultValue: '11.2', holderName: ' 张三 ', achievedAt: '', holderTeamId: null }),
)
assert.equal(plan.ok, true)
if (plan.ok !== true) return
assert.equal(plan.input.source, 'history')
assert.equal(plan.input.holderName, '张三')
assert.equal(plan.input.achievedAt, null)
assert.equal(plan.input.holderTeamId, null)
assert.equal(plan.input.level, 'meet')
})
test('保持单位与创造日期有值时原样提交', () => {
const plan = planRecordInput(form({ resultValue: '11.2', holderTeamId: 3, achievedAt: '2025-09-01' }))
assert.equal(plan.ok, true)
if (plan.ok !== true) return
assert.equal(plan.input.holderTeamId, 3)
assert.equal(plan.input.achievedAt, '2025-09-01')
})
// ── 界面接线 ────────────────────────────────────────────────────────────────
test('纪录库卡片有「新增历史纪录」入口,表单字段齐全', () => {
assert.match(RECORDS_SOURCE, /新增历史纪录\s*<\/button>/)
const modal = section(RECORDS_SOURCE, '新增历史纪录表单', 'class="mx-modal-foot"')
assert.match(modal, /v-for="event in data\.events"/)
assert.match(modal, /v-for="option in RECORD_LEVEL_OPTIONS"/)
assert.match(modal, /createForm\.resultValue/)
assert.match(modal, /createForm\.resultText/)
assert.match(modal, /createForm\.holderName/)
assert.match(modal, /createForm\.holderTeamId/)
assert.match(modal, /type="date"/)
})
test('提交走 POST /api/records 并刷新列表与疑似推导', () => {
const submit = section(RECORDS_SOURCE, 'async function submitCreate(', 'function requestDelete(')
assert.match(submit, /const plan = planRecordInput\(createForm\.value\)/)
assert.match(submit, /await adminApi\.createRecord\(plan\.input\)/)
assert.match(submit, /await loadRecords\(\)/)
assert.match(submit, /await loadSuspected\(\)/)
assert.ok(
submit.indexOf('createRecord') < submit.indexOf('loadRecords()'),
'先写入再刷新,顺序反了会把旧数据当新数据',
)
})
test('表单校验不通过时把错误显示出来,不发请求', () => {
const submit = section(RECORDS_SOURCE, 'async function submitCreate(', 'function requestDelete(')
assert.match(submit, /if \(!plan\.ok\) \{\n\s*createError\.value = plan\.error\n\s*return\n\s*\}/)
assert.match(RECORDS_SOURCE, /v-if="createError !== ''"/)
})
test('每行一个「删除」,确认框说清「当前纪录会恢复上一条」', () => {
assert.match(RECORDS_SOURCE, />\s*删除\s*<\/button>/)
assert.match(RECORDS_SOURCE, /await adminApi\.removeRecord\(record\.id\)/)
assert.match(RECORDS_SOURCE, /若它是当前纪录,会恢复上一条/)
assert.match(RECORDS_SOURCE, /@confirm="confirmDelete"/)
})
test('列表把 source 显示出来(历史 / 本届),缺字段显示 —', () => {
assert.match(RECORDS_SOURCE, /\{\{ recordSourceLabel\(record\.source\) \}\}/)
assert.match(RECORDS_SOURCE, /record\.source === 'history' \? 'mx-badge-neutral' : 'mx-badge-info'/)
})
test('guest 只读:新增与删除都置灰,写操作前置检查先拦示例数据', () => {
const create = section(RECORDS_SOURCE, 'function openCreate(', 'function closeCreate(')
assert.match(create, /if \(blockSampleData\('新增历史纪录'\)\) return/)
assert.match(create, /if \(!canWrite\.value\) \{/)
const remove = section(RECORDS_SOURCE, 'function requestDelete(', 'async function confirmDelete(')
assert.match(remove, /if \(blockSampleData\('删除纪录'\)\) return/)
assert.match(remove, /if \(!canWrite\.value\) \{/)
const head = section(RECORDS_SOURCE, '<span class="mx-card-title">纪录库</span>', '<div class="mx-tabs">')
assert.match(head, /:disabled="!canWrite"/)
assert.match(RECORDS_SOURCE, /:disabled="!canWrite \|\| deleting"/)
})
test('示例数据拦截用统一口径(不是各写一句提示)', () => {
const block = section(RECORDS_SOURCE, 'function blockSampleData(', 'function requestRevoke(')
assert.match(block, /if \(!data\.isMock\) return false/)
assert.match(block, /sampleDataBlockedNotice\(action\)/)
assert.match(block, /toast\.error\(notice\.title, notice\.detail\)/)
})
+43
View File
@@ -34,7 +34,10 @@ import type {
PublishPayload,
RecordBreak,
RecordBreakInput,
RecordBreakState,
RecordEntry,
RecordEntryInput,
ReopenResultPayload,
Result,
ResultQuery,
RulePack,
@@ -324,6 +327,14 @@ export interface ResultsApi {
publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]>
void(id: number, payload: VoidResultPayload): Promise<Result>
/**
* 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。
*
* 与 void 成对:作废只把 `status` 改成 withdrawn 并撤掉这一条的积分,
* 想把这一条改回来只能先 reopen 退回草稿,再用常规的存草稿 / 发布流程重新发。
* 服务端在同一事务里重算推榜,返回更新后的成绩。
*/
reopen(id: number, payload: ReopenResultPayload): Promise<Result>
}
export interface StandingsApi {
@@ -342,6 +353,27 @@ export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak>
/**
* 破纪录登记列表(`GET /api/record-breaks?state=...`,recorder 及以上可读)。
*
* 不传 state 时由服务端决定(当前返回全部);按 id 降序。
* 「已批准的破纪录」卡片要 state=approved,仪表盘的「待确认」计数要 state=pending。
*/
listBreaks(state?: RecordBreakState): Promise<RecordBreak[]>
/**
* 驳回 / 作废一条登记(`POST /api/record-breaks/{id}/reject`,原因必填)。
*
* 原本是 approved 的登记被驳回时,服务端会撤掉那笔加分并把该级别纪录恢复成上一条。
*/
rejectBreak(id: number, reason: string): Promise<RecordBreak>
/** 登记一条历史纪录(`POST /api/records`);服务端固定把 source 写成 history。 */
createRecord(input: RecordEntryInput): Promise<RecordEntry>
/**
* 删除一条纪录(`DELETE /api/records/{id}`)。
*
* 删掉的若是当前纪录(isCurrent),服务端会把同级别的上一条恢复成当前。
*/
removeRecord(id: number): Promise<void>
}
/**
@@ -590,6 +622,8 @@ function createResultsApi(http: Http): ResultsApi {
unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
reopen: (id, payload) =>
http.request<Result>(`/results/${id}/reopen`, { method: 'POST', body: payload }),
}
}
@@ -831,6 +865,15 @@ export class ApiClient {
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) =>
http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }),
// state 缺省时不带查询串:让服务端用自己的默认口径,前端不替它决定「全部」怎么写。
listBreaks: (state) =>
http.request<RecordBreak[]>('/record-breaks', {
query: state === undefined ? undefined : { state },
}),
rejectBreak: (id, reason) =>
http.request<RecordBreak>(`/record-breaks/${id}/reject`, { method: 'POST', body: { reason } }),
createRecord: (input) => http.request<RecordEntry>('/records', { method: 'POST', body: input }),
removeRecord: (id) => http.requestVoid(`/records/${id}`, { method: 'DELETE' }),
}
this.display = {
+29
View File
@@ -377,6 +377,12 @@ export interface VoidResultPayload {
requestId?: string
}
/** POST /api/results/{id}/reopen 的请求体:把已作废的一条成绩退回草稿继续录入。 */
export interface ReopenResultPayload {
reason: string
requestId?: string
}
export interface ResultQuery extends PageQuery {
eventId?: number
state?: ResultState
@@ -489,6 +495,24 @@ export interface RecordEntry {
source: RecordSource
}
/**
* POST /api/records 的请求体:登记一条历史纪录。
*
* 成绩值 / 成绩文本至少给一个——历史资料常常只有「12.34 秒」这种手抄文本,
* 也可能只有换算后的数值,两种都允许;服务端按给的字段补齐另一侧。
*/
export interface RecordEntryInput {
eventId: number
level: RecordLevel
resultValue?: number | null
resultText?: string | null
holderName?: string | null
holderTeamId?: number | null
achievedAt?: string | null
/** 服务端把新登记的历史纪录固定写成 history;字段保留是为了与响应体同构。 */
source?: RecordSource
}
export type RecordBreakState = 'pending' | 'approved' | 'rejected'
export interface RecordBreak {
@@ -502,6 +526,11 @@ export interface RecordBreak {
state: RecordBreakState
approvedBy: number | null
approvedAt: string | null
/**
* 本次登记 / 驳回的说明(驳回时写的是驳回原因)。
* 旧服务端 / 旧缓存不下发它,所以可选;界面缺字段时不渲染那一行,不显示 undefined。
*/
note?: string | null
}
export interface RecordBreakInput {