diff --git a/docs/quality/regression-matrix.md b/docs/quality/regression-matrix.md index 0d098130..aafc1b8a 100644 --- a/docs/quality/regression-matrix.md +++ b/docs/quality/regression-matrix.md @@ -89,9 +89,40 @@ - 症状:`_cache_warmup_task` 非 CancelledError 异常会中断后续全部清理步骤;jieba 预热 await 无超时且 `to_thread` 不可取消,可挂死停机;整体停机无 deadline。 - 回归测试:`tests/test_shutdown_prewarm.py`(异常不外抛且留痕 / jieba 超时不挂死 / 正常与已取消路径)。owner:#73(Parent #6,修复 PR #77:`_shutdown_prewarm_tasks`)。 +### 管理后台批量操作静默失败 — #88 🔧 修复完成待合并(2026-09-29) +- 症状:`sources/page.tsx` `handleBatchToggle` 逐条 `sourcesApi.update` 的 `catch` 只写 `console.error`,循环结束后**无条件** `setSelectedIds(new Set())` + `fetchSources()`。批量停用 30 个信源、其中 5 个失败时,UI 表现为「全部处理完」,失败的 5 个仍按原状态继续采集并污染内容池,而操作者无从察觉。 +- 环境/前提:生产数据,仅管理员账号;`PATCH /api/v1/sources/{id}` 任一请求失败(网络抖动 / 404 / 5xx)即可触发。 +- 复现:信源管理页多选 ≥2 个信源 → 批量停用 → 中途阻断网络或使其中之一更新失败 → 观察 UI 与浏览器控制台对照。 +- 期望 vs 实际:期望逐条失败上抛 UI、成功项与失败项分别反馈;实际只有 console 记录,UI 无任何差异。 +- 边界:仅前端 `sources/page.tsx`;**不改写入语义**,只改失败可见性与选择态保留。 +- 严重度:P0(数据污染且不可察觉);**复现性**:conditional(依赖批量中至少一条更新失败)。 +- 回归测试:`npx vitest run src/app/admin/sources/_batch-utils.test.ts`(11 条)。选择态重算与结果文案已抽为纯函数 `_batch-utils.ts`,`.tsx` 只做调用;已用变异测试验证——把实现改回旧行为(失败进 console + 无条件清空)后 3 条转红,其中包含「中途新勾选项被静默丢弃」这条。 +- owner:#88。 + +### Webhook 日志页读数口径撒谎 — #88 🔧 修复完成待合并(2026-09-29) +- 症状:`webhook-logs/page.tsx` 的 `successCount` / `failCount` 只统计**当前页** 30 行,却与全局 `total` 并排渲染成同款 Badge。读者会把「本页 2 失败」除以「共 1240 条」读成 0.16% 失败率,真相是第 5 页可能还躺着 40 条。更严重的是失败徽章以 `failCount > 0` 为条件渲染——翻到失败为 0 的分页时「失败」整枚徽章消失,**徽章的缺席本身制造错误信念**。 +- 环境/前提:任何有 ≥1 页推送日志的生产数据。 +- 复现:`/admin/webhook-logs` 翻页,观察任意分页顶部的 Badge 组。 +- 期望 vs 实际:期望页内计数带明确口径标注、失败数显式渲染;实际两者视觉同层且口径未标注,失败为 0 时徽章缺席。 +- 边界:仅前端展示层;**不改数值来源**,不改 API。 +- 严重度:P1(对交付健康度给出错误读数,不直接损坏数据);**复现性**:always。 +- 回归测试:`npx vitest run src/app/admin/webhook-logs/_scope-utils.test.ts`(15 条)。徽章**构造**(口径标注 + 无条件渲染)已抽为 `buildSummaryBadges` 纯函数并被锁住。 + - **覆盖边界(独立复核 M3 实证,勿夸大)**:修复前 `summarizeLogPage` 的 6 个公式与修复后逐字相同,只测它**证明不了**本缺陷;真正的修复是把「渲染成什么」下沉成 `buildSummaryBadges`,其断言(失败徽章 0 时仍存在且为中性色、全部徽章带「本页」/「全部」口径词、空页只留 1 枚)才真正对应缺陷本身。JSX 渲染层仍无组件测试(见下方遗留项)。 +- owner:#88。 + +### 管理后台面包屑缺 4 项映射 — #88 🔧 修复完成待合并(2026-09-29) +- 症状:`AdminTopBar.tsx` 的 `ADMIN_PAGE_LABELS` 只有 10 条,侧边栏 `ADMIN_NAV_ITEMS` 有 15 项。`prompts` / `scoring-dashboard` / `evidence` / `webhook-logs` 四页无映射,`findPageLabel` 回退显示兜底文案「管理」,与侧边栏自相矛盾。(另经核实:概览卡片 13 张、面包屑 10 条、侧边栏 15 项三份目录互相矛盾,说明分类从未被写下来过。) +- 环境/前提:always。 +- 复现:访问上述四页之一,观察顶栏面包屑第二段。 +- 期望 vs 实际:期望显示真实页面名;**实际显示「概览」**(独立复核更正:实现者原记为「管理」有误——旧实现里 `/admin` 本身是前缀匹配键,`/admin/prompts` 先命中 `/admin/` 落成「概览」,压根走不到兜底文案。真实症状比原记录更严重:不是泛化文案,而是谎称在概览页)。 +- 边界:前端。实际改动含 `AdminTopBar.tsx` + `AdminSidebar.tsx` 重构 + 新增 `src/lib/admin-nav.ts` 单一事实源(实现者原记为「仅 AdminTopBar.tsx」,有误)。 +- 严重度:P2(说错话的廉价困惑,不改变任何决定的对错);**复现性**:always。 +- 回归测试:`npx vitest run src/lib/__tests__/admin-nav.test.ts`(13 条)。导航清单与面包屑映射合并到 `src/lib/admin-nav.ts` 单一事实源,映射由清单派生;`nav-checklist` 等价断言(壳内每项都有映射、label 与清单逐项一致)已在其中,漏项即红。顺带修掉一个被测试抓出的旧行为:`/admin/任何未知路径` 曾因壳根前缀匹配被判成「概览」,现已回退兜底。 +- owner:#88。 + ## 三、关键流程基线(9 项) -状态标记:✅ = 2026-09-27 在 main @ 7203847 新鲜复跑通过;📋 = 现有套件覆盖、未逐项复跑(跑全量即覆盖)。 +状态标记:✅ = 2026-09-27 在 main @ 7203847 新鲜复跑通过;📋 = 现有套件覆盖、未逐项复跑(跑全量即覆盖);🔧 = 修复完成待合并(PR 已开、CI 绿、独立复核非 pass 尚未全部处置)。 | # | 关键流程 | 验证命令(backend/ 下,遵守§一) | 状态 | |---|---|---|---| diff --git a/frontend/src/app/admin/sources/_batch-utils.test.ts b/frontend/src/app/admin/sources/_batch-utils.test.ts new file mode 100644 index 00000000..2bc8f424 --- /dev/null +++ b/frontend/src/app/admin/sources/_batch-utils.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { + recomputeSelectionAfterBatch, + describeBatchResult, + runBatchToggle, +} from './_batch-utils'; + +/** + * 独立复核 M4/M4b:删掉 `.tsx` 里的 `failedIds.push(id)` 或整段横幅渲染后, + * 179 条测试全绿存活——因为「失败不可见」的执行层原本写在组件里。 + * 以下断言直接锁住执行与收集本身。 + */ +describe('runBatchToggle — 缺陷 #1 的真修复(#88)', () => { + const makeUpdate = (failIds: number[]) => { + const calls: number[] = []; + const update = async (id: number) => { + calls.push(id); + if (failIds.includes(id)) throw new Error(`boom ${id}`); + }; + return { update, calls }; + }; + + it('全部成功时返回空失败列表', async () => { + const { update, calls } = makeUpdate([]); + expect(await runBatchToggle([1, 2, 3], update)).toEqual([]); + expect(calls).toEqual([1, 2, 3]); + }); + + it('收集每一条失败的 id,顺序与入参一致(#88 核心修复)', async () => { + const { update } = makeUpdate([2, 5]); + expect(await runBatchToggle([1, 2, 3, 4, 5], update)).toEqual([2, 5]); + }); + + it('单条失败不得中断整批——后续条目仍会被执行', async () => { + const { update, calls } = makeUpdate([1]); + const failed = await runBatchToggle([1, 2, 3], update); + expect(calls).toEqual([1, 2, 3]); + expect(failed).toEqual([1]); + }); + + it('每条失败都触发 onError 回调(console 留痕等副作用)', async () => { + const { update } = makeUpdate([1, 3]); + const seen: number[] = []; + await runBatchToggle([1, 2, 3], update, (id) => seen.push(id)); + expect(seen).toEqual([1, 3]); + }); + + it('成功条目不触发 onError', async () => { + const { update } = makeUpdate([2]); + const seen: number[] = []; + await runBatchToggle([1, 2, 3], update, (id) => seen.push(id)); + expect(seen).toEqual([2]); + }); + + it('onError 自身抛错不得影响失败收集结果', async () => { + const { update } = makeUpdate([1, 2]); + const failed = await runBatchToggle([1, 2, 3], update, () => { + throw new Error('callback exploded'); + }); + expect(failed).toEqual([1, 2]); + }); + + it('空批次不调用 update', async () => { + const { update, calls } = makeUpdate([]); + expect(await runBatchToggle([], update)).toEqual([]); + expect(calls).toEqual([]); + }); + + it('重复 id 的失败会被如实计入两次(不去重,避免谎报成功数)', async () => { + const { update } = makeUpdate([1]); + expect(await runBatchToggle([1, 1], update)).toEqual([1, 1]); + }); +}); + +/** + * #88 回归:批量启停的失败可见性与选择态重算。 + * + * 旧实现(修复前):逐条 catch 只 console.error,循环后无条件 + * setSelectedIds(new Set()),UI 报告「全部处理完」而实际有 N 条未改。 + */ + +describe('recomputeSelectionAfterBatch', () => { + it('全部成功时清空选择态', () => { + const prev = new Set([1, 2, 3]); + const next = recomputeSelectionAfterBatch(prev, [1, 2, 3], []); + expect(next.size).toBe(0); + }); + + it('失败项保持选中,便于直接重试(#88 核心行为)', () => { + const prev = new Set([1, 2, 3, 4, 5]); + const next = recomputeSelectionAfterBatch(prev, [1, 2, 3, 4, 5], [2, 4]); + expect([...next].sort((a, b) => a - b)).toEqual([2, 4]); + }); + + it('执行期间用户新勾选的项不得被静默丢弃(自审发现的回归)', () => { + // 批量是逐条 await,期间复选框未锁定:用户中途勾了 99(本批快照内没有) + const prev = new Set([1, 2, 3, 99]); + const batchIds = [1, 2, 3]; + const next = recomputeSelectionAfterBatch(prev, batchIds, [2]); + + // 失败项 2 保留 + expect(next.has(2)).toBe(true); + // 成功项 1、3 清除 + expect(next.has(1)).toBe(false); + expect(next.has(3)).toBe(false); + // 中途新勾选的 99 绝不能丢 + expect(next.has(99)).toBe(true); + }); + + it('中途取消勾选失败项后,它仍会被重新勾上——这是有意的重试入口,不是 bug', () => { + // 独立复核指出:批量期间复选框未锁定,用户中途取消勾选一个注定失败的项, + // 执行结束后它会被无条件加回。批量按钮虽 disabled={batchProcessing}, + // 行复选框没有,所以这个状态确实可达。 + // 契约选择:失败项一律重新入选,保证「失败即可重试」不因用户误操作丢失。 + const prev = new Set([1, 3]); // 2 已被用户取消勾选 + const next = recomputeSelectionAfterBatch(prev, [1, 2, 3], [2]); + expect(next.has(2)).toBe(true); + }); + + it('不修改传入的 prevSelection(原 Set 不被就地改写)', () => { + const prev = new Set([1, 2, 3]); + recomputeSelectionAfterBatch(prev, [1, 2, 3], [2]); + expect([...prev].sort((a, b) => a - b)).toEqual([1, 2, 3]); + }); + + it('空批次返回空选择态', () => { + expect(recomputeSelectionAfterBatch(new Set(), [], []).size).toBe(0); + }); +}); + +describe('describeBatchResult', () => { + it('全部成功 → teal,报告处理条数', () => { + const r = describeBatchResult(30, [], false); + expect(r.tone).toBe('teal'); + expect(r.text).toContain('已停用 30 个信源'); + }); + + it('全部成功时动词随 enabled 变化', () => { + expect(describeBatchResult(3, [], true).text).toContain('已启用 3 个信源'); + }); + + it('部分失败 → red,列出失败数与失败 id(#88 核心行为)', () => { + const r = describeBatchResult(30, [12, 47], true); + expect(r.tone).toBe('red'); + expect(r.text).toContain('已启用 28 个信源'); + expect(r.text).toContain('2 个失败'); + expect(r.text).toContain('12、47'); + }); + + it('部分失败时成功数 = 总数 - 失败数', () => { + const r = describeBatchResult(5, [1, 2, 3, 4], false); + expect(r.text).toContain('已停用 1 个信源'); + expect(r.text).toContain('4 个失败'); + }); + + it('全部失败时成功数为 0,且不为 teal', () => { + const r = describeBatchResult(2, [7, 8], false); + expect(r.tone).toBe('red'); + expect(r.text).toContain('已停用 0 个信源'); + }); +}); diff --git a/frontend/src/app/admin/sources/_batch-utils.ts b/frontend/src/app/admin/sources/_batch-utils.ts new file mode 100644 index 00000000..d2b0b009 --- /dev/null +++ b/frontend/src/app/admin/sources/_batch-utils.ts @@ -0,0 +1,114 @@ +/** + * 信源批量启停:选择态重算与结果反馈(#88)。 + * + * 抽成纯函数而不是就地写进 `.tsx` 的原因:本项目 vitest 为 + * `environment: 'node'` + `include` 限定 `src` 下的 `.test.ts`, + * `.tsx` 组件行为完全不在覆盖内(CI 五 job 也无 eslint)。而批量选择态的 + * 重算恰恰是 #88 修复中最微妙的一段逻辑——必须能回归,否则「中途新勾选项 + * 被静默丢弃」这类错误下次还会回来,且没有任何机器能发现。 + */ + +/** 批量操作的可见结果。tone 决定横幅配色。 */ +export interface BatchResult { + tone: 'teal' | 'red'; + text: string; +} + +/** + * 批量执行完成后重算选择态。 + * + * 规则(缺一不可): + * 1. 本批失败项**保持选中**,便于操作者直接重试,不必回列表里重新勾选。 + * 2. 本批成功项**清除选中**。 + * 3. 执行期间(批量是逐条 await,期间 UI 未锁定复选框)用户新勾选的项, + * 若不在本批快照内,必须**保留**——它们从未参与本批操作,无权被这次 + * 重算静默丢弃。 + * + * @param prevSelection 批量结束瞬间的当前选择态 + * @param batchIds 本批操作的 id 快照(循环开始时捕获) + * @param failedIds 本批中更新失败的 id + */ +export function recomputeSelectionAfterBatch( + prevSelection: ReadonlySet, + batchIds: readonly number[], + failedIds: readonly number[], +): Set { + const batch = new Set(batchIds); + const failed = new Set(failedIds); + const next = new Set(); + + for (const id of failed) { + next.add(id); + } + for (const id of prevSelection) { + if (!batch.has(id)) next.add(id); + } + return next; +} + +/** + * 逐条执行批量启停并收集失败项——**#88 缺陷 #1 的真修复就在这里**。 + * + * 此前「catch 里记 failedIds」这段写在 `.tsx` 的 for 循环里,`.tsx` 在本仓库 + * 结构性不可测(无 jsdom/testing-library),独立复核的变异测试 M4 删掉 + * `failedIds.push(id)` 后 179 条测试全绿存活。把执行与收集也变成纯函数, + * 「失败不可见」这个缺陷才真正被钉住。 + * + * 关键语义:**单条失败不得中断整批**——修复前的循环正是这么吞掉的, + * UI 报告「全部处理完」而实际有 N 条未改。 + * + * @param ids 本批 id 快照(循环开始时捕获,不受执行期间用户操作影响) + * @param update 单条更新操作,抛错即视为该条失败 + * @param onError 失败回调(用于 console 留痕等副作用),不影响返回值 + * @returns 失败 id 列表,顺序与 `ids` 一致 + */ +export async function runBatchToggle( + ids: readonly number[], + update: (id: number) => Promise, + onError?: (id: number, err: unknown) => void, +): Promise { + const failedIds: number[] = []; + for (const id of ids) { + try { + await update(id); + } catch (err) { + // onError 是留痕用的旁路,自身抛错绝不能中断整批——否则一个日志回调 + // 就能让「收集失败」退化成「整批中止」,反而比原缺陷更糟。 + try { + onError?.(id, err); + } catch { + // 留痕失败不重抛:调用方需要的是失败 id 列表,不是异常 + } + failedIds.push(id); + } + } + return failedIds; +} + +/** + * 生成批量操作的结果反馈文案。 + * + * 全部成功给 teal;只要有一条失败就给 red 并逐条列出失败 id——失败不可见 + * 是 #88 的原始缺陷:旧实现只 `console.error`,UI 报告「全部处理完」, + * 实际失败的信源仍按原状态继续采集并污染内容池。 + */ +export function describeBatchResult( + total: number, + failedIds: readonly number[], + enabled: boolean, +): BatchResult { + const verb = enabled ? '启用' : '停用'; + const failedCount = failedIds.length; + + if (failedCount === 0) { + return { tone: 'teal', text: `已${verb} ${total} 个信源。` }; + } + + const succeededCount = total - failedCount; + return { + tone: 'red', + text: + `已${verb} ${succeededCount} 个信源,${failedCount} 个失败并保持选中:` + + `${failedIds.join('、')}。请检查网络或该信源状态后重试。`, + }; +} diff --git a/frontend/src/app/admin/sources/page.tsx b/frontend/src/app/admin/sources/page.tsx index 29ac3b2b..ff69658e 100644 --- a/frontend/src/app/admin/sources/page.tsx +++ b/frontend/src/app/admin/sources/page.tsx @@ -28,6 +28,7 @@ import { SourceMapCard, SourceMapView } from './_components'; import { AddSourceModal, BatchImportModal, EditSourceModal } from './_modals'; import { EvidenceProfileModal } from '@/components/EvidenceProfileModal'; import { RSSHubManager, SourceListPanel, FeatureFlagsPanel } from './_panels'; +import { describeBatchResult, recomputeSelectionAfterBatch, runBatchToggle } from './_batch-utils'; // ─── Page Component ─── @@ -426,6 +427,8 @@ export default function SourcesPage() { // 批量选择:多选 + 批量启用/暂停 const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchProcessing, setBatchProcessing] = useState(false); + // 批量操作结果反馈(成功 / 部分失败)。失败必须可见——见 handleBatchToggle。 + const [batchResult, setBatchResult] = useState<{ tone: 'teal' | 'red'; text: string } | null>(null); const handleSelectSource = (source: BackendSource, checked: boolean) => { setSelectedIds((prev) => { const next = new Set(prev); @@ -445,19 +448,22 @@ export default function SourcesPage() { const handleBatchToggle = async (enabled: boolean) => { if (selectedIds.size === 0 || batchProcessing) return; setBatchProcessing(true); + setBatchResult(null); + const ids = Array.from(selectedIds); try { - // 循环调 update 端点(已存在,单 source 调) - const ids = Array.from(selectedIds); - for (const id of ids) { - try { - await sourcesApi.update(id, { enabled }); - } catch (err) { - console.error(`Batch toggle source ${id} failed:`, err); - } - } - setSelectedIds(new Set()); + // 逐条执行与失败收集见 _batch-utils.ts(纯函数,有单测覆盖)。 + // 单条失败不中断整批,但必须被收集并上抛到 UI。 + const failedIds = await runBatchToggle( + ids, + (id) => sourcesApi.update(id, { enabled }), + (id, err) => console.error(`Batch toggle source ${id} failed:`, err), + ); + // 执行完成后重建选择态:失败项保持选中以便直接重试。 + setSelectedIds((prev) => recomputeSelectionAfterBatch(prev, ids, failedIds)); await fetchSources(); await fetchSourceMap(); + + setBatchResult(describeBatchResult(ids.length, failedIds, enabled)); } finally { setBatchProcessing(false); } @@ -852,6 +858,10 @@ export default function SourcesPage() { setError(null)}>{error} )} + {batchResult && ( + setBatchResult(null)}>{batchResult.text} + )} + {/* Status filter tabs — applies to all source list views */}
状态筛选: diff --git a/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts b/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts new file mode 100644 index 00000000..cfb8a761 --- /dev/null +++ b/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeLogPage, buildSummaryBadges } from './_scope-utils'; + +/** + * #88 回归:Webhook 日志页的读数口径。 + * + * 旧实现(修复前):页内 30 行的 successCount/failCount 与全局 total 并排 + * 渲染成同款 Badge,诱导 0.16% 失败率的误读;且失败徽章以 failCount > 0 + * 为条件渲染,0 失败时整枚徽章消失——徽章缺席本身即错误信念。 + */ + +const PAGE_SIZE = 30; + +/** 造 n 条日志,其中 failures 条为失败。 */ +const makeLogs = (n: number, failures: number) => + Array.from({ length: n }, (_, i) => ({ success: i >= failures })); + +describe('summarizeLogPage', () => { + it('只对当前页求和,绝不把全局 total 混进页内计数(#88 核心不变量)', () => { + // 全局 1240 条,当前页 30 条里 2 条失败 + const s = summarizeLogPage(makeLogs(30, 2), 1240, 0, PAGE_SIZE); + expect(s.successCount).toBe(28); + expect(s.failCount).toBe(2); + // 失败率不能被读成 2/1240:页内计数与全局 total 是两条不相加的口径。 + // 用「换 total 不改变页内计数」来证明,而非断言一个不可能产出的常量。 + const samePage = summarizeLogPage(makeLogs(30, 2), 99999, 0, PAGE_SIZE); + expect(samePage.successCount).toBe(s.successCount); + expect(samePage.failCount).toBe(s.failCount); + }); + + it('中段分页的页码范围正确', () => { + const s = summarizeLogPage(makeLogs(30, 0), 1240, 900, PAGE_SIZE); + expect(s.pageStart).toBe(901); + expect(s.pageEnd).toBe(930); + expect(s.currentPage).toBe(31); + }); + + it('末页不足一页时,页尾不超出实际条数', () => { + const s = summarizeLogPage(makeLogs(10, 0), 1240, 1230, PAGE_SIZE); + expect(s.pageStart).toBe(1231); + expect(s.pageEnd).toBe(1240); + expect(s.hasNext).toBe(false); + }); + + it('空页的范围为 0-0,不产出假区间', () => { + const s = summarizeLogPage([], 1240, 30, PAGE_SIZE); + expect(s.pageStart).toBe(0); + expect(s.pageEnd).toBe(0); + expect(s.successCount).toBe(0); + expect(s.failCount).toBe(0); + }); + + it('空数据集时 totalPages 为 1 而非 0(避免除零渲染)', () => { + const s = summarizeLogPage([], 0, 0, PAGE_SIZE); + expect(s.totalPages).toBe(1); + expect(s.currentPage).toBe(1); + }); + + it('首页无上一页、末页无下一页', () => { + const first = summarizeLogPage(makeLogs(30, 0), 1240, 0, PAGE_SIZE); + expect(first.hasPrev).toBe(false); + expect(first.hasNext).toBe(true); + + const last = summarizeLogPage(makeLogs(10, 0), 1240, 1230, PAGE_SIZE); + expect(last.hasPrev).toBe(true); + expect(last.hasNext).toBe(false); + }); + + it('每一条日志恰被计入成功或失败之一(逐条核对,不依赖构造性恒等式)', () => { + for (const failures of [0, 1, 15, 30]) { + const logs = makeLogs(30, failures); + const s = summarizeLogPage(logs, 999, 0, PAGE_SIZE); + const counted = logs.filter((l) => l.success).length; + expect(s.successCount).toBe(counted); + expect(s.failCount).toBe(logs.filter((l) => !l.success).length); + } + }); + + it('失败为 0 时 failCount 显式为 0——供 UI 无条件渲染而非缺席', () => { + const s = summarizeLogPage(makeLogs(30, 0), 1240, 0, PAGE_SIZE); + expect(s.failCount).toBe(0); + }); +}); + +/** + * 独立复核 M3:把修复前的 JSX 整段还原后,summarizeLogPage 的断言全部存活 + * ——因为缺陷 #2 的真修复(口径标注 + 徽章无条件渲染)原本写在 JSX 里。 + * 以下断言直接锁住「渲染成什么」,M3 变异会转红。 + */ +describe('buildSummaryBadges — 缺陷 #2 的真修复(#88)', () => { + const badgesFor = (logs: ReturnType, total: number, offset = 0) => + buildSummaryBadges(summarizeLogPage(logs, total, offset, PAGE_SIZE), total); + + it('全局计数与页内计数都带口径词,读者无法再把两者混算', () => { + const labels = badgesFor(makeLogs(30, 2), 1240).map((b) => b.label); + expect(labels[0]).toBe('全部 1240 条'); + expect(labels.some((l) => l.startsWith('本页成功'))).toBe(true); + expect(labels.some((l) => l.startsWith('本页失败'))).toBe(true); + // 不得出现不带口径词的裸「成功 N」/「失败 N」 + expect(labels).not.toContain('成功 28'); + expect(labels).not.toContain('失败 2'); + }); + + it('失败徽章无条件渲染:0 失败时显式存在且为中性色(#88 核心修复)', () => { + const badges = badgesFor(makeLogs(30, 0), 1240); + const fail = badges.find((b) => b.key === 'page-fail'); + expect(fail).toBeDefined(); + expect(fail?.label).toBe('本页失败 0'); + expect(fail?.tone).toBe('neutral'); + }); + + it('有失败时失败徽章转为红色警示', () => { + const fail = badgesFor(makeLogs(30, 2), 1240).find((b) => b.key === 'page-fail'); + expect(fail?.tone).toBe('red'); + expect(fail?.label).toBe('本页失败 2'); + }); + + it('成功徽章恒为 teal,不随失败数变色', () => { + const withFail = badgesFor(makeLogs(30, 5), 1240).find((b) => b.key === 'page-success'); + const without = badgesFor(makeLogs(30, 0), 1240).find((b) => b.key === 'page-success'); + expect(withFail?.tone).toBe('teal'); + expect(without?.tone).toBe('teal'); + }); + + it('空页只留全局计数,不产出「本页 0-0」假区间', () => { + const badges = badgesFor([], 1240, 30); + expect(badges).toHaveLength(1); + expect(badges[0].label).toBe('全部 1240 条'); + }); + + it('非空页恒为 4 枚徽章,key 唯一', () => { + const badges = badgesFor(makeLogs(30, 1), 1240); + expect(badges).toHaveLength(4); + expect(new Set(badges.map((b) => b.key)).size).toBe(4); + }); + + it('中段分页的页码范围出现在徽章文案里', () => { + const range = badgesFor(makeLogs(30, 0), 1240, 900).find((b) => b.key === 'page-range'); + expect(range?.label).toBe('本页 901-930'); + }); +}); diff --git a/frontend/src/app/admin/webhook-logs/_scope-utils.ts b/frontend/src/app/admin/webhook-logs/_scope-utils.ts new file mode 100644 index 00000000..cbe13dd2 --- /dev/null +++ b/frontend/src/app/admin/webhook-logs/_scope-utils.ts @@ -0,0 +1,112 @@ +/** + * Webhook 日志页的口径摘要(#88)。 + * + * 本模块存在的唯一理由是守住一条不变量:**页内计数与全局 total 必须分开表述**。 + * + * 旧实现里 `successCount` / `failCount` 只统计当前页 PAGE_SIZE 行,却与全局 + * `total` 并排渲染成同款 Badge,读者会把「本页 2 失败」除以「共 1240 条」 + * 读成 0.16% 失败率——而真相是第 5 页可能还躺着 40 条。Webhook 是产品交付 + * 通道,这个读数错了等于对交付健康度撒谎。 + * + * 另一个不变量:失败数为 0 时**显式渲染 0**,而不是让徽章整枚消失。 + * 徽章的缺席本身就在制造错误信念。 + */ + +/** 判定成功/失败所需的最小日志形状。 */ +export interface LogOutcome { + success: boolean; +} + +export interface LogPageSummary { + /** 当前页首条在全局中的序号(1-based);空页为 0。 */ + pageStart: number; + /** 当前页末条在全局中的序号(1-based);空页为 0。 */ + pageEnd: number; + /** 当前页成功数。 */ + successCount: number; + /** 当前页失败数。 */ + failCount: number; + hasPrev: boolean; + hasNext: boolean; + currentPage: number; + totalPages: number; +} + +/** + * 计算当前分页的口径摘要。 + * + * 全部计数只对**当前页**求和,total 只参与分页边界计算,两条口径不相加。 + */ +export function summarizeLogPage( + logs: readonly LogOutcome[], + total: number, + offset: number, + pageSize: number, +): LogPageSummary { + let successCount = 0; + for (const log of logs) { + if (log.success) successCount += 1; + } + + return { + pageStart: logs.length === 0 ? 0 : offset + 1, + pageEnd: logs.length === 0 ? 0 : offset + logs.length, + successCount, + failCount: logs.length - successCount, + hasPrev: offset > 0, + hasNext: offset + pageSize < total, + currentPage: Math.floor(offset / pageSize) + 1, + totalPages: Math.ceil(total / pageSize) || 1, + }; +} + +/** 顶部徽章的呈现描述。tone 为 null 表示该徽章不渲染。 */ +export interface SummaryBadge { + key: string; + label: string; + tone: 'neutral' | 'teal' | 'red'; + icon: 'check' | 'x' | 'none'; +} + +/** + * 构建顶部徽章组——**#88 缺陷 #2 的真修复就在这里**。 + * + * 此前该修复写在 JSX 里,`.tsx` 在本仓库结构性不可测(无 jsdom/testing-library), + * 独立复核的变异测试 M3 把整段 JSX 还原成修复前形态后 179 条测试全绿存活。 + * 把「渲染成什么」也变成纯函数,修复才真正被钉住。 + * + * 三条硬规则(每条都对应一个曾经的错误信念): + * 1. 全局计数与页内计数**视觉分层**且都带口径词(「全部」/「本页」),不得并排同款。 + * 2. 成功/失败徽章**无条件渲染**——0 失败显式写 0,徽章的缺席本身就是错误信念。 + * 3. 空页不产出「本页 0-0」这种假区间,只留全局计数。 + */ +export function buildSummaryBadges( + summary: LogPageSummary, + total: number, +): SummaryBadge[] { + const badges: SummaryBadge[] = [ + { key: 'total', label: `全部 ${total} 条`, tone: 'neutral', icon: 'none' }, + ]; + + if (summary.pageEnd === 0) return badges; + + badges.push({ + key: 'page-range', + label: `本页 ${summary.pageStart}-${summary.pageEnd}`, + tone: 'neutral', + icon: 'none', + }); + badges.push({ + key: 'page-success', + label: `本页成功 ${summary.successCount}`, + tone: 'teal', + icon: 'check', + }); + badges.push({ + key: 'page-fail', + label: `本页失败 ${summary.failCount}`, + tone: summary.failCount > 0 ? 'red' : 'neutral', + icon: 'x', + }); + return badges; +} diff --git a/frontend/src/app/admin/webhook-logs/page.tsx b/frontend/src/app/admin/webhook-logs/page.tsx index cdc2771a..42c456ff 100644 --- a/frontend/src/app/admin/webhook-logs/page.tsx +++ b/frontend/src/app/admin/webhook-logs/page.tsx @@ -17,6 +17,7 @@ import { AdminPageShell, AdminPageHeader, AdminNoticeBanner } from '@/components import { LoadingState } from '@/components/StateView'; import { dailyReportApi } from '@/lib/api'; import type { WebhookDeliveryLogItem } from '@/lib/api/_domains'; +import { buildSummaryBadges, summarizeLogPage } from './_scope-utils'; const PAGE_SIZE = 30; @@ -99,12 +100,14 @@ export default function WebhookLogsPage() { ); } - const successCount = logs.filter((l) => l.success).length; - const failCount = logs.length - successCount; - const hasPrev = offset > 0; - const hasNext = offset + PAGE_SIZE < total; - const currentPage = Math.floor(offset / PAGE_SIZE) + 1; - const totalPages = Math.ceil(total / PAGE_SIZE) || 1; + // 顶部徽章的构造见 _scope-utils.ts(有单测)——缺陷 #2 的真修复全在那个 + // 纯函数里,JSX 只负责渲染,`.tsx` 不可测的问题不再存在。 + const { hasPrev, hasNext, currentPage, totalPages } = summarizeLogPage( + logs, + total, + offset, + PAGE_SIZE, + ); return ( @@ -137,19 +140,16 @@ export default function WebhookLogsPage() { 加载中... ) : ( <> - 共 {total} 条 - {successCount > 0 && ( - - - 成功 {successCount} - - )} - {failCount > 0 && ( - - - 失败 {failCount} + {buildSummaryBadges( + summarizeLogPage(logs, total, offset, PAGE_SIZE), + total, + ).map((b) => ( + + {b.icon === 'check' && } + {b.icon === 'x' && } + {b.label} - )} + ))} )}
diff --git a/frontend/src/components/AdminSidebar.tsx b/frontend/src/components/AdminSidebar.tsx index 5d172d99..159844e1 100644 --- a/frontend/src/components/AdminSidebar.tsx +++ b/frontend/src/components/AdminSidebar.tsx @@ -24,6 +24,7 @@ import { } from 'lucide-react'; import { cx } from '@/components/ui'; import { useAppContext } from '@/components/ClientLayout'; +import { ADMIN_NAV_ITEMS as ADMIN_NAV_ENTRIES } from '@/lib/admin-nav'; interface AdminNavItem { id: string; @@ -32,24 +33,35 @@ interface AdminNavItem { icon: LucideIcon; } -// admin 全量导航(含不在 NAV_SPACES 里的 /admin/contents、/admin/mother-topics) -const ADMIN_NAV_ITEMS: AdminNavItem[] = [ - { id: 'dashboard', label: '概览', href: '/admin', icon: LayoutDashboard }, - { id: 'monitor', label: '监控大盘', href: '/dashboard', icon: Activity }, - { id: 'sources', label: '信源管理', href: '/admin/sources', icon: RadioTower }, - { id: 'contents', label: '内容管理', href: '/admin/contents', icon: Newspaper }, - { id: 'content-events', label: '内容事件治理', href: '/admin/content-events', icon: GitMerge }, - { id: 'users', label: '用户管理', href: '/admin/users', icon: Users }, - { id: 'model-eval', label: 'AI 引擎', href: '/admin/model-eval', icon: BrainCircuit }, - { id: 'mother-topics', label: '系统母题模板库', href: '/admin/mother-topics', icon: BookOpen }, - { id: 'updates', label: '发版记录', href: '/admin/updates', icon: Rocket }, - { id: 'prompts', label: 'Prompt 管理', href: '/admin/prompts', icon: ScrollText }, - { id: 'scoring-dashboard', label: '评分看板', href: '/admin/scoring-dashboard', icon: BarChart3 }, - { id: 'evidence', label: '可信线索', href: '/admin/evidence', icon: ShieldCheck }, - { id: 'feedback', label: '反馈工作台', href: '/admin/feedback', icon: MessageSquareWarning }, - { id: 'webhook-logs', label: 'Webhook 日志', href: '/admin/webhook-logs', icon: Send }, - { id: 'settings', label: '系统设置', href: '/admin/settings', icon: Settings }, -]; +// 图标是渲染关注点,留在组件侧(@/lib/admin-nav 保持纯逻辑、可在 node 环境测)。 +// 缺映射时响亮抛错而不是静默渲染空白——「挂起/未挂载/被跳过」必须显式报错。 +const ADMIN_NAV_ICONS: Record = { + dashboard: LayoutDashboard, + monitor: Activity, + sources: RadioTower, + contents: Newspaper, + 'content-events': GitMerge, + users: Users, + 'model-eval': BrainCircuit, + 'mother-topics': BookOpen, + updates: Rocket, + prompts: ScrollText, + 'scoring-dashboard': BarChart3, + evidence: ShieldCheck, + feedback: MessageSquareWarning, + 'webhook-logs': Send, + settings: Settings, +}; + +// admin 全量导航(含不在 NAV_SPACES 里的 /admin/contents、/admin/mother-topics)。 +// href/label 来自 @/lib/admin-nav 的唯一清单,顶栏面包屑由同一份派生(#88)。 +const ADMIN_NAV_ITEMS: AdminNavItem[] = ADMIN_NAV_ENTRIES.map((entry) => { + const icon = ADMIN_NAV_ICONS[entry.id]; + if (!icon) { + throw new Error(`AdminSidebar: 导航项 ${entry.id} 缺少图标映射`); + } + return { id: entry.id, label: entry.label, href: entry.href, icon }; +}); export default function AdminSidebar() { const pathname = usePathname(); diff --git a/frontend/src/components/AdminTopBar.tsx b/frontend/src/components/AdminTopBar.tsx index 35ed4a28..81c80f0e 100644 --- a/frontend/src/components/AdminTopBar.tsx +++ b/frontend/src/components/AdminTopBar.tsx @@ -4,36 +4,15 @@ import React from 'react'; import { usePathname } from 'next/navigation'; import { ChevronRight, ShieldCheck } from 'lucide-react'; import { useAppContext } from '@/components/ClientLayout'; - -// 路径 → 页面名映射(面包屑用) -const ADMIN_PAGE_LABELS: Record = { - '/admin': '概览', - '/admin/sources': '信源管理', - '/admin/contents': '内容管理', - '/admin/content-events': '内容事件治理', - '/admin/users': '用户管理', - '/admin/model-eval': 'AI 引擎', - '/admin/mother-topics': '系统母题模板库', - '/admin/updates': '发版记录', - '/admin/feedback': '反馈工作台', - '/admin/settings': '系统设置', -}; - -function findPageLabel(pathname: string): string { - // 精确匹配 - if (ADMIN_PAGE_LABELS[pathname]) return ADMIN_PAGE_LABELS[pathname]; - // 前缀匹配(子路径) - const sorted = Object.keys(ADMIN_PAGE_LABELS).sort((a, b) => b.length - a.length); - for (const key of sorted) { - if (pathname.startsWith(`${key}/`)) return ADMIN_PAGE_LABELS[key]; - } - return '管理'; -} +import { findAdminPageLabel } from '@/lib/admin-nav'; export default function AdminTopBar() { const pathname = usePathname(); const { currentUser } = useAppContext(); - const pageLabel = findPageLabel(pathname); + // 页面名由 @/lib/admin-nav 的导航清单派生(#88),不再手写第二份映射表。 + // 此前两份目录互相矛盾(侧边栏 15 项 / 面包屑 10 条),4 个页面的顶栏 + // 回退显示兜底文案「管理」,与侧边栏自相矛盾。 + const pageLabel = findAdminPageLabel(pathname); return (
diff --git a/frontend/src/lib/__tests__/admin-nav.test.ts b/frontend/src/lib/__tests__/admin-nav.test.ts new file mode 100644 index 00000000..0939bbfe --- /dev/null +++ b/frontend/src/lib/__tests__/admin-nav.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { + ADMIN_NAV_ITEMS, + ADMIN_PAGE_LABELS, + ADMIN_IN_SHELL_PATHS, + ADMIN_FALLBACK_LABEL, + findAdminPageLabel, +} from '@/lib/admin-nav'; + +/** + * #88 回归:管理后台导航清单与面包屑映射的一致性。 + * + * 旧实现(修复前):AdminSidebar 维护 15 项,AdminTopBar 另有一份 10 条的 + * ADMIN_PAGE_LABELS。prompts / scoring-dashboard / evidence / webhook-logs + * 四页无映射。注意真实症状是顶栏显示「**概览**」而非「管理」——旧实现里 + * `/admin` 是前缀匹配键,`/admin/prompts` 先命中 `/admin/` 落成「概览」。 + * + * 现在映射由清单派生,因此这些断言能防止同类漏项复发。 + */ + +describe('ADMIN_NAV_ITEMS 结构不变式', () => { + it('id 唯一', () => { + const ids = ADMIN_NAV_ITEMS.map((i) => i.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('href 唯一', () => { + const hrefs = ADMIN_NAV_ITEMS.map((i) => i.href); + expect(new Set(hrefs).size).toBe(hrefs.length); + }); + + it('每一项都有非空 label', () => { + for (const item of ADMIN_NAV_ITEMS) { + expect(item.label.length).toBeGreaterThan(0); + } + }); + + /** + * 独立复核 M7:结构性不变量(唯一性/派生一致)锁不住**字面值**—— + * 把 `updates` 的 label+href 同时改掉,179 条测试无一报警。 + * 这里把 15 项逐字锁死:改导航必须是有意识的动作,并同步改这条期望。 + */ + it('15 项字面值逐项锁死(改导航必须同步改本表)', () => { + const actual = ADMIN_NAV_ITEMS.map((i) => [i.id, i.label, i.href, i.inShell]); + expect(actual).toEqual([ + ['dashboard', '概览', '/admin', true], + ['monitor', '监控大盘', '/dashboard', false], + ['sources', '信源管理', '/admin/sources', true], + ['contents', '内容管理', '/admin/contents', true], + ['content-events', '内容事件治理', '/admin/content-events', true], + ['users', '用户管理', '/admin/users', true], + ['model-eval', 'AI 引擎', '/admin/model-eval', true], + ['mother-topics', '系统母题模板库', '/admin/mother-topics', true], + ['updates', '发版记录', '/admin/updates', true], + ['prompts', 'Prompt 管理', '/admin/prompts', true], + ['scoring-dashboard', '评分看板', '/admin/scoring-dashboard', true], + ['evidence', '可信线索', '/admin/evidence', true], + ['feedback', '反馈工作台', '/admin/feedback', true], + ['webhook-logs', 'Webhook 日志', '/admin/webhook-logs', true], + ['settings', '系统设置', '/admin/settings', true], + ]); + }); +}); + +describe('面包屑映射与清单一致(#88 核心不变量)', () => { + it('壳内每一项都有面包屑映射——缺项即红', () => { + const missing = ADMIN_IN_SHELL_PATHS.filter((href) => !ADMIN_PAGE_LABELS[href]); + expect(missing).toEqual([]); + }); + + it('映射表不含任何壳外路径(监控大盘由 window.open 单独打开)', () => { + expect(ADMIN_PAGE_LABELS['/dashboard']).toBeUndefined(); + }); + + it('壳内 14 项、壳外 1 项(/dashboard 监控大盘)', () => { + expect(ADMIN_IN_SHELL_PATHS).toHaveLength(14); + expect(ADMIN_NAV_ITEMS).toHaveLength(15); + expect(ADMIN_NAV_ITEMS.filter((i) => !i.inShell).map((i) => i.href)).toEqual(['/dashboard']); + }); + + it('#88 修复的四个页面都有真实页面名,不再回退到「管理」', () => { + expect(findAdminPageLabel('/admin/prompts')).toBe('Prompt 管理'); + expect(findAdminPageLabel('/admin/scoring-dashboard')).toBe('评分看板'); + expect(findAdminPageLabel('/admin/evidence')).toBe('可信线索'); + expect(findAdminPageLabel('/admin/webhook-logs')).toBe('Webhook 日志'); + }); + + it('映射表的 label 与清单 label 逐项一致(不是两份各写各的)', () => { + for (const item of ADMIN_NAV_ITEMS.filter((i) => i.inShell)) { + expect(ADMIN_PAGE_LABELS[item.href]).toBe(item.label); + } + }); +}); + +describe('findAdminPageLabel', () => { + it('精确匹配', () => { + expect(findAdminPageLabel('/admin')).toBe('概览'); + expect(findAdminPageLabel('/admin/users')).toBe('用户管理'); + }); + + it('子路径按最长前缀匹配', () => { + expect(findAdminPageLabel('/admin/sources/123')).toBe('信源管理'); + expect(findAdminPageLabel('/admin/model-eval/history')).toBe('AI 引擎'); + }); + + it('最长前缀:/admin 不吞掉更具体的子路径', () => { + // '/admin' 是所有 /admin/* 的前缀,必须让更长的键先命中 + expect(findAdminPageLabel('/admin/sources')).not.toBe('概览'); + expect(findAdminPageLabel('/admin/sources')).toBe('信源管理'); + }); + + it('未知路径回退到兜底文案', () => { + expect(findAdminPageLabel('/admin/does-not-exist')).toBe(ADMIN_FALLBACK_LABEL); + expect(findAdminPageLabel('/totally/unknown')).toBe(ADMIN_FALLBACK_LABEL); + }); + + it('壳外路径(监控大盘)不在壳内时也走兜底', () => { + expect(findAdminPageLabel('/dashboard')).toBe(ADMIN_FALLBACK_LABEL); + }); +}); diff --git a/frontend/src/lib/admin-nav.ts b/frontend/src/lib/admin-nav.ts new file mode 100644 index 00000000..704aa0ab --- /dev/null +++ b/frontend/src/lib/admin-nav.ts @@ -0,0 +1,85 @@ +/** + * 管理后台导航清单 —— 侧边栏与顶栏面包屑的唯一数据源。 + * + * 存在理由(#88):此前 `AdminSidebar` 维护 15 项导航,`AdminTopBar` 另有一份 + * 10 条的 `ADMIN_PAGE_LABELS`。两份目录互相矛盾,导致 4 个页面的顶栏回退显示 + * 兜底文案「管理」——顶栏说错了话,操作者与侧边栏自相矛盾。 + * + * 现在面包屑映射由本清单**派生**而非手写:只要清单里有 `inShell` 的项, + * 面包屑就必然有映射,该类漏项在结构上不可复发。 + * + * 症状更正(独立复核发现,实现者原记有误):修复前那 4 个无映射的页面 + * 顶栏显示的是「**概览**」而不是「管理」——旧实现里 `/admin` 本身就是 + * 前缀匹配键,`/admin/prompts` 先命中 `/admin/` 落成「概览」,压根走不到 + * 兜底文案。真实症状比原记录更严重:不是显示泛化文案,而是谎称在概览页。 + * + * 图标是渲染关注点,留在 AdminSidebar(此处不 import React 组件,保证本模块 + * 保持纯逻辑、可在 node 环境直接测)。 + */ + +export interface AdminNavEntry { + id: string; + label: string; + href: string; + /** + * 是否在 admin 壳内渲染并出现在面包屑里。 + * `/dashboard`(监控大盘)由侧边栏 `window.open` 在新标签页打开, + * 脱离本壳,因此不参与面包屑映射。 + */ + inShell: boolean; +} + +export const ADMIN_NAV_ITEMS: readonly AdminNavEntry[] = [ + { id: 'dashboard', label: '概览', href: '/admin', inShell: true }, + { id: 'monitor', label: '监控大盘', href: '/dashboard', inShell: false }, + { id: 'sources', label: '信源管理', href: '/admin/sources', inShell: true }, + { id: 'contents', label: '内容管理', href: '/admin/contents', inShell: true }, + { id: 'content-events', label: '内容事件治理', href: '/admin/content-events', inShell: true }, + { id: 'users', label: '用户管理', href: '/admin/users', inShell: true }, + { id: 'model-eval', label: 'AI 引擎', href: '/admin/model-eval', inShell: true }, + { id: 'mother-topics', label: '系统母题模板库', href: '/admin/mother-topics', inShell: true }, + { id: 'updates', label: '发版记录', href: '/admin/updates', inShell: true }, + { id: 'prompts', label: 'Prompt 管理', href: '/admin/prompts', inShell: true }, + { id: 'scoring-dashboard', label: '评分看板', href: '/admin/scoring-dashboard', inShell: true }, + { id: 'evidence', label: '可信线索', href: '/admin/evidence', inShell: true }, + { id: 'feedback', label: '反馈工作台', href: '/admin/feedback', inShell: true }, + { id: 'webhook-logs', label: 'Webhook 日志', href: '/admin/webhook-logs', inShell: true }, + { id: 'settings', label: '系统设置', href: '/admin/settings', inShell: true }, +]; + +/** 壳内路由 → 页面名。由清单派生,不手写第二份。 */ +export const ADMIN_PAGE_LABELS: Readonly> = Object.freeze( + Object.fromEntries(ADMIN_NAV_ITEMS.filter((i) => i.inShell).map((i) => [i.href, i.label])), +); + +/** 壳内全部路由,测试用它做「清单 ↔ 面包屑」一一对应核对。 */ +export const ADMIN_IN_SHELL_PATHS: readonly string[] = ADMIN_NAV_ITEMS.filter( + (i) => i.inShell, +).map((i) => i.href); + +/** 未知路径的兜底文案。 */ +export const ADMIN_FALLBACK_LABEL = '管理'; + +/** admin 壳根路由。概览页只有它自己一个路径,没有任何子页。 */ +const SHELL_ROOT = '/admin'; + +/** + * 由 pathname 解析顶栏面包屑的页面名。 + * + * 先精确匹配,再按**最长前缀**匹配子路径,最后回退到兜底文案。 + * + * 壳根 `/admin` 参与前缀匹配会吞掉一切:`/admin/任何未知路径` 都会被判成 + * 「概览」,让顶栏对不存在的页面说错话。概览无子页,故它只做精确匹配。 + */ +export function findAdminPageLabel(pathname: string): string { + const exact = ADMIN_PAGE_LABELS[pathname]; + if (exact) return exact; + + const sorted = Object.keys(ADMIN_PAGE_LABELS) + .filter((key) => key !== SHELL_ROOT) + .sort((a, b) => b.length - a.length); + for (const key of sorted) { + if (pathname.startsWith(`${key}/`)) return ADMIN_PAGE_LABELS[key]; + } + return ADMIN_FALLBACK_LABEL; +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 20774c9c..3517edcf 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -27,6 +27,10 @@ export default defineConfig({ 'src/lib/navigation.ts', 'src/lib/utils.ts', 'src/lib/datetime.ts', + // #88:导航清单是侧边栏与面包屑的唯一数据源,派生关系必须锁住 + 'src/lib/admin-nav.ts', + 'src/app/admin/sources/_batch-utils.ts', + 'src/app/admin/webhook-logs/_scope-utils.ts', ], reporter: ['text', 'html', 'lcov'], reportsDirectory: './coverage',