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;