feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复
## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
operations/ 放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
interceptions/ 拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。
## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。
修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。
测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
This commit is contained in:
1 parent
8e707e1e9b
commit
e5e0944d26
32 files changed
+2017
-138
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
eventInputOf,
|
||||
formatPoints,
|
||||
@@ -17,6 +17,12 @@ import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
|
||||
import {
|
||||
DISCARD_CONFIRM_MESSAGE,
|
||||
DISCARD_CONFIRM_TITLE,
|
||||
createLeaveGuard,
|
||||
resultCellIssue,
|
||||
} from './entryGuards'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow } from '../api/scoring'
|
||||
import { entryRowLabel } from '../components/baseData/entrySelection'
|
||||
@@ -67,6 +73,7 @@ const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const data = useEventStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
@@ -84,6 +91,10 @@ const unpublishDialog = ref(false)
|
||||
const focusedEntryId = ref<number | null>(null)
|
||||
const tableRef = ref<HTMLElement | null>(null)
|
||||
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
|
||||
/** 未保存修改的确认框:切换项目与离开页面共用同一个,风格与发布确认一致。 */
|
||||
const discardDialog = ref(false)
|
||||
/** 用户确认丢弃后要执行的动作(切项目 / 重新跳转)。 */
|
||||
let pendingDiscard: (() => void) | null = null
|
||||
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
|
||||
let requestSeq = 0
|
||||
|
||||
@@ -116,6 +127,25 @@ function unitOf(type: Event['resultType']): string {
|
||||
|
||||
const unit = computed(() => unitOf(selectedEvent.value?.resultType ?? 'none'))
|
||||
|
||||
/**
|
||||
* 成绩格的示例文本。
|
||||
*
|
||||
* 必须带「例」字:光秃秃一个数字会被当成已填内容——用户实机就是这么误判成「填了 1:06」、
|
||||
* 点发布才知道那一格其实是空的。
|
||||
*/
|
||||
const RESULT_SAMPLE: Record<string, string> = {
|
||||
s: '1:06.33',
|
||||
m: '12.34',
|
||||
次: '12',
|
||||
pts: '96.5',
|
||||
}
|
||||
|
||||
const resultPlaceholder = computed(() => {
|
||||
const sample = RESULT_SAMPLE[unit.value]
|
||||
// resultType 为 none 时没有单位,给一个中性的数字例子,不留空白示例。
|
||||
return sample === undefined ? '例 12' : `例 ${sample}`
|
||||
})
|
||||
|
||||
const visibleRows = computed(() =>
|
||||
allRows.value.filter((row) => selectedCategoryId.value === null || row.categoryId === selectedCategoryId.value),
|
||||
)
|
||||
@@ -177,6 +207,46 @@ const affectedTeams = computed(() => preview.value.filter((row) => row.delta !==
|
||||
const isPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
|
||||
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
||||
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
||||
|
||||
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
|
||||
const leaveGuard = createLeaveGuard(() => dirty.value)
|
||||
|
||||
/** 有未保存修改时先弹确认框,确认后才执行动作(切换项目 / 离开页面共用)。 */
|
||||
function guardDirty(action: () => void): void {
|
||||
if (!dirty.value) {
|
||||
action()
|
||||
return
|
||||
}
|
||||
pendingDiscard = action
|
||||
discardDialog.value = true
|
||||
}
|
||||
|
||||
function confirmDiscard(): void {
|
||||
const action = pendingDiscard
|
||||
pendingDiscard = null
|
||||
discardDialog.value = false
|
||||
action?.()
|
||||
}
|
||||
|
||||
function cancelDiscard(): void {
|
||||
pendingDiscard = null
|
||||
discardDialog.value = false
|
||||
leaveGuard.cancel()
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (leaveGuard.beforeLeave(to.fullPath)) return true
|
||||
// 已拦下:等用户在这唯一一个确认框里做选择,确认后重放这次跳转。
|
||||
pendingDiscard = () => {
|
||||
const target = leaveGuard.confirm()
|
||||
if (target === null) return
|
||||
// 跳转结束后收回「已放行」:万一这次跳转被别处拦下,下次离开仍要重新确认。
|
||||
void router.push(target).finally(() => leaveGuard.reset())
|
||||
}
|
||||
discardDialog.value = true
|
||||
return false
|
||||
})
|
||||
|
||||
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
|
||||
function rowLocked(row: EditableRow): boolean {
|
||||
@@ -189,24 +259,22 @@ const issues = computed<Issue[]>(() => {
|
||||
if (event === null) return list
|
||||
const seenRanks = new Map<number, number>()
|
||||
|
||||
for (const row of visibleRows.value) {
|
||||
for (const [index, row] of visibleRows.value.entries()) {
|
||||
if (row.status !== 'ok') continue
|
||||
const needsRank = manualRank.value
|
||||
if (row.resultText.trim() === '') {
|
||||
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
|
||||
if (!(needsRank && row.rank !== null)) {
|
||||
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少成绩` })
|
||||
}
|
||||
} else {
|
||||
const value = parseResultValue(row.resultText, unit.value)
|
||||
if (value === null) {
|
||||
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:成绩格式无法解析` })
|
||||
} else if (value <= 0) {
|
||||
list.push({ entryId: row.entryId, level: 'warn', message: `${rowLabel(row)}:成绩 ≤ 0,疑似录入错误` })
|
||||
}
|
||||
const label = issueLabel(row, index)
|
||||
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
|
||||
const emptyAllowed = row.resultText.trim() === '' && needsRank && row.rank !== null
|
||||
const value = row.resultText.trim() === '' ? null : parseResultValue(row.resultText, unit.value)
|
||||
// 文案由纯函数生成:必须带出那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
|
||||
const cellIssue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit.value)
|
||||
if (cellIssue !== null) {
|
||||
list.push({ entryId: row.entryId, level: 'error', message: cellIssue.message })
|
||||
} else if (value !== null && value <= 0) {
|
||||
list.push({ entryId: row.entryId, level: 'warn', message: `${label}:成绩 ≤ 0,疑似录入错误` })
|
||||
}
|
||||
if (needsRank && row.rank === null) {
|
||||
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少名次` })
|
||||
list.push({ entryId: row.entryId, level: 'error', message: `${label}:缺少名次` })
|
||||
}
|
||||
if (needsRank && row.rank !== null) {
|
||||
seenRanks.set(row.rank, (seenRanks.get(row.rank) ?? 0) + 1)
|
||||
@@ -221,7 +289,7 @@ const issues = computed<Issue[]>(() => {
|
||||
|
||||
const record = recordForEvent.value
|
||||
if (record !== null && record.resultValue > 0) {
|
||||
for (const row of visibleRows.value) {
|
||||
for (const [index, row] of visibleRows.value.entries()) {
|
||||
const value = parseResultValue(row.resultText, unit.value)
|
||||
if (value === null || row.status !== 'ok') continue
|
||||
const ratio = value / record.resultValue
|
||||
@@ -229,7 +297,7 @@ const issues = computed<Issue[]>(() => {
|
||||
list.push({
|
||||
entryId: row.entryId,
|
||||
level: 'warn',
|
||||
message: `${rowLabel(row)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
|
||||
message: `${issueLabel(row, index)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -246,6 +314,12 @@ function rowLabel(row: EditableRow): string {
|
||||
return entryRowLabel(row)
|
||||
}
|
||||
|
||||
/** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */
|
||||
function issueLabel(row: EditableRow, index: number): string {
|
||||
const name = rowLabel(row)
|
||||
return `第 ${index + 1} 行 ${name === '' ? '(未署名)' : name}`
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Number(value.toFixed(2))
|
||||
}
|
||||
@@ -306,15 +380,14 @@ function issueFor(entryId: number): Issue | undefined {
|
||||
return issues.value.find((issue) => issue.entryId === entryId)
|
||||
}
|
||||
|
||||
/** 切换项目会丢弃当前编辑,先让操作员确认。 */
|
||||
function confirmDiscard(): boolean {
|
||||
if (!dirty.value) return true
|
||||
return window.confirm('当前有未保存的成绩修改,切换项目会丢弃这些修改。确定继续吗?')
|
||||
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
|
||||
function selectEvent(id: number): void {
|
||||
if (selectedEventId.value === id && detail.value !== null) return
|
||||
guardDirty(() => void loadEvent(id))
|
||||
}
|
||||
|
||||
async function selectEvent(id: number): Promise<void> {
|
||||
if (selectedEventId.value === id && detail.value !== null) return
|
||||
if (!confirmDiscard()) return
|
||||
/** 真正加载项目:只在确认框放行后调用,加载失败时保持当前项目不变。 */
|
||||
async function loadEvent(id: number): Promise<void> {
|
||||
selectedEventId.value = id
|
||||
const seq = (requestSeq += 1)
|
||||
loading.value = true
|
||||
@@ -594,11 +667,11 @@ async function pickInitialEvent(): Promise<void> {
|
||||
const query = route.query.eventId
|
||||
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
|
||||
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
|
||||
await selectEvent(fromQuery)
|
||||
selectEvent(fromQuery)
|
||||
return
|
||||
}
|
||||
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
|
||||
if (fallback !== undefined) await selectEvent(fallback.id)
|
||||
if (fallback !== undefined) selectEvent(fallback.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -776,15 +849,15 @@ watch(
|
||||
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
|
||||
</td>
|
||||
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
|
||||
<td>
|
||||
<td class="result-cell">
|
||||
<input
|
||||
v-model="row.resultText"
|
||||
data-cell
|
||||
class="mx-cell-input mono"
|
||||
class="mx-cell-input mono result-input"
|
||||
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
:placeholder="unit === 's' ? '1:06.33' : unit"
|
||||
:placeholder="resultPlaceholder"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
@@ -836,7 +909,7 @@ watch(
|
||||
class="mx-cell-input"
|
||||
:disabled="!canWrite || rowLocked(row)"
|
||||
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
|
||||
placeholder="—"
|
||||
placeholder="可留空"
|
||||
@focus="focusedEntryId = row.entryId"
|
||||
@blur="focusedEntryId = null"
|
||||
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
|
||||
@@ -928,6 +1001,21 @@ watch(
|
||||
@confirm="confirmUnpublish"
|
||||
@cancel="unpublishDialog = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="discardDialog"
|
||||
tone="warning"
|
||||
:title="DISCARD_CONFIRM_TITLE"
|
||||
:message="DISCARD_CONFIRM_MESSAGE"
|
||||
:details="[
|
||||
`未保存的行:${dirtyCount} 行`,
|
||||
'如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页',
|
||||
]"
|
||||
confirm-text="丢弃并继续"
|
||||
cancel-text="留在本页"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1081,6 +1169,29 @@ watch(
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
/*
|
||||
* 成绩格必须放得下 `1:06.33`:表格是自动布局,输入框 width:100% 对列宽没有贡献,
|
||||
* 这一列会被挤到连 4 位数字都看不见。
|
||||
* 12px 等宽字体下实测 `1:06.33` 最宽约 50.6px(DejaVu Sans Mono),加 20px 左右内边距
|
||||
* 与 2px 边框约需 73px;取 112px 留 ~1.5 倍余量,可容纳 12 个等宽字符。
|
||||
* 输入框与单元格各写一份 min-width:前者保证输入本身够宽,后者保证列被撑开
|
||||
* (自动布局下有些浏览器只认其中一边)。140 = 112 + 12/16px 左右内边距。
|
||||
* 单位 / 运动员列是 nowrap,最小宽度天然等于文字宽度,不会被这一列压没。
|
||||
*/
|
||||
.result-input {
|
||||
min-width: 112px;
|
||||
}
|
||||
.result-cell {
|
||||
min-width: 140px;
|
||||
}
|
||||
/*
|
||||
* 示例文本要一眼看出不是已填内容:更淡(--mx-t3)+ 斜体。
|
||||
* 只挂在录入表格范围内,不动全局 .mx-cell-input——基础数据等页面也在用它。
|
||||
*/
|
||||
.mx-table-entry .mx-cell-input::placeholder {
|
||||
color: var(--mx-t3);
|
||||
font-style: italic;
|
||||
}
|
||||
.rank-input {
|
||||
width: 72px;
|
||||
text-align: center;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ApiError,
|
||||
formatPoints,
|
||||
parseResultValue,
|
||||
type EntryWithResult,
|
||||
type Event,
|
||||
type EventCategory,
|
||||
@@ -14,6 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { adminApi } from '../api/client'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
import { resultCellIssue } from './entryGuards'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useEventStore } from '../stores/event'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
@@ -146,19 +146,19 @@ function unitOf(type: Event['resultType']): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 发布前防呆:与录入页同一套校验,避免整组发布缺成绩 / 无法解析的行。 */
|
||||
/** 发布前防呆:与录入页共用同一套文案,避免整组发布缺成绩 / 无法解析的行。 */
|
||||
function publishBlockers(group: DraftGroup): string[] {
|
||||
const unit = unitOf(group.event.resultType)
|
||||
const manual = group.event.resultMode === 'manual' || group.event.compare === 'none'
|
||||
const blockers: string[] = []
|
||||
for (const row of group.rows) {
|
||||
for (const [index, row] of group.rows.entries()) {
|
||||
if (row.status !== 'ok') continue
|
||||
const label = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||||
if (row.resultText.trim() === '') {
|
||||
if (!(manual && row.rank !== null)) blockers.push(`${label}:缺少成绩`)
|
||||
} else if (parseResultValue(row.resultText, unit) === null) {
|
||||
blockers.push(`${label}:成绩格式无法解析`)
|
||||
}
|
||||
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
|
||||
const label = `第 ${index + 1} 行 ${name}`
|
||||
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
|
||||
const emptyAllowed = row.resultText.trim() === '' && manual && row.rank !== null
|
||||
const issue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit)
|
||||
if (issue !== null) blockers.push(issue.message)
|
||||
if (manual && row.rank === null) blockers.push(`${label}:缺少名次`)
|
||||
}
|
||||
return blockers
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { parseResultValue, parseSecondsDetail } from '@/shared'
|
||||
|
||||
/**
|
||||
* 成绩录入 / 发布前的防呆守卫(纯函数)。
|
||||
*
|
||||
* 抽出来的原因:`.vue` 组件跑不进 `node --test`,而这里恰好是用户踩过的两个坑——
|
||||
* 一是成绩格的报错必须自证「那一格里到底有什么」,二是未保存的成绩不能静默丢失。
|
||||
*/
|
||||
|
||||
// ── 成绩格报错文案 ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ResultCellIssueKind = 'empty' | 'seconds-overflow' | 'unrecognized'
|
||||
|
||||
export interface ResultCellIssue {
|
||||
kind: ResultCellIssueKind
|
||||
/** 可直接展示的报错文案。 */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 那一格的实际内容:空值也必须显形,用户才能确认「我填的东西到底进没进这一格」。 */
|
||||
function cellText(text: string): string {
|
||||
const trimmed = text.trim()
|
||||
return trimmed === '' ? '(空)' : trimmed
|
||||
}
|
||||
|
||||
/** 无法识别时的可填写法提示,按项目单位区分,避免拿时间格式去教距离项目。 */
|
||||
function formatHint(unit: string): string {
|
||||
if (unit === 's') return '可用 66、66.33、1:06、1:06.33、1分06秒'
|
||||
if (unit === 'm') return '可用 12.34 这类数字(单位:米)'
|
||||
if (unit === '次') return '可用 12 这类整数(单位:次)'
|
||||
if (unit === 'pts') return '可用 12 或 12.5 这类数字(单位:分)'
|
||||
return '请填写数字'
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个成绩格是否阻止发布;通过时返回 null。
|
||||
*
|
||||
* 三种失败原因的文案必须互不混淆,且都带上那一格的实际内容:
|
||||
* 「空的」和「填错格子」看起来一样,只写「缺少成绩」会把人引去改本来就对的格式。
|
||||
*/
|
||||
export function resultCellIssue(label: string, text: string, unit: string): ResultCellIssue | null {
|
||||
if (text.trim() === '') {
|
||||
return { kind: 'empty', message: `${label}:成绩为空(当前「${cellText(text)}」),请填写成绩` }
|
||||
}
|
||||
if (parseResultValue(text, unit) !== null) return null
|
||||
|
||||
if (unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow') {
|
||||
return {
|
||||
kind: 'seconds-overflow',
|
||||
message: `${label}:秒位不能 ≥ 60(当前「${cellText(text)}」),请写成 1:06 或 66 这类`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'unrecognized',
|
||||
message: `${label}:成绩写法无法识别(当前「${cellText(text)}」),${formatHint(unit)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 未保存修改的离开守卫 ────────────────────────────────────────────────────
|
||||
|
||||
export const DISCARD_CONFIRM_TITLE = '有未保存的成绩修改'
|
||||
export const DISCARD_CONFIRM_MESSAGE = '未保存的成绩修改会被丢弃,且无法恢复。'
|
||||
|
||||
export interface LeaveGuard {
|
||||
/** onBeforeRouteLeave 的判定:true 放行;false 表示已拦下,等用户确认。 */
|
||||
beforeLeave(target: string): boolean
|
||||
/** 用户确认离开时调用:返回需要重新跳转的目标,null 表示没有待确认的离开。 */
|
||||
confirm(): string | null
|
||||
/** 用户选择留在本页。 */
|
||||
cancel(): void
|
||||
/** 重新跳转结束后调用,清除「已放行」;跳转被别处拦下时不能让下次离开静默通过。 */
|
||||
reset(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由离开守卫的状态机。
|
||||
*
|
||||
* onBeforeRouteLeave 不能 await 一个确认框,所以拆成「先拦下 → 用户确认 → 重新跳转放行」:
|
||||
* 拦下时记住目标地址,确认后由页面用 router.push 重放,重放那一次必须放行,
|
||||
* 否则守卫会把自己拦下来变成死循环。
|
||||
*/
|
||||
export function createLeaveGuard(isDirty: () => boolean): LeaveGuard {
|
||||
let pending: string | null = null
|
||||
let released = false
|
||||
return {
|
||||
beforeLeave(target) {
|
||||
if (released) {
|
||||
released = false
|
||||
return true
|
||||
}
|
||||
if (!isDirty()) return true
|
||||
pending = target
|
||||
return false
|
||||
},
|
||||
confirm() {
|
||||
const target = pending
|
||||
pending = null
|
||||
if (target === null) return null
|
||||
released = true
|
||||
return target
|
||||
},
|
||||
cancel() {
|
||||
pending = null
|
||||
},
|
||||
reset() {
|
||||
pending = null
|
||||
released = false
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
|
||||
|
||||
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
|
||||
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
|
||||
test('空格子:文案说明成绩为空并带出「(空)」', () => {
|
||||
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
|
||||
assert.equal(issue?.kind, 'empty')
|
||||
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
|
||||
})
|
||||
|
||||
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
|
||||
const issue = resultCellIssue('第 3 行 张三', '1:70', 's')
|
||||
assert.equal(issue?.kind, 'seconds-overflow')
|
||||
assert.equal(issue?.message, '第 3 行 张三:秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类')
|
||||
})
|
||||
|
||||
test('写法不认识:文案带出原内容和可填写法', () => {
|
||||
const issue = resultCellIssue('第 3 行 张三', '约1分06秒', 's')
|
||||
assert.equal(issue?.kind, 'unrecognized')
|
||||
assert.equal(
|
||||
issue?.message,
|
||||
'第 3 行 张三:成绩写法无法识别(当前「约1分06秒」),可用 66、66.33、1:06、1:06.33、1分06秒',
|
||||
)
|
||||
})
|
||||
|
||||
test('三种失败原因的文案互不相同,且都能定位到那一格', () => {
|
||||
const empty = resultCellIssue('第 3 行 张三', '', 's')
|
||||
const overflow = resultCellIssue('第 3 行 张三', '1:70', 's')
|
||||
const unknown = resultCellIssue('第 3 行 张三', '快点跑', 's')
|
||||
const messages = [empty, overflow, unknown].map((issue) => issue?.message ?? '')
|
||||
assert.equal(new Set(messages).size, 3)
|
||||
for (const message of messages) {
|
||||
assert.ok(message.startsWith('第 3 行 张三:'), message)
|
||||
}
|
||||
assert.ok(messages[1]?.includes('「1:70」'))
|
||||
assert.ok(messages[2]?.includes('「快点跑」'))
|
||||
assert.ok(messages[0]?.includes('「(空)」'))
|
||||
})
|
||||
|
||||
test('容错后的写法不再报错', () => {
|
||||
for (const text of ['1:06', '1:06', '1分06秒', '1分6秒', '1分06', '66', '66.33']) {
|
||||
assert.equal(resultCellIssue('第 1 行 李四', text, 's'), null, text)
|
||||
}
|
||||
})
|
||||
|
||||
test('距离 / 计数项目的文案不套用时间格式提示', () => {
|
||||
const issue = resultCellIssue('第 2 行 王五', '1.2.3', 'm')
|
||||
assert.equal(issue?.kind, 'unrecognized')
|
||||
assert.ok(issue?.message.includes('(当前「1.2.3」)'))
|
||||
assert.ok(issue?.message.includes('米'))
|
||||
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
|
||||
})
|
||||
|
||||
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
|
||||
test('未保存时离开被拦下,确认后重放放行', () => {
|
||||
let dirty = true
|
||||
const guard = createLeaveGuard(() => dirty)
|
||||
|
||||
assert.equal(guard.beforeLeave('/dashboard'), false)
|
||||
assert.equal(guard.confirm(), '/dashboard')
|
||||
assert.equal(guard.beforeLeave('/dashboard'), true)
|
||||
|
||||
// 用户取消后仍然拦下。
|
||||
assert.equal(guard.beforeLeave('/review'), false)
|
||||
guard.cancel()
|
||||
assert.equal(guard.confirm(), null)
|
||||
assert.equal(guard.beforeLeave('/review'), false)
|
||||
|
||||
// 保存后就不再拦。
|
||||
dirty = false
|
||||
assert.equal(guard.beforeLeave('/review'), true)
|
||||
})
|
||||
|
||||
test('重放没走到守卫时,重新跳转结束仍要重新确认', () => {
|
||||
const guard = createLeaveGuard(() => true)
|
||||
assert.equal(guard.beforeLeave('/dashboard'), false)
|
||||
assert.equal(guard.confirm(), '/dashboard')
|
||||
// 这次跳转被别处直接拦下:reset 必须在下次离开前收回「已放行」。
|
||||
guard.reset()
|
||||
assert.equal(guard.beforeLeave('/review'), false)
|
||||
})
|
||||
|
||||
test('没有未保存修改时直接放行,且不产生待确认的离开', () => {
|
||||
const guard = createLeaveGuard(() => false)
|
||||
assert.equal(guard.beforeLeave('/standings'), true)
|
||||
assert.equal(guard.confirm(), null)
|
||||
})
|
||||
+65
-10
@@ -30,27 +30,82 @@ export function formatSeconds(seconds: number, fractionDigits = 2): string {
|
||||
return `${sign}${body}`
|
||||
}
|
||||
|
||||
/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。 */
|
||||
export function parseSeconds(text: string): number | null {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return null
|
||||
/** 成绩格解析失败的原因:报错文案要据此区分「空格子 / 秒位越界 / 写法不认识」。 */
|
||||
export type SecondsParseFailure = 'empty' | 'seconds-overflow' | 'unrecognized'
|
||||
|
||||
const parts = trimmed.split(':')
|
||||
if (parts.length > 2) return null
|
||||
export interface SecondsParseResult {
|
||||
value: number | null
|
||||
/** value 为 null 时一定给出原因;解析成功时为 null。 */
|
||||
failure: SecondsParseFailure | null
|
||||
}
|
||||
|
||||
/** 全角数字与标点归一:中文输入法下「1:06」看起来和「1:06」一模一样。 */
|
||||
function normalizeTimeText(text: string): string {
|
||||
return text
|
||||
.replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
|
||||
.replace(/:/g, ':')
|
||||
.replace(/./g, '.')
|
||||
}
|
||||
|
||||
/** 中文时间单位写法:`1小时2分3秒` / `1分06秒` / `1分06` / `2分`。 */
|
||||
const CN_TIME_UNIT = /[小时分秒]/
|
||||
const CN_TIME_PATTERN =
|
||||
/^(?:(\d+(?:\.\d+)?)小时)?(?:(\d+(?:\.\d+)?)分)?(?:(\d+(?:\.\d+)?)秒)?(?:(\d+(?:\.\d+)?))?$/
|
||||
|
||||
function parseChineseTime(text: string): SecondsParseResult {
|
||||
const matched = CN_TIME_PATTERN.exec(text)
|
||||
if (matched === null) return { value: null, failure: 'unrecognized' }
|
||||
const [, hoursText, minutesText, secondsText, bareSecondsText] = matched
|
||||
// 一个单位都没出现时正则会匹配空串,必须挡掉,避免把纯数字也吞进来。
|
||||
if (hoursText === undefined && minutesText === undefined && secondsText === undefined) {
|
||||
return { value: null, failure: 'unrecognized' }
|
||||
}
|
||||
// 「1分06」的 06 没带「秒」字,按秒理解;已经带「秒」时以它为准。
|
||||
const secondsPart = secondsText ?? bareSecondsText
|
||||
const seconds = secondsPart === undefined ? 0 : Number(secondsPart)
|
||||
if (seconds >= 60) return { value: null, failure: 'seconds-overflow' }
|
||||
const hours = hoursText === undefined ? 0 : Number(hoursText)
|
||||
const minutes = minutesText === undefined ? 0 : Number(minutesText)
|
||||
return { value: hours * 3600 + minutes * 60 + seconds, failure: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 parseSeconds 同一套规则,但解析失败时带上原因。
|
||||
*
|
||||
* 容错只加在「同一件事的不同写法」上:全角冒号 / 全角数字、`1分06秒` 这类中文写法;
|
||||
* 秒位 ≥ 60 属于真的不合法(`1:70` 不是 70 秒),仍然拒绝,但原因单独标出来。
|
||||
*/
|
||||
export function parseSecondsDetail(text: string): SecondsParseResult {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return { value: null, failure: 'empty' }
|
||||
|
||||
const normalized = normalizeTimeText(trimmed)
|
||||
if (CN_TIME_UNIT.test(normalized)) return parseChineseTime(normalized)
|
||||
|
||||
const parts = normalized.split(':')
|
||||
if (parts.length > 2) return { value: null, failure: 'unrecognized' }
|
||||
|
||||
const numbers: number[] = []
|
||||
for (const part of parts) {
|
||||
// Number('') 是 0,会把「1:」悄悄当成 60 秒,这里按无法识别处理。
|
||||
if (part.trim() === '') return { value: null, failure: 'unrecognized' }
|
||||
const value = Number(part)
|
||||
if (!Number.isFinite(value)) return null
|
||||
if (!Number.isFinite(value)) return { value: null, failure: 'unrecognized' }
|
||||
numbers.push(value)
|
||||
}
|
||||
|
||||
if (numbers.length === 1) return numbers[0] ?? null
|
||||
if (numbers.length === 1) return { value: numbers[0] ?? null, failure: null }
|
||||
|
||||
const minutes = numbers[0] ?? 0
|
||||
const seconds = numbers[1] ?? 0
|
||||
if (seconds < 0 || seconds >= 60) return null
|
||||
return minutes * 60 + seconds
|
||||
if (seconds < 0) return { value: null, failure: 'unrecognized' }
|
||||
if (seconds >= 60) return { value: null, failure: 'seconds-overflow' }
|
||||
return { value: minutes * 60 + seconds, failure: null }
|
||||
}
|
||||
|
||||
/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。容错写法见 parseSecondsDetail。 */
|
||||
export function parseSeconds(text: string): number | null {
|
||||
return parseSecondsDetail(text).value
|
||||
}
|
||||
|
||||
/** 按单位把比较值渲染成展示文本,如 `66.33` + `s` → `1:06.33`。 */
|
||||
|
||||
@@ -35,4 +35,6 @@ export {
|
||||
formatSeconds,
|
||||
parseResultValue,
|
||||
parseSeconds,
|
||||
parseSecondsDetail,
|
||||
} from './format'
|
||||
export type { SecondsParseFailure, SecondsParseResult } from './format'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { formatClock, formatDateTime, formatSeconds } from '../format'
|
||||
import { formatClock, formatDateTime, formatSeconds, parseResultValue, parseSeconds, parseSecondsDetail } from '../format'
|
||||
|
||||
// 服务端 datetime('now') 存的是 UTC 且不带时区,必须按 UTC 解析,否则东八区整体早 8 小时。
|
||||
test('formatDateTime 把无时区时间串按 UTC 解析', () => {
|
||||
@@ -26,3 +26,59 @@ test('formatDateTime 对空值返回占位', () => {
|
||||
assert.equal(formatDateTime(null), '—')
|
||||
assert.equal(formatClock(null), '--:--:--')
|
||||
})
|
||||
|
||||
test('parseSeconds 保留原有写法', () => {
|
||||
assert.equal(parseSeconds('66'), 66)
|
||||
assert.equal(parseSeconds('66.33'), 66.33)
|
||||
assert.equal(parseSeconds('1:06'), 66)
|
||||
assert.equal(parseSeconds('1:06.33'), 66.33)
|
||||
assert.equal(parseSeconds('1:6'), 66)
|
||||
assert.equal(parseSeconds(' 1:06 '), 66)
|
||||
assert.equal(parseSeconds('0:59'), 59)
|
||||
})
|
||||
|
||||
test('parseSeconds 认得全角冒号与全角数字', () => {
|
||||
assert.equal(parseSeconds('1:06'), 66)
|
||||
assert.equal(parseSeconds('1:06.33'), 66.33)
|
||||
assert.equal(parseSeconds('1:06'), 66)
|
||||
})
|
||||
|
||||
test('parseSeconds 认得中文时间写法', () => {
|
||||
assert.equal(parseSeconds('1分06秒'), 66)
|
||||
assert.equal(parseSeconds('1分6秒'), 66)
|
||||
assert.equal(parseSeconds('1分06'), 66)
|
||||
assert.equal(parseSeconds('1分6.5秒'), 66.5)
|
||||
assert.equal(parseSeconds('2分'), 120)
|
||||
assert.equal(parseSeconds('6秒'), 6)
|
||||
assert.equal(parseSeconds('1小时2分3秒'), 3723)
|
||||
assert.equal(parseSeconds('1小时'), 3600)
|
||||
})
|
||||
|
||||
test('parseSeconds 秒位 ≥ 60 仍然拒绝,但原因可区分', () => {
|
||||
for (const text of ['1:60', '1:70', '1:70', '1分60秒', '1分70秒']) {
|
||||
assert.equal(parseSeconds(text), null, text)
|
||||
assert.equal(parseSecondsDetail(text).failure, 'seconds-overflow', text)
|
||||
}
|
||||
// 「70:06」是 70 分钟,不是秒位越界,照旧接受。
|
||||
assert.equal(parseSeconds('70:06'), 4206)
|
||||
})
|
||||
|
||||
test('parseSecondsDetail 区分空 / 秒位越界 / 写法不认识', () => {
|
||||
assert.deepEqual(parseSecondsDetail(' '), { value: null, failure: 'empty' })
|
||||
assert.deepEqual(parseSecondsDetail('1:70'), { value: null, failure: 'seconds-overflow' })
|
||||
assert.deepEqual(parseSecondsDetail('约1分06秒'), { value: null, failure: 'unrecognized' })
|
||||
assert.deepEqual(parseSecondsDetail('1:2:3'), { value: null, failure: 'unrecognized' })
|
||||
assert.deepEqual(parseSecondsDetail('abc'), { value: null, failure: 'unrecognized' })
|
||||
assert.deepEqual(parseSecondsDetail('1:'), { value: null, failure: 'unrecognized' })
|
||||
assert.deepEqual(parseSecondsDetail('1:06'), { value: 66, failure: null })
|
||||
})
|
||||
|
||||
test('parseResultValue 的距离 / 计数分支不受时间容错影响', () => {
|
||||
assert.equal(parseResultValue('12.34', 'm'), 12.34)
|
||||
assert.equal(parseResultValue('12米', 'm'), 12)
|
||||
assert.equal(parseResultValue('', 'm'), null)
|
||||
// 该分支只做「去掉非数字字符」的宽松解析:'abc' 去掉后是空串,Number('') 为 0,属既有行为。
|
||||
assert.equal(parseResultValue('abc', 'm'), 0)
|
||||
// 时间写法不归这个分支管,行为保持不变。
|
||||
assert.equal(parseResultValue('1分06秒', 'm'), 106)
|
||||
})
|
||||
Reference in new issue
Block a user