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:
AskaEth committed 2026-09-11 01:59:58 +08:00
1 parent 8e707e1e9b
commit e5e0944d26
32 files changed
+2017 -138

No files matched your search

+141 -30
View File
@@ -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;
+9 -9
View File
@@ -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
+110
View File
@@ -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
},
}
}
+89
View File
@@ -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
View File
@@ -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`。 */
+2
View File
@@ -35,4 +35,6 @@ export {
formatSeconds,
parseResultValue,
parseSeconds,
parseSecondsDetail,
} from './format'
export type { SecondsParseFailure, SecondsParseResult } from './format'
+57 -1
View File
@@ -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)
})