## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 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 节「服务端日志与拦截复盘」)。
90 lines
3.8 KiB
TypeScript
90 lines
3.8 KiB
TypeScript
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)
|
||
})
|