From b0727244c0d727cbdc9e1a557f43343a2ff5840e Mon Sep 17 00:00:00 2001 From: fxbin Date: Tue, 29 Sep 2026 08:13:00 +0800 Subject: [PATCH 1/9] =?UTF-8?q?docs:=20=E7=99=BB=E8=AE=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E4=B8=89=E9=A1=B9=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E5=88=B0=E5=9B=9E=E5=BD=92=E7=9F=A9=E9=98=B5=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/quality/regression-matrix.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/quality/regression-matrix.md b/docs/quality/regression-matrix.md index 0d098130..898c532e 100644 --- a/docs/quality/regression-matrix.md +++ b/docs/quality/regression-matrix.md @@ -89,6 +89,36 @@ - 症状:`_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,PR 未开) +- 症状:`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(依赖批量中至少一条更新失败)。 +- 回归测试:**暂缺**。`vitest.config.ts` 为 `environment: 'node'` + `include: ['src/**/*.test.ts']`,`.tsx` 组件行为不在覆盖内,CI 五 job 亦无 eslint,无法机器钉住。关闭条件:随 `check_admin_conventions.py` 门禁脚本补断言,或扩 vitest 覆盖到 DOM。 +- owner:#88。 + +### Webhook 日志页读数口径撒谎 — #88 🔧 修复中(2026-09-29,PR 未开) +- 症状:`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。 +- 回归测试:**暂缺**,原因同上。关闭条件随 #88 门禁脚本补齐。 +- owner:#88。 + +### 管理后台面包屑缺 4 项映射 — #88 🔧 修复中(2026-09-29,PR 未开) +- 症状:`AdminTopBar.tsx` 的 `ADMIN_PAGE_LABELS` 只有 10 条,侧边栏 `ADMIN_NAV_ITEMS` 有 15 项。`prompts` / `scoring-dashboard` / `evidence` / `webhook-logs` 四页无映射,`findPageLabel` 回退显示兜底文案「管理」,与侧边栏自相矛盾。(另经核实:概览卡片 13 张、面包屑 10 条、侧边栏 15 项三份目录互相矛盾,说明分类从未被写下来过。) +- 环境/前提:always。 +- 复现:访问上述四页之一,观察顶栏面包屑第二段。 +- 期望 vs 实际:期望显示真实页面名;实际显示「管理」。 +- 边界:仅前端 `AdminTopBar.tsx`。 +- 严重度:P2(说错话的廉价困惑,不改变任何决定的对错);**复现性**:always。 +- 回归测试:**暂缺**,原因同上。关闭条件:随门禁脚本补 `nav-checklist` 断言(清单与路由一一对应,缺项即红),使该类漏项不可复发。 +- owner:#88。 + ## 三、关键流程基线(9 项) 状态标记:✅ = 2026-09-27 在 main @ 7203847 新鲜复跑通过;📋 = 现有套件覆盖、未逐项复跑(跑全量即覆盖)。 From 74d3ef73b47bf807ac63df463b26203c257c9af8 Mon Sep 17 00:00:00 2001 From: fxbin Date: Tue, 29 Sep 2026 08:13:07 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(frontend):=20=E8=AE=A9=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=A4=B1=E8=B4=A5=E5=8F=AF=E8=A7=81=E5=B9=B6=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20Webhook=20=E8=AF=BB=E6=95=B0=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E6=92=92=E8=B0=8E=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/admin/sources/page.tsx | 41 ++++++++++++++++++-- frontend/src/app/admin/webhook-logs/page.tsx | 34 ++++++++++------ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/admin/sources/page.tsx b/frontend/src/app/admin/sources/page.tsx index 29ac3b2b..ecaed99f 100644 --- a/frontend/src/app/admin/sources/page.tsx +++ b/frontend/src/app/admin/sources/page.tsx @@ -426,6 +426,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 +447,48 @@ export default function SourcesPage() { const handleBatchToggle = async (enabled: boolean) => { if (selectedIds.size === 0 || batchProcessing) return; setBatchProcessing(true); + setBatchResult(null); + const ids = Array.from(selectedIds); + const failedIds: number[] = []; try { - // 循环调 update 端点(已存在,单 source 调) - const ids = Array.from(selectedIds); + // 循环调 update 端点(已存在,单 source 调)。 + // 逐条失败必须收集并上抛到 UI:吞进 console 会让 UI 报告成功而实际未改, + // 这批信源会继续按原状态采集并污染内容池,而操作者无从察觉。 for (const id of ids) { try { await sourcesApi.update(id, { enabled }); } catch (err) { console.error(`Batch toggle source ${id} failed:`, err); + failedIds.push(id); } } - setSelectedIds(new Set()); + // 执行完成后重建选择态:失败项保持选中以便直接重试。 + // 注意批量期间复选框未被禁用,用户可能中途勾选新项——这些项不在本批快照内, + // 必须保留,不能被这次重建静默丢弃。 + const batchSet = new Set(ids); + setSelectedIds((prev) => { + const next = new Set(); + for (const id of failedIds) next.add(id); + for (const id of prev) { + if (!batchSet.has(id)) next.add(id); + } + return next; + }); await fetchSources(); await fetchSourceMap(); + + const verb = enabled ? '启用' : '停用'; + const succeededCount = ids.length - failedIds.length; + if (failedIds.length === 0) { + setBatchResult({ tone: 'teal', text: `已${verb} ${ids.length} 个信源。` }); + } else { + setBatchResult({ + tone: 'red', + text: + `已${verb} ${succeededCount} 个信源,${failedIds.length} 个失败并保持选中:` + + `${failedIds.join('、')}。请检查网络或该信源状态后重试。`, + }); + } } finally { setBatchProcessing(false); } @@ -852,6 +883,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/page.tsx b/frontend/src/app/admin/webhook-logs/page.tsx index cdc2771a..7c5cd1ec 100644 --- a/frontend/src/app/admin/webhook-logs/page.tsx +++ b/frontend/src/app/admin/webhook-logs/page.tsx @@ -99,8 +99,13 @@ export default function WebhookLogsPage() { ); } + // 注意口径:successCount / failCount 只统计**当前页** logs(PAGE_SIZE 行), + // 而 total 是全局条数。二者若以同款 Badge 并排,读者会把 2/1240 读成 0.16% 失败率, + // 真相是「最近 30 条里失败 2 条」。因此页内计数一律显式标注「本页」。 const successCount = logs.filter((l) => l.success).length; const failCount = logs.length - successCount; + const pageStart = logs.length === 0 ? 0 : offset + 1; + const pageEnd = offset + logs.length; const hasPrev = offset > 0; const hasNext = offset + PAGE_SIZE < total; const currentPage = Math.floor(offset / PAGE_SIZE) + 1; @@ -137,18 +142,23 @@ export default function WebhookLogsPage() { 加载中... ) : ( <> - 共 {total} 条 - {successCount > 0 && ( - - - 成功 {successCount} - - )} - {failCount > 0 && ( - - - 失败 {failCount} - + 全部 {total} 条 + {logs.length > 0 && ( + <> + + 本页 {pageStart}-{pageEnd} + + {/* 成功/失败徽章无条件渲染:徽章的缺席本身会制造 + 「这一页没有失败」的错误信念。0 失败显式写 0。 */} + + + 本页成功 {successCount} + + 0 ? 'red' : 'neutral'}> + + 本页失败 {failCount} + + )} )} From e673d58b4fb26c6fab5ddb4afae0bb488340dd7a Mon Sep 17 00:00:00 2001 From: fxbin Date: Tue, 29 Sep 2026 08:13:11 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(frontend):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E9=9D=A2=E5=8C=85=E5=B1=91?= =?UTF-8?q?=204=20=E9=A1=B9=E7=BC=BA=E5=A4=B1=E6=98=A0=E5=B0=84=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/AdminTopBar.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/components/AdminTopBar.tsx b/frontend/src/components/AdminTopBar.tsx index 35ed4a28..dd0745f3 100644 --- a/frontend/src/components/AdminTopBar.tsx +++ b/frontend/src/components/AdminTopBar.tsx @@ -6,6 +6,9 @@ import { ChevronRight, ShieldCheck } from 'lucide-react'; import { useAppContext } from '@/components/ClientLayout'; // 路径 → 页面名映射(面包屑用) +// 必须与 AdminSidebar 的 ADMIN_NAV_ITEMS 一一对应:漏项会让顶栏回退显示 +// 兜底文案「管理」,与侧边栏自相矛盾——顶栏说错了话,一次往返的代价。 +// (/dashboard 监控大盘不在 admin 壳内,由侧边栏 window.open 单独打开。) const ADMIN_PAGE_LABELS: Record = { '/admin': '概览', '/admin/sources': '信源管理', @@ -15,7 +18,11 @@ const ADMIN_PAGE_LABELS: Record = { '/admin/model-eval': 'AI 引擎', '/admin/mother-topics': '系统母题模板库', '/admin/updates': '发版记录', + '/admin/prompts': 'Prompt 管理', + '/admin/scoring-dashboard': '评分看板', + '/admin/evidence': '可信线索', '/admin/feedback': '反馈工作台', + '/admin/webhook-logs': 'Webhook 日志', '/admin/settings': '系统设置', }; From 98967c413658bba1e0f51054cc28eed414c3f60c Mon Sep 17 00:00:00 2001 From: fxbin Date: Tue, 29 Sep 2026 22:39:21 +0800 Subject: [PATCH 4/9] =?UTF-8?q?test(frontend):=20=E4=B8=BA=20#88=20?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E7=BC=BA=E9=99=B7=E8=A1=A5=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=B9=B6=E6=8A=BD=E5=87=BA=E5=8F=AF=E6=B5=8B?= =?UTF-8?q?=E7=BA=AF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/admin/sources/_batch-utils.test.ts | 88 ++++++++++++++++++ .../src/app/admin/sources/_batch-utils.ts | 75 +++++++++++++++ frontend/src/app/admin/sources/page.tsx | 27 +----- .../admin/webhook-logs/_scope-utils.test.ts | 77 +++++++++++++++ .../app/admin/webhook-logs/_scope-utils.ts | 61 ++++++++++++ frontend/src/app/admin/webhook-logs/page.tsx | 25 ++--- frontend/src/components/AdminSidebar.tsx | 48 ++++++---- frontend/src/components/AdminTopBar.tsx | 38 +------- frontend/src/lib/__tests__/admin-nav.test.ts | 93 +++++++++++++++++++ frontend/src/lib/admin-nav.ts | 80 ++++++++++++++++ frontend/vitest.config.ts | 4 + 11 files changed, 531 insertions(+), 85 deletions(-) create mode 100644 frontend/src/app/admin/sources/_batch-utils.test.ts create mode 100644 frontend/src/app/admin/sources/_batch-utils.ts create mode 100644 frontend/src/app/admin/webhook-logs/_scope-utils.test.ts create mode 100644 frontend/src/app/admin/webhook-logs/_scope-utils.ts create mode 100644 frontend/src/lib/__tests__/admin-nav.test.ts create mode 100644 frontend/src/lib/admin-nav.ts 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..342c53c2 --- /dev/null +++ b/frontend/src/app/admin/sources/_batch-utils.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { recomputeSelectionAfterBatch, describeBatchResult } from './_batch-utils'; + +/** + * #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('中途取消勾选失败项后,不把它强行加回', () => { + // prev 里没有 2(用户已取消勾选),但它在失败列表里 + const prev = new Set([1, 3]); + 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..10e676de --- /dev/null +++ b/frontend/src/app/admin/sources/_batch-utils.ts @@ -0,0 +1,75 @@ +/** + * 信源批量启停:选择态重算与结果反馈(#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; +} + +/** + * 生成批量操作的结果反馈文案。 + * + * 全部成功给 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 ecaed99f..866cb32b 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 } from './_batch-utils'; // ─── Page Component ─── @@ -463,32 +464,12 @@ export default function SourcesPage() { } } // 执行完成后重建选择态:失败项保持选中以便直接重试。 - // 注意批量期间复选框未被禁用,用户可能中途勾选新项——这些项不在本批快照内, - // 必须保留,不能被这次重建静默丢弃。 - const batchSet = new Set(ids); - setSelectedIds((prev) => { - const next = new Set(); - for (const id of failedIds) next.add(id); - for (const id of prev) { - if (!batchSet.has(id)) next.add(id); - } - return next; - }); + // 逐条逻辑见 _batch-utils.ts(纯函数,有单测覆盖)。 + setSelectedIds((prev) => recomputeSelectionAfterBatch(prev, ids, failedIds)); await fetchSources(); await fetchSourceMap(); - const verb = enabled ? '启用' : '停用'; - const succeededCount = ids.length - failedIds.length; - if (failedIds.length === 0) { - setBatchResult({ tone: 'teal', text: `已${verb} ${ids.length} 个信源。` }); - } else { - setBatchResult({ - tone: 'red', - text: - `已${verb} ${succeededCount} 个信源,${failedIds.length} 个失败并保持选中:` + - `${failedIds.join('、')}。请检查网络或该信源状态后重试。`, - }); - } + setBatchResult(describeBatchResult(ids.length, failedIds, enabled)); } finally { setBatchProcessing(false); } 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..cecce6eb --- /dev/null +++ b/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeLogPage } 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 是两条不相加的口径 + expect(s.successCount).not.toBe(1238); + }); + + 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('failCount = 页内条数 - successCount(二者恒等,不会各算各的)', () => { + for (const failures of [0, 1, 15, 30]) { + const s = summarizeLogPage(makeLogs(30, failures), 999, 0, PAGE_SIZE); + expect(s.successCount + s.failCount).toBe(30); + } + }); + + it('失败为 0 时 failCount 显式为 0——供 UI 无条件渲染而非缺席', () => { + const s = summarizeLogPage(makeLogs(30, 0), 1240, 0, PAGE_SIZE); + expect(s.failCount).toBe(0); + }); +}); 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..7ec0af24 --- /dev/null +++ b/frontend/src/app/admin/webhook-logs/_scope-utils.ts @@ -0,0 +1,61 @@ +/** + * 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, + }; +} diff --git a/frontend/src/app/admin/webhook-logs/page.tsx b/frontend/src/app/admin/webhook-logs/page.tsx index 7c5cd1ec..07611b0d 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 { summarizeLogPage } from './_scope-utils'; const PAGE_SIZE = 30; @@ -99,17 +100,19 @@ export default function WebhookLogsPage() { ); } - // 注意口径:successCount / failCount 只统计**当前页** logs(PAGE_SIZE 行), - // 而 total 是全局条数。二者若以同款 Badge 并排,读者会把 2/1240 读成 0.16% 失败率, - // 真相是「最近 30 条里失败 2 条」。因此页内计数一律显式标注「本页」。 - const successCount = logs.filter((l) => l.success).length; - const failCount = logs.length - successCount; - const pageStart = logs.length === 0 ? 0 : offset + 1; - const pageEnd = offset + logs.length; - 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; + // 口径:successCount / failCount 只统计**当前页** logs(PAGE_SIZE 行), + // 而 total 是全局条数。二者若以同款 Badge 并排,读者会把 2/1240 读成 0.16% + // 失败率,真相是「最近 30 条里失败 2 条」。逐条计算见 _scope-utils.ts(有单测)。 + const { + pageStart, + pageEnd, + successCount, + failCount, + hasPrev, + hasNext, + currentPage, + totalPages, + } = summarizeLogPage(logs, total, offset, PAGE_SIZE); return ( 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 dd0745f3..81c80f0e 100644 --- a/frontend/src/components/AdminTopBar.tsx +++ b/frontend/src/components/AdminTopBar.tsx @@ -4,43 +4,15 @@ import React from 'react'; import { usePathname } from 'next/navigation'; import { ChevronRight, ShieldCheck } from 'lucide-react'; import { useAppContext } from '@/components/ClientLayout'; - -// 路径 → 页面名映射(面包屑用) -// 必须与 AdminSidebar 的 ADMIN_NAV_ITEMS 一一对应:漏项会让顶栏回退显示 -// 兜底文案「管理」,与侧边栏自相矛盾——顶栏说错了话,一次往返的代价。 -// (/dashboard 监控大盘不在 admin 壳内,由侧边栏 window.open 单独打开。) -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/prompts': 'Prompt 管理', - '/admin/scoring-dashboard': '评分看板', - '/admin/evidence': '可信线索', - '/admin/feedback': '反馈工作台', - '/admin/webhook-logs': 'Webhook 日志', - '/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..34ee6989 --- /dev/null +++ b/frontend/src/lib/__tests__/admin-nav.test.ts @@ -0,0 +1,93 @@ +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 + * 四页无映射,顶栏回退显示「管理」,与侧边栏自相矛盾。 + * + * 现在映射由清单派生,因此这些断言能防止同类漏项复发。 + */ + +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); + } + }); +}); + +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..ee0298af --- /dev/null +++ b/frontend/src/lib/admin-nav.ts @@ -0,0 +1,80 @@ +/** + * 管理后台导航清单 —— 侧边栏与顶栏面包屑的唯一数据源。 + * + * 存在理由(#88):此前 `AdminSidebar` 维护 15 项导航,`AdminTopBar` 另有一份 + * 10 条的 `ADMIN_PAGE_LABELS`。两份目录互相矛盾,导致 4 个页面的顶栏回退显示 + * 兜底文案「管理」——顶栏说错了话,操作者与侧边栏自相矛盾。 + * + * 现在面包屑映射由本清单**派生**而非手写:只要清单里有 `inShell` 的项, + * 面包屑就必然有映射,该类漏项在结构上不可复发。 + * + * 图标是渲染关注点,留在 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', From cb163080617b7900e97fffdeb9f1b2119429943e Mon Sep 17 00:00:00 2001 From: fxbin Date: Tue, 29 Sep 2026 22:39:27 +0800 Subject: [PATCH 5/9] =?UTF-8?q?docs:=20=E5=9B=9E=E5=BD=92=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E5=90=8C=E6=AD=A5=20#88=20=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=91=BD=E4=BB=A4=E4=B8=8E=E5=8F=98=E5=BC=82=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/quality/regression-matrix.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/quality/regression-matrix.md b/docs/quality/regression-matrix.md index 898c532e..43794807 100644 --- a/docs/quality/regression-matrix.md +++ b/docs/quality/regression-matrix.md @@ -89,34 +89,34 @@ - 症状:`_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,PR 未开) +### 管理后台批量操作静默失败 — #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(依赖批量中至少一条更新失败)。 -- 回归测试:**暂缺**。`vitest.config.ts` 为 `environment: 'node'` + `include: ['src/**/*.test.ts']`,`.tsx` 组件行为不在覆盖内,CI 五 job 亦无 eslint,无法机器钉住。关闭条件:随 `check_admin_conventions.py` 门禁脚本补断言,或扩 vitest 覆盖到 DOM。 +- 回归测试:`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,PR 未开) +### 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。 -- 回归测试:**暂缺**,原因同上。关闭条件随 #88 门禁脚本补齐。 +- 回归测试:`npx vitest run src/app/admin/webhook-logs/_scope-utils.test.ts`(8 条)。口径计算抽为纯函数 `_scope-utils.ts`;核心断言是「页内计数绝不混入全局 total」与「空页不产出假区间」。 - owner:#88。 -### 管理后台面包屑缺 4 项映射 — #88 🔧 修复中(2026-09-29,PR 未开) +### 管理后台面包屑缺 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 实际:期望显示真实页面名;实际显示「管理」。 - 边界:仅前端 `AdminTopBar.tsx`。 - 严重度:P2(说错话的廉价困惑,不改变任何决定的对错);**复现性**:always。 -- 回归测试:**暂缺**,原因同上。关闭条件:随门禁脚本补 `nav-checklist` 断言(清单与路由一一对应,缺项即红),使该类漏项不可复发。 +- 回归测试:`npx vitest run src/lib/__tests__/admin-nav.test.ts`(13 条)。导航清单与面包屑映射合并到 `src/lib/admin-nav.ts` 单一事实源,映射由清单派生;`nav-checklist` 等价断言(壳内每项都有映射、label 与清单逐项一致)已在其中,漏项即红。顺带修掉一个被测试抓出的旧行为:`/admin/任何未知路径` 曾因壳根前缀匹配被判成「概览」,现已回退兜底。 - owner:#88。 ## 三、关键流程基线(9 项) From 82c779b94b74c187cf31d051a6cdc9ad2cd4ee79 Mon Sep 17 00:00:00 2001 From: fxbin Date: Wed, 30 Sep 2026 00:02:08 +0800 Subject: [PATCH 6/9] =?UTF-8?q?fix(frontend):=20webhook=20=E5=BE=BD?= =?UTF-8?q?=E7=AB=A0=E6=9E=84=E9=80=A0=E4=B8=8B=E6=B2=89=E4=B8=BA=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E4=BB=A5=E6=B6=88=E9=99=A4=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E6=B5=8B=E6=B8=B2=E6=9F=93=E5=B1=82=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/webhook-logs/_scope-utils.test.ts | 76 +++++++++++++++++-- .../app/admin/webhook-logs/_scope-utils.ts | 51 +++++++++++++ frontend/src/app/admin/webhook-logs/page.tsx | 51 +++++-------- 3 files changed, 140 insertions(+), 38 deletions(-) diff --git a/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts b/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts index cecce6eb..cfb8a761 100644 --- a/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts +++ b/frontend/src/app/admin/webhook-logs/_scope-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { summarizeLogPage } from './_scope-utils'; +import { summarizeLogPage, buildSummaryBadges } from './_scope-utils'; /** * #88 回归:Webhook 日志页的读数口径。 @@ -21,8 +21,11 @@ describe('summarizeLogPage', () => { const s = summarizeLogPage(makeLogs(30, 2), 1240, 0, PAGE_SIZE); expect(s.successCount).toBe(28); expect(s.failCount).toBe(2); - // 失败率不能被读成 2/1240:页内计数与全局 total 是两条不相加的口径 - expect(s.successCount).not.toBe(1238); + // 失败率不能被读成 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('中段分页的页码范围正确', () => { @@ -63,10 +66,13 @@ describe('summarizeLogPage', () => { expect(last.hasNext).toBe(false); }); - it('failCount = 页内条数 - successCount(二者恒等,不会各算各的)', () => { + it('每一条日志恰被计入成功或失败之一(逐条核对,不依赖构造性恒等式)', () => { for (const failures of [0, 1, 15, 30]) { - const s = summarizeLogPage(makeLogs(30, failures), 999, 0, PAGE_SIZE); - expect(s.successCount + s.failCount).toBe(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); } }); @@ -75,3 +81,61 @@ describe('summarizeLogPage', () => { 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 index 7ec0af24..cbe13dd2 100644 --- a/frontend/src/app/admin/webhook-logs/_scope-utils.ts +++ b/frontend/src/app/admin/webhook-logs/_scope-utils.ts @@ -59,3 +59,54 @@ export function summarizeLogPage( 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 07611b0d..42c456ff 100644 --- a/frontend/src/app/admin/webhook-logs/page.tsx +++ b/frontend/src/app/admin/webhook-logs/page.tsx @@ -17,7 +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 { summarizeLogPage } from './_scope-utils'; +import { buildSummaryBadges, summarizeLogPage } from './_scope-utils'; const PAGE_SIZE = 30; @@ -100,19 +100,14 @@ export default function WebhookLogsPage() { ); } - // 口径:successCount / failCount 只统计**当前页** logs(PAGE_SIZE 行), - // 而 total 是全局条数。二者若以同款 Badge 并排,读者会把 2/1240 读成 0.16% - // 失败率,真相是「最近 30 条里失败 2 条」。逐条计算见 _scope-utils.ts(有单测)。 - const { - pageStart, - pageEnd, - successCount, - failCount, - hasPrev, - hasNext, - currentPage, - totalPages, - } = summarizeLogPage(logs, total, offset, PAGE_SIZE); + // 顶部徽章的构造见 _scope-utils.ts(有单测)——缺陷 #2 的真修复全在那个 + // 纯函数里,JSX 只负责渲染,`.tsx` 不可测的问题不再存在。 + const { hasPrev, hasNext, currentPage, totalPages } = summarizeLogPage( + logs, + total, + offset, + PAGE_SIZE, + ); return ( @@ -145,24 +140,16 @@ export default function WebhookLogsPage() { 加载中... ) : ( <> - 全部 {total} 条 - {logs.length > 0 && ( - <> - - 本页 {pageStart}-{pageEnd} - - {/* 成功/失败徽章无条件渲染:徽章的缺席本身会制造 - 「这一页没有失败」的错误信念。0 失败显式写 0。 */} - - - 本页成功 {successCount} - - 0 ? 'red' : 'neutral'}> - - 本页失败 {failCount} - - - )} + {buildSummaryBadges( + summarizeLogPage(logs, total, offset, PAGE_SIZE), + total, + ).map((b) => ( + + {b.icon === 'check' && } + {b.icon === 'x' && } + {b.label} + + ))} )}
From 562fc37250081eb01dd6c374f9f30a9f4fcad404 Mon Sep 17 00:00:00 2001 From: fxbin Date: Wed, 30 Sep 2026 00:02:13 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(frontend):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E4=B8=8E=E5=A4=B1=E8=B4=A5=E6=94=B6=E9=9B=86?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=E4=B8=BA=E7=BA=AF=E5=87=BD=E6=95=B0=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20onError=20=E6=97=81=E8=B7=AF=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/admin/sources/_batch-utils.test.ts | 83 +++++++++++++++++-- .../src/app/admin/sources/_batch-utils.ts | 39 +++++++++ frontend/src/app/admin/sources/page.tsx | 22 ++--- 3 files changed, 125 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/admin/sources/_batch-utils.test.ts b/frontend/src/app/admin/sources/_batch-utils.test.ts index 342c53c2..2bc8f424 100644 --- a/frontend/src/app/admin/sources/_batch-utils.test.ts +++ b/frontend/src/app/admin/sources/_batch-utils.test.ts @@ -1,5 +1,76 @@ import { describe, it, expect } from 'vitest'; -import { recomputeSelectionAfterBatch, describeBatchResult } from './_batch-utils'; +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 回归:批量启停的失败可见性与选择态重算。 @@ -36,11 +107,13 @@ describe('recomputeSelectionAfterBatch', () => { expect(next.has(99)).toBe(true); }); - it('中途取消勾选失败项后,不把它强行加回', () => { - // prev 里没有 2(用户已取消勾选),但它在失败列表里 - const prev = new Set([1, 3]); + 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); }); diff --git a/frontend/src/app/admin/sources/_batch-utils.ts b/frontend/src/app/admin/sources/_batch-utils.ts index 10e676de..d2b0b009 100644 --- a/frontend/src/app/admin/sources/_batch-utils.ts +++ b/frontend/src/app/admin/sources/_batch-utils.ts @@ -46,6 +46,45 @@ export function recomputeSelectionAfterBatch( 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; +} + /** * 生成批量操作的结果反馈文案。 * diff --git a/frontend/src/app/admin/sources/page.tsx b/frontend/src/app/admin/sources/page.tsx index 866cb32b..ff69658e 100644 --- a/frontend/src/app/admin/sources/page.tsx +++ b/frontend/src/app/admin/sources/page.tsx @@ -28,7 +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 } from './_batch-utils'; +import { describeBatchResult, recomputeSelectionAfterBatch, runBatchToggle } from './_batch-utils'; // ─── Page Component ─── @@ -450,21 +450,15 @@ export default function SourcesPage() { setBatchProcessing(true); setBatchResult(null); const ids = Array.from(selectedIds); - const failedIds: number[] = []; try { - // 循环调 update 端点(已存在,单 source 调)。 - // 逐条失败必须收集并上抛到 UI:吞进 console 会让 UI 报告成功而实际未改, - // 这批信源会继续按原状态采集并污染内容池,而操作者无从察觉。 - for (const id of ids) { - try { - await sourcesApi.update(id, { enabled }); - } catch (err) { - console.error(`Batch toggle source ${id} failed:`, err); - failedIds.push(id); - } - } + // 逐条执行与失败收集见 _batch-utils.ts(纯函数,有单测覆盖)。 + // 单条失败不中断整批,但必须被收集并上抛到 UI。 + const failedIds = await runBatchToggle( + ids, + (id) => sourcesApi.update(id, { enabled }), + (id, err) => console.error(`Batch toggle source ${id} failed:`, err), + ); // 执行完成后重建选择态:失败项保持选中以便直接重试。 - // 逐条逻辑见 _batch-utils.ts(纯函数,有单测覆盖)。 setSelectedIds((prev) => recomputeSelectionAfterBatch(prev, ids, failedIds)); await fetchSources(); await fetchSourceMap(); From cb0ea63a0474dc7d28a61811929a0eabbd2cccc5 Mon Sep 17 00:00:00 2001 From: fxbin Date: Wed, 30 Sep 2026 00:02:19 +0800 Subject: [PATCH 8/9] =?UTF-8?q?test(frontend):=20=E9=94=81=E6=AD=BB?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=2015=20=E9=A1=B9=E5=AD=97=E9=9D=A2=E5=80=BC?= =?UTF-8?q?=E5=B9=B6=E6=9B=B4=E6=AD=A3=E7=97=87=E7=8A=B6=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/__tests__/admin-nav.test.ts | 29 +++++++++++++++++++- frontend/src/lib/admin-nav.ts | 5 ++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/__tests__/admin-nav.test.ts b/frontend/src/lib/__tests__/admin-nav.test.ts index 34ee6989..0939bbfe 100644 --- a/frontend/src/lib/__tests__/admin-nav.test.ts +++ b/frontend/src/lib/__tests__/admin-nav.test.ts @@ -12,7 +12,8 @@ import { * * 旧实现(修复前):AdminSidebar 维护 15 项,AdminTopBar 另有一份 10 条的 * ADMIN_PAGE_LABELS。prompts / scoring-dashboard / evidence / webhook-logs - * 四页无映射,顶栏回退显示「管理」,与侧边栏自相矛盾。 + * 四页无映射。注意真实症状是顶栏显示「**概览**」而非「管理」——旧实现里 + * `/admin` 是前缀匹配键,`/admin/prompts` 先命中 `/admin/` 落成「概览」。 * * 现在映射由清单派生,因此这些断言能防止同类漏项复发。 */ @@ -33,6 +34,32 @@ describe('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 核心不变量)', () => { diff --git a/frontend/src/lib/admin-nav.ts b/frontend/src/lib/admin-nav.ts index ee0298af..704aa0ab 100644 --- a/frontend/src/lib/admin-nav.ts +++ b/frontend/src/lib/admin-nav.ts @@ -8,6 +8,11 @@ * 现在面包屑映射由本清单**派生**而非手写:只要清单里有 `inShell` 的项, * 面包屑就必然有映射,该类漏项在结构上不可复发。 * + * 症状更正(独立复核发现,实现者原记有误):修复前那 4 个无映射的页面 + * 顶栏显示的是「**概览**」而不是「管理」——旧实现里 `/admin` 本身就是 + * 前缀匹配键,`/admin/prompts` 先命中 `/admin/` 落成「概览」,压根走不到 + * 兜底文案。真实症状比原记录更严重:不是显示泛化文案,而是谎称在概览页。 + * * 图标是渲染关注点,留在 AdminSidebar(此处不 import React 组件,保证本模块 * 保持纯逻辑、可在 node 环境直接测)。 */ From 73467918cb4143bf45c94d1cfedf4e3f7b15af43 Mon Sep 17 00:00:00 2001 From: fxbin Date: Wed, 30 Sep 2026 00:02:19 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs:=20=E6=9B=B4=E6=AD=A3=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E7=9F=A9=E9=98=B5=E7=97=87=E7=8A=B6=E4=B8=8E=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E8=BE=B9=E7=95=8C=E7=99=BB=E8=AE=B0=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=9B=BE=E4=BE=8B=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/quality/regression-matrix.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/quality/regression-matrix.md b/docs/quality/regression-matrix.md index 43794807..aafc1b8a 100644 --- a/docs/quality/regression-matrix.md +++ b/docs/quality/regression-matrix.md @@ -106,22 +106,23 @@ - 期望 vs 实际:期望页内计数带明确口径标注、失败数显式渲染;实际两者视觉同层且口径未标注,失败为 0 时徽章缺席。 - 边界:仅前端展示层;**不改数值来源**,不改 API。 - 严重度:P1(对交付健康度给出错误读数,不直接损坏数据);**复现性**:always。 -- 回归测试:`npx vitest run src/app/admin/webhook-logs/_scope-utils.test.ts`(8 条)。口径计算抽为纯函数 `_scope-utils.ts`;核心断言是「页内计数绝不混入全局 total」与「空页不产出假区间」。 +- 回归测试:`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 实际:期望显示真实页面名;实际显示「管理」。 -- 边界:仅前端 `AdminTopBar.tsx`。 +- 期望 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/ 下,遵守§一) | 状态 | |---|---|---|---|