Forráskód Böngészése

feat: add ai-voc-insight, data-center, feedback-inbox and validation-center

- AI VOC insight lifecycle with analysis runs and topic explorer
- data center, feedback inbox and validation center modules
- workbench context, AI gateway and product knowledge services
- shared action-create-panel, crud-table-workbench and global-context-bar components
- JD products import tool and iteration docs

Co-Authored-By: Claude <noreply@anthropic.com>
gangvy 1 hónapja
szülő
commit
657d86c64e
100 módosított fájl, 26002 hozzáadás és 3437 törlés
  1. 5 0
      .gitignore
  2. 44 20
      README.md
  3. 234 0
      docs/AI-VOC-INSIGHT-LIFECYCLE.md
  4. 393 0
      docs/AI-VOC-NEXT-ITERATION.md
  5. 549 0
      docs/GITHUB-AI-VOC-BENCHMARK-2026-08.md
  6. 691 0
      docs/GITHUB-DATA-DASHBOARD-BENCHMARK-2026-08.md
  7. 105 0
      docs/JD-API-CONNECTION-ASSESSMENT-2026-08-07.md
  8. 568 0
      docs/JD-API-CONNECTION-ASSESSMENT-2026-08-15.md
  9. 38 0
      docs/JD-FIRST-BATCH-IMPORT.md
  10. 15 0
      package-lock.json
  11. 2 0
      package.json
  12. 12 0
      proxy.conf.json
  13. 5 4
      src/app/app.component.scss
  14. 64 4
      src/app/app.routes.ts
  15. 1 1
      src/app/core/interceptors/parse-session.interceptor.ts
  16. 24 0
      src/app/core/models/domestic.models.ts
  17. 48 0
      src/app/core/services/ai-gateway.service.ts
  18. 7 5
      src/app/core/services/domestic-analytics-adapter.service.ts
  19. 62 1
      src/app/core/services/domestic-dataset.service.spec.ts
  20. 70 5
      src/app/core/services/domestic-dataset.service.ts
  21. 103 13
      src/app/core/services/domestic-monitoring-view.service.ts
  22. 73 0
      src/app/core/services/domestic-review-insight.service.ts
  23. 170 0
      src/app/core/services/saas-platform.service.spec.ts
  24. 91 0
      src/app/core/services/saas-platform.service.ts
  25. 111 0
      src/app/core/services/workbench-context.service.spec.ts
  26. 248 0
      src/app/core/services/workbench-context.service.ts
  27. 535 0
      src/modules/ai-voc-insight/ai-voc-insight.component.html
  28. 1302 0
      src/modules/ai-voc-insight/ai-voc-insight.component.scss
  29. 210 0
      src/modules/ai-voc-insight/ai-voc-insight.component.spec.ts
  30. 978 0
      src/modules/ai-voc-insight/ai-voc-insight.component.ts
  31. 81 0
      src/modules/ai-voc-insight/ai-voc-insight.models.ts
  32. 368 0
      src/modules/ai-voc-insight/ai-voc-insight.service.spec.ts
  33. 391 0
      src/modules/ai-voc-insight/ai-voc-insight.service.ts
  34. 126 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-components.spec.ts
  35. 241 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.html
  36. 447 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.scss
  37. 307 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.ts
  38. 140 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.html
  39. 415 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.scss
  40. 245 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.ts
  41. 364 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run.models.ts
  42. 571 0
      src/modules/data-center/data-center.component.html
  43. 1635 0
      src/modules/data-center/data-center.component.scss
  44. 12 0
      src/modules/data-center/data-center.component.spec.ts
  45. 812 0
      src/modules/data-center/data-center.component.ts
  46. 118 0
      src/modules/data-center/data-center.service.spec.ts
  47. 124 0
      src/modules/data-center/data-center.service.ts
  48. 46 0
      src/modules/domestic-voc/category-analysis.component.scss
  49. 185 16
      src/modules/domestic-voc/category-analysis.component.ts
  50. 535 61
      src/modules/domestic-voc/competitor-detail.component.scss
  51. 464 123
      src/modules/domestic-voc/competitor-detail.component.ts
  52. 354 0
      src/modules/domestic-voc/competitor-relations.component.scss
  53. 566 36
      src/modules/domestic-voc/competitor-relations.component.ts
  54. 25 3
      src/modules/domestic-voc/data-status.component.ts
  55. 14 5
      src/modules/domestic-voc/domestic-page.shared.scss
  56. 406 82
      src/modules/domestic-voc/framework/domestic-action-workbench.component.html
  57. 449 93
      src/modules/domestic-voc/framework/domestic-action-workbench.component.ts
  58. 1371 5
      src/modules/domestic-voc/framework/domestic-framework.shared.scss
  59. 77 21
      src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.html
  60. 90 3
      src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.ts
  61. 81 11
      src/modules/domestic-voc/framework/domestic-product-development.component.html
  62. 54 2
      src/modules/domestic-voc/framework/domestic-product-development.component.ts
  63. 2 2
      src/modules/domestic-voc/framework/domestic-return-analysis.component.html
  64. 1052 0
      src/modules/domestic-voc/operating-overview.component.scss
  65. 711 63
      src/modules/domestic-voc/operating-overview.component.ts
  66. 312 0
      src/modules/feedback-inbox/feedback-inbox.component.html
  67. 1069 0
      src/modules/feedback-inbox/feedback-inbox.component.scss
  68. 477 0
      src/modules/feedback-inbox/feedback-inbox.component.ts
  69. 42 0
      src/modules/feedback-inbox/feedback-inbox.models.ts
  70. 216 0
      src/modules/knowledge-center/knowledge-center.component.html
  71. 236 1006
      src/modules/knowledge-center/knowledge-center.component.scss
  72. 486 1287
      src/modules/knowledge-center/knowledge-center.component.ts
  73. 29 1
      src/modules/monitoring/pages/category-voc/category-voc.component.ts
  74. 93 42
      src/modules/monitoring/pages/risk-alert/risk-alert.component.ts
  75. 274 102
      src/modules/monitoring/pages/voc-overview/voc-overview.component.ts
  76. 158 0
      src/modules/shared/components/action-create-panel/action-create-panel.component.html
  77. 472 0
      src/modules/shared/components/action-create-panel/action-create-panel.component.scss
  78. 270 0
      src/modules/shared/components/action-create-panel/action-create-panel.component.ts
  79. 7 1
      src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.html
  80. 9 0
      src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.scss
  81. 127 0
      src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.spec.ts
  82. 25 2
      src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.ts
  83. 24 10
      src/modules/shared/components/ai-chat-modal/ai-chat-modal.component.ts
  84. 2 2
      src/modules/shared/components/ai-visual-report/ai-visual-report.component.ts
  85. 26 2
      src/modules/shared/components/alert-card/alert-card.component.scss
  86. 15 9
      src/modules/shared/components/all-reviews-modal/all-reviews-modal.component.ts
  87. 3 2
      src/modules/shared/components/content-card/content-card.component.scss
  88. 395 0
      src/modules/shared/components/crud-table-workbench/crud-table-workbench.component.scss
  89. 397 0
      src/modules/shared/components/crud-table-workbench/crud-table-workbench.component.ts
  90. 167 0
      src/modules/shared/components/global-context-bar/global-context-bar.component.html
  91. 273 0
      src/modules/shared/components/global-context-bar/global-context-bar.component.scss
  92. 61 0
      src/modules/shared/components/global-context-bar/global-context-bar.component.spec.ts
  93. 202 0
      src/modules/shared/components/global-context-bar/global-context-bar.component.ts
  94. 10 10
      src/modules/shared/components/header/header.component.html
  95. 159 0
      src/modules/shared/components/header/header.component.scss
  96. 28 8
      src/modules/shared/components/header/header.component.ts
  97. 132 281
      src/modules/shared/components/navigation/navigation.component.scss
  98. 130 87
      src/modules/shared/components/navigation/navigation.component.ts
  99. 30 0
      src/modules/shared/components/page-header/page-header.component.scss
  100. 9 6
      src/modules/shared/components/review-detail-modal/review-detail-modal.component.ts

+ 5 - 0
.gitignore

@@ -16,3 +16,8 @@ data/source/
 .idea/
 .DS_Store
 Thumbs.db
+
+# Build artifacts and local caches
+.npm-cache/
+out-tsc/
+output/

+ 44 - 20
README.md

@@ -2,7 +2,7 @@
 
 基于现有 `msq-voc-web` 前端框架打包出的国内电商 VOC 通用模板,首个案例为德玛仕京东商品。项目保留原系统的 Angular App Shell、顶部搜索、侧边导航、共享组件、VOC 页面结构和 ECharts 能力,只替换最小闭环所需的数据入口与国内商品语义。
 
-> 当前状态(2026-07-24):国内 VOC 前端框架已经收口。原项目 App Shell、共享组件体系、经营分析、市场与竞品、VOC 监测、退款分析、新品开发、生命周期复盘和行动建议均已安全迁入;默认从国内 VOC 后端读取 Parse 中的真实数据,静态案例仅作为显式回退模式
+> 当前状态(2026-07-29):国内 VOC 前端框架、本地后端、AI 分析闭环、商品知识库和国内竞品采集已经接通。原项目 App Shell、共享组件体系、经营分析、市场与竞品、VOC 监测、退款分析、新品开发、生命周期复盘、行动建议、AI 可视化报告与多轮追问均已迁入;演示环境统一由前端请求本地后端,浏览器不直连数据库或外部数据服务
 
 ## 迁入基线
 
@@ -14,11 +14,13 @@
 
 当前启用路由:
 
+- `/feedback-inbox`:统一反馈收件箱,支持来源/评分筛选、真实评价证据审阅、主题置信度判断与行动项创建
+- `/voc-insight/ai`:AI VOC 洞察工作台,支持评价范围/情感筛选、证据覆盖审计、结构化洞察生成、引用回跳、风险与追问及行动项创建
 - `/domestic/overview`:国内经营概览、核心指标、经营趋势、类目贡献和成交商品
-- `/domestic/product`:单商品经营详情、流量转化、退款表现和商品档案
+- `/domestic/product`:兼容入口,统一重定向到复用后的 `/voc-insight/detail` 商品 VOC 详情
 - `/domestic/categories`:二级/三级类目经营对比
 - `/domestic/competitors`:竞品关系、映射覆盖率、搜索和本品跳转
-- `/domestic/competitors/:productId`:竞品实图、商品档案、关联本品、评分分布、评论证据趋势和评论明细
+- `/domestic/competitors/:productId`:竞品实图、商品档案、关联本品、评分分布、评论证据、AI 可视化报告和多轮追问
 - `/domestic/voc-overview`:VOC 核心指标、情绪健康度、问题与热词总览
 - `/domestic/category-voc`:类目 VOC 四象限、下钻维度与诊断摘要
 - `/domestic/risk-alerts`:商品风险、场景化投诉与异常激增预警框架
@@ -35,6 +37,9 @@
 - `/product-development/trend|requirement|competitor-benchmark|product-definition|lifecycle`:新品开发五环节
 - `/action-suggestion/kanban|experience|iteration|strategy`:行动建议四页
 - `/data-status`:导入结果、待接依赖和数据质量问题
+- `/system/api`:本地 AI 网关配置状态与真实连通性测试
+- `/system/ai-prompts`:复用原系统的 AI 提示词、输出风格和追问配置管理
+- `/knowledge-center`:复用原知识库信息结构,统一管理本店商品、重点商品、竞品资料、标签、备注和负责人
 
 `/login` 保留为独立 Parse 应用接入后的入口,但当前路由未启用 `AuthGuard`。
 
@@ -47,20 +52,26 @@
 3. 查看经营概览、商品经营详情、二级/三级类目分析和竞品关系。
 4. 按商品 ID、型号或名称搜索并选择商品,进入复用原系统布局的商品 VOC 详情。
 5. 在商品 VOC 详情、经营详情、竞品关系和深度洞察页面之间保持商品上下文。
-6. 自有商品仍按真实的 0 条评论展示空状态;竞品详情使用已入库的 355 条评论样本,不伪造情绪、痛点或价格销量趋势。
+6. 自有商品仍按真实的 0 条评论展示空状态;竞品详情使用当前采集的 206 条评论样本,不伪造情绪、痛点或价格销量趋势。
 7. 查看 VOC 趋势总览、类目 VOC 和风险预警的完整页面结构与真实空状态。
 8. 查看退款汇总、高退款商品和等待订单级原因的归因/关联空态。
 9. 查看趋势、需求、竞品、产品定义和生命周期五个新品开发环节。
 10. 查看基于真实经营规则的行动建议,并与等待评论证据的体验建议分开。
 11. 查看导入统计和数据质量问题。
+12. 基于已入库竞品评论生成原版视觉报告,并在同一证据上下文中继续追问。
+13. 在系统设置中检查 AI 网关状态、执行真实连通性测试并管理提示词配置。
+14. 在商品知识库中查询 2,817 个本店商品和 96 个竞品,将真实商品加入重点库并维护标签、备注和负责人。
+15. 在反馈收件箱按来源、评分和关键词筛选评价,审阅原声及证据口径,并将确认后的反馈创建为行动中心任务。
+16. 在 AI VOC 洞察工作台筛选真实评价,生成带 evidence ID 引用的用户需求、机会状态、建议动作和验证指标,并将人工确认后的洞察转入行动中心。
 
 德玛仕数据摘要:
 
 - 日指标记录:9,717 条
 - 经营商品:2,817 个
-- 竞品关系:40 条
-- 唯一竞品商品:37 个
-- 评论正文:355 条竞品首批样本;自有商品评论 0 条
+- 竞品关系:138 条,其中表格关系 40 条、品牌品类发现 98 条
+- 唯一竞品商品:96 个,其中 70 个具有公司中转返回的市场快照
+- 评论正文:206 条,覆盖 22 个竞品商品;自有商品评论 0 条
+- 采集维度:27 个品牌/品类组合,商品搜索 27/27 成功
 - 指标日期:2026-07-14 至 2026-07-20
 
 源文件:
@@ -71,7 +82,7 @@ E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟
 
 规范化结果位于 `src/assets/data/demashi-summary.json`。
 
-当前真实数据链为 `德玛仕 Excel -> Parse REST -> saas-voc-server -> 前端`。Parse 中已保存 2,817 个自有商品、37 个竞品商品、9,717 条日指标、40 条竞品关系和 355 条竞品评论;37 个竞品商品详情及首批评论均已通过公司电商中转完成回填。自有商品评论仍为真实的 0 条,对应 VOC 页面继续展示可解释空状态。
+当前演示数据链为 `德玛仕 Excel + 公司电商数据中转 -> saas-voc-server 本地快照 -> 前端 /api/*`。本地后端提供 2,817 个自有商品、96 个竞品商品、9,717 条日指标、138 条竞品关系和 206 条竞品评论;市场商品按“品牌 + 品类”检索,并经过品牌相关性过滤。自有商品评论仍为真实的 0 条,对应 VOC 页面继续展示可解释空状态。独立 Parse REST 保留为可切换的持久化实现,切换时只需调整后端配置。
 
 ## 复用策略
 
@@ -84,13 +95,16 @@ E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟
 
 ## 环境隔离
 
-浏览器默认只访问同源地址:
+浏览器默认只访问同源后端地址:
 
-- Parse:`/parse`
-- 国内电商中转:`/api/voc-e-commerce`
 - 国内 VOC 后端:`/api/domestic-voc`
+- SaaS 工作区:`/api/saas`
+- 商品知识库:`/api/knowledge`
+- AI 分析网关:`/api/ai`
 - 德玛仕本地数据:`/assets/data/demashi-summary.json`
 
+公司电商数据中转、Parse REST 和 AI 上游凭据均由 `saas-voc-server` 持有,前端不直接请求这些服务。
+
 运行时配置定义在 `src/runtime-config.js` 和 `src/app/core/config/runtime-config.ts`。开发环境默认使用 `api` 模式并通过 `/api/domestic-voc` 读取后端快照;部署环境可以在 Angular 脚本加载前覆盖 `window.__SAAS_VOC_CONFIG__`。
 
 禁止将以下内容写入浏览器代码或 Git:
@@ -104,6 +118,7 @@ Fmode Key 只能由服务端或 Worker 持有。前端通过同源服务端调
 
 ```text
 /api/voc-e-commerce/{platform}/{upstream-path}
+/api/ai/chat/completions
 ```
 
 参考中转模块:
@@ -132,7 +147,14 @@ npm run start:local
 
 前端地址为 `http://127.0.0.1:4200/`。`/api/domestic-voc` 通过 `proxy.conf.json` 转发到 `http://127.0.0.1:4400`。本地后端读取同一份德玛仕规范化快照,不访问 PostgreSQL、Parse 或外部采集接口。
 
-`/api/voc-e-commerce` 仍代理到 `http://127.0.0.1:3000`;需要真实采集时,API Key 只能配置在服务端进程中。
+需要启用 AI 分析时,在后端进程环境中配置 `FMODE_AI_BASE_URL`、`FMODE_AI_TOKEN`、`FMODE_AI_MODEL` 和 `FMODE_AI_TIMEOUT_MS`。前端无需也不得配置 token;`/api/ai/status`、`/api/ai/test`、`/api/ai/chat/completions` 和 `/api/ai/prompts` 均由本地后端提供。
+
+竞品补采通过后端命令执行,API Key 只配置在后端进程中:
+
+```bash
+cd E:\workspace\server\saas-voc-server
+npm run enrich:competitors:local
+```
 
 从 Excel 重新生成规范化数据:
 
@@ -143,20 +165,22 @@ npm run import:demashi -- "E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\20
 ## 当前验证
 
 - `npm run build`:通过
-- `npm test`:8 项 Jasmine 测试通过
+- `npm test`:26 项 Jasmine 测试通过
 - `npm run verify:data`:通过
-- 桌面端:30 个启用入口的页面身份、主内容、导航、空状态和交互通过
+- 桌面端:31 个业务入口及竞品详情页的页面身份、主内容、导航、空状态和交互通过
 - 移动端:30 个启用入口无页面级横向溢出,导航抽屉展开/收起、标题和控件布局通过
-- 浏览器控制台:启用入口无 Angular 运行时错误、警告或失败请求
-- 运行时请求:当前激活闭环请求同源 `/api/domestic-voc`,本地后端返回 2,817 个自有商品、37 个竞品、9,717 条日指标、40 条竞品关系和 355 条竞品评论,没有访问旧跨境域名
+- 浏览器控制台:竞品 AI 报告、继续追问、API 管理和提示词管理闭环无 Angular 运行时错误、警告或失败请求
+- AI 闭环:竞品真实评论 -> `/api/ai/chat/completions` -> 原版视觉报告 -> 继续追问通过;API 状态与测试页通过
+- 独立 Parse REST 开发库:16/16 `Voc*` Schema、2,817 个本品、37 个竞品、9,717 条指标、40 条关系、355 条评论和 8 条重点商品知识校验通过
+- 商品知识库闭环:真实商品读取、三库切换、筛选、列管理、重点标记、标签/备注/负责人编辑、审计记录和商品详情下钻通过;桌面与 390x844 移动端无页面级溢出
+- 运行时请求:当前激活闭环请求同源 `/api/*`,本地后端返回 2,817 个自有商品、96 个竞品、9,717 条日指标、138 条竞品关系和 206 条竞品评论;31 个业务入口无失败接口请求
 - `npm audit --omit=dev`:13 项原框架依赖公告(0 critical、10 high、3 moderate),正式公网部署前必须升级 Angular、ECharts 并替换无修复版本的 `xlsx`
 
 ## 尚未完成
 
-- 创建独立 PostgreSQL 数据库和独立 Parse 应用
-- 接通当前价格接口并开始积累价格/销量历史快照;当前价格接口 TLS 仍不可用
-- 将 `/api/domestic-voc` 从本地快照实现切换为新 PostgreSQL / Parse 持久化实现
-- 部署独立环境并完成端到端验收
+- 创建独立 PostgreSQL 生产数据库;独立 Parse REST 开发应用已完成
+- 开始定时积累价格与销量历史;当前已接入单次市场快照,但尚未形成可计算趋势的多期序列
+- 部署独立生产环境并完成端到端验收;开发环境已切换至独立 Parse REST
 - 升级原框架的 Angular 17、ECharts、Parse 依赖基线,并替换无修复版本的 `xlsx`
 
 完整任务顺序见 [TASKS.md](TASKS.md)。

+ 234 - 0
docs/AI-VOC-INSIGHT-LIFECYCLE.md

@@ -0,0 +1,234 @@
+# AI VOC 洞察生命周期审计
+
+> 审计范围:`E:\workspace\Saas-voc` 前端与 `E:\workspace\server\saas-voc-server` 后端。
+> 审计对象:反馈收件箱、AI VOC 洞察、行动创建面板、`SaasPlatformService`、后端 `AnalysisRun` / `Action` 路由与本地仓储。
+> 审计时间:2026-08-06
+
+## 1. 结论摘要
+
+当前系统已经形成一条可运行的 VOC 工作流:
+
+```text
+真实评价
+  -> 证据准备与筛选
+  -> AI 结构化洞察 / deterministic 规则摘要
+  -> 用户查看原始证据并提交行动表单
+  -> ActionItem 进入行动中心
+```
+
+其中,证据回跳、证据 ID 校验、`needs_validation` 标记、AnalysisRun 持久化状态和 Action 创建审计已经具备。当前的“人工确认”由用户查看洞察后主动打开并提交“创建行动项”面板来体现,但还不是一个可查询、可审计、可阻断后续动作的独立业务状态。
+
+因此当前实现适合表达“生成结果并由人决定是否转行动”,还不适合表达完整的“洞察发布、行动执行、效果验证、结果回流”闭环。
+
+## 2. 当前已实现的证据到行动链路
+
+### 2.1 证据进入分析上下文
+
+`ai-voc-insight` 通过 `AiVocInsightService.prepare(...)` 对数据集中的有效评价进行准备:
+
+- 按本品、竞品或全部范围筛选;
+- 按负向、中性或正向倾向筛选;
+- 按样本上限截取输入;
+- 为每条评价生成稳定的证据 ID,并保留评价正文、商品、来源、评分、日期和主题;
+- 生成样本量、来源分布、情感分布和主题分布,作为分析输入审计信息。
+
+`feedback-inbox` 提供同一类评价的队列和详情视图。详情页保留原始评价正文、主题判断、同类样本量、来源类型、置信表达和处理建议;证据链接通过 `reviewId` 回到收件箱详情。
+
+### 2.2 AnalysisRun 记录生成过程
+
+点击生成后,前端先调用:
+
+```text
+POST /workspaces/:workspaceId/analyses
+```
+
+请求类型为 `analysisType: voc_insight`,目标为当前 workspace,并把筛选条件、输入证据 ID、主题统计、样本数和来源/情感统计写入 `input`。后端创建记录时状态为 `pending`,接口返回 `202`。
+
+随后前端用:
+
+```text
+PATCH /workspaces/:workspaceId/analyses/:id
+```
+
+把运行推进到 `processing`,生成结束后再写入 `result`、`evidenceCount`、终态和时间戳。后端上下文能力明确声明 `analysisExecution: false`,所以当前 AnalysisRun 是生成过程的持久化记录,实际 AI 执行仍由前端调用 AI 网关完成。
+
+### 2.3 AI 结果形成结构化洞察
+
+AI 输入携带 `evidenceId`,提示约束要求每条洞察引用输入中的真实 ID,并输出:
+
+- 发现、用户未满足需求和建议动作;
+- 严重程度、置信度和机会状态;
+- `evidenceIds`、`evidenceCount` 和验证指标;
+- 风险与后续追问。
+
+AI 返回结果会再次解析和归一化:未知证据 ID 会被过滤,没有有效证据引用的洞察会被丢弃;如果没有可验证洞察、请求超时或网关异常,则从同一输入生成 deterministic 规则摘要并明确标记为待验证。前端展示引用数量和每个证据 ID,并可跳回 `feedback-inbox` 查看原始评价。
+
+### 2.4 人工确认与行动创建
+
+当前 UI 的人工决策路径是:
+
+1. 用户在洞察卡片中查看发现、用户需求、机会状态、风险、验证指标和引用证据;
+2. 用户点击“创建行动项”,打开 `action-create-panel`;
+3. 面板预填洞察标题和描述,描述包含洞察发现、建议动作、验证指标和 AI 路径的证据 ID;
+4. 用户可以修改标题、描述、优先级、负责人和截止日期;
+5. 用户提交表单后调用:
+
+   ```text
+   POST /workspaces/:workspaceId/actions
+   ```
+
+6. 后端要求 `action:write` 权限,校验负责人是 workspace 内的 active 成员,创建 `ActionItem`,初始状态默认为 `open`,并记录 `action.created` 审计事件。
+
+`feedback-inbox` 也能从单条反馈直接创建行动草稿,并把原始反馈和主题上下文带入面板。前端用本地 `createdActionIds` 防止当前页面重复点击,但该集合不是后端关联关系。
+
+需要特别区分:页面文案中的“已确认”与正式确认状态不是同一件事。当前没有 `confirmedBy`、`confirmedAt`、确认意见或洞察决策接口;用户提交 ActionItem 可以视作一次显式的人为转行动决定,但这次决定没有作为独立记录保存。
+
+## 3. AnalysisRun 状态语义
+
+后端 `AnalysisRun` 的状态转移和字段约束如下:
+
+| 状态 | 语义 | 当前进入方式 | 关键约束 |
+| --- | --- | --- | --- |
+| `pending` | 请求已创建,等待生成开始 | `POST /analyses` 创建后的初始状态 | 没有结果,未开始处理 |
+| `processing` | 生成正在进行 | 前端 PATCH 状态;若未提供 `startedAt`,后端自动补时间 | 只能从 `pending` 进入;前端负责实际生成 |
+| `completed` | AI 结果完整完成 | AI 结果成功解析并写入 | 必须有 `result`;终态不可再次 PATCH |
+| `partial` | 有结果但需要人工复核,当前主要用于 deterministic fallback | 规则摘要写入结果 | 必须有 `result`;终态不可再次 PATCH |
+| `failed` | 本次生成没有可用结果 | AI 请求、解析或持久化流程报告失败 | 必须有非空 `errorSummary`;终态不可再次 PATCH |
+| `cancelled` | 运行被取消 | 后端允许从 `pending` 或 `processing` 取消 | 当前没有前端取消控件;终态不可再次 PATCH |
+
+允许的状态转移是:
+
+```text
+pending    -> processing | cancelled | failed
+processing -> completed | partial | failed | cancelled
+completed  -> terminal
+partial    -> terminal
+failed     -> terminal
+cancelled  -> terminal
+```
+
+后端还会自动补齐:进入 `processing` 时的 `startedAt`,以及进入任意终态时的 `completedAt`。GET 列表支持按状态筛选和游标分页;前端只恢复 `voc_insight`、当前 workspace 且状态为 `completed` 或 `partial` 并且有结果的运行。
+
+当前 `local-platform.repository` 使用进程内数组保存 AnalysisRun 和 ActionItem,服务重启后记录会丢失;Parse REST / Postgres 仓储具备持久化实现。该差异必须在本地演示和验收环境中明确标注。
+
+## 4. AI 与 deterministic fallback 的区别
+
+### AI 模式
+
+- AI 网关状态为已配置时,由 `AiVocInsightService.generate(...)` 调用 `AiAnalysisService.streamAnalysis(...)`;
+- 输出要求为结构化 JSON,并经过 JSON 提取、字段归一化和证据 ID 校验;
+- 结果 `mode` 为 `ai`,前端将 AnalysisRun 写成 `completed`;
+- AI 可以输出 `supported`、`partially_supported`、`needs_validation` 或 `not_supported`,但最终仍需人工判断;
+- 生成内容只能把当前输入评价当作事实证据,不得凭空补充销量、金额、比例或原话。
+
+### deterministic fallback 模式
+
+当 AI 网关未配置,或 AI 请求、解析发生异常时,系统按固定主题规则聚合当前证据:
+
+- 以主题分组并生成规则摘要;
+- 每条摘要保留可回溯的证据 ID;
+- 所有机会状态设为 `needs_validation`;
+- 同时给出样本外推风险和后续补证问题;
+- 结果 `mode` 为 `deterministic`,前端将 AnalysisRun 写成 `partial`,并提示人工复核后再转行动。
+
+deterministic fallback 是可解释的可用性降级,属于规则层信号;主题命中仍需人工复核,尚不足以表达总体问题率或已确认机会。
+
+## 5. 三条业务不变量
+
+以下三条不变量是当前链路必须保持的业务契约。现状中的“已实现”与“仍需补强”分别列出,避免把 UI 行为误当成后端保证。
+
+### 不变量一:证据 ID 必须可回溯
+
+**规则:** 每条洞察的 `evidenceIds` 必须是本次分析上下文 `evidence[].id` 的子集;每个引用都能回到反馈收件箱的原始评价;`evidenceCount` 和覆盖率只能基于实际引用 ID 计算。
+
+**现状:** AI 提示词要求真实 ID,结果归一化会过滤未知 ID,并丢弃没有有效引用的洞察;规则摘要也保留真实 ID。前端提供引用链接和证据回跳。
+
+**缺口:** ActionItem 没有 `analysisId`、`insightId` 或 `evidenceIds` 字段。AI 创建行动时证据 ID 会进入描述文本,收件箱直接创建行动时主要带入原始反馈文本,因此数据库层还没有强引用约束。
+
+### 不变量二:`needs_validation` 与已确认机会保持区分
+
+**规则:** 样本不足、规则降级、主题边界不确定或证据覆盖不足的洞察必须保持 `needs_validation`;此状态只触发补证、人工复核或带验证指标的草稿,正式发布前需要显式确认。
+
+**现状:** deterministic 结果统一使用 `needs_validation`;AI 缺失或非法机会状态时归一化默认使用 `needs_validation`;结果页展示“待验证”标签和验证指标。
+
+**缺口:** 当前 `openAction(...)` 没有根据 `opportunityStatus` 阻断操作,用户仍可以直接为 `needs_validation` 洞察创建 ActionItem;后端也没有对结果内容或样本阈值做服务端校验。
+
+### 不变量三:行动前必须有可审计的人工确认
+
+**规则:** 只有用户查看了证据并明确确认洞察成立、确认验证目标和行动责任后,才允许创建可执行行动;确认人、时间、决策和意见必须可查询,并写入审计。
+
+**现状:** 创建行动前必须经过用户点击和表单提交;后端用 `action:write` 权限保护创建接口,并记录 `action.created`。这能阻止纯展示状态自动创建行动。
+
+**缺口:** 当前没有独立的确认 API 或确认字段,尚未区分“确认”“驳回”“需要补充证据”;也缺少用户完成证据审阅的可验证记录。行动创建本身是隐式确认,且对 `needs_validation` 没有统一阻断。
+
+## 6. 尚缺的闭环能力
+
+### 6.1 行动效果回流
+
+当前 `ActionItem` 只有执行状态、负责人、优先级、截止时间和完成时间,没有以下对象或字段:
+
+- 关联的 `analysisRunId`、`insightId`、`evidenceIds`;
+- 行动目标、验证指标、基线值、目标值和观测窗口;
+- 执行后的观测值、效果结论、验证人和验证时间;
+- “有效、部分有效、无效、未测量”等效果状态;
+- 从效果结果回写洞察置信度、机会状态或下一轮 AnalysisRun 的机制。
+
+所以当前链路止于“行动已创建/执行状态变化”,还没有“行动是否解决了用户问题”的证据闭环。
+
+### 6.2 洞察发布生命周期
+
+AnalysisRun 的 `completed` / `partial` 是计算运行状态,不是洞察的业务发布状态。当前不存在独立 Insight 实体或版本对象,也没有:
+
+```text
+draft -> confirmed -> published -> archived
+```
+
+所需的发布人、发布时间、版本号、确认意见、归档原因、回滚关系和跨页面可见性策略。当前历史列表只能恢复有结果的 AnalysisRun,尚未表达某条洞察是否已被正式发布给产品、运营或管理层。
+
+## 7. 下一轮验收标准
+
+### P0:形成可审计、可阻断的闭环
+
+1. **洞察与证据强关联**
+   - Given 一次 `voc_insight` AnalysisRun,When 结果保存,Then 每条洞察只能引用该运行输入中的证据 ID,服务端拒绝未知 ID、空引用和跨运行引用。
+   - Given 从洞察创建行动,Then ActionItem 至少保存 `analysisRunId`、`insightId` 和去重后的 `evidenceIds`,页面仍可从行动跳回原始评价。
+
+2. **人工确认成为显式门槛**
+   - Given 洞察状态为 `needs_validation` 或 deterministic fallback,When 用户未提交确认,Then创建行动请求被 UI 和 API 双重阻断。
+   - Given 用户完成证据审阅,Then可提交 `confirmed`、`rejected` 或 `needs_more_evidence` 决策,并保存 `confirmedBy`、`confirmedAt`、确认说明及审计事件。
+   - Given 洞察已经确认,Then 创建行动必须携带确认记录 ID;确认记录不可被静默覆盖。
+
+3. **AnalysisRun 执行契约稳定**
+   - Given 同一生成请求发生刷新、重试或网络延迟,Then 应保持单一有效运行和单次终态写入;运行完成后只允许一次终态提交。
+   - Given AI 成功,Then状态为 `completed` 且 `result` 可恢复;Given deterministic fallback,Then状态为 `partial` 且结果中明确 `mode: deterministic` 和 `needs_validation`。
+   - Given 生成失败,Then状态为 `failed` 且有可读 `errorSummary`;终态之后的 PATCH 必须被拒绝。
+
+4. **行动效果可回流**
+   - Given ActionItem 被标记完成,Then必须有验证指标、观测窗口和效果记录,或明确记录“未测量”的原因。
+   - Given 效果记录提交,Then可以按行动、洞察、主题和时间范围查询,并生成下一轮分析的输入。
+
+### P1:建立洞察资产的发布与运营能力
+
+1. **独立洞察发布生命周期**
+   - Given AnalysisRun 产生结果,Then生成一个带版本号的洞察草稿,而不是直接把运行结果当作发布资产。
+   - Only confirmed 洞察可进入 `published`;已发布版本不可被原地覆盖,修改必须生成新版本。
+   - Given 洞察归档,Then保留归档人、归档时间、原因和历史版本,并能恢复指定版本。
+
+2. **历史、行动和效果一体化视图**
+   - 可按洞察查看证据、确认记录、关联行动、执行状态和效果结果;
+   - 可按主题查看多次 AnalysisRun 的趋势,区分 AI、规则降级和人工修订版本;
+   - 页面刷新、跨页面跳转和权限变化后,仍能恢复同一洞察上下文。
+
+3. **质量与回归评估**
+   - 为证据引用正确率、`needs_validation` 识别率、人工确认通过率、行动完成率和效果回流完整率建立可重复测试;
+   - AI 输出和 deterministic fallback 使用同一套 schema 校验、引用校验和审计断言;
+   - 对低样本、无证据、未知证据 ID、网关超时、重复提交和终态修改建立自动化验收用例。
+
+## 8. 审计依据
+
+- 前端:`src/modules/feedback-inbox/feedback-inbox.component.ts`、`feedback-inbox.component.html`;
+- 前端:`src/modules/ai-voc-insight/ai-voc-insight.component.ts`、`ai-voc-insight.component.html`、`ai-voc-insight.service.ts`、`ai-voc-insight.models.ts`;
+- 前端:`src/modules/shared/components/action-create-panel/action-create-panel.component.ts`、`action-create-panel.component.html`;
+- 前端:`src/app/core/services/saas-platform.service.ts`;
+- 后端:`src/modules/saas-platform/domain.ts`、`routes.ts`、`local-platform.repository.ts`;
+- 后端:`src/modules/saas-platform/parse-rest-voc.repository.ts`、`postgres-platform.repository.ts` 中 AnalysisRun / Action 的持久化实现。

+ 393 - 0
docs/AI-VOC-NEXT-ITERATION.md

@@ -0,0 +1,393 @@
+# AI VOC 下一阶段可执行方案
+
+**版本**:2026-08-06
+**范围**:`E:\workspace\Saas-voc` 前端、`E:\workspace\server\saas-voc-server` 后端
+**目标**:把当前可运行的“反馈证据 -> AI 洞察 -> 行动项创建”提升为可审计、可执行、可验证、可持续学习的 VOC 业务闭环。
+
+## 1. 执行结论
+
+当前系统已经具备一条可用的生成链路:反馈收件箱提供可筛选的原始评价,AI VOC 页面准备证据并生成结构化洞察,洞察可以回跳到原始评价并预填行动项,后端 `AnalysisRun` 和 `ActionItem` 也已经有状态、权限、审计和来源字段。
+
+但目前的链路仍停在“生成结果并创建行动项”,还没有完整表达以下业务事实:
+
+1. **洞察是否被人确认**:`AnalysisRun.completed/partial` 是计算运行状态,不是业务确认状态;当前没有独立的确认、驳回或“补证据”记录。
+2. **行动中心是否消费同一批行动项**:AI 页面写入 SaaS `ActionItem`,但现有行动中心仍通过本地 `DomesticAnalyticsAdapterService.actions(dataset)` 生成规则行动,来源和状态没有统一。
+3. **行动是否有效**:行动项有执行状态和 `completedAt`,但没有基线、目标、观察窗口、观测值、验证结论或未测量原因。
+4. **原始反馈是否越过了洞察层**:反馈收件箱的快捷创建使用 `sourceAnalysisId=null`、`sourceInsightId=null`、空 `evidenceIds`,会产生一条没有证据链的行动项。
+5. **AI 结果是否被服务端重新验证**:服务端会校验行动项引用的分析、洞察和证据,但 `PATCH analysis` 目前允许写入任意 JSON 结果,尚未在服务端强制校验 `insight.evidenceIds` 属于本次 `input.evidenceIds`。
+
+因此下一阶段不以增加更多“AI 摘要卡片”为主,而以三个可度量结果为主:
+
+```text
+可追溯:每个可执行行动都能回到洞察、分析运行和原始证据
+可决策:每个洞察都有显式的人审结论,AI 生成完成不等于业务确认
+可验证:每个完成行动都有结果或明确的未测量原因,并能进入下一轮 VOC 分析
+```
+
+## 2. 当前实现盘点
+
+### 2.1 已经形成的主链路
+
+| 环节 | 当前实现 | 已具备能力 | 当前限制 |
+| --- | --- | --- | --- |
+| 反馈证据 | `src/modules/feedback-inbox/feedback-inbox.component.ts:157-245`、`feedback-inbox.models.ts:13-27` | 来源、情感、主题、样本量、置信级别、详情回看 | 主要由前端数据集即时计算,没有统一的反馈事件/证据登记表 |
+| AI 输入 | `src/modules/ai-voc-insight/ai-voc-insight.service.ts:63-94` | 过滤范围、样本上限、证据 ID、主题和情感/来源统计 | 输入快照缺少模型、提示词、规则和分类版本;服务端不认识输入证据集合 |
+| AI/规则生成 | `ai-voc-insight.service.ts:96-152`、`:154-186` | 结构化 JSON、超时降级、deterministic fallback、`needs_validation` | 结果解析和证据白名单主要在前端;fallback 仍可直接打开行动创建面板 |
+| 运行记录 | `ai-voc-insight.component.ts:265-345`、`:507-642` | `pending -> processing -> completed/partial/failed`、历史恢复、终态锁定、审计 | 运行状态没有拆成洞察业务状态;生成执行在前端,`analysisExecution: false` |
+| 洞察展示 | `ai-voc-insight.component.html:311-394` | finding、user need、severity、confidence、opportunity status、recommendation、validation metric、证据回跳 | 没有“已阅证据/确认/驳回/补证据”的持久化交互 |
+| 洞察转行动 | `ai-voc-insight.component.html:427-440`、`action-create-panel.component.ts:81-115` | 自动带入 `sourceAnalysisId`、`sourceInsightId`、`evidenceIds`、`validationMetric` | `createdActionIds` 只在页面内存中;没有幂等键;没有按机会状态阻断 |
+| 单条反馈转行动 | `feedback-inbox.component.ts:255-325`、`:281-304` | 可从反馈详情预填行动项 | 当前未携带分析/洞察来源,绕过证据->洞察关系;前端本地集合无法跨刷新判断已创建 |
+| Action API | `server/src/modules/saas-platform/routes.ts:221-266` | 分页、权限、成员校验、来源审计、状态更新 | 当前缺少行动详情、决策和验证 API;尚未表达效果回流 |
+| 来源完整性 | `server/src/modules/saas-platform/domain.ts:142-200`、`migrations/003...`、`004...` | 同 workspace、已完成/部分完成的 `voc_insight`、洞察 ID、证据子集校验 | 仅校验行动创建时的来源;不校验生成结果本身,也不校验人审决策 |
+| 行动中心 | `src/modules/domestic-voc/framework/domestic-action-workbench.component.ts:88-106` | 有看板、体验优化、产品迭代、策略四个视图 | 读取 `analytics.actions(dataset)` 本地规则结果,没有消费 `SaasPlatformService.actions()` 或更新 ActionItem |
+| 持久化 | `server/src/modules/saas-platform/local-platform.repository.ts:208-287`、Parse/Postgres repository | 三种 repository 接口已对齐 | local repository 为进程内数组,重启丢失;Postgres 迁移 `003/004` 必须在部署验收时确认已执行 |
+
+### 2.2 当前状态机边界
+
+```text
+AnalysisRun: pending -> processing -> completed | partial | failed | cancelled
+ActionItem:  open -> planned -> in_progress -> completed | blocked | cancelled
+```
+
+`AnalysisRun` 的终态约束在 `server/src/modules/saas-platform/domain.ts:95-140` 已有实现;`ActionItem` 的状态更新在各 repository 已有实现。但缺少业务层状态机:
+
+```text
+Insight: draft -> confirmed | rejected | needs_more_evidence -> published -> archived
+ActionValidation: pending -> effective | partially_effective | ineffective | not_measured
+```
+
+第一条用于判断“洞察能否进入正式行动”,第二条用于判断“行动是否解决了问题”。它们需独立于 `AnalysisRun.status` 和 `ActionItem.status`。
+
+## 3. 闭环断点与优先级
+
+| 优先级 | 断点 | 业务风险 | 处理原则 |
+| --- | --- | --- | --- |
+| P0 | 没有独立的人审决策;`needs_validation` 仍可创建行动 | AI/规则摘要可能被误当作正式结论 | 先保存决策,再允许可执行行动;“补证据”只能生成数据收集任务 |
+| P0 | 生成结果的证据白名单只在前端完成 | 任何可写入 API 的客户端都可能提交无关证据 | 服务端对结果做 schema、证据子集、coverage 一致性校验 |
+| P0 | 反馈收件箱快捷行动绕过洞察 | 行动缺少洞察与分析批次归因 | 改为“加入分析/创建待验证项”;确需快捷行动时必须用独立的原始反馈来源模型 |
+| P0 | AI ActionItem 与行动中心断开 | 用户看不到刚创建的行动,也缺少状态推进入口 | 行动中心改读 SaaS Action API,并保留本地规则行动为独立来源 |
+| P1 | 没有洞察版本、发布和归档 | 重新生成会覆盖上下文,难以比较口径变化 | 引入 `InsightRecord` 版本化,AnalysisRun 只代表一次计算 |
+| P1 | 完成行动没有结果回流 | 缺少“哪类 VOC 被解决”的事实依据 | 引入 `ActionValidation`,完成行动必须记录验证结果或未测量原因 |
+| P1 | 反馈缺少统一事件与快照 | 增量同步、去重、跨周期主题追踪不稳定 | 引入稳定 evidence registry 和 ingestion quality |
+| P2 | 没有相似反馈/跨周期记忆 | 重复主题反复生成,分析师需要手工合并 | 规则过滤后再做向量相似检索,保留人工合并/拆分 |
+| P2 | AI 质量和成本不可运营 | 缺少判断模型升级收益的事实依据 | 建立 golden set、schema/evidence 评估、延迟/成本看板和版本回归 |
+| P2 | 没有受控的周报和问答 | 信息只能停留在页面内,组织传播成本高 | 只输出带引用的摘要,确认和行动仍回到平台完成 |
+
+## 4. P0:先闭合可审计决策链
+
+**目标**:任何进入正式行动中心的事项,都能证明“谁审阅了哪些证据、确认了什么、基于哪次分析、准备如何验证”。
+
+### P0-1 数据模型与 API
+
+#### A. 新增 `InsightDecision`
+
+建议新增 `voc.insight_decision`(Parse 对应 `VocInsightDecision`):
+
+| 字段 | 类型 | 约束/含义 |
+| --- | --- | --- |
+| `publicId` | text/UUID | 对外 ID |
+| `workspaceId` | FK/text | 与 source analysis 一致 |
+| `sourceAnalysisId` | FK/text | 必须是 `voc_insight` 且状态为 `completed/partial` |
+| `sourceInsightId` | text | 必须存在于 `analysis.result.insights[].id` |
+| `decision` | enum | `confirmed`、`rejected`、`needs_more_evidence` |
+| `reviewedEvidenceIds` | jsonb/array | 非空,且是该 insight 的 `evidenceIds` 子集 |
+| `comment` | text | 决策理由;`rejected` 和 `needs_more_evidence` 必填 |
+| `decidedBy` | user ID | 当前 workspace active 成员 |
+| `decidedAt` | timestamp | 服务端写入,不信任客户端时间 |
+| `createdAt/updatedAt` | timestamp | 审计与幂等 |
+
+约束:同一 `(workspaceId, sourceAnalysisId, sourceInsightId)` 只允许一个当前决策;重新判断产生新版本或显式 supersede 记录,不静默覆盖旧决策。
+
+建议 API:
+
+```text
+POST /api/saas/workspaces/:workspaceId/insight-decisions
+GET  /api/saas/workspaces/:workspaceId/insight-decisions?analysisId=:id
+GET  /api/saas/workspaces/:workspaceId/insight-decisions/:id
+```
+
+`POST /actions` 增加 `sourceDecisionId`。服务端必须检查决策与分析、洞察、workspace 三者一致:
+
+- `confirmed`:允许创建 `experience/product/strategy/general` 行动。
+- `needs_more_evidence`:只允许创建 `data_quality` 的补证据任务,标题和验证指标必填。
+- `rejected`:正式行动创建入口关闭。
+- deterministic fallback 的洞察默认只能走 `needs_more_evidence`,除非已有明确人工 `confirmed`。
+
+#### B. 强化 `AnalysisRun` grounding contract
+
+保留当前 `AnalysisRun`,但要求 `input` 和 `result` 使用固定版本结构:
+
+```ts
+input: {
+  schemaVersion: 1;
+  evidenceIds: string[];
+  filters: { scope: 'all' | 'own' | 'competitor'; sentiment: string; sampleLimit: number };
+  datasetSnapshotId: string;
+  promptVersion?: string;
+  model?: string;
+  ruleVersion?: string;
+  taxonomyVersion?: string;
+  redaction: { applied: boolean; fieldCounts: Record<string, number> };
+}
+
+result: {
+  schemaVersion: 1;
+  mode: 'ai' | 'deterministic';
+  evidenceCoverage: number;
+  insights: Array<{ id: string; evidenceIds: string[]; opportunityStatus: string; validationMetric: string }>;
+  risks: string[];
+  followUpQuestions: string[];
+}
+```
+
+服务端在 `PATCH /analyses/:id` 的终态写入前执行:
+
+1. 所有 `insight.evidenceIds` 为非空且属于 `input.evidenceIds`。
+2. `evidenceCoverage` 等于实际被引用的去重证据数除以输入证据数,由服务端重算。
+3. `mode=deterministic` 必须写成 `partial`,且所有 insight 至少有 `needs_validation` 或显式风险说明。
+4. `mode=ai`、`partial`、`completed` 的结构均通过同一 schema validator。
+5. 记录 `model/promptVersion/ruleVersion/taxonomyVersion`,用于回归和审计;日志仅保存脱敏统计,不保存原始反馈正文。
+
+#### C. ActionItem 幂等和来源补全
+
+在现有 `sourceAnalysisId/sourceInsightId/evidenceIds/validationMetric` 之上增加:
+
+```text
+sourceDecisionId: string | null
+creationKey: string           // workspace + analysis + insight + decision,唯一
+sourceKind: 'insight' | 'raw_feedback' | 'rule_action'
+```
+
+`creationKey` 用于双击、刷新重试和多标签页幂等。现有 `domain.ts:149-193` 的来源完整性检查继续保留,并扩展为校验 `sourceDecisionId`。
+
+对于反馈收件箱:
+
+- 默认按钮改为“加入待分析”,把 `reviewId` 加入分析批次,不直接创建正式 ActionItem。
+- 如果业务必须保留快捷行动,新增 `sourceKind=raw_feedback` 和 `sourceFeedbackIds`,UI 必须显式标注“原始反馈行动”,并与 AI 洞察行动区分;其验证指标和后续洞察归因单独处理。
+
+### P0-2 页面交互
+
+#### AI 洞察页
+
+在 `ai-voc-insight.component.html` 当前洞察卡片上增加一个明确的“决策区”,不把“生成完成”当作“已确认”:
+
+1. 卡片展示 `证据覆盖率`、输入证据数、已审阅证据数、运行模式和当前决策状态。
+2. 点击证据 ID 或“查看证据”打开右侧证据抽屉;抽屉支持逐条标记已阅、查看反馈收件箱详情、查看同主题样本。
+3. 只有已阅证据数达到最小门槛且用户填写理由后,`确认洞察`、`驳回`、`需要补证据` 才可提交。
+4. `needs_validation`、deterministic 或低覆盖率洞察只显示“需要补证据”;确认后才显示“创建正式行动”。
+5. 提交后卡片显示决策人、时间、决策理由和决策 ID;刷新页面、恢复历史或跨页面后保持一致。
+6. 创建行动面板从 `sourceDecisionId` 读取来源;二次提交返回既有行动时,UI 显示“已存在行动”,不再创建第二条。
+
+#### 反馈收件箱
+
+1. 保留当前队列、来源/情感筛选和 `reviewId` 回跳。
+2. 详情页将“创建行动项”改为“加入分析批次”;用户选择已有分析运行或新建待分析批次。
+3. 如保留快捷行动,必须显示来源类型、证据 ID、风险提示,并只能创建 `raw_feedback` 或 `data_quality` 类型。
+4. 创建结果来自服务端列表而非 `createdActionIds` 内存集合,避免刷新后重复创建。
+
+#### 行动中心
+
+1. 增加来源筛选:`AI 洞察`、`原始反馈`、`规则行动`。
+2. 列表从 `GET /workspaces/:workspaceId/actions` 读取,支持状态、优先级、负责人、到期日、来源分析筛选。
+3. 行动详情固定展示:`ActionItem -> InsightDecision -> Insight -> AnalysisRun -> Evidence`。
+4. 状态更新使用 `PATCH /actions/:id`,完成时若没有验证记录,状态显示“待验证”而不是“问题已解决”。
+5. 本地 `DomesticAnalyticsAdapterService.actions()` 保留为规则建议来源,迁移期用标签区分,不与 SaaS ActionItem 混在同一个完成计数中。
+
+### P0-3 AI 能力
+
+- **Grounded generation**:模型只接收脱敏后的结构化证据和允许的 evidence ID;原始正文仅用于当前分析,prompt 日志仅保存脱敏统计。
+- **Schema-first output**:前端解析保留,但终态写入必须经过服务端 schema validator;未知字段可忽略,未知证据 ID 必须拒绝。
+- **Confidence calibration**:置信度由证据量、来源覆盖、情感一致性和重复度共同计算;模型负责补充解释,数量和比例由确定性数据层提供。
+- **Fallback discipline**:AI 网关不可用时继续提供 deterministic 摘要,结果统一为 `partial + needs_validation`,并沿用人审门槛。
+- **可解释追问**:每条洞察生成 1-3 个补证据问题,问题必须能映射到来源、时间窗、产品或主题,避免生成无数据依赖的泛化建议。
+
+### P0-4 成功指标与验收标准
+
+| 指标 | 目标 | 口径 |
+| --- | --- | --- |
+| Evidence precision | >= 99% | 洞察引用 ID 中属于本次输入的比例;服务端拒绝未知 ID |
+| Evidence coverage correctness | 100% | 存储 coverage 与去重引用数/输入数一致 |
+| Decision traceability | >= 99% | 正式行动能找到决策、洞察、分析运行和至少 1 条证据 |
+| Unconfirmed action leakage | 0 | 未确认洞察不得创建正式行动 |
+| Duplicate action rate | < 1% | 相同 creationKey 的重复创建数/创建请求数 |
+| UI recoverability | 100% | 刷新、重新登录、历史恢复后决策和来源仍可见 |
+
+验收用例:
+
+- 提交未知 `evidenceId`、跨 analysis 的 `evidenceId`、空证据数组,API 返回 4xx 且数据库无脏结果。
+- 对 deterministic 结果直接点击创建正式行动,UI 禁用,API 直接拒绝;提交 `needs_more_evidence` 后只能创建补证据任务。
+- 同一洞察双击或并发提交两次,最终只有一个 `creationKey` 对应的 ActionItem。
+- 从 AI 洞察、行动中心、反馈收件箱往返跳转,URL 中的分析/洞察/反馈 ID 能恢复同一上下文。
+- 用 local、Parse REST、Postgres 三种 repository 各跑一次来源与决策测试;local 明确标注为演示存储。
+
+## 5. P1:建立洞察资产和行动效果回流
+
+**目标**:让洞察成为可版本化的业务资产,让行动完成后能产生下一轮分析的事实输入。
+
+### P1-1 数据模型
+
+#### `InsightRecord`
+
+新增独立洞察实体,`AnalysisRun` 只保留计算运行:
+
+| 字段 | 含义 |
+| --- | --- |
+| `publicId/workspaceId` | 洞察资产和权限边界 |
+| `insightKey` | 稳定主题键,由 taxonomy + product/category + normalized topic 组成 |
+| `version` | 同一 insightKey 的递增版本 |
+| `sourceAnalysisId` | 产生该版本的运行 |
+| `status` | `draft/confirmed/published/archived` |
+| `title/finding/userNeed/recommendation` | 可编辑业务内容 |
+| `evidenceIds/evidenceCoverage` | 版本快照,随源数据刷新时保持不变 |
+| `ownerUserId/publishedBy/publishedAt` | 运营责任和发布审计 |
+| `supersedesId/archiveReason` | 版本链和归档原因 |
+
+#### `ActionValidation`
+
+新增 `voc.action_validation`(Parse 对应 `VocActionValidation`):
+
+```text
+publicId, workspaceId, actionId, metricKey, baselineValue,
+targetValue, observationWindowStart, observationWindowEnd,
+observedValue, verdict, notMeasuredReason, notes,
+verifiedBy, verifiedAt, createdAt, updatedAt
+```
+
+`verdict`:`effective | partially_effective | ineffective | not_measured`。当 `not_measured` 时 `notMeasuredReason` 必填;行动只有在验证记录提交后才显示“已验证完成”。
+
+#### `FeedbackEvidence`
+
+统一反馈登记表/registry,至少包含:稳定内部 ID、来源平台/渠道、原始来源 ID、产品/类别、正文哈希、时间戳、情感/主题版本、去重键、快照批次、可见性和数据质量状态。原始正文与脱敏正文分开保存,AI 只拿到最小必要字段。
+
+### P1-2 页面交互
+
+- 洞察页新增 `Evidence / Decision / Actions / Validation / Versions` 五个页签。
+- 发布动作只对已确认洞察开放;发布时选择受众范围和版本说明,已发布版本不可原地改写。
+- 行动中心增加“待验证”泳道,完成行动后自动打开验证表单:指标、基线、目标、窗口、观测值、结论。
+- 验证结果页支持按洞察、主题、产品、负责人和时间窗口过滤,并可回跳原始证据。
+- 当验证结果为 `ineffective` 或 `partially_effective` 时,系统生成复盘草稿和下一轮补证据问题,不自动创建新的产品行动。
+- 行动中心可同时展示 SaaS ActionItem 与本地规则建议,但分别计数、分别标注数据来源。
+
+### P1-3 AI 能力
+
+- **主题演化**:基于确定性 topic key 计算新增、上升、缓解、复发,不让模型自行决定趋势;模型只解释已计算的变化。
+- **相似反馈检索**:先按 workspace、产品、时间、来源和权限过滤,再按 embedding/关键词排序;每条结果带匹配原因和真实 evidence ID。
+- **Grounded Explore**:支持对当前分析范围追问,回答必须返回引用、过滤条件、计算来源和数据缺口;对话仅生成草稿,发布洞察和创建行动仍需业务表单提交。
+- **验证辅助**:根据 action 的 metric/baseline/target/window 生成观测模板,observed value 和 verdict 只能来自业务人员或确定性数据源。
+
+### P1-4 成功指标与验收标准
+
+| 指标 | 目标 |
+| --- | --- |
+| Published insight provenance | 100% |
+| Version overwrite | 0 |
+| Action validation completion | >= 80% of completed actions within due window |
+| Verified action rate | >= 70% of completed actions |
+| Feedback-to-insight reuse | >= 60% of published insights can list their evidence snapshot |
+| Trend explanation grounding | >= 95% of explanations contain valid evidence/metric references |
+
+验收:发布 v1 后编辑产生 v2,v1 仍可恢复;行动完成但没有验证记录时,系统拒绝标记“已解决”或强制进入待验证;提交验证后能在洞察详情显示结果,并能作为下一次 `AnalysisRun.input` 的统计来源。
+
+## 6. P2:规模化智能与质量运营
+
+**目标**:在 P0/P1 数据可信和闭环可回流后,降低重复分析成本并让 AI 升级可量化。
+
+### P2-1 能力与数据模型
+
+- `AiEvaluationRun`:记录 golden set、模型、提示词版本、schema pass rate、evidence precision、decision agreement、latency、token/cost、fallback rate。
+- `TopicMergeSplit`:保存主题合并/拆分操作、操作者、旧 key、新 key 和生效时间,维护跨周期主题记忆。
+- `ReportDelivery`:记录周报版本、收件范围、引用的 insight/action/validation ID 和发送结果,不保存无来源的自由生成结论。
+
+### P2-2 页面交互
+
+- AI 质量中心只对管理员/分析师开放,展示运行量、成功率、fallback、平均耗时、schema 失败、证据精度、人工一致率和成本趋势。
+- 周报页面支持按 P0/P1、主题上升、逾期行动、待确认洞察和验证结果生成摘要;所有摘要可点击回平台详情。
+- 相似反馈结果支持人工“合并/拆分/忽略”,系统展示相似原因,不自动合并原始证据。
+
+### P2-3 AI 能力
+
+- 受控模型/提示词 A/B 与 golden set 回归;只有达到门槛的组合才标记 production。
+- Schema-aware dashboard:AI 只能从白名单指标、维度和图表类型中选择,数值由确定性查询提供。
+- 基于验证结果的检索增强:将“有效/无效/未测量”作为反馈信号,提升建议排序,不直接把人工结论当作自动真相。
+
+### P2-4 成功指标与验收标准
+
+| 指标 | 目标 |
+| --- | --- |
+| Golden set schema pass rate | >= 99% |
+| Evidence precision | >= 99.5% |
+| Human decision agreement | >= 85% |
+| AI fallback rate | < 10%,异常期可单独告警 |
+| Median insight latency | < 15s for configured sample window |
+| Duplicate topic review load | 下降 >= 30% |
+| Weekly report citation coverage | 100% |
+
+验收:同一 golden set 上比较模型/提示词版本,报告可重现;任一 evidence precision 或 schema pass rate 低于门槛时,版本保持非 production;周报中的每条重要结论至少能跳到一个洞察和一条原始证据。
+
+## 7. 开发拆分与依赖顺序
+
+### Sprint 1:P0 数据契约和门禁
+
+1. 新增 `InsightDecision` 的 domain、repository、Parse/Postgres migration、routes、audit 和测试。
+2. 扩展 `ActionItem` 的 `sourceDecisionId/sourceKind/creationKey`,实现服务端决策门禁和幂等。
+3. 在 `PATCH analysis` 终态写入前加入 schema + evidence subset + coverage validator。
+4. 确认部署环境的 `schema_migration` 已应用 `003_action_item_insight_source.sql`、`004_action_item_source_integrity.sql`;未确认前不把 Postgres 视为已验收。
+
+### Sprint 2:P0 前端闭环和行动中心接通
+
+1. AI 洞察页实现证据审阅、决策状态、决策提交和创建行动门禁。
+2. 反馈收件箱将快捷行动改为加入分析,或实现明确的 `raw_feedback` 类型。
+3. 行动中心接入 `SaasPlatformService.actions()`,支持列表、详情、状态更新和来源回跳。
+4. 用真实数据跑桌面/移动端验收,覆盖刷新、历史恢复、重复提交、权限失败、AI 降级。
+
+### Sprint 3:P1 资产发布和效果验证
+
+1. `InsightRecord` 版本化和发布/归档页面。
+2. `ActionValidation` 表单、接口、查询和验证结果回流。
+3. 统一 feedback evidence registry 与增量同步质量指标。
+4. 建立 50-100 条 golden set,纳入 CI 或发布前回归。
+
+### Sprint 4+:P2 规模化智能
+
+1. AI 质量中心、成本/延迟/失败观测。
+2. 相似反馈、主题合并拆分和跨周期趋势。
+3. 受控问答、schema-aware 报告和带引用周报。
+
+依赖规则:P0 的决策门禁、服务端 grounding 和 Action API 接通完成前,不做自动发布、自动创建行动、自动发送周报或无引用的对话式推荐。
+
+## 8. 最终验收清单
+
+- [ ] 反馈证据有稳定 ID、来源、时间、产品和权限边界。
+- [ ] AnalysisRun 的输入、结果、模型/提示词/规则版本可复现。
+- [ ] 任何洞察证据 ID 都属于本次输入,coverage 可由服务端重算。
+- [ ] 洞察有 `confirmed/rejected/needs_more_evidence` 决策,含人员、时间、理由和已阅证据。
+- [ ] 未确认洞察的正式行动入口关闭;补证据只创建数据质量任务。
+- [ ] ActionItem 可通过来源链回到 InsightDecision、Insight、AnalysisRun 和原始 Evidence。
+- [ ] 行动中心读取 SaaS ActionItem,规则建议与正式行动分开统计。
+- [ ] ActionItem 完成后有验证指标、基线、目标、观察窗口、观测值、结论或未测量原因。
+- [ ] 刷新、历史恢复、权限变化和跨页面跳转不丢失上下文。
+- [ ] local/Parse/Postgres 的持久化差异有验收说明,Postgres migration 状态可查询。
+- [ ] AI schema、证据精度、人工一致率、fallback、延迟和成本有可重复评估。
+
+## 9. 核心决策
+
+1. **先做可审计门禁,再做更多 AI 功能**:`completed` 只代表计算结束,不代表洞察成立;`confirmed` 才是正式行动入口。
+2. **服务端是最终事实源**:前端负责体验和预填,服务端负责 schema、证据子集、workspace、权限、决策和幂等校验。
+3. **原始反馈快捷行动不再伪装成洞察行动**:默认加入分析;保留快捷入口时必须显式标注 `raw_feedback`,并独立归因。
+4. **行动中心统一消费 SaaS ActionItem**:本地规则建议保留,但作为独立来源,不与正式行动混合计数。
+5. **洞察和运行分离**:AnalysisRun 记录一次计算,InsightRecord 记录可发布版本,避免刷新或重新生成覆盖业务资产。
+6. **完成不等于有效**:ActionValidation 是闭环的最后一跳,验证结论必须能回写下一轮分析输入。
+7. **AI 只能解释已计算和已引用的事实**:趋势、KPI、成本和结果由确定性数据层提供,模型输出必须带引用并经过版本化评估。
+
+## 10. 代码依据
+
+- 前端 AI 洞察:`src/modules/ai-voc-insight/ai-voc-insight.component.ts`、`ai-voc-insight.component.html`、`ai-voc-insight.service.ts`、`ai-voc-insight.models.ts`
+- 前端反馈收件箱:`src/modules/feedback-inbox/feedback-inbox.component.ts`、`feedback-inbox.component.html`、`feedback-inbox.models.ts`
+- 前端行动创建:`src/modules/shared/components/action-create-panel/action-create-panel.component.ts`、`action-create-panel.component.html`
+- 前端 SaaS 契约:`src/app/core/services/saas-platform.service.ts`
+- 前端行动中心:`src/modules/domestic-voc/framework/domestic-action-workbench.component.ts`、`domestic-action-workbench.component.html`、`src/app/core/services/domestic-analytics-adapter.service.ts`
+- 后端契约与状态机:`src/modules/saas-platform/domain.ts`、`src/modules/saas-platform/routes.ts`
+- 后端 repository:`src/modules/saas-platform/local-platform.repository.ts`、`parse-rest-voc.repository.ts`、`postgres-platform.repository.ts`
+- 数据库迁移:`migrations/002_saas_platform.sql`、`migrations/003_action_item_insight_source.sql`、`migrations/004_action_item_source_integrity.sql`
+- 迁移执行器:`src/db/migrations.ts`
+- 既有审计说明:`docs/AI-VOC-INSIGHT-LIFECYCLE.md`

+ 549 - 0
docs/GITHUB-AI-VOC-BENCHMARK-2026-08.md

@@ -0,0 +1,549 @@
+# GitHub AI VOC / Review Intelligence 对标报告
+
+> 调研快照:2026-08-06  
+> 对标对象:`E:\workspace\Saas-voc` 的反馈收件箱、AI VOC 洞察历史与 ActionItem 流程  
+> 资料口径:仅使用项目官方 GitHub README、仓库源码、测试与 GitHub 仓库元数据;Star 数为调研时快照,不作为唯一质量判断。
+
+## 1. 结论先行
+
+当前系统已经具备一条可运行的主链路:
+
+```text
+反馈收件箱
+  -> 筛选并审阅原始评价
+  -> AI / deterministic 洞察
+  -> AnalysisRun 历史保存与恢复
+  -> 洞察引用 evidenceIds
+  -> 创建 ActionItem
+```
+
+本轮源码复核还确认,前端创建 ActionItem 时已经携带:
+
+- `sourceAnalysisId`
+- `sourceInsightId`
+- `evidenceIds`
+- `validationMetric`
+
+因此下一步不应重做收件箱或再建一套孤立的 AI 卡片,而应把已有链路升级为四个可审计闭环:
+
+1. **输入可信**:多来源反馈进入统一事件模型,先归一化、去重、脱敏,再进入 AI。
+2. **结论有据**:每条洞察只能引用本次 AnalysisRun 输入中的证据 ID,服务端再次校验。
+3. **决策可审计**:`needs_validation`、人工确认、发布和行动创建是不同业务状态。
+4. **结果可回流**:行动完成后记录验证指标、基线、结果与结论,作为下一轮分析输入。
+
+最值得组合采用的路线不是复制某一个项目,而是:
+
+```text
+AWS 的连接器与统一反馈事件
+  + ReviewRadar 的规则优先、脱敏和 P0/P1 分类
+  + OpenVoC / Qiaomu 的证据引用与稳定历史页面
+  + Microsoft 的 grounded retrieval 与 schema validator
+  + Formbricks 的 Summary / Responses 双层工作台
+  + Langfuse 的运行追踪、版本和评估
+  + 当前 Saas-voc 的人工行动流
+```
+
+## 2. 当前 Saas-voc 基线
+
+### 2.1 已具备
+
+| 能力 | 当前实现 | 判断 |
+| --- | --- | --- |
+| 原始证据收件箱 | 正文、来源、评分、主题、同类样本、置信表达、搜索与筛选 | 已形成专业工作台基础 |
+| 证据回跳 | `reviewId` 查询参数恢复反馈详情 | 已可审阅单条证据 |
+| AI 输入边界 | scope、sentiment、sample limit、来源/主题统计、稳定 evidence ID | 已具备输入审计信息 |
+| 结构化洞察 | finding、user need、severity、opportunity status、recommendation、validation metric | 已具备可执行结果结构 |
+| Grounding | AI 输出 evidence ID 白名单过滤,无有效引用的洞察不进入结果 | 已在前端生成层生效 |
+| 规则降级 | deterministic 聚合保留证据 ID,并统一标记 `needs_validation` | 方向正确 |
+| 运行历史 | AnalysisRun 创建、processing、completed/partial/failed、列表与恢复 | 已具备运行级历史 |
+| 行动关联 | ActionItem 请求携带分析、洞察、证据与验证指标 | 前端契约已具备 |
+| 操作确认 | 用户点击并提交行动表单后才创建 ActionItem | 有显式用户动作,但不是独立确认记录 |
+
+### 2.2 当前关键缺口
+
+| 缺口 | 风险 | 对标启发 |
+| --- | --- | --- |
+| 证据约束主要在前端 | 绕过前端或历史数据写入时可能出现跨运行、未知或空 evidence ID | OpenVoC、Microsoft、Langfuse |
+| AI 前脱敏未成为固定处理阶段 | 原始反馈可能携带邮箱、电话、地址或账号信息 | ReviewRadar、Microsoft |
+| AnalysisRun 是运行状态,不是洞察发布状态 | `completed` 容易被误解为“业务结论已确认” | Langfuse 的 desired/effective status 分离 |
+| 没有独立人工决策记录 | 无法查询谁确认、为何驳回、要求补什么证据 | OpenVoC roadmap、当前业务审计 |
+| ActionItem 关联字段虽已发送,但需服务端引用完整性和幂等约束 | 重复创建、孤儿引用或跨 workspace 引用仍可能发生 | OpenVoC action draft、Langfuse API 约束 |
+| 缺少行动效果记录 | 链路停在“任务已创建/已完成”,无法证明问题改善 | ADK backlog、产品实验思路 |
+| 缺少稳定的 AI 回归数据集 | 更换模型、提示词或聚类规则后无法判断质量变化 | ADK eval、ReviewRadar golden dataset、Langfuse datasets |
+| 数据源接入仍与业务代码耦合 | 新增平台可能重复改前端、后端和配置 | AWS plugin manifest |
+
+## 3. 项目总览
+
+| 项目 | 快照活跃度 | 类型 | 本项目最值得借鉴的部分 | 适用层级 |
+| --- | ---: | --- | --- | --- |
+| [OpenVoC Radar](https://github.com/IreliaWuW/openvoc-radar) | 0 Star,2026-06 新项目,MIT | 本地 VOC MVP | normalize → dedupe → classify → persist;洞察、报告、行动草稿始终保留 source ticket IDs | 业务链路样板,不是成熟度基准 |
+| [Qiaomu App Review Insights](https://github.com/joeseesun/qiaomu-app-review-insights) | 217 Star,MIT | App Review Intelligence | 稳定缓存页、评论历史、版本风险、来源分布、map/reduce 式证据样例 | 评论洞察与 UI 样板 |
+| [ReviewRadar](https://github.com/abhishekdubey331/ReviewRadar) | 0 Star,2026-03 推送,MIT | MCP Review Intelligence | 规则 + 选择性 LLM、PII 清理、P0/P1、趋势、周报、golden dataset | AI 前处理与质量规则样板 |
+| [Microsoft Conversation Knowledge Mining](https://github.com/microsoft/Conversation-Knowledge-Mining-Solution-Accelerator) | 461 Star,2026-08-06 有推送,MIT | Grounded knowledge mining | 混合检索、SQL、引用、schema-aware dashboard、LLM plan validator | Grounding 与自适应洞察样板 |
+| [AWS Voice of Customer Data Lake](https://github.com/aws-samples/sample-voice-of-customer-datalake) | 22 Star,2026-08 活跃,MIT-0 | 多源 VOC 数据平台 | manifest 驱动连接器、统一 feedback schema、队列、去重、LLM 失败可观测 | 数据接入与标准化样板 |
+| [ADK Product Engineers / VoC Insights](https://github.com/addyosmani/adk-product-engineers) | 43 Star,未检测到仓库许可证 | VOC Agent 示例 | deterministic clustering、memory、趋势、backlog artifacts、eval | 分析编排与回归样板 |
+| [Formbricks](https://github.com/formbricks/formbricks) | 12,727 Star,2026-08-06 活跃,混合许可证 | 成熟反馈/问卷平台 | Summary / Responses 双视图、强筛选、可配置表格、单条响应详情 | 专业工作台 UI/IA 样板 |
+| [Langfuse](https://github.com/langfuse/langfuse) | 32,611 Star,2026-08-06 活跃,核心 MIT/EE 分区 | LLM 工程平台 | trace、prompt version、dataset schema、experiment/eval rule、effective status | AI 运行与评估样板 |
+
+说明:OpenVoC Radar 与 ReviewRadar 的社区规模很小,本报告只采用其代码中可验证的业务机制,不把它们视为生产成熟度证明。Formbricks 和 Langfuse 不是 VOC 洞察产品,但其工作台和 AI 工程机制对本项目有高参考价值。
+
+## 4. 逐项目对标
+
+### 4.1 OpenVoC Radar
+
+官方依据:[README](https://github.com/IreliaWuW/openvoc-radar/blob/main/README.md)、[架构](https://github.com/IreliaWuW/openvoc-radar/blob/main/docs/ARCHITECTURE.md)、[报告与行动草稿代码](https://github.com/IreliaWuW/openvoc-radar/blob/main/backend/app/services/reports.py)、[Issue Clusters 页面](https://github.com/IreliaWuW/openvoc-radar/blob/main/frontend/app/clusters/page.tsx)。
+
+**可借鉴的业务机制**
+
+- 明确的四段管线:`normalize -> dedupe -> classify -> persist`,先确定事实层,再做 AI 分类。
+- `VocItem`、cluster、weekly report 和 product action draft 都携带 `source_ticket_ids`。
+- 行动草稿按 `voc_item_id + draft_type` 去重,避免同一洞察重复生成同类草稿。
+- OpenAI 结构化结果校验失败时回退 mock/deterministic 分类,运行不因模型输出格式中断。
+- README 主动标注聚类和相似度只是 placeholder,避免把 demo 能力包装成生产语义聚类。
+
+**UI / 信息架构做法**
+
+- 顶层导航拆为 Overview、Trends、Issue Clusters、Reports、Product Actions、Settings。
+- cluster 卡片直接展示严重度、关联数量和 source ticket ID,而不是只给摘要。
+- Product Actions 先展示可编辑的 bug / feature draft,强调“草稿”而不是自动创建正式任务。
+
+**我们不应照搬**
+
+- 同用户、时间窗和关键词的简单去重不足以处理中文评论、模板化评价和跨来源重复。
+- cluster 页是卡片平铺,数据规模上升后扫描效率不如“表格/列表 + 详情侧栏”。
+- action draft 没有负责人、验证指标、审批和效果回流,不足以替代当前 ActionItem。
+- 本地 SQLite 与 mock 模式适合演示,不应成为多 workspace SaaS 的生产边界。
+
+**映射到 Saas-voc**
+
+- 保留当前 evidence ID 设计,并在服务端建立 `sourceAnalysisId + sourceInsightId + evidenceIds` 的引用校验。
+- 给 ActionItem 增加服务端幂等键,避免同一洞察被重复提交成同类行动。
+
+### 4.2 Qiaomu App Review Insights
+
+官方依据:[README](https://github.com/joeseesun/qiaomu-app-review-insights/blob/main/README.md)、[缓存与生成](https://github.com/joeseesun/qiaomu-app-review-insights/blob/main/src/lib/appstore/cache.ts)、[评论历史](https://github.com/joeseesun/qiaomu-app-review-insights/blob/main/src/lib/appstore/review-history.ts)、[AI map/reduce](https://github.com/joeseesun/qiaomu-app-review-insights/blob/main/src/lib/analysis/ai-insights-service.ts)、[洞察卡片](https://github.com/joeseesun/qiaomu-app-review-insights/blob/main/src/components/app-review/insight-cards.tsx)、[版本诊断](https://github.com/joeseesun/qiaomu-app-review-insights/blob/main/src/components/app-review/version-diagnostics.tsx)。
+
+**可借鉴的业务机制**
+
+- 每个 App 生成稳定、可分享、可再次访问的缓存洞察页,不把一次 LLM 响应当临时聊天消息。
+- 评论历史按 review ID 合并并原子写文件,避免增量抓取覆盖已有语料。
+- AI 分析按 chunk 做 map,再按 `category + normalized title` reduce;样例保留 review ID 和 snippet。
+- 同时保留 `reviews`、`allReviews`、source breakdown、diagnostics、model 和 generatedAt,结果页能解释样本边界。
+- 版本维度展示评分、负向占比、问题热力图和情绪时间线,直接服务发布风险判断。
+
+**UI / 信息架构做法**
+
+- 洞察分为核心痛点、产品机会、正向信号、用户分层、版本风险、行动建议六类。
+- 独立来源分布和版本诊断,帮助用户区分“洞察结论”和“数据口径”。
+- ECharts 使用稳定高度、ResizeObserver 和无数据状态,避免图表布局跳动。
+- 提供 regenerate 与缓存复用,用户能理解当前页面是历史结果还是新生成结果。
+
+**我们不应照搬**
+
+- 项目主要围绕 App Store,不能直接覆盖客服工单、问卷、社媒和国内电商评价。
+- 仓库并存两套洞察结构:部分卡片使用自由文本 `evidence`,另一套 taxonomy 使用 review ID examples;我们应坚持唯一 evidence schema。
+- 以 title 文本归并主题在同义词、多语言和短标题场景中容易产生碎片。
+- 其行动建议停留在报告内容,没有当前 Saas-voc 的责任人、期限和状态流。
+
+**映射到 Saas-voc**
+
+- 在现有 AI 洞察历史上增加“版本/时间窗口对比”,优先显示新增、升温、缓解和复发主题。
+- 历史恢复卡需要同时显示模型/规则模式、样本时间范围、来源分布和 evidence coverage。
+
+### 4.3 ReviewRadar
+
+官方依据:[README](https://github.com/abhishekdubey331/ReviewRadar/blob/main/README.md)、[架构](https://github.com/abhishekdubey331/ReviewRadar/blob/main/docs/architecture.md)、[聚类工具](https://github.com/abhishekdubey331/ReviewRadar/blob/main/src/tools/cluster_reviews.ts)、[PII 清理](https://github.com/abhishekdubey331/ReviewRadar/blob/main/src/utils/redact.ts)、[golden dataset](https://github.com/abhishekdubey331/ReviewRadar/blob/main/__tests__/fixtures/golden_dataset.json)。
+
+**可借鉴的业务机制**
+
+- 两阶段处理:Schema/limits、PII、规则和 severity precedence 在前,只有必要样本才路由 LLM。
+- 输出采用严格 JSON schema,MCP Host 接收结构化结果,不依赖自然语言解析。
+- P0/P1/P2/FYI 规则强调安全、支付、崩溃、性能等业务优先级,不只按情感负面程度排序。
+- 持久化向量索引与 metadata,支持跨进程的语义搜索和诊断。
+- golden dataset 明确每条 review 的 expected issue type 和 severity,适合做变更回归。
+
+**UI / 信息架构做法**
+
+- 该项目核心是 MCP tool IA:import、analyze、cluster、trend、search、weekly report。
+- 它没有成熟 Web UI,因此适合学习“能力边界和返回结构”,不适合作为页面视觉基准。
+- 周报输出按 P0/P1 和趋势变化组织,适合作为 Action Center 的管理摘要入口。
+
+**我们不应照搬**
+
+- 当前 PII 代码主要是邮箱、电话和坐标正则,无法覆盖姓名、账号、订单号、地址和中文语境。
+- severity precedence 需要按本项目品类和业务风险配置,不能硬编码移动 App 分类。
+- MCP-only 交互不适合需要证据审阅、人工确认和跨角色协作的 SaaS 主流程。
+- 0 Star 小项目缺少大规模生产验证,工程机制应通过本项目自己的数据回归后采用。
+
+**映射到 Saas-voc**
+
+- 在 AI 网关调用前增加 `normalize -> pii_redact -> rule_tag -> llm` 固定阶段,并记录每阶段版本。
+- 建立至少 50 条中文 golden feedback,覆盖未知证据 ID、低样本、重复反馈、P0 风险和无效文本。
+
+### 4.4 Microsoft Conversation Knowledge Mining
+
+官方依据:[README](https://github.com/microsoft/Conversation-Knowledge-Mining-Solution-Accelerator/blob/main/README.md)、[统一检索引擎](https://github.com/microsoft/Conversation-Knowledge-Mining-Solution-Accelerator/blob/main/src/api/modules/runtime/retrieval_engine.py)、[洞察 planner 与 validator](https://github.com/microsoft/Conversation-Knowledge-Mining-Solution-Accelerator/blob/main/src/api/modules/insights/service.py)、[提示词](https://github.com/microsoft/Conversation-Knowledge-Mining-Solution-Accelerator/blob/main/src/api/config/prompts.yaml)、[Insights 页面](https://github.com/microsoft/Conversation-Knowledge-Mining-Solution-Accelerator/blob/main/src/app/src/pages/Insights.tsx)。
+
+**可借鉴的业务机制**
+
+- ChatAgent 同时调用语义检索与 SQL 结构化分析,回答既能找原文,也能计算真实聚合。
+- 检索结果统一为 source kind、source name、source file 等字段,便于跨来源引用。
+- LLM 先根据数据 schema 规划 KPI 和 chart,再由代码 validator 删除未知字段和非法图表。
+- planner 明确排除 identifier、contact、PII 字段,不允许进入图表、KPI 和筛选。
+- 对 field coverage 设置阈值,只让覆盖率足够的字段成为主指标。
+
+**UI / 信息架构做法**
+
+- Home、Explore、Insights 三个主面:数据接入、对话探索、结构化洞察职责分离。
+- Insights 页按 headline、KPI、filter、insight cards、distribution/chart 和 evidence 组织。
+- 使用响应式 grid、固定最小列宽、skeleton、empty/error/notice 状态,适合专业数据工作台。
+- 问答支持引用,dashboard 支持 suggested questions,用户能从发现继续追问。
+
+**我们不应照搬**
+
+- Azure Search、SQL、Foundry Agent 和 Content Understanding 的完整栈成本与复杂度高于当前阶段。
+- 完全 schema-aware 的动态 dashboard 容易产生视觉和指标漂移;核心 VOC 指标仍应由产品定义。
+- README 自身明确该方案是加速器/PoC,生成内容仍需业务判断。
+- 对话入口不能取代收件箱和洞察审批;它应是探索工具,不应直接改变业务状态。
+
+**映射到 Saas-voc**
+
+- 保持固定 VOC 工作台骨架,只允许 AI 在受控 schema 内建议图表和追问。
+- 后续 Grounded Explore 同时调用 evidence search 与结构化统计 API,回答中返回可点击 review ID。
+
+### 4.5 AWS Voice of Customer Data Lake
+
+官方依据:[README](https://github.com/aws-samples/sample-voice-of-customer-datalake/blob/main/README.md)、[插件架构](https://github.com/aws-samples/sample-voice-of-customer-datalake/blob/main/docs/plugin-architecture.md)、[处理管线](https://github.com/aws-samples/sample-voice-of-customer-datalake/blob/main/docs/processing-pipeline.md)、[统一反馈 schema](https://github.com/aws-samples/sample-voice-of-customer-datalake/blob/main/voc-datalake/schemas/feedback-event.schema.json)、[反馈详情页](https://github.com/aws-samples/sample-voice-of-customer-datalake/blob/main/voc-datalake/frontend/src/pages/FeedbackDetail/FeedbackDetail.tsx)。
+
+**可借鉴的业务机制**
+
+- 每个数据源使用自包含插件;`manifest.json` 同时驱动基础设施、配置 UI、密钥和 setup 文案。
+- manifest 由 Zod 校验,目录名必须与 plugin ID 一致,避免连接器配置漂移。
+- 统一 feedback event 包含内部 ID、source ID、platform、channel、source URL、原文、时间和 LLM metadata。
+- `feedback_id` 使用来源 ID 或稳定组合键生成,LLM 前先查重。
+- Bedrock 失败时保留基础处理结果,并记录 processed with/without LLM、throttle 和 processing error 指标。
+
+**UI / 信息架构做法**
+
+- Settings 根据 manifest 动态渲染来源配置,连接器启停不占用业务页面。
+- Feedback Detail 分为原文、分类、persona、问题分析、标签、metadata 和 similar feedback。
+- 原文与 normalized/translated text 分开展示,避免用户误把转换文本当原声。
+- Similar Feedback 作为详情页标签,不挤占收件箱主列表。
+
+**我们不应照搬**
+
+- S3、SQS、Lambda、DynamoDB、CDK 全套基础设施不适合直接移植到当前 Node/Angular 架构。
+- 固定分类和 journey stage 必须做 workspace 配置与版本管理,不能成为全局硬编码。
+- `direct_customer_quote` 等 LLM 字段需要绑定 source offset 或 evidence ID,不能只存一段模型生成文本。
+- 动态连接器 UI 只能负责配置,不能让 manifest 控制核心权限和数据治理规则。
+
+**映射到 Saas-voc**
+
+- 先定义平台无关的 `NormalizedFeedbackEvent`,再让电商、App Review、客服和 CSV adapter 输出同一结构。
+- 来源注册表可借鉴 manifest,但使用当前后端配置和权限体系实现,不引入 AWS 依赖。
+
+### 4.6 ADK Product Engineers / VoC Insights
+
+官方依据:[仓库 README](https://github.com/addyosmani/adk-product-engineers/blob/main/README.md)、[VoC README](https://github.com/addyosmani/adk-product-engineers/blob/main/python/agents/voc_insights/README.md)、[Agent 与 deterministic tools](https://github.com/addyosmani/adk-product-engineers/blob/main/python/agents/voc_insights/agent.py)、[eval cases](https://github.com/addyosmani/adk-product-engineers/blob/main/python/agents/voc_insights/eval/test_cases.jsonl)。
+
+**可借鉴的业务机制**
+
+- load、clean/dedupe、cluster、synthesize、artifact 是明确工作流,不让 LLM 同时承担所有步骤。
+- deterministic keyword clustering 确保相同输入可复现,LLM 负责解释和建议。
+- MemoryService 用于保存上次主题并回答“与上周相比有什么变化”。
+- 输出 `themes.json` 与 `recommendations.csv`,把洞察和 backlog 作为可版本化 artifact。
+- eval 关注主题稳定、不得编造引用、建议必须匹配主题、执行轨迹必须正确。
+
+**UI / 信息架构做法**
+
+- 该示例没有产品级 UI,但输出结构适合映射到“主题趋势、建议、验收标准”三个区域。
+- artifact-first 比聊天记录更适合长期运营;当前 AnalysisRun result 可承担第一阶段 artifact。
+
+**我们不应照搬**
+
+- 默认 MemoryService 重启后丢失,不满足当前历史恢复要求。
+- 关键词表和 20 条样本只能用于演示,不适合中文、多品类和同义表达。
+- eval 只有 3 条高层案例,不能代替 evidence precision、schema pass rate 和人工一致性测试。
+- 仓库未检测到明确许可证,不能直接复制源码进入本项目。
+
+**映射到 Saas-voc**
+
+- 将 `promptVersion + modelVersion + ruleVersion + taxonomyVersion` 写入 AnalysisRun。
+- 把历史对比变成确定性统计:主题新增率、升温幅度、复发率、证据增量;LLM 只解释变化。
+
+### 4.7 Formbricks
+
+官方依据:[README](https://github.com/formbricks/formbricks/blob/main/README.md)、[Summary / Responses 导航](https://github.com/formbricks/formbricks/blob/main/apps/web/app/%28app%29/workspaces/%5BworkspaceId%5D/surveys/%5BsurveyId%5D/%28analysis%29/components/SurveyAnalysisNavigation.tsx)、[ResponseTable](https://github.com/formbricks/formbricks/blob/main/apps/web/app/%28app%29/workspaces/%5BworkspaceId%5D/surveys/%5BsurveyId%5D/%28analysis%29/responses/components/ResponseTable.tsx)、[Response detail modal](https://github.com/formbricks/formbricks/blob/main/apps/web/app/%28app%29/workspaces/%5BworkspaceId%5D/surveys/%5BsurveyId%5D/%28analysis%29/responses/components/ResponseCardModal.tsx)、[SummaryPage](https://github.com/formbricks/formbricks/blob/main/apps/web/app/%28app%29/workspaces/%5BworkspaceId%5D/surveys/%5BsurveyId%5D/%28analysis%29/summary/components/SummaryPage.tsx)。
+
+**可借鉴的业务机制**
+
+- 原始 responses 与 aggregate summary 使用同一套 filter context,证据与汇总口径一致。
+- 服务端分页、筛选和导出,避免把全部反馈加载到浏览器后再处理。
+- 表格列顺序、显隐、宽度和展开偏好可持久化,适合高频运营人员。
+- 单条响应详情支持前后翻页,保留用户在当前筛选队列中的位置。
+
+**UI / 信息架构做法**
+
+- `Summary` 与 `Responses` 是同一分析对象的二级导航,先看总体,再下钻原声。
+- ResponseTable 使用固定布局、列固定、列拖动、批量选择、下载和 skeleton。
+- SummaryPage 组合 metadata、drop-off、impressions、filter 和各题型 summary,不把所有信息塞进一层卡片。
+- 详情使用 modal/side detail,用户无需离开当前筛选上下文。
+
+**我们不应照搬**
+
+- Formbricks 以问卷和 response 为核心,无法直接表达主题聚类、证据链、洞察确认和行动效果。
+- 当前系统不需要复制其问卷编辑器和大量题型组件。
+- 仓库核心 AGPLv3,另有 EE 和 SDK 分区许可证;可学习 IA,不应直接复制受约束代码。
+- 高自由度列配置应只用于证据表格,洞察主字段必须保持统一,避免每个用户看到不同结论结构。
+
+**映射到 Saas-voc**
+
+- AI 洞察页采用 `洞察摘要 / 原始证据 / 分析历史` 二级视图,三者共用同一筛选上下文。
+- 收件箱桌面端可增加可配置密度和列偏好,但保留当前 queue + detail 的核心工作流。
+
+### 4.8 Langfuse
+
+官方依据:[README](https://github.com/langfuse/langfuse/blob/main/README.md)、[Trace API](https://github.com/langfuse/langfuse/blob/main/fern/apis/server/definition/trace.yml)、[Dataset API](https://github.com/langfuse/langfuse/blob/main/fern/apis/server/definition/datasets.yml)、[Evaluation Rules](https://github.com/langfuse/langfuse/blob/main/fern/apis/server/definition/unstable/evaluation-rules.yml)、[Prompt Version](https://github.com/langfuse/langfuse/blob/main/fern/apis/server/definition/prompt-version.yml)。
+
+**可借鉴的业务机制**
+
+- trace 将输入、输出、metadata、scores、observations、token、cost、latency、version 和 release 关联起来。
+- prompt 有不可混淆的 version 与 label;`latest` 由系统管理,避免用户手工覆盖。
+- dataset 支持 input schema 和 expected output schema,所有 dataset item 必须通过同一校验。
+- evaluation rule 明确 target、filter、variable mapping、sampling 和 evaluator version。
+- `enabled` 是用户期望状态,`status` 是系统校验后的有效状态;`enabled=true/status=paused` 能解释阻塞原因。
+
+**UI / 信息架构做法**
+
+- 从 trace 列表进入单次输入/输出,再查看 score、observation 和成本,适合作为 AnalysisRun 的技术详情抽屉。
+- dataset、experiment、evaluation 和 prompt management 分开,避免把运营工作台变成 AI 配置页。
+- 坏结果可以从 trace 进入 playground 复现,缩短提示词迭代路径。
+
+**我们不应照搬**
+
+- Langfuse 是通用 LLMOps,不具备 VOC 的证据确认、洞察发布和行动业务语义。
+- ClickHouse、trace ingestion 和完整评估平台在当前规模可能过重。
+- 仓库包含核心 MIT 与 EE 许可证分区,采用前需确认具体目录边界。
+- 技术 trace 不应直接暴露给所有业务用户;业务页只展示模型、版本、模式、耗时和质量摘要。
+
+**映射到 Saas-voc**
+
+- 先为 AnalysisRun 增加轻量 trace metadata,而不是引入完整 LLMOps 平台。
+- AI 质量中心建立 dataset run:同一证据集在不同模型/提示词/规则版本上对比 schema、引用和人工评分。
+
+## 5. 横向机制对比
+
+| 机制 | 代表项目 | 当前 Saas-voc | 建议目标 |
+| --- | --- | --- | --- |
+| 多来源接入 | AWS、Formbricks | 已有业务数据适配,但未形成插件契约 | manifest/registry + `NormalizedFeedbackEvent` |
+| 归一化与去重 | OpenVoC、AWS、ADK | 主题准备已有,跨来源去重弱 | 稳定 source key + text fingerprint + 可解释相似度 |
+| AI 前脱敏 | ReviewRadar、Microsoft | 未形成固定阶段 | 可配置 PII policy + redaction audit |
+| 规则与 LLM 分工 | ReviewRadar、ADK | deterministic fallback 已有 | 规则产事实,LLM 做语义归纳与建议 |
+| 证据引用 | OpenVoC、Microsoft、Qiaomu | 前端 evidence ID 白名单已具备 | 服务端 subset 校验 + source offset/snippet |
+| 分析历史 | Qiaomu、Langfuse | AnalysisRun 已保存并恢复 | 增加模型、提示词、规则、taxonomy 版本 |
+| 人工确认 | OpenVoC roadmap | 只有创建行动表单这一隐式确认 | 独立 InsightDecision 与审计事件 |
+| 洞察发布 | Langfuse 的版本思想 | AnalysisRun 终态被用作结果状态 | `draft -> confirmed -> published -> archived` |
+| 行动关联 | OpenVoC | 前端已传 source IDs/evidence/metric | 服务端引用、唯一性、反向查询 |
+| 效果回流 | ADK backlog 可提供起点 | 缺失 | ActionValidation + 新一轮 AnalysisRun 输入 |
+| 趋势/版本风险 | Qiaomu、ReviewRadar、ADK | 主要是单次结果 | 时间窗 diff + 版本/批次诊断 |
+| AI 评估 | ReviewRadar、ADK、Langfuse | 单元测试为主 | golden dataset + experiment + scorecard |
+
+## 6. 推荐目标信息架构
+
+### 6.1 反馈收件箱
+
+保持当前 `队列 + 证据详情`,补充:
+
+- 可保存的筛选视图:来源、产品、时间、主题、评分、处理状态。
+- 详情中的“相似反馈”标签,展示相似原因和真实 evidence ID。
+- 原文、归一化文本、翻译文本分栏,明确哪个字段是原始证据。
+- 去重组视图:主反馈、重复成员、去重规则和可撤销合并。
+
+### 6.2 AI 洞察工作台
+
+采用受控的三层结构:
+
+1. `洞察摘要`:结论、状态、严重度、证据覆盖、建议与验证指标。
+2. `原始证据`:与当前筛选一致的证据列表,可回跳收件箱。
+3. `分析历史`:运行状态、模式、模型/提示词/规则版本、样本窗口和恢复。
+
+历史列表应显示全部状态,但只有 `completed/partial` 且通过 schema 校验的结果可恢复;`failed/cancelled` 用于诊断与重试,不伪装成业务洞察。
+
+### 6.3 洞察资产详情
+
+新增独立业务对象后,单条洞察详情使用五个标签:
+
+```text
+Evidence | Decision | Actions | Validation | Versions
+```
+
+- Evidence:引用、coverage、样本限制、来源分布。
+- Decision:confirmed/rejected/needs_more_evidence、决策人、时间、备注。
+- Actions:关联 ActionItem、负责人、截止时间和状态。
+- Validation:基线、目标、观察窗口、结果与结论。
+- Versions:来自哪次 AnalysisRun、人工修改和发布版本。
+
+### 6.4 行动中心
+
+ActionItem 详情顶部固定展示来源链:
+
+```text
+ActionItem -> Insight -> AnalysisRun -> Evidence
+```
+
+完成行动前要求填写验证方式;关闭行动时记录 `effective / partially_effective / ineffective / not_measured`,其中 `not_measured` 必须填写原因。
+
+### 6.5 AI 质量中心
+
+该页面面向管理员/分析师,不进入普通业务用户主导航:
+
+- 运行量、成功率、fallback 率、平均耗时、模型与提示词版本。
+- JSON schema pass rate、evidence precision、evidence coverage、unknown ID rejection。
+- golden dataset 运行对比与人工评分。
+- 失败样本进入可复现详情,不展示敏感原文给无权限角色。
+
+## 7. P0 / P1 / P2 实施清单
+
+### P0:先闭合可信决策链
+
+#### P0-1 服务端 Grounding 不变量
+
+- 服务端保存 AnalysisRun 结果前校验每个 `insight.evidenceIds` 是本次 `input.evidenceIds` 的非空子集。
+- 拒绝未知 ID、跨 workspace ID、重复 ID、空引用和 `evidenceCount` 不一致。
+- 保存 `schemaVersion`、`promptVersion`、`model`、`ruleVersion`、`taxonomyVersion`。
+- 对相同 workspace、输入指纹和版本组合支持幂等创建,避免刷新产生并发运行。
+
+验收:构造未知 evidence ID 和跨运行 ID 的 PATCH,API 返回 4xx;合法结果可恢复且引用数一致。
+
+#### P0-2 AI 前脱敏与审计
+
+- 建立 `normalize -> redact -> rule tag -> LLM` 固定管线。
+- 首批覆盖邮箱、手机号、账号/订单号、URL 参数、地址候选和自定义词典。
+- AnalysisRun 只记录脱敏统计和策略版本,不把原始敏感内容写入错误日志。
+- 允许按 workspace 配置字段白名单与“完全不发送 AI”的数据源。
+
+验收:golden feedback 中的敏感字段在网关请求与日志中均不可见,原始证据仍按权限保留在收件箱。
+
+#### P0-3 独立人工决策门槛
+
+- 新增 `InsightDecision`:`confirmed | rejected | needs_more_evidence`。
+- 保存 `decidedBy`、`decidedAt`、`comment`、`reviewedEvidenceIds` 和审计事件。
+- `needs_validation` 与 deterministic 洞察默认只能“请求补证据”,确认后才开放正式行动创建。
+- 不允许以修改 AnalysisRun 终态代替人工确认。
+
+验收:未确认洞察在 UI 与 API 均不能创建正式 ActionItem;确认记录可查询且不可静默覆盖。
+
+#### P0-4 ActionItem 引用与幂等
+
+- 延续现有前端字段:`sourceAnalysisId`、`sourceInsightId`、`evidenceIds`、`validationMetric`。
+- 服务端校验分析、洞察、证据、确认记录与 workspace 一致。
+- 增加 `sourceDecisionId` 和 action type 维度的唯一约束或幂等键。
+- Action Center 支持从行动反向跳回洞察和每条原始证据。
+
+验收:重复提交只产生一个行动;删除/归档洞察不破坏已存在行动的历史引用。
+
+#### P0-5 最小 AI 质量门禁
+
+- 建立 50-100 条中文 golden dataset,覆盖本品/竞品、低样本、重复、P0 风险、无效文本和混合情感。
+- 指标至少包括:schema pass rate、evidence precision、unknown ID rejection、fallback rate、人工确认一致率。
+- AI 与 deterministic 结果使用同一 schema validator。
+- 提示词、模型或 taxonomy 变更必须跑回归,并保存对比结果。
+
+验收:CI 可重复运行;任一 evidence precision 下降或 schema 失败超过阈值时阻止发布。
+
+### P1:形成可运营的洞察资产
+
+#### P1-1 统一反馈事件与来源注册表
+
+- 定义 `NormalizedFeedbackEvent`:internal ID、source ID、platform、channel、URL、original text、normalized text、language、timestamps、product/workspace、metadata。
+- 每个来源 adapter 输出同一 schema,并提供健康状态、增量游标和错误统计。
+- 来源配置、密钥引用与 UI 字段由 registry/manifest 驱动,但权限规则仍由平台控制。
+
+#### P1-2 洞察生命周期与版本
+
+- 新增 `InsightRecord`,生命周期为 `draft -> confirmed -> published -> archived`。
+- AnalysisRun 只负责生成;InsightRecord 负责业务发布。
+- 人工编辑或重新生成产生新版本,不原地覆盖已发布版本。
+- 发布、归档、恢复均记录原因和操作者。
+
+#### P1-3 趋势与版本诊断
+
+- 以确定性统计计算主题新增、升温、缓解、复发和证据增量。
+- 支持按商品版本、渠道、地区、批次和时间窗口对比。
+- AI 只解释已计算的变化,并引用造成变化的 evidence IDs。
+- 图表下方保留样本量、时间窗和数据缺口说明。
+
+#### P1-4 行动效果回流
+
+- 新增 `ActionValidation`:metric、baseline、target、window、observed value、verdict、verifiedBy、verifiedAt。
+- 行动完成后进入待验证,而不是直接视为问题已解决。
+- 验证结果可按洞察、主题、产品和时间查询,并进入下一轮 AnalysisRun context。
+- 对 ineffective/partially_effective 自动生成复盘草稿,不自动创建新任务。
+
+#### P1-5 Grounded Explore
+
+- 增加受控追问入口,同时使用 evidence search 与结构化统计。
+- 每个回答返回 citations、筛选口径和计算来源。
+- 对话不能直接确认、发布或创建行动,只能预填业务表单并等待用户提交。
+
+### P2:在基础稳定后扩展规模与智能
+
+#### P2-1 受控 schema-aware dashboard
+
+- AI 可建议 KPI、图表和追问,但仅从白名单 field、metric 和 visualization 中选择。
+- 代码 validator 检查字段存在、覆盖率、PII、基数和图表类型。
+- 核心 VOC 总览保持产品定义,不随模型输出改变布局。
+
+#### P2-2 自动周报与多渠道分发
+
+- 周报按 P0/P1、趋势变化、待确认洞察、逾期行动和验证结果组织。
+- 每条摘要携带可点击 insight/evidence 链接。
+- Slack/飞书/邮件只发送摘要,确认和操作回到平台完成。
+
+#### P2-3 相似反馈与跨周期记忆
+
+- 建立混合检索:稳定规则过滤 + embedding 相似度 + source/time/product 权限过滤。
+- 相似结果展示匹配原因和分数,不自动合并。
+- 主题跨期 identity 由确定性 key 和人工 merge/split 维护,避免模型每周重命名。
+
+#### P2-4 规模化 AI 实验
+
+- 支持 dataset experiment、sampling evaluator、模型/提示词 A/B 和人工盲评。
+- 业务评分与技术 trace 分层展示。
+- 只有通过回归阈值的版本可标记为 production label。
+
+## 8. 建议执行顺序
+
+| 阶段 | 目标 | 依赖 | 完成信号 |
+| --- | --- | --- | --- |
+| 1 | 服务端 evidence subset 校验、PII、版本元数据 | 现有 AnalysisRun | 任意历史结果可解释“输入、版本、证据、模式” |
+| 2 | InsightDecision 与 ActionItem 幂等引用 | 阶段 1 | 未确认洞察无法进入正式行动 |
+| 3 | golden dataset 与质量门禁 | 阶段 1 | 模型/提示词升级有可比较报告 |
+| 4 | InsightRecord 生命周期与版本 | 阶段 2 | 运行完成与业务发布彻底分离 |
+| 5 | ActionValidation 效果回流 | 阶段 2、4 | 可回答“哪类行动真正改善了哪类 VOC” |
+| 6 | 连接器 registry、趋势、Grounded Explore | 前述契约稳定 | 新来源和新智能能力不破坏证据链 |
+
+## 9. 明确不做的复制
+
+- 不复制单一项目的完整云基础设施;当前架构优先复用 Angular、现有 Node 服务和仓储实现。
+- 不把 LLM 聚类结果直接当作主题事实;聚类、计数、趋势和版本差异优先确定性计算。
+- 不允许自由文本 `evidence` 代替 evidence ID 与原文回跳。
+- 不把 `AnalysisRun.completed` 当作“洞察已确认”或“行动已批准”。
+- 不让聊天或 Agent 自动确认、发布或创建正式行动。
+- 不使用只有颜色和卡片数量变化的“AI 仪表盘”;专业 UI 优先支持扫描、筛选、比较、恢复和审计。
+- 不直接复制 AGPL/EE 或无明确许可证仓库的源码;只学习公开的产品机制与信息架构。
+
+## 10. 最终产品判断
+
+Saas-voc 当前已超过“把评论丢给 LLM 得到摘要”的阶段。真正的产品壁垒应放在:
+
+```text
+可追溯证据
+  + 可解释规则
+  + 可恢复运行
+  + 可审计人工决策
+  + 可执行行动
+  + 可量化效果回流
+```
+
+GitHub 对标显示,成熟项目通常只在其中两到三项做得突出。我们的机会是把这些能力统一到一条业务链,而不是继续增加孤立 AI 功能。按本报告优先级,先完成 P0 的服务端证据约束、人工决策门槛、行动幂等和 AI 质量门禁,才能让后续趋势、对话和自动周报建立在可信数据上。

+ 691 - 0
docs/GITHUB-DATA-DASHBOARD-BENCHMARK-2026-08.md

@@ -0,0 +1,691 @@
+# GitHub 开源数据看板深度对标与 VOC 经营看板实施建议
+
+> 调研快照:2026-08-07  
+> 目标仓库:`E:\workspace\Saas-voc`  
+> 目标页面:`/domestic/overview`  
+> 证据范围:项目官方 GitHub 仓库、官方前端源码目录和官方文档。本文不以 Star 数代替产品判断。  
+> 结论标签:`直接采用`、`调整后采用`、`暂缓`。
+
+## 1. 结论先行
+
+当前页面已经具备经营看板的基本骨架:经营结论、数据口径、4 个 KPI、7 日趋势、类目贡献、经营诊断和 Top 10 商品表。但与成熟系统的差距,不只是“图表数量少”,而是以下四个结构性问题:
+
+1. **对比基线不成立**:当前代码把 7 天序列拆成前 4 天和后 3 天,直接比较求和结果。不同长度周期的 GMV、件数和退款额不可直接环比,会系统性压低后半段表现。该逻辑同时影响 KPI 和“本期执行结论”。见 `operating-overview.component.ts:317-351`、`:460-473`。
+2. **首屏层级偏平**:页面头、执行结论、口径说明、4 张 KPI 卡依次占据首屏,真正回答“哪里异常、为什么、下一步看什么”的分析内容出现较晚。
+3. **全局分析上下文不足**:时间固定为 7 日,缺少平台、店铺、类目、商品、对比周期、数据更新时间、刷新和 URL 状态。用户看到的是一张静态快照,不是可复用的经营分析工作台。
+4. **数据状态不完整**:模板只有成功渲染和 loading 分支。数据为空、部分成功、陈旧、请求错误会被混为“持续加载”或空图,降低可信度。见 `operating-overview.component.ts:64-212`。
+
+本项目不应照搬某一个开源产品,而应组合其最成熟的机制:
+
+```text
+Grafana 的全局时间、变量、刷新、状态与高密度表格
++ Superset 的语义指标、时间粒度、滚动值和真实基线比较
++ Metabase 的业务筛选、跨图点击与低学习成本钻取
++ OpenObserve 的部分/缓存/无数据/错误状态和依赖变量
++ Sentry 的异常优先、单卡放大、上下文探索与阈值
++ PostHog 的注释、日期覆盖规则和经营事件解释
+```
+
+最终目标不是“更多卡片”,而是提高**决策密度**:用户在首屏回答本期表现、相对基线、首要风险、影响对象和下一步入口;继续向下才查看归因、证据和完整排行。
+
+## 2. 当前页面基线
+
+### 2.1 已有能力
+
+| 维度 | 当前实现 | 判断 |
+| --- | --- | --- |
+| 页面摘要 | 页面标题、平台、日期范围、商品列表入口 | 基础上下文已具备,但占高偏大且不可筛选 |
+| 经营结论 | 规则生成 GMV 与退款结论,提供风险语气 | 有价值,应压缩为首屏异常摘要而非独占大卡 |
+| 数据口径 | 显示日明细行数、自有/竞品评论数和日期 | 口径意识正确,应并入全局上下文和数据状态 |
+| KPI | GMV、成交件数、访客转化率、退款金额占比;含 sparkline | 基础指标正确;对比算法、基线标签和指标层级需重做 |
+| 趋势 | GMV 折线与订单柱状图,共用时间轴、双 Y 轴 | 可用,但双轴增加解码成本且没有真实对比线、注释和选区联动 |
+| 类目 | 二级类目 GMV 排序横条 | 可扫描,但缺少占比、基线变化、退款/VOC 风险和点击筛选 |
+| 诊断 | 增长主力、退款风险商品、VOC 证据准备度 | 方向正确,但与趋势、表格、行动状态未形成联动 |
+| 商品表 | Top 10,6 列,商品可进入详情 | 可作为骨架;缺少排序、筛选、变化、风险、VOC、行动和列配置 |
+| 移动端 | KPI 在 560px 以下单列;900px 表格横向滚动 | 基础不溢出,但信息路径过长,表格不适合反复经营查看 |
+
+### 2.2 当前最需要先修的事实问题
+
+#### P0-1 对比周期
+
+当前逻辑:
+
+```text
+7 天数据 -> 前 4 天 previous + 后 3 天 current -> 比较两段求和
+```
+
+目标逻辑:
+
+```text
+当前 7 天 -> 紧邻的前 7 天
+当前 14 天 -> 紧邻的前 14 天
+当前自然周 -> 上一个完整自然周
+自定义 N 天 -> 紧邻的前 N 天
+```
+
+比率型指标必须区分两种变化:
+
+- 转化率 `3.2% -> 3.8%`:主显示 `+0.6 个百分点`,辅助显示相对变化 `+18.8%`。
+- GMV `100 万 -> 120 万`:显示相对变化 `+20.0%`,并允许查看绝对变化 `+20 万`。
+- 当前或基线分母为 0:显示“基线不足”,不显示无限大或伪造的 0%。
+
+#### P0-2 数据可用性
+
+当前页面明确说明“本品评论尚未接入”,因此自有 VOC 指标不能用 `0` 伪装成“没有负面反馈”。必须显示:
+
+```text
+未接入 / 无权限 / 无数据 / 已接入但本周期为 0
+```
+
+这四种状态的业务含义不同。竞品评论可以用于“市场风险观察”,不能直接成为本品满意度结论。
+
+#### P0-3 URL 与返回恢复
+
+时间、平台、类目、商品、对比基线、选中图表点和表格排序应进入 URL 查询参数。点击商品或证据再返回时,应恢复同一分析上下文,而不是回到默认 7 日视图。
+
+## 3. 开源项目逐项对标
+
+## 3.1 Grafana
+
+官方证据:[GitHub 仓库](https://github.com/grafana/grafana)、[Dashboard 前端源码](https://github.com/grafana/grafana/tree/main/public/app/features/dashboard-scene)、[Dashboard 文档](https://grafana.com/docs/grafana/latest/dashboards/)、[时间范围与刷新](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/)、[变量](https://grafana.com/docs/grafana/latest/dashboards/variables/)、[表格](https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/)、[Annotations](https://grafana.com/docs/grafana/latest/dashboards/annotations/)、[Panel options](https://grafana.com/docs/grafana/latest/panels-visualizations/configure-panel-options/)。
+
+### 可学习机制
+
+| 维度 | Grafana 做法 | 映射到 VOC/电商经营 |
+| --- | --- | --- |
+| 首屏信息架构 | 时间选择、变量、刷新等分析上下文固定在顶部,下面直接进入指标面板 | 把当前大段页面说明压缩为 44px 上下文栏,首屏尽快进入 KPI 和异常 |
+| 指标层级 | Stat 面板把当前值、阈值、趋势和 sparkline 放在一个稳定单元 | KPI 同时展示当前值、真实基线差值、迷你趋势和状态,不再用说明文字填满卡片 |
+| 时间范围 | 相对/绝对时间、时区、缩放、手动刷新、自动刷新均为一等控制;时间可进入 URL | 增加 7/14/30 天、自定义、上一周期、刷新和可分享 URL |
+| 图表 | 时间序列、状态时间线、表格等围绕监控任务,不追求装饰 | 经营趋势保持克制,异常和数据状态使用状态条,不增加无意义饼图 |
+| 表格密度 | 支持列过滤、分页、单元格色彩/背景/gauge/sparkline/data link;官方特别说明小屏应降低最小列宽 | 商品表加入变化列、风险色、in-cell sparkline、证据/详情链接;移动端改为核心列加详情,而非完整 900px 表格 |
+| 筛选/钻取 | 变量改变后更新整个 Dashboard;panel/data link 将当前变量和时间传给下一视图 | 平台、类目、商品联动所有模块;进入商品详情时携带当前日期和对比基线 |
+| 状态 | 面板标题、描述、阈值、链接和查询上下文相互独立 | 把“指标定义”放 tooltip/口径抽屉,把异常状态留在内容区域 |
+| 移动 | 表格文档明确要求小屏降低最小列宽;核心值不依赖 hover | 手机使用 tap/focus 明细、固定点击区域和缩减列集 |
+
+### 结论
+
+- `直接采用`:全局时间栏、手动刷新、最后更新时间、URL 状态、KPI sparkline、表格单元格趋势和 data link。
+- `调整后采用`:自动刷新。电商数据更新频率通常低于监控数据,默认关闭,只提供与数据源 SLA 对齐的 15 分钟、1 小时选项。
+- `暂缓`:用户自由搭建任意面板、秒级实时刷新、完整 dashboard builder。当前更需要固定的经营语义和正确口径。
+
+## 3.2 Apache Superset
+
+官方证据:[GitHub 仓库](https://github.com/apache/superset)、[Dashboard 前端源码](https://github.com/apache/superset/tree/master/superset-frontend/src/dashboard)、[创建 Dashboard](https://superset.apache.org/docs/using-superset/creating-your-first-dashboard/)、[Explore 与 Advanced Analytics](https://superset.apache.org/docs/using-superset/exploring-data/)、[官方 README](https://github.com/apache/superset/blob/master/README.md)。
+
+### 可学习机制
+
+| 维度 | Superset 做法 | 映射到 VOC/电商经营 |
+| --- | --- | --- |
+| 首屏信息架构 | 可调整网格承载表格、时间序列和 Pivot Table,强调从 Dataset 到 Chart 再到 Dashboard | 页面模块应由统一指标语义生成,避免每张卡各算一遍同名指标 |
+| 指标层级 | 轻量语义层定义 metric 和 dimension,图表只选择分组与聚合 | 建立 `gmv`、`conversion_rate`、`refund_to_gmv_rate` 等统一定义,列表、KPI、趋势共用 |
+| 时间范围 | 时间列、Time Range、Time Grain 分离;Advanced Analytics 支持 7 日 rolling mean、time shift、绝对差值和重采样 | 用户选择“看多久”和“按日/周聚合”应分开;真实 previous-period 作为第二序列或差值展示 |
+| 图表 | Table、Pivot Table、时间序列和多种分析图可在同一数据集上组合 | 类目/商品分析可用排序表、透视表和小多图,但首页只保留回答核心问题的图 |
+| 表格密度 | 普通表负责明细,Pivot 负责多维汇总;Dashboard 卡片可调整尺寸 | 首页商品表负责行动排序,类目 x 指标透视放入展开态或独立分析页 |
+| 筛选/钻取 | Dashboard 作为多个 Chart 的统一分析容器,Explore 保留从图回到查询配置的路径 | 首页负责“发现”,类目/商品详情负责“解释”,两者通过固定筛选契约连接 |
+| 空/错误/陈旧 | Superset 更偏 BI 查询工作台,错误通常和查询/数据源上下文绑定 | 学习“让用户知道是哪张图、哪个查询失败”,但状态文案应换成业务语言 |
+| 移动 | 可调整网格主要服务桌面分析,官方材料未把移动经营巡检作为核心范式 | 不复制其编辑网格;手机使用预定义阅读顺序 |
+
+### 结论
+
+- `直接采用`:统一 metric/dimension 语义、时间范围和时间粒度分离、equal-period time shift、滚动均值。
+- `调整后采用`:Pivot Table 和可调整布局只用于分析详情/管理员配置,不进入默认首页。
+- `暂缓`:SQL Lab、用户自由定义 Dataset/Chart、任意 Dashboard 编辑器。这会显著扩大产品和权限边界。
+
+## 3.3 Metabase
+
+官方证据:[GitHub 仓库](https://github.com/metabase/metabase)、[Dashboard 前端源码](https://github.com/metabase/metabase/tree/master/frontend/src/metabase/dashboard)、[Dashboard 介绍](https://www.metabase.com/docs/latest/dashboards/introduction)、[Dashboard filters](https://www.metabase.com/docs/latest/dashboards/filters)、[Dashboard interactivity](https://www.metabase.com/docs/latest/dashboards/interactive)、[Dashboard subscriptions](https://www.metabase.com/docs/latest/dashboards/subscriptions)。
+
+### 可学习机制
+
+| 维度 | Metabase 做法 | 映射到 VOC/电商经营 |
+| --- | --- | --- |
+| 首屏信息架构 | Dashboard 由问题卡、标题/文本、链接等组成,可通过 tab 分组;语言面向业务用户 | 本项目保持业务词汇,如“退款风险商品”,不要把查询、聚合器暴露给经营人员 |
+| 指标层级 | 每张卡回答一个明确问题,文本/标题负责解释,不要求每个元素都做成重卡 | 页面按“结果、驱动、风险、对象”分带,减少嵌套卡片和重复说明 |
+| 时间/刷新 | URL 可控制 fullscreen 和 auto-refresh | 筛选状态全部可分享;大屏模式和刷新策略可作为后续运营场景 |
+| 筛选 | Filter widget 可连接多个问题;支持默认值、必填、多选和 linked filters | 店铺 -> 类目 -> 商品使用依赖筛选;大数据范围必须要求日期或店铺,避免无界查询 |
+| 钻取 | Click behavior 可跳往 dashboard/question/URL,或更新 dashboard filter;官方称后者为 cross-filtering | 点击类目横条不应只弹 tooltip,而应筛选全页;点击风险商品进入带上下文的详情 |
+| 表格/导出 | Dashboard 订阅可附 CSV/XLSX/PDF;PDF 保留 Dashboard 布局和 tab 分页 | 周报/订阅有价值,但应等指标口径和历史数据稳定后实施 |
+| 空/错误/陈旧 | 强调业务问题卡的独立性,单卡可以单独解释或失效 | 单模块失败不应让整个页面失效;保留其他已成功结果 |
+| 移动 | 官方文档重点是网格与交互,不提供足够的移动规范 | 采用其低学习成本交互,不照搬桌面网格尺寸 |
+
+### 结论
+
+- `直接采用`:点击图表更新全局筛选、依赖筛选、必填范围、URL 可恢复、业务化文案。
+- `调整后采用`:tab 只用于“经营 / 风险 / 数据质量”等稳定信息域,不把首屏关键异常藏在 tab 后。
+- `暂缓`:邮件/Slack/PDF 订阅,待基线、数据新鲜度和权限模型稳定后进入 P2。
+
+## 3.4 OpenObserve
+
+官方证据:[GitHub 仓库](https://github.com/openobserve/openobserve)、[Dashboard 前端源码](https://github.com/openobserve/openobserve/tree/main/web/src/views/Dashboards)、[Dashboard](https://openobserve.ai/docs/user-guide/analytics/dashboards/dashboards-in-openobserve/)、[Filters](https://openobserve.ai/docs/user-guide/analytics/dashboards/filters/)、[Variable dependencies](https://openobserve.ai/docs/user-guide/analytics/dashboards/variables/variable-dependencies/)、[Comparison Against](https://openobserve.ai/docs/user-guide/analytics/dashboards/comparison-against-in-dashboards/)、[Table Chart](https://openobserve.ai/docs/user-guide/analytics/dashboards/panels/table-chart/)、[Panel troubleshooting](https://openobserve.ai/docs/user-guide/analytics/dashboards/panels/troubleshooting/)。
+
+### 可学习机制
+
+| 维度 | OpenObserve 做法 | 映射到 VOC/电商经营 |
+| --- | --- | --- |
+| 首屏信息架构 | 实时/历史数据统一在 Dashboard,panel 可按 tab 组织 | 经营结果与数据质量可在同一工作台,但默认必须先展示经营结论 |
+| 时间/基线 | Comparison Against 用同一查询、同一时长执行当前和过去时间框,并并列展示 | 后端用同一指标定义同时返回 current/baseline,杜绝前端拼两套口径 |
+| 筛选 | Filter 支持静态值、动态变量、操作符、AND/OR 嵌套;变量支持 parent-child 依赖 | 平台 -> 店铺 -> 类目 -> 商品是天然的依赖链;高级组合条件放二级筛选抽屉 |
+| 表格密度 | 支持列格式、Excel 式列过滤、分页、条件样式、转置和动态列 | 商品表可加入列筛选、分页、条件色、列配置;不需要照搬转置到首页 |
+| 空状态 | 明确区分 loading placeholder、查询成功但 `No data`,并建议调整时间/筛选后刷新 | 首页必须把“本周期 0 条”与“接口失败”分开,空状态提供下一步动作 |
+| 部分/陈旧 | Troubleshooting 明确提示查询被中断导致 partial/no data,以及缓存结果与选中时间不一致 | 引入 `partial`、`stale`,继续显示已有值并标注影响范围、更新时间和重试按钮 |
+| 错误 | 错误图标和 panel toolbar 说明具体失败原因 | 错误应落到模块级,并提供“重试该模块”和“查看数据状态” |
+| 移动 | 官方重心仍是桌面面板,表格功能复杂 | 手机只保留核心列,条件与完整列放详情抽屉;复杂 AND/OR 条件不占首屏 |
+
+### 结论
+
+- `直接采用`:同查询对比基线、依赖变量、`loading/empty/partial/stale/error` 明确分态、模块级重试。
+- `调整后采用`:高级 AND/OR 筛选放“更多筛选”,默认栏只显示高频条件。
+- `暂缓`:多 SQL query 面板、日志级实时刷新和用户自定义复杂查询。
+
+## 3.5 Sentry
+
+官方证据:[GitHub 仓库](https://github.com/getsentry/sentry)、[Dashboard 前端源码](https://github.com/getsentry/sentry/tree/master/static/app/views/dashboards)、[Dashboards](https://docs.sentry.io/product/dashboards/)、[Widget Builder](https://docs.sentry.io/product/dashboards/widget-builder/)、[Widget Library](https://docs.sentry.io/product/dashboards/widget-library/)。
+
+### 可学习机制
+
+| 维度 | Sentry 做法 | 映射到 VOC/电商经营 |
+| --- | --- | --- |
+| 首屏信息架构 | Dashboard 先给应用健康概览,再用 widget 指向错误、性能、日志等 explorer | 经营首页先给结果与风险,详细 VOC、退款和商品证据进入各自 explorer |
+| 全局筛选 | project、environment、date range、release 统一作用于所有 widget;时间序列缩放会更新全页 | 平台、店铺、类目、商品、日期统一作用于全页;框选异常时间后其他模块同步 |
+| 异常优先 | Widget 支持阈值;Issues/Errors 围绕“最需要处理的对象”排序 | 退款率、退款金额、负面 VOC 和未完成行动组合为优先级,不只按 GMV 排行 |
+| 钻取 | Widget 菜单可打开 Errors、Traces、Logs、Replay、Metrics;Viewer 提供更强过滤、排序和导航 | 每个模块提供“进入退款分析 / VOC 证据 / 商品详情 / 行动中心”,不在首页塞完整明细 |
+| 表格密度 | Widget viewer 可显示更多行、分页、排序、调列宽;图表展开后附分组结果表 | 首页表格保持 10-20 行,展开查看完整排行并共享当前 URL |
+| 图表类型 | Big number、时间序列、表格都服务于错误/性能任务;多序列有上限 | 默认不展示超过 5 个类目/主题序列;长尾进入表格或展开态 |
+| 空/错误/陈旧 | 模块按 dataset 和查询独立,失败和探索上下文可定位 | 经营数据与 VOC 数据独立标注状态,不能因评论源失败隐藏经营数据 |
+| 移动 | 官方文档没有专门移动范式,但单 widget viewer 天然降低单屏复杂度 | 手机点击图表后打开全屏单模块查看器,代替桌面多栏原样缩放 |
+
+### 结论
+
+- `直接采用`:异常优先队列、阈值、模块级 explorer、单卡展开 viewer、全局时间缩放联动。
+- `调整后采用`:Widget Library 改为受控的 VOC/经营模块库,由产品定义,不允许任意查询。
+- `暂缓`:完整自助 Widget Builder 和跨数据集自由组合。
+
+## 3.6 PostHog(补充)
+
+官方证据:[GitHub 仓库](https://github.com/PostHog/posthog)、[Dashboard 前端源码](https://github.com/PostHog/posthog/tree/master/frontend/src/scenes/dashboard)、[Dashboards](https://posthog.com/docs/product-analytics/dashboards)、[Insights](https://posthog.com/docs/product-analytics/insights)、[Annotations](https://posthog.com/docs/data/annotations)。
+
+### 可学习机制
+
+- Dashboard 文档把 date range override、dashboard filter、tile-level filter precedence、单 tile 刷新、自动刷新和文本/按钮/widget 作为明确能力。这说明筛选优先级必须显式定义,不能由组件各自决定。
+- Annotations 可把版本发布、营销活动和事件写在时间线上,帮助解释指标突变。映射到本项目,可标记调价、促销、换包装、物流策略、客服规则和行动完成时间。
+- Trends/Funnels/Retention/Lifecycle 是产品行为分析语义。VOC 首页不应生搬漏斗留存,但“变化事件 + 指标结果”的解释方式值得采用。
+
+### 结论
+
+- `直接采用`:经营事件 annotation、dashboard 与模块筛选优先级规则、单模块刷新。
+- `调整后采用`:AI refresh analysis 只能生成带证据的解释草稿,并显示数据范围、模型和生成时间。
+- `暂缓`:产品行为 retention/lifecycle 模型,除非后续接入用户级行为事件且具备合规的数据边界。
+
+## 4. 横向结论矩阵
+
+| 能力 | 最强参考 | 本项目结论 | 优先级 |
+| --- | --- | --- | --- |
+| 首屏经营层级 | Sentry + Metabase | 结果 KPI -> 首要异常 -> 驱动趋势 -> 对象排行 | P0 |
+| 时间与刷新 | Grafana | 7/14/30/自定义、刷新、更新时间、URL | P0 |
+| 对比基线 | Superset + OpenObserve | 相同查询、相同长度、相邻周期;支持绝对差与相对差 | P0 |
+| 指标语义 | Superset | 所有视图复用同一 metric definition | P0 |
+| 依赖筛选 | Metabase + OpenObserve | 平台 -> 店铺 -> 类目 -> 商品 | P0 |
+| 跨图联动 | Metabase + Sentry | 点击类目/时间范围更新全页或进入 explorer | P1 |
+| 空/错/部分/陈旧 | OpenObserve | 模块级状态、已有数据保留、局部重试 | P0 |
+| 高密度表格 | Grafana + Sentry + OpenObserve | 条件格式、变化列、排序、分页、列管理、展开 viewer | P1 |
+| 时间线解释 | Grafana + PostHog | 促销、调价、版本、行动完成 annotation | P1 |
+| 移动分析 | Sentry viewer 思路 + 本项目定制 | 单模块全屏、核心列、筛选底部抽屉 | P1 |
+| 自助搭建 | Grafana/Superset/Metabase | 先固定业务模板,后续再评估 | P3/暂缓 |
+
+## 5. 目标页面信息架构
+
+## 5.1 桌面首屏(1366x768 也成立)
+
+```text
+┌ 经营概览 | 京东 / 店铺 / 类目 / 商品 | 近7天 | 对比上一周期 | 更新于 10:30 | 刷新 ┐
+├ GMV ─┬ 订单 ─┬ 访客 ─┬ 转化率 ─┬ 退款占比 ─┬ 高风险商品 ┤
+├ 本期关键结论:退款风险升高,影响 3 个高 GMV 商品;数据状态:经营完整 / 本品 VOC 未接入 ┤
+├ 经营结果与基线趋势(8列,当前/基线/事件注释) ─────┬ 风险待办 Top 5(4列) ┤
+└ 首屏底部露出“转化驱动 / 类目风险 / VOC 证据”下一分析带的标题或首行 ┘
+```
+
+首屏规则:
+
+- 页面标题不使用大面积 hero,不重复解释系统能力。
+- 筛选栏和数据更新时间保持可见;向下滚动时可收缩为 sticky compact bar。
+- KPI 不超过 6 个,必须分为结果、效率、风险三类;颜色只表达状态,不为每张卡分配装饰色。
+- “本期执行结论”和“数据口径”合并为一条 compact status band,避免连续两个大卡占满首屏。
+- 主趋势与风险队列在首屏形成“发生了什么”和“先处理谁”的配对。
+- 下一内容带应在首屏底部露出,避免用户误认为页面只有 KPI。
+
+## 5.2 完整页面阅读顺序
+
+### A. 全局上下文栏
+
+字段:
+
+- 平台:京东,后续可扩展天猫/抖音等。
+- 店铺/账号:支持单选或多选,默认当前 workspace 主店铺。
+- 类目:二级类目,依赖店铺。
+- 商品:依赖类目,支持 SKU/名称搜索。
+- 时间:近 7/14/30 天、自定义、自然周。
+- 对比:上一等长周期、上周同期、无对比;同比在有足够历史后开放。
+- 数据状态:完整/部分/陈旧,最后成功更新时间。
+- 操作:刷新、复制当前视图链接;保存视图放 P2。
+
+### B. 一级经营结果
+
+默认 6 项:
+
+1. 成交金额 GMV。
+2. 成交订单数。当前页面只把订单藏在趋势与帮助文案中,应提升为首屏结果指标。
+3. 访客数。
+4. 访客转化率。
+5. 退款金额占比。
+6. 高风险商品数,必须带阈值版本和影响 GMV。
+
+每项固定结构:
+
+```text
+指标名 | 当前值 | 对比差值 | 基线值 | sparkline | 状态/数据缺口 | 口径 tooltip
+```
+
+“成交件数”保留在二级驱动或订单详情中,首屏优先展示订单数,因为转化率分子使用订单数,语义链更一致。
+
+### C. 经营结果与基线趋势
+
+默认使用上下对齐的小多图,而不是当前双 Y 轴:
+
+- 图 1:GMV 当前值与基线值,直接标注期末差值。
+- 图 2:订单数或转化率当前值与基线值,共享 X 轴。
+- annotation:促销、调价、库存、履约变更、行动完成时间。
+- 交互:框选时间更新下方类目、商品和风险模块;“重置时间”清晰可见。
+
+若空间受限,可通过 segmented control 在 `GMV / 订单 / 转化 / 退款` 间切换,但当前值、基线和差值始终同时可见。
+
+### D. 风险待办 Top 5
+
+按“影响规模 x 异常程度 x 证据可信度”排序,而不是只按退款率或 GMV:
+
+```text
+priorityScore = normalized(affectedGmv)
+              * normalized(riskLift)
+              * evidenceConfidence
+```
+
+每行显示:对象、异常、相对基线、影响 GMV、证据状态、已有行动状态、入口。不要把 AI 置信度当成业务严重度。
+
+### E. 驱动与归因层
+
+| 用户问题 | 推荐图形 | 当前数据适配 | 交互 |
+| --- | --- | --- | --- |
+| 流量在哪一步流失 | 同口径漏斗或逐步转化条 | impressions/clicks/cart/orders 均已有字段,但必须先校验同一人群和周期 | 点击阶段筛选商品排行 |
+| 哪些类目贡献增长 | 排序横条,当前值 + 基线差值 | 已有二级类目 GMV,可直接扩展 | 点击类目 cross-filter 全页 |
+| 哪些商品“规模大且风险高” | GMV x 退款占比散点,点大小为退款金额 | 已有 GMV、退款占比、退款金额 | 点击点打开商品 viewer |
+| 经营变化是否伴随 VOC 变化 | 两个共享 X 轴的小多图 | 仅在本品 VOC 接入后开放;目前显示数据缺口 | 选中时间进入反馈证据 |
+| 哪些主题在升温 | 主题 x 周期 heatmap 或排名变化表 | 需要稳定 taxonomy 和跨周期主题记录 | 点击主题打开证据列表 |
+
+不建议:
+
+- 用饼图展示多个接近的类目占比。
+- 用 gauge 表达没有明确目标区间的普通指标。
+- 用 word cloud 代替主题数量、占比和趋势。
+- 在同一图上继续增加第三条不同量纲的 Y 轴。
+- 仅靠红绿表达好坏;必须同时有箭头、文本和数值。
+
+### F. 商品经营排行
+
+桌面默认列:
+
+| 列 | 表达 |
+| --- | --- |
+| 商品 | sticky,名称 + SKU,点击进入详情 |
+| 类目 | 支持列筛选 |
+| GMV | 当前值,右对齐 |
+| GMV 变化 | 百分比 + in-cell sparkline |
+| 订单 | 当前值 |
+| 转化率 | 当前值 + 百分点变化 |
+| 退款占比 | 条件色 + 基线变化 |
+| 退款金额 | 影响规模 |
+| VOC | `未接入` / 样本数 + 负向率;不把未接入显示成 0 |
+| 主要问题 | 稳定 taxonomy 标签,点击看证据 |
+| 行动 | open/in progress/blocked/completed 数量 |
+| 更新时间 | 该行对应数据的新鲜度 |
+
+表格规格:
+
+- 行高默认 44px,可切换紧凑 36px;表头 sticky。
+- 商品列和操作列固定,数值列右对齐并使用 tabular nums。
+- 默认按风险优先或 GMV 排序,明确显示排序字段。
+- 支持 10/20/50 行分页,完整结果进入 viewer,不无限拉长首页。
+- 支持列显隐和当前视图导出,列宽持久化放 P2。
+- 颜色只用于超过阈值的单元格,不整行铺红。
+
+### G. 数据质量与证据准备度
+
+不要继续用普通“诊断卡”描述数据缺口。改为紧凑状态表:
+
+| 数据域 | 覆盖 | 最新时间 | 状态 | 影响 |
+| --- | ---: | --- | --- | --- |
+| 商品日经营 | N 行 / N 商品 | 时间戳 | 完整/部分/陈旧 | 影响 KPI、趋势和排行 |
+| 本品评论 | N 条 / N 商品 | 时间戳 | 未接入/完整 | 影响本品 VOC 结论 |
+| 竞品评论 | N 条 / N 商品 | 时间戳 | 完整/部分 | 仅用于竞品观察 |
+| 行动项 | N 条 | 时间戳 | 正常/错误 | 影响风险闭环 |
+
+状态行可跳到 `/data-status`,并保留当前筛选范围。
+
+## 6. 指标与数据契约
+
+## 6.1 指标层级
+
+```text
+L0 数据可信度
+  source status / coverage / last successful update / partial reason
+
+L1 经营结果
+  GMV / orders / visitors / conversion rate / refund-to-GMV rate
+
+L2 经营驱动
+  impressions / clicks / CTR / cart units / sold units / average unit price
+
+L3 风险与 VOC
+  affected GMV / risky products / refund amount / own VOC volume
+  negative rate / rising topics / competitor signals / evidence coverage
+
+L4 执行闭环
+  open actions / overdue actions / completed actions / validation result
+```
+
+L0 是其他层级能否被相信的前提,不能藏到页面底部;L3 的本品 VOC 数据未接入时只展示准备度,不生成正负结论。
+
+## 6.2 前端查询状态
+
+建议固定为可序列化契约:
+
+```ts
+interface DomesticOverviewQuery {
+  platform: string;
+  storeIds: string[];
+  categoryIds: string[];
+  productIds: string[];
+  from: string;
+  to: string;
+  grain: 'day' | 'week';
+  comparison: 'previous_period' | 'previous_week' | 'none';
+  reviewScope: 'own' | 'competitor' | 'all';
+}
+```
+
+URL 示例:
+
+```text
+/domestic/overview?from=2026-07-31&to=2026-08-06&compare=previous_period&category=厨电&sort=risk_desc
+```
+
+## 6.3 KPI 返回结构
+
+```ts
+interface DashboardMetric {
+  key: string;
+  label: string;
+  unit: string;
+  value: number | null;
+  baselineValue: number | null;
+  absoluteDelta: number | null;
+  relativeDelta: number | null;
+  deltaUnit: 'percent' | 'percentage_point' | 'absolute';
+  series: Array<{ at: string; value: number | null }>;
+  baselineSeries: Array<{ at: string; value: number | null }>;
+  status: 'ready' | 'empty' | 'partial' | 'stale' | 'error';
+  updatedAt: string | null;
+  definitionVersion: string;
+}
+```
+
+同名指标必须由一个 definition 产生。KPI、图表、表格总计不允许各自在组件内重新聚合。
+
+## 6.4 页面状态机
+
+| 状态 | 判定 | UI 行为 |
+| --- | --- | --- |
+| loading | 首次请求未完成 | 保留稳定布局 skeleton,不显示伪造 0 |
+| ready | 所有关键数据源成功 | 正常显示值和更新时间 |
+| empty | 请求成功但当前筛选无记录 | 说明是哪一范围无数据,提供清除筛选/扩大时间 |
+| partial | 部分数据源或部分分片成功 | 保留成功数据,标注缺失模块、覆盖率和重试 |
+| stale | 当前筛选下显示的是过期缓存/上次成功值 | 显示“截至时间”,不清空图表,提供刷新 |
+| error | 模块请求失败且无可用缓存 | 模块级错误说明、重试和数据状态入口 |
+
+页面顶部展示全局状态,模块内部展示局部状态。不得用 toast 作为唯一错误载体。
+
+## 7. 筛选、钻取与联动契约
+
+### 7.1 筛选优先级
+
+```text
+URL 显式参数
+  > 用户当前会话选择
+  > 保存视图默认值
+  > workspace 默认值
+  > 系统默认值
+```
+
+模块级筛选只能缩小范围,不能静默覆盖全局日期、店铺或权限范围。任何覆盖都要在模块标题附近可见。
+
+### 7.2 Cross-filter 行为
+
+- 点击类目横条:设置 `categoryIds`,更新 KPI、趋势、风险和商品表。
+- 点击趋势区间:更新 `from/to`,保留原始范围为“重置”目标。
+- 点击散点商品:打开单商品 viewer;“进入完整详情”再路由。
+- 点击主题:打开证据抽屉,显示样本数、代表反馈和来源边界。
+- 点击 KPI:进入对应 explorer,而不是 GMV、件数和转化率全部跳到同一个商品列表。
+
+### 7.3 Drill-down 路径
+
+```text
+经营结果
+  -> 异常指标
+  -> 类目/商品影响对象
+  -> 退款或 VOC 证据
+  -> 已有关联行动
+  -> 行动效果验证
+```
+
+每一级都携带当前查询状态,并支持浏览器返回恢复。
+
+## 8. 移动端实施规范
+
+390x844 不是桌面页面缩小版,应有独立阅读状态:
+
+1. 顶部只保留标题、日期、状态和筛选按钮;完整筛选在 bottom sheet。
+2. KPI 使用 2 列稳定网格,每卡约 104-116px 高;文字不随 viewport 缩放,长值采用单位缩写和完整值 tooltip/详情。
+3. “本期关键结论”限制 2-3 行,展开后查看数据口径,不使用嵌套卡片。
+4. 主趋势一次显示一个指标,使用 segmented control 切换;当前与基线仍同时存在。
+5. hover 行为全部有 tap/focus 等价实现;tooltip 可固定并关闭。
+6. 风险队列使用纵向 5 行列表,每行保持对象、异常、影响和入口。
+7. 商品表改为排名列表,只展示商品、GMV、变化、退款风险;其余字段在全屏 viewer/抽屉查看。
+8. 不允许页面级横向滚动。复杂图表和完整表格只在自身容器或 viewer 内处理。
+9. 筛选、选中商品和滚动前的分析上下文进入 URL;从详情返回后恢复。
+
+## 9. 采用决策清单
+
+## 9.1 直接采用
+
+| 能力 | 来源 | 本项目落点 |
+| --- | --- | --- |
+| 全局时间、刷新、更新时间 | Grafana | 顶部 compact context bar |
+| 真实等长周期基线 | Superset/OpenObserve | 服务层一次返回 current/baseline |
+| metric/dimension 统一定义 | Superset | analytics service/domain contract |
+| linked filter 与 cross-filter | Metabase/OpenObserve | 平台 -> 店铺 -> 类目 -> 商品,全页联动 |
+| 模块级 viewer/explorer | Sentry | 风险、商品、退款、VOC 独立展开 |
+| empty/partial/stale/error | OpenObserve | 页面级 + 模块级状态机 |
+| 高密度表格条件格式和 data link | Grafana/OpenObserve/Sentry | 商品排行与完整 viewer |
+| 经营事件 annotation | Grafana/PostHog | 趋势图上的促销、调价和行动事件 |
+
+## 9.2 调整后采用
+
+| 能力 | 调整原因 | 调整方案 |
+| --- | --- | --- |
+| 自动刷新 | 电商数据不是秒级监控 | 默认关闭,按数据源 SLA 提供刷新周期 |
+| Dashboard tabs | 关键异常可能被隐藏 | 只拆稳定信息域,默认总览仍露出风险 |
+| Pivot/Heatmap | 手机和首屏解码成本高 | 放二级分析或全屏 viewer |
+| Widget Library | 任意组合会破坏口径 | 只提供经过定义和验收的业务模块 |
+| AI refresh analysis | 可能把数据缺口写成结论 | 只生成引用型解释草稿,标明模型、范围、证据和时间 |
+| 高级 AND/OR 筛选 | 高频用户才需要 | 放“更多筛选”,支持保存视图后再推广 |
+
+## 9.3 暂缓
+
+- 用户自由拖拽和搭建任意 Dashboard。
+- SQL Lab、自助 Dataset、任意查询表达式。
+- 秒级实时刷新和大规模 streaming 图表。
+- 任意第三方 iframe/widget。
+- 用户级留存/lifecycle,直到存在合规且稳定的用户行为事件。
+- 邮件/Slack/PDF 订阅,直到指标版本、权限、数据新鲜度均可审计。
+- 3D、装饰性地图、word cloud 和不能形成行动的图表类型。
+
+## 10. 分阶段实施建议
+
+## P0:先把看板做“可信且可比较”
+
+### 交付
+
+1. 修复 4 天 vs 3 天对比,改为等长相邻周期。
+2. 引入 `DomesticOverviewQuery`,时间、对比、平台/类目/商品进入 URL。
+3. 顶部合并页面说明、数据口径和刷新状态为 compact context bar。
+4. KPI 扩为 6 项,返回 current/baseline/delta/series/status/updatedAt。
+5. 引入 `loading/ready/empty/partial/stale/error` 状态机。
+6. 本品 VOC 未接入时显示缺口状态,不显示 0% 正负面率。
+7. 趋势改为当前 vs 基线小多图,移除默认双 Y 轴。
+
+### 建议代码落点
+
+- `src/modules/domestic-voc/operating-overview.component.ts`
+- `src/modules/domestic-voc/operating-overview.component.scss`
+- `src/app/core/services/domestic-analytics.service.ts`
+- `src/app/core/services/domestic-dataset.service.ts`
+- 新增查询状态/指标契约时优先放 `src/app/core/models`,不要继续扩张组件内 interface。
+
+### 验收
+
+- 近 7 天只和紧邻前 7 天比较,单测覆盖 0 分母、缺日、奇数天、自定义日期。
+- 比率变化用百分点,规模变化用百分比;tooltip 显示公式和基线范围。
+- URL 刷新、复制、浏览器前进后退均恢复同一筛选。
+- 任一数据源失败时,其他成功模块继续显示。
+- stale 状态保留最后成功值并显示“截至时间”。
+- 1366x768 首屏可见全局上下文、6 个 KPI、关键结论、主趋势和风险队列标题/主体。
+
+## P1:提高分析密度与可行动性
+
+### 交付
+
+1. 类目横条 cross-filter,全页同步。
+2. GMV x 退款风险散点和流量转化驱动模块。
+3. 风险待办 Top 5,关联商品、退款/VOC 证据和 ActionItem。
+4. 商品表加入变化、退款金额、VOC 状态、主要问题、行动和更新时间。
+5. 完整表格 viewer:排序、分页、列管理、导出、可分享 URL。
+6. annotation 模型与趋势图展示。
+7. 移动端 KPI 2 列、单图切换、风险列表和商品 viewer。
+
+### 验收
+
+- 点击类目、趋势区间、风险商品后,所有关联模块使用同一 query state。
+- 风险排行同时体现异常幅度和影响规模,排序理由可解释。
+- 桌面表格 1440px 无截断;390px 页面无横向滚动,核心排行无需横移即可阅读。
+- 键盘可访问所有筛选、图表数据点等价入口、表格排序和 viewer。
+- 颜色之外同时提供文本、图标或数值状态。
+
+## P2:保存、传播与 AI 解释
+
+### 交付
+
+1. 保存视图与角色默认视图。
+2. 周报/PDF/消息订阅,保留筛选、指标版本、生成时间和数据状态。
+3. AI 解释当前变化,但只引用结构化指标、annotation 和 evidence ID。
+4. Dashboard 使用分析、慢查询和数据质量运营指标。
+
+### 验收
+
+- 同一保存视图在权限范围内得到相同口径和布局。
+- 订阅报告能说明数据是否 partial/stale,不能把旧缓存包装成当前结果。
+- AI 生成的每个数字与结论可回到具体指标或证据;无本品 VOC 时不生成本品满意度判断。
+
+## 11. 设计与工程验收总表
+
+| 类别 | 必须满足 |
+| --- | --- |
+| 数据正确性 | 等长基线;比率与规模变化口径区分;缺失不等于 0 |
+| 首屏 | 上下文、KPI、关键异常、主趋势、风险入口形成完整阅读路径 |
+| 图表 | 关键值不依赖 hover;直接标签;序列不超过可辨识上限;无无意义双轴 |
+| 表格 | sticky 标识列、数值右对齐、明确排序、条件格式、分页、完整 viewer |
+| 状态 | loading/empty/partial/stale/error 可区分;局部失败不清空全页 |
+| 筛选 | 依赖关系、优先级、URL 序列化、返回恢复、权限边界明确 |
+| 移动 | 390px 无页面级横向滚动;tap 等价;表格有移动阅读模式 |
+| 可访问性 | 键盘操作、可见 focus、色彩冗余编码、图表文本摘要 |
+| 性能 | 首屏关键数据优先;下方重图延迟加载;筛选防抖和请求取消 |
+| 审计 | 指标定义版本、数据更新时间、筛选范围、AI 生成信息均可追踪 |
+
+## 12. 最终判断
+
+`/domestic/overview` 的下一轮不应继续以“新增若干漂亮卡片”为目标。正确顺序是:
+
+```text
+先修正比较事实
+  -> 建立统一查询与状态契约
+  -> 重排首屏决策层级
+  -> 增加驱动、风险和对象密度
+  -> 打通筛选、钻取、证据和行动
+  -> 最后再做保存、订阅与 AI 解释
+```
+
+最值得立即落地的组合是:Grafana 的控制与表格、Superset 的指标和时间比较、Metabase 的业务交互、OpenObserve 的数据状态、Sentry 的异常工作流。PostHog 的 annotation 应作为 P1 增强。自助搭建、SQL 和任意 widget 暂不进入本项目主线。
+
+## 13. 官方资料索引
+
+### Grafana
+
+- [GitHub](https://github.com/grafana/grafana)
+- [Dashboard frontend](https://github.com/grafana/grafana/tree/main/public/app/features/dashboard-scene)
+- [Dashboards](https://grafana.com/docs/grafana/latest/dashboards/)
+- [Time range controls](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/)
+- [Variables](https://grafana.com/docs/grafana/latest/dashboards/variables/)
+- [Table visualization](https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/table/)
+- [Annotations](https://grafana.com/docs/grafana/latest/dashboards/annotations/)
+
+### Apache Superset
+
+- [GitHub](https://github.com/apache/superset)
+- [Dashboard frontend](https://github.com/apache/superset/tree/master/superset-frontend/src/dashboard)
+- [Creating your first dashboard](https://superset.apache.org/docs/using-superset/creating-your-first-dashboard/)
+- [Exploring data and Advanced Analytics](https://superset.apache.org/docs/using-superset/exploring-data/)
+
+### Metabase
+
+- [GitHub](https://github.com/metabase/metabase)
+- [Dashboard frontend](https://github.com/metabase/metabase/tree/master/frontend/src/metabase/dashboard)
+- [Dashboard introduction](https://www.metabase.com/docs/latest/dashboards/introduction)
+- [Dashboard filters](https://www.metabase.com/docs/latest/dashboards/filters)
+- [Dashboard interactivity](https://www.metabase.com/docs/latest/dashboards/interactive)
+- [Dashboard subscriptions](https://www.metabase.com/docs/latest/dashboards/subscriptions)
+
+### OpenObserve
+
+- [GitHub](https://github.com/openobserve/openobserve)
+- [Dashboard frontend](https://github.com/openobserve/openobserve/tree/main/web/src/views/Dashboards)
+- [Dashboards](https://openobserve.ai/docs/user-guide/analytics/dashboards/dashboards-in-openobserve/)
+- [Filters](https://openobserve.ai/docs/user-guide/analytics/dashboards/filters/)
+- [Variable dependencies](https://openobserve.ai/docs/user-guide/analytics/dashboards/variables/variable-dependencies/)
+- [Comparison Against](https://openobserve.ai/docs/user-guide/analytics/dashboards/comparison-against-in-dashboards/)
+- [Table Chart](https://openobserve.ai/docs/user-guide/analytics/dashboards/panels/table-chart/)
+- [Panel troubleshooting](https://openobserve.ai/docs/user-guide/analytics/dashboards/panels/troubleshooting/)
+
+### Sentry
+
+- [GitHub](https://github.com/getsentry/sentry)
+- [Dashboard frontend](https://github.com/getsentry/sentry/tree/master/static/app/views/dashboards)
+- [Dashboards](https://docs.sentry.io/product/dashboards/)
+- [Widget Builder](https://docs.sentry.io/product/dashboards/widget-builder/)
+- [Widget Library](https://docs.sentry.io/product/dashboards/widget-library/)
+
+### PostHog
+
+- [GitHub](https://github.com/PostHog/posthog)
+- [Dashboard frontend](https://github.com/PostHog/posthog/tree/master/frontend/src/scenes/dashboard)
+- [Dashboards](https://posthog.com/docs/product-analytics/dashboards)
+- [Insights](https://posthog.com/docs/product-analytics/insights)
+- [Annotations](https://posthog.com/docs/data/annotations)
+
+以上链接均在调研日直接访问核验。`latest` 文档会随上游版本更新;实施时应记录实际采用机制对应的版本或提交。

+ 105 - 0
docs/JD-API-CONNECTION-ASSESSMENT-2026-08-07.md

@@ -0,0 +1,105 @@
+# 京东店铺 API 连接与能力评估
+
+## 2026-08-13 复测更新
+
+- **AppKey 已恢复可用**:新版 SP-API 与旧版 JOS 均不再返回“AppKey 已禁用”。
+- **本店授权 token 尚未接入测试**:商品、库存、店铺、评价、流量等只读接口均已进入 `access_token` 校验,旧版 JOS 统一返回错误码 `19`(accessToken 不正确/为空),新版商品和库存接口返回 `99904000016`(accessToken 为空)。
+- **订单与售后仍有部署前置条件**:新版订单列表和售后服务单接口返回 `99904030005`,明确要求从云鼎平台发起调用。
+- **已确认无权限的接口**:新版客服列表和承运商列表返回 `99904030008`,当前应用没有这两个接口的调用权限。
+- **待单独复核**:新版报表定义探测返回 `99904000013`(签名校验失败)。该接口需要以官方 SDK 或确认路径参与规则后的服务端签名实现复测,不能据此判定报表权限。
+
+## 结论摘要
+
+- 初次测试时 AppKey 被禁用;2026-08-13 复测确认 AppKey 已恢复,不再是当前阻断项。
+- 当前未形成可用的店铺数据访问能力,直接原因是本店 `access_token` 尚未接入测试;需要授权的 API 均在 token 校验阶段拦截。
+- 当前无法确认商品、店铺、评价、流量等接口是否已真正返回店铺数据;解禁后仍须完成店铺授权并提供有效 `access_token`。
+- 订单和售后在 token 之外还有云鼎调用前置条件;客服与承运商接口还需补充相应 API 权限包。
+- 仓库当前仅把 `jd` 作为平台标识并读取本地快照,没有京东签名、OAuth token 交换或真实京东 API 连接实现。
+
+## 官方文档依据
+
+- [SP-API 调用方法](https://open.jd.com/v2/#/doc/dev-guide?listId=1100587):新版入口为 `https://api-cn.jd.com/rest`;GET 请求需要 AppKey、时间戳、签名,标记为需要授权的接口还需要 `X-JOS-Access-Token`。
+- [自研与 ISV 授权](https://open.jd.com/v2/#/doc/dev-guide?listId=1100981):`appKey/appSecret` 用于创建应用;商家授权后通过 `code` 换取 `access_token`,并用 `refresh_token` 续期。
+- [订单详情 getOrder](https://open.jd.com/v2/#/doc/api?apiCateId=100066&apiId=100251&apiName=getOrder&gwType=1):订单详情接口 `sp-order/v0/orders/{orderId}` 标明需要授权,且订单数据属于敏感数据范围。
+- [旧版 JOS 调用说明](https://open.jd.com/v2/#/doc/guide?listId=533):旧版接口使用 `api.jd.com/routerjson`、`app_key`、`access_token`、GMT+8 格式的 `yyyy-MM-dd HH:mm:ss` 时间戳和签名参数。
+- [开发工具授权管理](https://open.jd.com/v2/#/devtools?listId=authorization):用于确认服务对象、授权状态和 token 获取状态。
+
+## 测试计划与执行
+
+### 测试原则
+
+1. 先验证入口、签名和凭证状态,再验证接口权限。
+2. 只调用 GET 或等价只读接口;不调用库存写入、发货、订单备注、售后操作、营销人群创建/删除、报表生成等有副作用接口。
+3. 每个结果按四层解释:传输层、签名层、AppKey 状态、API 权限/授权层。
+4. 原始凭证仅通过进程环境变量传入,未写入仓库、浏览器存储或本报告。
+
+### 已执行探测
+
+| 入口 | 探测范围 | 结果 |
+|---|---|---|
+| `https://api-cn.jd.com/rest` | 商品列表、商品详情、订单列表、订单详情、售后列表、客服列表、报表定义、承运商、物流轨迹、库存查询,共 10 个 GET | 10/10 HTTP 403,`99904030002`,AppKey 已禁用 |
+| `https://api-cn-pre.jd.com/rest` | 商品列表 GET | HTTP 403,`99904030002`,AppKey 已禁用 |
+| `https://api.jd.com/routerjson` | 店铺基本信息、店铺信息、商品搜索/详情/库存、售后、评价、销售排行、店铺流量、客服评价等 V1 只读方法 | 普通方法返回错误码 `21`,AppKey 已禁用 |
+| `https://api.jd.com/routerjson` | `jingdong.pop.order.search`、`jingdong.pop.order.get` | 错误码 `73`,敏感 API 需要云鼎入驻;未进入业务数据返回 |
+
+### 2026-08-13 复测
+
+| 入口 | 探测范围 | 结果 |
+|---|---|---|
+| `https://api-cn.jd.com/rest` | 商品列表、SKU 库存 | HTTP 400,`99904000016`,请求已通过 AppKey 校验但缺少 `access_token` |
+| `https://api-cn.jd.com/rest` | 订单列表、售后服务单列表 | HTTP 403,`99904030005`,请求必须从云鼎平台发起 |
+| `https://api-cn.jd.com/rest` | 客服列表、承运商列表 | HTTP 403,`99904030008`,应用没有该 API 调用权限 |
+| `https://api-cn.jd.com/rest` | 报表定义 | HTTP 400,`99904000013`,待按该接口的官方签名规则/SDK复测 |
+| `https://api.jd.com/routerjson` | 店铺、商品、库存、评价、流量、客服评价只读接口 | HTTP 200 的业务错误响应,错误码 `19`,`access_token` 为空或不正确 |
+
+代表性请求追踪号已在终端结果中保留,建议提交工单时附上对应时间段的 requestId,而不要附带 AppSecret。
+
+## 当前能力判断
+
+### 已确认可用
+
+没有确认到任何店铺数据 API 可用。公开文档可证明接口存在,不能证明当前应用拥有该接口权限。
+
+### 解禁并授权后,理论上可覆盖
+
+| 数据域 | 官方接口能力 | 对系统的可用场景 | 关键限制 |
+|---|---|---|---|
+| 店铺与经营范围 | 商家基本信息、店铺信息、店内分类、经营分类 | 店铺档案、类目树、商品归属校验 | 需对应店铺身份和权限包 |
+| 商品与 SKU | 商品列表、商品详情、SKU 列表、图片、标题/卖点、状态、库存、价格相关接口 | 自有商品主数据、SKU 维度经营分析、商品详情页 | 商品查询与库存/价格字段可能分权限;写接口必须单独申请,不纳入本次测试 |
+| 订单 | 订单列表、订单详情、订单商品、支付、订单状态、发货和物流轨迹 | GMV、销量、订单数、客单价、履约状态、订单级退款关联 | `getOrder` 明确需要授权;旧版订单接口命中敏感 API/云鼎要求;收货人等字段需遵守脱敏和敏感数据规则 |
+| 售后与退款 | 售后服务单列表/详情、日志、价保、小额打款等 | 退款金额、退款订单数、售后原因、商品/订单售后归因 | 不同售后类型对应不同接口和权限包,仍需按真实店铺数据回归 |
+| 商品评价 | 旧版评价 API 包含 `jingdong.pop.PopCommentJsfService.getVenderCommentsForJos` | 评价正文、星级、评价时间、商品 VOC 分析 | 当前 AppKey 已禁用;评价接口的字段、分页和权限未取得成功响应验证 |
+| 客服与服务质量 | 客服列表、客服评价、会话、聊天记录、客服绩效等 | 客服 VOC、服务质量、咨询问题归因 | 聊天记录和用户信息属于敏感数据,需额外授权、脱敏和合规控制 |
+| 经营指标与报表 | 新版 `sp-data` 报表定义/报表查询;旧版数据 API | 流量、销量、经营日报、趋势指标 | 旧版数据 API 目录已提示下线并指向云海数据开放平台;新版报表接口需要 schema 和数据权限,当前未执行 POST 报表查询 |
+| 会员与营销 | 会员列表/详情/积分、策略、人群包、营销活动 | 会员运营、营销活动效果 | 与 VOC 首期无关,涉及用户数据或写操作,需单独权限与审计 |
+
+### 当前无法覆盖或不应假设可覆盖
+
+- 竞品价格、竞品销量、竞品评价和全网市场快照:店铺商家 API 主要面向本店数据,不能把本店授权推导为竞品数据权限。
+- 稳定的日粒度流量/转化/GMV趋势:需要实际可用的 `sp-data` 报表 schema 和店铺授权验证;旧版数据 API 已有下线提示。
+- 未经授权的评价正文、订单收货人明文、用户 PIN、手机号和聊天记录:这些属于敏感数据场景,需单独授权、脱敏和数据留存策略。
+- 自动修改商品、库存、价格、订单发货、订单备注、售后处理:即使接口存在,也必须按写权限单独验收,本次没有调用。
+
+## 对现有仓库的影响
+
+- `src/app/core/config/runtime-config.ts` 固定 `domesticPlatform: 'jd'`,但这只是数据模型平台标识。
+- `src/app/core/services/domestic-ecommerce-gateway.service.ts` 只提供 `/api/voc-e-commerce/{platform}/{path}` 的通用 GET 代理,不实现京东签名或 OAuth。
+- `proxy.conf.json` 的京东相关代理指向本地后端;当前前端默认国内数据仍可使用本地快照模式。
+- 因此,当前不能把本次 API 探测结果直接接入前端页面,也不应把 AppKey/AppSecret 放入 Angular 环境变量或浏览器代码。
+
+## 恢复后的复测顺序
+
+1. 在京东开放平台确认应用状态,提交错误码 `21`/`99904030002` 对应的解禁工单。
+2. 在授权管理中确认服务对象为目标 POP 店铺,并完成商家授权。
+3. 获取并安全保存 `access_token`、`refresh_token`、过期时间和店铺标识;AppSecret 只放服务端密钥管理,不放前端。
+4. 先复测一个免授权 GET 和一个需要授权 GET,确认签名、店铺身份和 token 关系。
+5. 再按商品、订单、售后、评价、客服、报表顺序扩大探测;每一域至少验证分页、增量字段、时间范围、空数据和错误码。
+6. 以真实店铺数据做字段映射和数据质量验收,再接入本地快照/增量同步任务。
+
+## 复测通过标准
+
+- AppKey 状态不再返回 `99904030002` 或 V1 错误码 `21`。
+- 需要授权的接口返回业务层响应,而不是 token 缺失、过期或店铺身份不匹配。
+- 商品、订单、售后、评价、报表至少各有一个真实店铺数据样本,分页和增量游标可重复运行。
+- 失败请求不记录 AppSecret、完整 token、用户 PIN、手机号、地址或聊天正文。
+- 同步任务具备限流、重试、断点、幂等、审计和撤销授权后的降级行为。

+ 568 - 0
docs/JD-API-CONNECTION-ASSESSMENT-2026-08-15.md

@@ -0,0 +1,568 @@
+# 京东店铺 API 连接与 SaaS-VOC 能力深度评估
+
+> 评估时间:2026-08-15,商品详情补充调研:2026-08-16  
+> 评估对象:已完成商家授权的 JD 店铺连接  
+> 评估范围:OAuth/token 状态、SP-API 商品/SKU/库存、订单/售后/评价/数据能力、与现有 SaaS-VOC 模型的覆盖关系
+
+## 1. 结论先行
+
+当前连接已经从“只有 OAuth、无法验证业务权限”进入“商品域真实可用”的阶段:
+
+- Parse 中已存在 1 条生产环境 JD 授权记录,包含店铺 UID、access token、refresh token 和过期时间;凭证本身未写入本报告。
+- 新版 SP-API 已真实返回本店商品数据:商品总数 **614**,商品列表、SKU 列表、库存查询均返回 `success: true`。
+- 当前已经可以支撑“本店商品主数据 + SKU 主数据 + 库存状态”的第一阶段同步。
+- 商品详情文档显示,详情页可以继续扩展到状态、价格、属性、媒体、物流和售后承诺,但详情接口本身不提供订单经营趋势或用户 VOC 证据。
+- 订单列表和售后服务单仍受京东侧云鼎调用前置条件影响;这不是代码签名错误,也不能直接解释为 token 无效。
+- 客服、承运商等接口在前次验证中返回“应用无该 API 调用权限”;报表接口仍需按接口专用签名/请求体规则复测。
+- 评价、流量、GMV、退款原因、竞品数据尚未形成真实成功样本,因此当前不能把现有本地快照中的这些字段当作 JD 实时能力。
+
+综合判断:**商品/SKU/库存能力可进入工程化接入;订单/售后是下一阶段的外部开通项;评价和经营报表是 SaaS-VOC 价值闭环的关键风险项。**
+
+## 2. 凭证与连接状态
+
+### 2.1 已验证状态
+
+授权记录通过 Parse `EcomAuth` 查询确认:
+
+| 项目 | 结果 |
+|---|---|
+| 平台 | `jd` |
+| 环境 | `production` |
+| 店铺标识 | 已保存 UID,报告不展开具体值 |
+| access token | 存在,长度 36;不记录原文 |
+| refresh token | 存在,长度 36;不记录原文 |
+| access token 有效期 | 约 1 年,当前记录到 2027-08-15 |
+| refresh 刷新阈值 | 2026-09-14 前后进入刷新窗口 |
+| 存储位置 | Parse `EcomAuth`,服务端读取 |
+
+### 2.2 现有模块边界
+
+当前 JD 模块已具备:
+
+- 生成 OAuth 授权链接和随机 `state`;
+- 处理回调 `code`,向京东换取 token;
+- 将授权信息保存到 Parse;
+- 按店铺 UID 查询最新授权记录;
+- access token/refresh token 临近过期时刷新;
+- 提供健康检查、token 状态和取 token 路由。
+
+当前 JD 模块尚未具备:
+
+- 通用 SP-API 请求客户端;
+- 服务端签名生成和参数规范化;
+- 商品、SKU、库存、订单、售后、评价、报表 API 封装;
+- 增量游标、分页断点、幂等写入和失败重试;
+- 将 JD 原始响应转换为 `DomesticDataset` 的同步任务。
+
+### 2.3 凭证安全结论
+
+`E:\workspace\server\future-server\fmode-server\modules\voc-ecom-jd-api\src\config.ts` 当前存在 AppKey/AppSecret 的硬编码 fallback。即使这是为了模块自包含,构建产物也可能被读取或反编译,生产环境应改为:
+
+1. AppKey 可作为公开配置或服务端配置保存;
+2. AppSecret 只通过服务端环境变量、密钥管理服务或部署平台 secret 注入;
+3. 前端和浏览器 URL 永远不出现 AppSecret;
+4. `get-access-token` 不应向客户浏览器返回完整 access token;
+5. 日志只记录 shop UID、接口名、HTTP 状态、京东 request_id 和脱敏错误码。
+
+## 3. 官方调用与签名规则
+
+本次使用新版 SP-API 入口:
+
+`https://api-cn.jd.com/rest`
+
+官方文档:
+
+- [SP-API 调用方法](https://open.jd.com/v2/#/doc/dev-guide?listId=1100587)
+- [SP-API 签名算法](https://open.jd.com/v2/#/doc/dev-guide?listId=1100604)
+- [商品列表 listProducts](https://open.jd.com/v2/#/doc/api?apiCateId=100081&apiId=100508&apiName=listProducts&gwType=1)
+- [SKU 列表 listSkus](https://open.jd.com/v2/#/doc/api?apiCateId=200375&apiId=100509&apiName=listSkus&gwType=1)
+- [库存 listSkuStocks](https://open.jd.com/v2/#/doc/api?apiCateId=200375&apiId=100667&apiName=listSkuStocks&gwType=1)
+- [商品详情 getProduct](https://open.jd.com/v2/#/doc/api?apiCateId=200375&apiId=100588&apiName=getProduct&gwType=1)
+- [订单详情 getOrder](https://open.jd.com/v2/#/doc/api?apiCateId=100066&apiId=100251&apiName=getOrder&gwType=1)
+
+当前已验证的 MD5 规则:
+
+1. 收集 `X-JOS-Access-Token`、`X-JOS-App-Key`、`X-JOS-Timestamp`、路径参数、查询参数和请求体参数;
+2. 按参数名 ASCII 升序排列;
+3. 拼接为 `key + value`,不插入 `&`;
+4. 计算 `MD5(AppSecret + 拼接结果 + AppSecret)`;
+5. 将摘要转成大写;
+6. 时间戳使用毫秒;
+7. `X-JOS-Request-Identity: vender` 是相关接口要求的请求头,但不加入成功请求的签名参数串。
+
+工程上必须把签名放在后端统一客户端中,禁止由 Angular 直接拼签名。
+
+## 4. 真实接口测试结果
+
+### 4.1 已通过的接口
+
+| 数据域 | 接口 | 测试参数摘要 | 结果 | 能力判断 |
+|---|---|---|---|---|
+| 商品 | `GET /sp-product/v0/products` | `scopeSet=productName`、分页、有效商品状态 | HTTP 200,`success: true`,总数 614 | 当前店铺商品列表可读 |
+| SKU | `GET /sp-product/v0/skus` | 以商品 ID 查询,返回 SKU ID、商品 ID、名称、状态 | HTTP 200,`success: true` | 当前商品的 SKU 主数据可读 |
+| 库存 | `GET /sp-product/v0/sku-stocks` | 以 SKU ID 查询 | HTTP 200,`success: true` | 当前 SKU 库存可读 |
+
+代表性样本(仅业务 ID 和数量,不包含凭证):
+
+- 商品 ID:`10026650610613`;
+- SKU ID:`10116518781681`;
+- 库存样本:`stockNum=1000`、`warehouseId=0`、`orderBookingNum=0`、`unpaidBookingNum=0`。
+
+这三个成功结果说明:AppKey、签名、access token、接口路径和店铺授权关系已经至少在商品域闭环成立。之前出现的 access token 为空错误来自跨进程环境变量未重新读取 Parse,不是当前授权失效;后续探测必须在同一进程内先读取最新授权记录再发请求。
+
+### 4.2 受云鼎或权限前置条件影响的接口
+
+| 数据域 | 接口/范围 | 既有结果 | 解释 |
+|---|---|---|---|
+| 订单 | `GET /sp-order/v0/orders` | `99904030005` | 需要从云鼎平台发起调用,未进入订单业务数据层 |
+| 订单 | `GET /sp-order/v0/orders/{orderId}` | 文档标记需授权;暂无真实成功样本 | 需要订单列表先拿到真实订单号,再验证详情 |
+| 售后 | 售后服务单列表 | `99904030005` | 同样受云鼎调用前置条件影响 |
+| 客服 | 客服列表相关接口 | `99904030008` | 当前应用没有该 API 调用权限 |
+| 物流 | 承运商列表相关接口 | `99904030008` | 当前应用没有该 API 调用权限 |
+| 报表 | `sp-data` 报表定义探测 | `99904000013` | 需要按该接口的专用签名/请求体规则复测,不能据此判定无权限 |
+
+这些错误需要分开处理:
+
+- `99904030005` 是调用平台/接入前置条件,不是简单的 token 续期问题;
+- `99904030008` 是 API 权限包问题;
+- `99904000013` 目前只能归类为请求签名或报表接口格式待复核;
+- 只有返回业务响应并携带真实店铺数据,才能认定某数据域已真正可用。
+
+### 4.3 尚未形成成功样本的能力
+
+| 数据域 | 文档或已知入口 | 当前结论 |
+|---|---|---|
+| 商品详情 | `getProduct` | 文档存在,需用有效商品 ID 做字段级回归 |
+| 商品评价 | 旧版 JOS `jingdong.pop.PopCommentJsfService.getVenderCommentsForJos` 等评价方法 | 只有方法线索,没有当前授权下的成功响应 |
+| 流量/转化 | 新版数据 API、报表 API | 需要 schema、数据权限和时间粒度验证 |
+| 退款原因 | 售后服务单详情/日志 | 受售后接口前置条件影响 |
+| 竞品数据 | 商品/市场/搜索类数据 | 本店授权不等于竞品数据授权,不能推导为可用 |
+
+## 5. 商品详情深度研究
+
+### 5.1 官方接口边界
+
+官方商品查询场景把 SPU 和 SKU 分开定义:SPU 是商品信息聚合单位,SKU 是物理上不可分割的最小库存单元。当前商品详情接口把两者合并到一次调用中:
+
+```text
+GET /sp-product/v0/products/{productId}
+```
+
+官方文档:
+
+- [商品查询场景方案](https://open.jd.com/v2/#/doc/scene?listId=1100463)
+- [获取商品详情 getProduct](https://open.jd.com/v2/#/doc/api?apiCateId=100081&apiId=100588&apiName=getProduct&gwType=1)
+- [新老商品状态码对应关系](https://joyspace.jd.com/pages/qbougEvCGW7f2Rcia0dL)
+
+已确认的请求约束:
+
+| 项目 | 规则 |
+|---|---|
+| productId | 路径参数,`int64`,即京东商品 ID/wareId |
+| request | 查询对象;至少应明确业务场景 |
+| scene | `pop` 表示 POP,`vc` 表示 VC/自营供应商场景 |
+| categoryId | 可选末级类目 ID;传入末级类目后才返回搜索推荐图 |
+| spuSkuApplyGray | 可选自营 SPU 融合灰度开关 |
+| 授权 | 文档标记“需用户授权” |
+| 数据维度 | 店铺范围、供应商简码范围;本店应使用 `X-JOS-Request-Identity: vender` |
+| 服务端时间 | `X-JOS-Timestamp` 精确到毫秒,服务端允许约 10 分钟误差 |
+
+官方商品查询方案还说明:
+
+- 商品列表支持商品状态、店内分类等筛选,最多支持查询 10 万条,应采用分页同步;
+- SKU 列表可按 SKU、上下架状态、库存等维度检索,但部分字段只支持 POP;
+- `wareId` 是京东商品 ID,一个 wareId 可以有多个 skuId;一个 skuId 只属于一个 wareId;
+- `getProduct` 的详情结果不应替代准确库存查询,详情里的 `stockNum` 与京麦商品列表口径一致,精确库存仍应使用 `listSkuStocks`;
+- 非盖亚商品可能出现京麦展示的子 SKU 与接口 `skuList` 结构不同的情况,接入时必须保留平台原始 SKU 关系,不按页面视觉层级猜测 SKU 数量。
+
+### 5.2 getProduct 可提供的数据
+
+下面按“可以展示/可以分析/应限制展示”拆分官方响应字段。
+
+#### A. 商品身份与状态
+
+| 字段组 | 典型字段 | 可形成的页面能力 |
+|---|---|---|
+| 标识 | `productId`、`itemNum`、`wareId`、`model`、`upcCode` | 商品 ID、商品编号、型号、条码检索和去重 |
+| 商品有效性 | `yn` | 有效/已删除状态 |
+| 上下架状态 | `productStatus`、`productStatusNew`、`saleState`、`saleStateName` | 在售、下架、审核中、违规下架等状态标签 |
+| 状态时间 | `onlineTime`、`offlineTime`、SKU `onShelfTime` | 上架、下架时间线和生命周期节点 |
+| SKU 状态 | `skuEnableStatus`、`valid`、`skuStatus.onOffShelfStatus` | SKU 启用、停用、无效、上下架状态 |
+
+注意:自营商品的 product 维度状态和 `createdTime`/`modifiedTime` 有缺失规则;不能用 POP 状态码的含义直接解释自营数据。状态应同时保存原始码和标准化标签。
+
+#### B. 标题、品牌与类目
+
+| 字段组 | 典型字段 | 页面用途 |
+|---|---|---|
+| 标题 | `productTitle.title`、`productNames` | 商品卡片、详情页标题、搜索 |
+| 推荐词 | `recommendWords` | 商品卖点候选词、内容提示,不直接等同于人工卖点 |
+| 品牌 | `brandInfo.brandId`、中文/英文品牌名、标题品牌名 | 品牌筛选、品牌归属校验 |
+| 类目 | 一级/二级/三级/末级 ID 与名称 | 类目树、类目分析、类目覆盖质量 |
+| 结构关系 | `currencySpuId`、`productId`、`skuId` | SPU/SKU 关系和变体组展示 |
+
+当前系统的 `category1/2/3` 可以直接承接类目名称;但商品详情接口对部分类目名称标注了业务模式限制,应以实际返回字段为准,不能在空字段时从 ID 反推名称。
+
+#### C. 规格、属性和变体
+
+| 字段组 | 典型字段 | 页面用途 |
+|---|---|---|
+| 基础尺寸 | `length`、`width`、`height`、`weight` | 规格档案、物流包装信息 |
+| 商品属性 | `goodsAttrInfos`、`attrNames`、`attrValues`、`unit` | 规格表、属性筛选、产品定义 |
+| 销售属性 | `saleAttrs`、`attrKey`、`attrValues`、颜色标识 | 颜色/容量/型号等变体选择 |
+| 自定义属性 | `customAttrInfos`、`customAttrValues` | 店铺自定义规格和产品定义 |
+| SKU 列表 | `skuId`、`skuNames`、`outerId`、UPC、SKU 属性、SKU 状态 | SKU 明细表、变体库存和状态 |
+| 套装关系 | `suiteSkus` | 组合商品的子 SKU 和数量 |
+
+当前 `DomesticProductProfile.color` 和 `specification` 是两个字符串,承载不了多属性、多 SKU 和属性值 ID。生产模型应保留结构化属性数组,字符串只作为兼容展示字段。
+
+#### D. 价格与销售条件
+
+| 字段组 | 典型字段 | 可用场景 | 权限/展示注意 |
+|---|---|---|---|
+| 公开价格 | `marketPrice`、`jdPrice` | 商品档案当前挂牌价 | 不等于历史成交均价 |
+| 特殊价格 | `agreementPrice`、优惠券价、限时价、参考价 | 价格构成和活动上下文 | 需按店铺业务身份确认 |
+| 划线价 | `lineationPrice`、类型、应用原因 | 价格合规提示 | 不直接当作实际售价 |
+| 价格范围 | `minSalePrice`、`maxSalePrice`、错误提示 | 多 SKU 价格区间 | 应以 SKU 维度展示 |
+| 成本/协议 | `costPrice`、协议价 | 内部经营或权限用户 | 不应默认展示给所有角色 |
+
+`DomesticProductMarketSnapshot.currentPrice` 当前用于竞品市场快照;本店 `jdPrice` 不应写入竞品 `market` 对象。`DomesticProduct.summary.averageUnitPrice` 也必须继续来自订单成交金额/成交件数,不能拿挂牌价代替。
+
+#### E. 描述、图片、视频与内容资产
+
+| 字段组 | 典型字段 | 页面用途 |
+|---|---|---|
+| 商品描述 | `productDetailDesc.desc`、移动端描述、小程序描述 | 商品详情内容预览、内容质量检查 |
+| 包装说明 | `packListing`、温馨提示、适用车型说明 | 购买前信息完整度、售后风险提示 |
+| 主图 | `mainImages.imageInfoList`、主图标识、排序 | 商品详情首图和图片画廊 |
+| 衍生图片 | 长图、透明图、白底图、规格图、搜索推荐图 | 视觉素材检查和渠道适配 |
+| 视频 | 视频 ID、封面、标题、播放地址、时长、状态 | 商品内容资产完整度 |
+| 素材状态 | 图片审核状态、审核描述、规格图标记 | 素材质量和审核问题追踪 |
+
+当前系统多数商品只有 `detail.imageUrl`,详情接口能够把它扩展成图片画廊和内容质量模块。图片与视频应保存 URL、排序、状态和 `collectedAt`,避免每次详情页打开时直接依赖京东实时接口。
+
+#### F. 售后、物流和履约属性
+
+| 字段组 | 典型字段 | 页面用途 |
+|---|---|---|
+| 售后承诺 | 保修、保质期、服务、服务描述、售后说明 | 商品详情页售后承诺、风险提示 |
+| 配送信息 | `transportId`、`promiseId`、配送方式、发货地、仓库 | 配送能力和履约上下文 |
+| 包装信息 | 包装清单、包装规格、箱规、单位 | 规格档案和履约准备 |
+| 仓库信息 | 仓库 ID、店铺 ID、仓库位置、状态 | 库存按仓库拆分 |
+| 合作类型 | `colType`、仓库状态 | SOP/FBP/FCS 等履约口径 |
+| 特殊服务 | 特殊服务列表、服务编码 | 服务标签和场景识别 |
+
+`jdExpress` 和 `localDelivery` 不是 getProduct 中可以直接一一对应的统一布尔字段。除非有明确的业务字段映射,否则页面应显示“未标记/未知”,不要用配送方式字符串推断即时零售或京东配送。
+
+#### G. 税务、质检和运营扩展
+
+接口还可能返回税务信息、质检信息、运营人员、活动、质检文件、商品健康或业务扩展字段。它们适合放入权限控制后的“商品合规/商品管理”页,不适合直接进入公开的 VOC 快照:
+
+- 成本价、协议价、税务和结算字段需要角色权限;
+- 运营人员 PIN、地址列表、仓库位置等字段属于敏感或内部字段;
+- 质检文件、资质文件和活动协议应保留引用与状态,文件内容单独受控;
+- 商品详情正文可以进入 AI 分析上下文,但要保留原始来源和采集时间,避免模型把描述文案当成用户评价。
+
+### 5.3 本次真实验证状态
+
+已真实成功的商品域接口仍是:商品列表 614 个、SKU 列表、准确库存查询。`getProduct` 的官方文档字段和请求规则已完成补充调研,但本次补充复测在读取 Parse 授权记录时收到 `unauthorized`,因此没有把商品详情接口标记为“真实成功”。
+
+这两个问题应分开:
+
+1. 先恢复服务端对 `EcomAuth` 的读取凭证,确认不是 Parse MasterKey/部署配置变化;
+2. 再用已验证商品 ID 调用 `scene=pop` 的详情接口;
+3. 记录真实返回字段覆盖率,尤其是 `productInfo`、`skuList`、图片、价格、详情描述、类目和物流字段;
+4. 对同一商品分别验证 `scene=pop` 和错误场景,确认错误码归因;
+5. 将准确库存继续以 `listSkuStocks` 作为唯一库存来源。
+
+## 6. 与现有 SaaS-VOC 系统的字段映射
+
+### 5.1 可以直接落地的部分
+
+现有模型位于 `src/app/core/models/domestic.models.ts`:
+
+| 现有字段 | JD 来源 | 接入判断 |
+|---|---|---|
+| `DomesticProduct.productId` | 商品 ID | 直接映射 |
+| `productKey` | 建议生成 `jd:{productId}` | 直接生成,保持现有关系表稳定 |
+| `title` | 商品名称/详情名称 | 商品列表或详情映射 |
+| `brand`、`category1/2/3` | 商品详情/类目信息 | 需商品详情接口确认字段 |
+| `model` | 商品型号或商家型号 | 需从详情/SKU 属性抽取 |
+| `detail.skuStatus` | SKU 状态 | SKU 列表映射 |
+| `detail.shopId`、`sellerId` | 店铺 UID、商家 UID | 授权记录和接口响应映射 |
+| 库存状态 | `stockNum`、预占库存、未付款预占 | 建议新增库存快照,不塞进销售汇总 |
+| `detail.collectedAt` | 同步时间 | 由同步任务统一生成 |
+
+### 5.2 不能由商品接口直接填充的部分
+
+现有 `DomesticMetricSummary` 需要:
+
+`gmv`、销量、订单数、成交客户、曝光、点击、访客、加购、退款金额、退款件数、退款订单数、转化率等。
+
+商品/SKU/库存接口不能提供这些经营指标。需要分别依赖:
+
+- 订单列表/详情:订单数、商品件数、订单金额、订单状态、支付/履约时间;
+- 售后服务单:退款金额、售后类型、售后原因、售后状态;
+- 数据/报表 API:曝光、点击、访客、加购及稳定的日粒度经营指标;
+- 评价 API:评分、评价正文、评价时间和 VOC 样本;
+- 竞品或市场数据源:竞品价格、销量、评价和市场排名。
+
+在这些接口未通过前,应把缺失指标保持为 `null` 或明确的“未接入”,不要填 0。填 0 会让页面把“没有采集到”误判为“真实为零”。
+
+### 5.3 与现有页面的对应关系
+
+现有 `DomesticDatasetService` 和 `DomesticAnalyticsAdapterService` 已经围绕以下页面场景工作:
+
+- 经营总览:GMV、销量、订单、访客、退款和趋势;
+- 商品运营:本品商品、SKU、状态、价格和销量趋势;
+- 退款风险:退款金额占比、退款订单数、售后归因;
+- VOC 分析:本品/竞品评价拆分、评分、正文和情绪分析;
+- 竞品映射:本品与竞品商品关系;
+- 数据质量:商品、评论、映射和采集完整度。
+
+当前 JD API 只能立即支撑“商品运营”的基础层,以及库存风险的新增看板。要支撑完整经营总览和退款风险,需要先解决订单/售后;要支撑 VOC 结论,需要先解决评价正文;要支撑竞品页面,需要另行接入市场数据源。
+
+## 7. 页面能力与可展示数据
+
+### 7.1 现有页面的真实消费关系
+
+| 页面 | 当前依赖 | 接入商品详情后可展示 | 仍需其他数据域 |
+|---|---|---|---|
+| 商品经营详情 | `DomesticProduct.summary`、`trend`、基础档案 | 标题、图片、品牌、型号、类目、状态、价格、SKU 数量、规格、图片/视频、商品描述、库存摘要、详情采集时间 | 订单/报表提供 GMV、销量、访客、转化、退款和日趋势;评价 API 提供评论样本 |
+| 商品运营总览 | 商品汇总、日指标、评论范围、类目聚合 | 商品数、有效/下架/审核状态、类目覆盖、库存风险和商品信息完整度 | 订单/报表提供经营排行和趋势;评价提供本品 VOC 覆盖 |
+| 品类分析 | 商品类目 + `summary` 聚合 | 类目商品数、状态分布、属性完整度、库存覆盖、类目详情链接 | 订单/报表提供类目 GMV、成交、访客和退款 |
+| 竞品详情 | `detail`、`market`、`reviews`、本品关系 | 该页面的商品档案、图片、规格、卖点、售后和履约字段可复用 | JD 本店授权不能提供竞品市场快照;评价需要评价 API;竞品价格/销量来自外部市场采集 |
+| VOC 详情/评价工作台 | `reviews` + 商品上下文 | 标题、类目、型号、商品描述、属性可作为 AI 分析上下文 | 评价列表、正文、星级、时间是核心输入,商品详情本身不产生 VOC 结论 |
+| 产品开发 | 商品、类目、趋势、竞品关系、评价 | 属性缺口、规格结构、图片/视频资产、售后承诺、商品定义素材 | 趋势需要经营指标;需求挖掘需要本品评价;竞品对标需要市场数据 |
+| 数据中心 | 来源、同步任务、质量规则、指标字典 | 商品主数据同步、详情字段覆盖率、SKU/库存同步任务、图片和状态异常 | 订单、售后、评价、报表、结算仍需各自连接器 |
+
+### 7.2 商品经营详情页建议升级
+
+当前 `src/modules/domestic-voc/product-operating-detail.component.ts` 的页面已经有四类经营指标、趋势图、商品档案、流量漏斗和数据口径,但档案只展示商品 ID、型号、品牌、平台、二/三级类目、竞品关系和评论样本。接入 getProduct 后建议增加以下四个层次:
+
+#### 顶部商品身份区
+
+- 主图和图片切换;
+- 商品标题、商品 ID、商品编号、型号、品牌;
+- 在售/下架/审核中/删除状态;
+- 当前 JD 价和 SKU 价格区间;
+- SKU 数量、有效 SKU 数量、库存风险标签;
+- 最后上架时间和详情采集时间。
+
+#### 商品内容区
+
+- 商品卖点候选词和详情描述摘要;
+- 类目 ID/名称;
+- 结构化属性表,显示属性名、属性值、单位和是否必填;
+- 主图、白底图、规格图、视频的数量、审核状态和排序;
+- 包装清单、温馨提示、适用车型/场景说明。
+
+#### SKU 与库存区
+
+- SKU ID、SKU 名称、外部 SKU、销售属性;
+- SKU 启用/上下架状态;
+- 当前准确库存、订单预占、未付款预占、仓库 ID;
+- 库存更新时间和数据源;
+- 按 SKU 的价格、库存和状态筛选。
+
+#### 经营与 VOC 区
+
+- 已验证订单/报表数据才显示 GMV、销量、订单、访客和转化;
+- 已验证售后数据才显示退款金额、退款件数和退款原因;
+- 已验证评价数据才显示本品评价数、评分分布、正文和 AI 主题;
+- 商品详情文案和用户评价分开标注来源,避免把商品宣传语当作用户声音。
+
+### 7.3 当前页面能达到的能力级别
+
+在只使用已经验证的商品列表、SKU 和库存接口时,系统可上线:
+
+- 本店商品目录和商品搜索;
+- SPU/SKU 关系查看;
+- 商品上下架/有效性状态查看;
+- SKU 维度库存监控和库存预占展示;
+- 商品类目、品牌、型号和规格档案;
+- 商品主图、详情图、视频和详情描述的完整度检查;
+- 商品信息质量和采集新鲜度检查;
+- 商品运营详情页的“商品资产”部分。
+
+在订单、售后、评价和报表都打通后,才能完整上线:
+
+- 商品日 GMV、销量、订单和转化趋势;
+- 退款风险和售后原因归因;
+- 本品评价健康度、主题、情绪和痛点;
+- 商品内容/规格/履约属性与用户评价的关联分析;
+- 产品迭代建议和生命周期复盘。
+
+### 7.4 当前模型需要调整的地方
+
+现有 `DomesticProductProfile` 只有图片、颜色、规格、产地、重量、尺寸、上架时间、店铺、商家、SKU 状态、履约布尔值和卖点等扁平字段。建议新增一个独立的结构化详情对象,而不是继续把 JD 全部字段压缩成字符串:
+
+```ts
+interface JdProductDetailSnapshot {
+  productId: string;
+  itemNum?: string;
+  model?: string;
+  status?: { raw?: number; label?: string; source?: string };
+  brand?: { id?: string; name?: string; enName?: string };
+  categories?: Array<{ level: number; id?: string; name?: string }>;
+  prices?: { jd?: number; market?: number; min?: number; max?: number };
+  attributes?: Array<{ id?: string; name: string; values: string[]; unit?: string }>;
+  skus?: Array<{ skuId: string; name?: string; attributes?: Record<string, string>; status?: string }>;
+  media?: { images: string[]; whiteBackgroundImages: string[]; videos: string[] };
+  description?: { pc?: string; mobile?: string; packageList?: string; tips?: string };
+  logistics?: { delivery?: string; promiseId?: string; transportId?: string; warehouseIds?: string[] };
+  afterSales?: { warranty?: string; shelfLifeDays?: number; service?: string; description?: string };
+  collectedAt: string;
+}
+```
+
+字段是否存在应由 `fieldAvailability` 或来源元数据标记,页面使用 `null`/“未返回”表示缺失,不把缺失字段格式化成 0。当前商品经营详情页中的 `number()`、`currency()` 和 `percent()` 都会把空值转成 0,生产数据接入前需要增加数据状态判断。
+
+### 7.5 数据中心状态需要同步调整
+
+当前 `src/modules/data-center/data-center.component.ts` 的连接器状态是:商品主数据 supported、商品评价 supported、订单/售后/库存/结算 building。结合本次真实结果,建议改成更准确的分层状态:
+
+- 商品主数据:`supported`,包含商品列表、详情待实测、SKU 列表;
+- 库存:`supported-read-only`,商品库存查询已经成功,但库存同步任务和仓库维度快照还未工程化;
+- 商品评价:`pending-permission`,文档和旧版方法线索存在,当前授权下没有成功样本;
+- 订单/售后:`blocked-by-platform`,待云鼎调用前置条件和权限包确认;
+- 结算:`building`,本次没有验证财务接口。
+
+同时,`DataCenterSyncScope` 目前只有 `product | reviews`,应增加 `inventory`,并让数据中心的“可售库存”指标从 missing 调整为 partial/read-only,而不是在有真实库存接口结果后继续显示缺失。
+
+## 8. 能力覆盖矩阵
+
+| 场景 | 当前状态 | 覆盖度 | 说明 |
+|---|---|---:|---|
+| 店铺授权与凭证续期 | 已验证 | 90% | OAuth、Parse 存储和刷新已具备;需补撤销授权、重复授权幂等 |
+| 商品目录同步 | 已验证 | 80% | 列表可读;商品详情、类目和媒体字段需继续回归 |
+| SKU 主数据同步 | 已验证 | 85% | SKU 列表成功;需补 SKU 属性、价格和变体关系 |
+| 库存监控 | 已验证 | 75% | 可读取库存和预占量;需确认分页、批量上限、仓库维度和频率 |
+| 订单经营分析 | 外部前置条件阻断 | 20% | 接口文档存在,但暂无订单业务样本 |
+| 退款/售后分析 | 外部前置条件阻断 | 10% | 没有售后服务单与原因明细 |
+| 评价 VOC | 待接口回归 | 20% | 有旧版方法线索,没有真实成功样本 |
+| 流量/转化分析 | 待报表验收 | 10% | 无法仅靠订单和商品接口可靠计算 |
+| 客服 VOC | 权限不足 | 0% | 需单独申请接口权限并评估敏感信息处理 |
+| 竞品对标 | 不属于本店授权直接能力 | 0% | 需要市场/搜索/外部数据源 |
+| 写操作 | 本次未测 | 0% | 库存写入、发货、订单备注、售后操作必须单独审批和验收 |
+
+## 9. 推荐的落地架构
+
+### 7.1 服务端数据链路
+
+```text
+Parse EcomAuth
+  -> JD token provider(按 shop UID 取最新 token)
+  -> JD SP-API signer(统一签名、超时、重试、限流)
+  -> domain clients(product / sku / stock / order / after-sales / review / report)
+  -> raw response audit(脱敏)
+  -> normalized snapshot + incremental tables
+  -> /api/domestic-voc/snapshot
+  -> Angular DomesticDatasetService
+```
+
+### 7.2 建议新增的后端边界
+
+不要继续让前端通过通用 GET 代理直接拼 JD 路径。建议在 JD 模块中增加:
+
+- `JdTokenProvider`:按 shop UID 获取并自动刷新 token;
+- `JdSpClient`:统一处理 URL、参数排序、MD5、请求头、超时和错误分类;
+- `JdProductClient`:商品、SKU、商品详情、库存;
+- `JdOrderClient`:订单列表、详情和物流只读能力;
+- `JdAfterSalesClient`:售后服务单、退款和原因;
+- `JdReviewClient`:评价分页和字段清洗;
+- `JdReportClient`:报表定义、任务查询和数据下载;
+- `JdSnapshotSync`:分页、增量窗口、断点、幂等和快照生成。
+
+前端只访问统一后的 SaaS-VOC 数据,不接触 JD 签名参数或完整 token。
+
+### 7.3 数据存储建议
+
+至少拆分以下实体:
+
+| 实体 | 关键字段 | 用途 |
+|---|---|---|
+| `jd_product` | shop UID、product ID、标题、类目、状态、raw hash、observedAt | 商品主数据 |
+| `jd_sku` | shop UID、SKU ID、product ID、名称、状态、属性 | SKU 维度分析 |
+| `jd_stock_snapshot` | SKU ID、仓库、可用库存、预占库存、采集时间 | 库存趋势和告警 |
+| `jd_order` | 订单 ID、状态、金额、支付/完成时间、raw hash | 经营指标 |
+| `jd_order_item` | 订单 ID、商品 ID、SKU、数量、成交金额 | 商品销量归因 |
+| `jd_after_sales` | 售后单、订单、商品、类型、金额、原因、状态 | 退款风险 |
+| `jd_review` | 评价 ID、商品 ID、星级、正文、时间、脱敏作者 | VOC 分析 |
+| `jd_sync_cursor` | shop UID、domain、window、cursor、lastSuccessAt | 增量同步和断点 |
+
+原始响应应保存 hash 或受控的脱敏原文,不应把地址、手机号、用户 PIN、聊天正文直接进入通用 VOC 快照。
+
+## 10. 下一步测试与开通计划
+
+### P0:商品域工程化验收
+
+1. 用商品 ID `10026650610613` 复测 `getProduct`,记录可返回字段、空字段和字段类型;
+2. 对 614 个商品做分页测试,确认最大 page size、重复数据和状态筛选;
+3. 对 SKU 列表和库存接口做批量上限、空 SKU、无效 SKU、重复 SKU 测试;
+4. 将成功响应转成 `DomesticProduct`、SKU 表和库存快照;
+5. 加入 request_id、接口耗时、错误码和响应 hash 的脱敏审计。
+
+### P1:订单与售后开通
+
+1. 向京东确认当前应用是否必须从云鼎平台发起订单/售后调用;
+2. 确认应用绑定的店铺类型、服务对象和订单/售后 API 权限包;
+3. 前置条件满足后,先用最小字段调用订单列表;
+4. 拿到真实订单号后再调用订单详情;
+5. 用只读方式验证售后列表、售后详情和退款原因;
+6. 验证时间窗口、分页、空数据、重复运行和数据脱敏。
+
+### P1:评价接口
+
+1. 确认评价 API 属于新版 SP-API 还是旧版 JOS 权限包;
+2. 用当前 access token 复测评价列表,不以旧版方法“存在”推导为有权限;
+3. 至少拿到一条本店真实评价,确认评价 ID、商品 ID、星级、正文和时间字段;
+4. 做内容脱敏、重复评价去重和增量窗口回归;
+5. 接入 `DomesticReview` 前区分本品与竞品,防止把竞品评论写成本品 VOC。
+
+### P2:报表与经营指标
+
+1. 按官方报表接口文档确认签名参数是否包含请求体、schema 和任务字段;
+2. 先查询报表定义,再执行最小只读报表任务;
+3. 验证日粒度的曝光、点击、访客、加购、订单、成交金额和退款字段;
+4. 只有在同一统计口径和时间窗口下同时拿到分子/分母,才计算转化率、点击率和退款率;
+5. 将“接口无数据”“店铺真实为零”“权限不足”分别建模。
+
+## 11. 验收标准
+
+### 连接层
+
+- 任何业务接口请求前都从 Parse 读取最新授权记录;
+- access token 过期前自动刷新,刷新失败有可定位错误;
+- AppSecret、MasterKey、token 和敏感用户字段不进入前端、URL、构建产物日志和报告;
+- 每个请求都有接口名、shop UID、request_id、状态码、错误码和耗时。
+
+### 数据层
+
+- 商品、SKU、库存分页可重复运行且不产生重复快照;
+- 订单、售后、评价均有真实店铺样本后才标记为“可用”;
+- 所有增量同步有时间窗口、游标或更新时间字段;
+- 失败任务支持重试、断点续跑和幂等;
+- 缺失字段使用 null/未接入状态,不使用 0 伪造经营结果。
+
+### SaaS-VOC 层
+
+- 商品详情页能够展示真实 JD 商品和 SKU;
+- 库存告警不会混入 GMV/销量指标;
+- 经营总览只使用已验证的订单、售后和报表口径;
+- VOC 分析只使用真实评价正文,并正确拆分本品与竞品;
+- 没有经过独立数据源验证时,不展示竞品价格、销量或市场排名。
+
+## 12. 最终判断
+
+本次授权已经证明 JD 连接不是“只有 token 但没有业务权限”:商品列表、SKU 和库存均已打通,足以启动第一阶段的本店商品资产同步。
+
+但是,SaaS-VOC 的核心价值不是商品目录,而是“经营结果 + 售后原因 + 评价正文 + 趋势解释”。目前订单和售后受云鼎前置条件影响,评价和报表尚未形成成功样本,因而系统还不能宣称已经覆盖完整的京东经营分析或本店 VOC 闭环。
+
+推荐的上线顺序是:
+
+1. 先把商品/SKU/库存能力做成服务端同步和快照;
+2. 同步推进云鼎/订单/售后开通;
+3. 单独完成评价 API 的权限和字段验收;
+4. 最后接入报表并校准经营口径;
+5. 在上述四个数据域都有真实样本后,再把 `DomesticDataset` 从本地案例快照切换为生产数据。

+ 38 - 0
docs/JD-FIRST-BATCH-IMPORT.md

@@ -0,0 +1,38 @@
+# 京东首批商品导入
+
+## 当前状态
+
+- 京东商品、SKU、库存接口已经在同一授权进程中验证成功。
+- 当前授权店铺商品总数会随店铺实时变化;最近一次复测为 821 条。
+- 线上 `https://server.fmode.cn/backend/vO3T8gAdwB/data` 仍返回 404,说明多应用 Parse 后端还没有挂载,暂时不能写入目标库。
+
+## 导入内容
+
+默认导入前 10 个商品,并写入:
+
+- `ProductDetail`:商品主档、类目、品牌、价格、原始详情响应;
+- `Product`:现有系统商品主表兼容字段;
+- `AsinSkuMapping`:京东商品与 SKU 关系;
+- `JdStockSnapshot`:库存、预占库存、仓库和采集时间。
+
+订单、销量、GMV、流量、退款、评价不会由商品接口伪造填充。
+
+## 运行方式
+
+先设置环境变量,不把密钥写进脚本或前端:
+
+```powershell
+$env:JD_SOURCE_PARSE_URL = "https://server.fmode.cn/parse"
+$env:JD_SOURCE_PARSE_APP_ID = "主 Parse 应用 ID"
+$env:JD_SOURCE_PARSE_MASTER_KEY = "主 Parse Master Key"
+$env:JD_TARGET_PARSE_URL = "https://server.fmode.cn/backend/vO3T8gAdwB/data"
+$env:JD_TARGET_PARSE_APP_ID = "vO3T8gAdwB_main"
+$env:JD_TARGET_PARSE_MASTER_KEY = "目标应用 Master Key"
+$env:JD_APP_KEY = "京东 AppKey"
+$env:JD_APP_SECRET = "京东 AppSecret"
+
+npm run import:jd-products -- --dry-run --limit 10
+npm run import:jd-products -- --limit 10
+```
+
+`--dry-run` 只读取京东并输出数量和商品 ID;正式导入使用业务键更新,重复运行不会重复创建同一商品、SKU 或当天库存快照。

+ 15 - 0
package-lock.json

@@ -26,6 +26,7 @@
         "echarts": "^5.6.0",
         "file-saver": "^2.0.5",
         "fmode-ng": "^0.0.243",
+        "lucide-angular": "0.468.0",
         "marked": "^17.0.4",
         "ngx-echarts": "^17.2.0",
         "parse": "^8.0.3",
@@ -10617,6 +10618,20 @@
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/lucide-angular": {
+      "version": "0.468.0",
+      "resolved": "https://registry.npmjs.org/lucide-angular/-/lucide-angular-0.468.0.tgz",
+      "integrity": "sha512-HU1AnetqUbhwZPfeRwvYMdDer4y/YiL5ZzrCyeQl/MY0nK4oVYEtMVqbEs2KztJMfm8UJgTRkuD7fRvpFYK/Eg==",
+      "deprecated": "Package deprecated. Please use @lucide/angular instead.",
+      "license": "ISC",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/common": "13.x - 19.x",
+        "@angular/core": "13.x - 19.x"
+      }
+    },
     "node_modules/magic-string": {
       "version": "0.30.8",
       "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.8.tgz",

+ 2 - 0
package.json

@@ -15,6 +15,7 @@
     "serve:prod": "ng serve --configuration production",
     "analyze": "ng build --stats-json && npx webpack-bundle-analyzer dist/stats.json",
     "import:demashi": "node tools/import-demashi.mjs",
+    "import:jd-products": "node tools/jd-import-products.mjs",
     "verify:data": "node tools/verify-generated-data.mjs"
   },
   "keywords": [
@@ -46,6 +47,7 @@
     "echarts": "^5.6.0",
     "file-saver": "^2.0.5",
     "fmode-ng": "^0.0.243",
+    "lucide-angular": "0.468.0",
     "marked": "^17.0.4",
     "ngx-echarts": "^17.2.0",
     "parse": "^8.0.3",

+ 12 - 0
proxy.conf.json

@@ -17,6 +17,18 @@
     "changeOrigin": true,
     "logLevel": "debug"
   },
+  "/api/ai": {
+    "target": "http://127.0.0.1:4400",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
+  },
+  "/api/knowledge": {
+    "target": "http://127.0.0.1:4400",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
+  },
   "/parse": {
     "target": "http://127.0.0.1:4400",
     "secure": false,

+ 5 - 4
src/app/app.component.scss

@@ -1,7 +1,8 @@
 .app-layout {
   min-height: 100vh;
-  background: #f5f7fa;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  background: #f6f8fb;
+  color: #0f172a;
+  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
   display: flex;
   flex-direction: column;
 }
@@ -19,8 +20,8 @@
   overflow-x: hidden;
   overflow-y: auto;
   height: calc(100vh - 56px); /* Subtract header height */
-  margin-left: 220px; /* Fixed sidebar width */
-  transition: margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+  margin-left: 232px;
+  transition: margin-left 180ms ease;
 
   &.nav-collapsed {
     margin-left: 64px;

+ 64 - 4
src/app/app.routes.ts

@@ -22,8 +22,8 @@ export const routes: Routes = [
       },
       {
         path: 'product',
-        title: '商品经营详情',
-        loadComponent: () => import('../modules/domestic-voc/product-operating-detail.component').then((m) => m.DomesticProductOperatingDetailComponent),
+        redirectTo: '/voc-insight/detail',
+        pathMatch: 'full',
       },
       {
         path: 'categories',
@@ -75,6 +75,12 @@ export const routes: Routes = [
       },
     ],
   },
+  {
+    path: 'feedback-inbox',
+    canActivate: protectedRoutes,
+    title: '反馈收件箱',
+    loadComponent: () => import('../modules/feedback-inbox/feedback-inbox.component').then((m) => m.FeedbackInboxComponent),
+  },
   {
     path: 'voc-insight',
     canActivate: protectedRoutes,
@@ -86,6 +92,26 @@ export const routes: Routes = [
         title: '国内商品 VOC',
         loadComponent: () => import('../modules/voc-insight/voc-insight.component').then((m) => m.VocInsightComponent),
       },
+      {
+        path: 'ai',
+        title: 'AI VOC 洞察',
+        loadComponent: () => import('../modules/ai-voc-insight/ai-voc-insight.component').then((m) => m.AiVocInsightComponent),
+      },
+      {
+        path: 'topics',
+        title: 'VOC 主题探索',
+        loadComponent: () => import('../modules/voc-insight/topic-explorer/topic-explorer.component').then((m) => m.TopicExplorerComponent),
+      },
+      {
+        path: 'topics/:id',
+        title: 'VOC 主题详情',
+        loadComponent: () => import('../modules/voc-insight/topic-explorer/topic-detail.component').then((m) => m.TopicDetailComponent),
+      },
+      {
+        path: 'runs',
+        title: 'AI 分析运行',
+        loadComponent: () => import('../modules/ai-voc-insight/analysis-runs/analysis-run-list.component').then((m) => m.AnalysisRunListComponent),
+      },
       {
         path: 'detail',
         title: '商品 VOC 详情',
@@ -113,11 +139,45 @@ export const routes: Routes = [
       },
     ],
   },
+  {
+    path: 'knowledge-center',
+    canActivate: protectedRoutes,
+    title: '商品知识库',
+    loadComponent: () => import('../modules/knowledge-center/knowledge-center.component').then((m) => m.KnowledgeCenterComponent),
+  },
+  {
+    path: 'data-center',
+    canActivate: protectedRoutes,
+    title: '数据中台',
+    loadComponent: () => import('../modules/data-center/data-center.component').then((m) => m.DataCenterComponent),
+  },
   {
     path: 'data-status',
+    redirectTo: '/data-center',
+    pathMatch: 'full',
+  },
+  {
+    path: 'validation',
+    canActivate: protectedRoutes,
+    title: '效果验证',
+    loadComponent: () => import('../modules/validation-center/validation-center.component').then((m) => m.ValidationCenterComponent),
+  },
+  {
+    path: 'system',
     canActivate: protectedRoutes,
-    title: '数据接入状态',
-    loadComponent: () => import('../modules/domestic-voc/data-status.component').then((m) => m.DomesticDataStatusComponent),
+    children: [
+      { path: '', redirectTo: 'api', pathMatch: 'full' },
+      {
+        path: 'api',
+        title: 'API 管理',
+        loadComponent: () => import('../modules/system/pages/api-management/api-management.component').then((m) => m.ApiManagementComponent),
+      },
+      {
+        path: 'ai-prompts',
+        title: 'AI 提示词管理',
+        loadComponent: () => import('../modules/system/pages/ai-prompt-management/ai-prompt-management.component').then((m) => m.AiPromptManagementComponent),
+      },
+    ],
   },
   {
     path: 'return-analysis',

+ 1 - 1
src/app/core/interceptors/parse-session.interceptor.ts

@@ -3,7 +3,7 @@ import { RUNTIME_CONFIG } from '../config/runtime-config';
 import { MSQParse } from '../../parse';
 
 export const parseSessionInterceptor: HttpInterceptorFn = (request, next) => {
-  const protectedPaths = [RUNTIME_CONFIG.domesticVocPath, RUNTIME_CONFIG.saasPath]
+  const protectedPaths = [RUNTIME_CONFIG.domesticVocPath, RUNTIME_CONFIG.saasPath, '/api/knowledge']
     .map((path) => `/${path.replace(/^\/+|\/+$/g, '')}`);
   const isProtectedApi = protectedPaths.some((path) => request.url.includes(path));
   if (RUNTIME_CONFIG.apiAuthMode !== 'parse' || !isProtectedApi) return next(request);

+ 24 - 0
src/app/core/models/domestic.models.ts

@@ -46,6 +46,17 @@ export interface DomesticProductProfile {
   collectedAt: string;
 }
 
+export interface DomesticProductMarketSnapshot {
+  currentPrice: number;
+  salesText: string;
+  monthSalesText: string;
+  shopName: string;
+  shopId: string;
+  searchKeyword: string;
+  onShelvesAt: string;
+  collectedAt: string;
+}
+
 export interface DomesticProduct {
   platform: string;
   productId: string;
@@ -63,6 +74,7 @@ export interface DomesticProduct {
   summary: DomesticMetricSummary;
   trend: Array<{ date: string; gmv: number; soldUnits: number; transactionOrders: number }>;
   detail?: DomesticProductProfile;
+  market?: DomesticProductMarketSnapshot;
 }
 
 export interface DomesticReview {
@@ -81,6 +93,9 @@ export interface DomesticProductRelation {
   competitorProductId: string;
   competitorBrand: string;
   category: string;
+  discoverySource?: 'workbook' | 'brand_category_search';
+  searchKeyword?: string;
+  discoveredAt?: string;
 }
 
 export interface DomesticMappingGroup {
@@ -123,5 +138,14 @@ export interface DomesticDataset {
   mappingGroups: DomesticMappingGroup[];
   relations: DomesticProductRelation[];
   reviews?: DomesticReview[];
+  enrichment?: {
+    status: 'complete' | 'partial';
+    queryCount: number;
+    successfulQueries: number;
+    discoveredProducts: number;
+    reviewedProducts: number;
+    collectedReviews: number;
+    collectedAt: string;
+  };
   quality: DomesticQuality;
 }

+ 48 - 0
src/app/core/services/ai-gateway.service.ts

@@ -0,0 +1,48 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { map, Observable } from 'rxjs';
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+
+export interface AiGatewayStatus {
+  service: string;
+  configured: boolean;
+  baseUrl: string;
+  defaultModel: string;
+  proxyEndpoint: string;
+}
+
+export interface AiGatewayTestResult {
+  ok: boolean;
+  status?: number;
+  latencyMs?: number;
+  model?: string;
+  message?: string;
+  error?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class AiGatewayService {
+  private readonly http = inject(HttpClient);
+  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/api/ai`;
+
+  status(): Observable<AiGatewayStatus> {
+    return this.http.get<AiGatewayStatus>(`${this.baseUrl}/status`);
+  }
+
+  testConnection(): Observable<AiGatewayTestResult> {
+    return this.http.post<AiGatewayTestResult>(`${this.baseUrl}/test`, {});
+  }
+
+  listPromptConfigs(): Observable<Record<string, any>[]> {
+    return this.http.get<{ items: Record<string, any>[] }>(`${this.baseUrl}/prompts`).pipe(
+      map((response) => response.items || [])
+    );
+  }
+
+  savePromptConfig(prompt: Record<string, any>): Observable<Record<string, any>> {
+    return this.http.put<Record<string, any>>(
+      `${this.baseUrl}/prompts/${encodeURIComponent(String(prompt['promptKey'] || ''))}`,
+      prompt
+    );
+  }
+}

+ 7 - 5
src/app/core/services/domestic-analytics-adapter.service.ts

@@ -1,4 +1,5 @@
 import { Injectable } from '@angular/core';
+import { buildDomesticReviewScope } from './domestic-dataset.service';
 import {
   DomesticDataset,
   DomesticDailyMetric,
@@ -136,8 +137,8 @@ export class DomesticAnalyticsAdapterService {
 
   refundRiskProducts(dataset: DomesticDataset, limit = 10): DomesticProduct[] {
     return [...dataset.products]
-      .filter((product) => product.role === 'own' && product.summary.gmv > 0 && product.summary.refundAmount > 0)
-      .sort((a, b) => b.summary.refundToGmvRate - a.summary.refundToGmvRate)
+      .filter((product) => product.role === 'own' && product.summary.gmv >= 5000 && product.summary.refundAmount > 0)
+      .sort((a, b) => b.summary.refundAmount - a.summary.refundAmount || b.summary.refundToGmvRate - a.summary.refundToGmvRate)
       .slice(0, limit);
   }
 
@@ -210,15 +211,16 @@ export class DomesticAnalyticsAdapterService {
       });
     }
 
-    if (dataset.summary.reviewCount === 0) {
+    const reviewScope = buildDomesticReviewScope(dataset);
+    if (reviewScope.ownCount === 0) {
       actions.push({
         id: 'data:reviews',
         title: '接入评论明细后再生成 VOC 行动',
-        description: '当前源文件没有评论正文,情绪、痛点、体验提案和产品迭代结论均保持空态。',
+        description: `当前没有德玛仕本品评论正文;已入库的 ${reviewScope.competitorCount} 条竞品评论不作为本品情绪、痛点或体验结论。`,
         priority: 'P0',
         type: '数据',
         metric: '0 条评论',
-        evidence: '数据集评论样本计数',
+        evidence: '按商品角色拆分后的本品评论样本计数',
       });
     }
 

+ 62 - 1
src/app/core/services/domestic-dataset.service.spec.ts

@@ -1,4 +1,10 @@
-import { buildDomesticDatasetUrl } from './domestic-dataset.service';
+import { DomesticDataset } from '../models/domestic.models';
+import {
+  buildDomesticDatasetUrl,
+  buildDomesticReviewScope,
+  selectDomesticOwnProducts,
+  selectDomesticReviews,
+} from './domestic-dataset.service';
 
 describe('domestic dataset source selection', () => {
   const baseConfig = {
@@ -31,3 +37,58 @@ describe('domestic dataset source selection', () => {
     })).toBe('https://voc.example.com/api/domestic-voc/snapshot?workspaceId=demashi&platform=jd');
   });
 });
+
+describe('domestic review scope', () => {
+  const dataset = {
+    products: [
+      { productId: 'own-1', role: 'own' },
+      { productId: 'competitor-1', role: 'competitor' },
+    ],
+    reviews: [
+      { productId: 'own-1', reviewId: 'r1', rating: 5, content: '本品评论' },
+      { productId: 'competitor-1', reviewId: 'r2', rating: 2, content: '竞品评论' },
+      { productId: 'missing-1', reviewId: 'r3', rating: 3, content: '未知商品评论' },
+    ],
+  } as DomesticDataset;
+
+  it('separates own and competitor evidence by product role', () => {
+    const scope = buildDomesticReviewScope(dataset);
+
+    expect(scope.allCount).toBe(3);
+    expect(scope.ownCount).toBe(1);
+    expect(scope.competitorCount).toBe(1);
+    expect(scope.unknownCount).toBe(1);
+    expect(scope.own[0].reviewId).toBe('r1');
+    expect(scope.competitor[0].reviewId).toBe('r2');
+  });
+
+  it('uses the review array instead of the ambiguous summary count', () => {
+    const scope = buildDomesticReviewScope({
+      ...dataset,
+      reviews: undefined,
+      summary: { reviewCount: 999 },
+    } as DomesticDataset);
+
+    expect(scope.allCount).toBe(0);
+    expect(scope.ownCount).toBe(0);
+    expect(scope.competitorCount).toBe(0);
+  });
+
+  it('defaults product VOC analysis to own-product reviews only', () => {
+    expect(selectDomesticReviews(dataset).map((review) => review.reviewId)).toEqual(['r1']);
+    expect(selectDomesticReviews(dataset, 'competitor-1').map((review) => review.reviewId)).toEqual(['r2']);
+  });
+
+  it('keeps competitor products out of the own-product picker and list', () => {
+    const products = selectDomesticOwnProducts({
+      ...dataset,
+      products: [
+        { productId: 'own-low', role: 'own', summary: { gmv: 10, soldUnits: 1 } },
+        { productId: 'competitor-high', role: 'competitor', summary: { gmv: 999, soldUnits: 99 } },
+        { productId: 'own-high', role: 'own', summary: { gmv: 20, soldUnits: 2 } },
+      ],
+    } as DomesticDataset);
+
+    expect(products.map((product) => product.productId)).toEqual(['own-high', 'own-low']);
+  });
+});

+ 70 - 5
src/app/core/services/domestic-dataset.service.ts

@@ -1,10 +1,61 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
-import { Observable, map, shareReplay } from 'rxjs';
+import { BehaviorSubject, Observable, map, shareReplay, switchMap } from 'rxjs';
 import { DomesticDataset, DomesticProduct, DomesticReview } from '../models/domestic.models';
 import { ProductInfo } from '../../../modules/shared/interfaces/voc-insight-interfaces';
 import { RUNTIME_CONFIG, SaasVocRuntimeConfig } from '../config/runtime-config';
 
+export interface DomesticReviewScope {
+  all: DomesticReview[];
+  own: DomesticReview[];
+  competitor: DomesticReview[];
+  unknown: DomesticReview[];
+  allCount: number;
+  ownCount: number;
+  competitorCount: number;
+  unknownCount: number;
+}
+
+export function buildDomesticReviewScope(dataset: DomesticDataset): DomesticReviewScope {
+  const roles = new Map(dataset.products.map((product) => [product.productId, product.role] as const));
+  const all = dataset.reviews ?? [];
+  const own: DomesticReview[] = [];
+  const competitor: DomesticReview[] = [];
+  const unknown: DomesticReview[] = [];
+
+  all.forEach((review) => {
+    const role = roles.get(review.productId);
+    if (role === 'own') own.push(review);
+    else if (role === 'competitor') competitor.push(review);
+    else unknown.push(review);
+  });
+
+  return {
+    all,
+    own,
+    competitor,
+    unknown,
+    allCount: all.length,
+    ownCount: own.length,
+    competitorCount: competitor.length,
+    unknownCount: unknown.length,
+  };
+}
+
+export function selectDomesticReviews(dataset: DomesticDataset, productId = ''): DomesticReview[] {
+  const normalizedProductId = String(productId || '').trim();
+  if (normalizedProductId) {
+    return (dataset.reviews ?? []).filter((review) => review.productId === normalizedProductId);
+  }
+  return buildDomesticReviewScope(dataset).own;
+}
+
+export function selectDomesticOwnProducts(dataset: DomesticDataset): DomesticProduct[] {
+  return [...dataset.products]
+    .filter((product) => product.role === 'own')
+    .sort((left, right) => right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits);
+}
+
 export function buildDomesticDatasetUrl(
   config: Pick<
     SaasVocRuntimeConfig,
@@ -26,19 +77,33 @@ export function buildDomesticDatasetUrl(
 export class DomesticDatasetService {
   private readonly http = inject(HttpClient);
   private readonly datasetUrl = buildDomesticDatasetUrl(RUNTIME_CONFIG);
+  private readonly refreshTrigger = new BehaviorSubject<void>(undefined);
 
-  readonly dataset$: Observable<DomesticDataset> = this.http
-    .get<DomesticDataset>(this.datasetUrl)
+  readonly dataset$: Observable<DomesticDataset> = this.refreshTrigger
+    .pipe(switchMap(() => this.http.get<DomesticDataset>(this.datasetUrl)))
     .pipe(shareReplay({ bufferSize: 1, refCount: false }));
 
   readonly products$: Observable<ProductInfo[]> = this.dataset$.pipe(
-    map((dataset) => dataset.products.map((product) => this.toProductInfo(product, dataset))),
+    map((dataset) => selectDomesticOwnProducts(dataset).map((product) => this.toProductInfo(product, dataset))),
+    shareReplay({ bufferSize: 1, refCount: false }),
+  );
+
+  readonly reviewScope$: Observable<DomesticReviewScope> = this.dataset$.pipe(
+    map((dataset) => buildDomesticReviewScope(dataset)),
     shareReplay({ bufferSize: 1, refCount: false }),
   );
 
+  reviewScope(dataset: DomesticDataset): DomesticReviewScope {
+    return buildDomesticReviewScope(dataset);
+  }
+
+  refresh(): void {
+    this.refreshTrigger.next();
+  }
+
   getReviews(productId: string): Observable<DomesticReview[]> {
     return this.dataset$.pipe(
-      map((dataset) => (dataset.reviews ?? []).filter((review) => review.productId === productId)),
+      map((dataset) => selectDomesticReviews(dataset, productId)),
     );
   }
 

+ 103 - 13
src/app/core/services/domestic-monitoring-view.service.ts

@@ -6,13 +6,15 @@ import {
   DesignerRiskRankingItem,
   EmotionHealthScore,
   KeywordCloud,
+  ProductAlert,
   RiskAlertItem,
   ScenarioComplaintCluster,
   SentimentSpikeEvent,
 } from '../../../modules/shared/interfaces/monitoring-interfaces';
 import { FilterOptions } from '../../../modules/shared/interfaces/voc-data.interface';
 import { DomesticProduct } from '../models/domestic.models';
-import { DomesticDatasetService } from './domestic-dataset.service';
+import { buildDomesticReviewScope, DomesticDatasetService } from './domestic-dataset.service';
+import { DomesticReviewInsightService } from './domestic-review-insight.service';
 
 interface DomesticVocMetrics {
   vocCount: number;
@@ -33,12 +35,19 @@ interface DomesticVocMetrics {
 @Injectable({ providedIn: 'root' })
 export class DomesticMonitoringViewService {
   private readonly dataset = inject(DomesticDatasetService);
+  private readonly reviewInsights = inject(DomesticReviewInsightService);
 
   getVocMetrics(_filters?: FilterOptions): Observable<DomesticVocMetrics> {
-    return this.dataset.dataset$.pipe(map((dataset) => ({
-      vocCount: dataset.summary.reviewCount,
+    return this.dataset.dataset$.pipe(map((dataset) => {
+      const reviewScope = buildDomesticReviewScope(dataset);
+      return ({
+      vocCount: reviewScope.ownCount,
       vocTrend: 0,
-      warningCount: 0,
+      warningCount: dataset.products.filter((product) =>
+        product.role === 'own'
+        && product.summary.gmv >= 5000
+        && product.summary.refundToGmvRate >= 0.1,
+      ).length,
       todayCount: 0,
       yesterdayCount: 0,
       recentPositiveRate: 0,
@@ -46,10 +55,11 @@ export class DomesticMonitoringViewService {
       productRatingCount: 0,
       productCount: dataset.summary.metricProducts,
       avgProductRating: 0,
-      periodReviewCount: dataset.summary.reviewCount,
-      scopedReviewCount: dataset.summary.reviewCount,
+      periodReviewCount: reviewScope.ownCount,
+      scopedReviewCount: reviewScope.ownCount,
       listingReviewVolume: 0,
-    })));
+    });
+    }));
   }
 
   getEmotionHealthScore(_filters?: FilterOptions): Observable<EmotionHealthScore> {
@@ -64,11 +74,29 @@ export class DomesticMonitoringViewService {
   }
 
   getCoreIssueSummary(_filters?: FilterOptions): Observable<CoreIssueSummary[]> {
-    return of([]);
+    return this.dataset.dataset$.pipe(map((dataset) => {
+      const scope = buildDomesticReviewScope(dataset);
+      return this.reviewInsights.build(scope.competitor).topics.map((topic) => ({
+        topic: topic.label,
+        count: topic.count,
+        pct: Math.round(topic.share * 1000) / 10,
+        sentiment: 'negative' as const,
+      }));
+    }));
   }
 
   getKeywordCloud(_filters?: FilterOptions): Observable<KeywordCloud[]> {
-    return of([]);
+    return this.dataset.dataset$.pipe(map((dataset) => {
+      const scope = buildDomesticReviewScope(dataset);
+      return this.reviewInsights.build(scope.competitor).topics.map((topic) => ({
+        text: topic.label,
+        weight: topic.count,
+        count: topic.count,
+        sentiment: 'negative' as const,
+        relatedAsinCount: topic.sample?.productId ? 1 : 0,
+        sampleQuote: topic.sample?.content || '',
+      }));
+    }));
   }
 
   getDesignerRiskRanking(_filters?: FilterOptions): Observable<DesignerRiskRankingItem[]> {
@@ -76,7 +104,69 @@ export class DomesticMonitoringViewService {
   }
 
   getRiskAlertItems(_filters?: FilterOptions): Observable<RiskAlertItem[]> {
-    return of([]);
+    return this.dataset.dataset$.pipe(map((dataset) => {
+      const eligible = dataset.products.filter((product) =>
+        product.role === 'own'
+        && product.summary.gmv >= 5000
+        && product.summary.soldUnits > 0,
+      );
+      const byRate = [...eligible]
+        .filter((product) => product.summary.refundAmount > 0)
+        .sort((left, right) => right.summary.refundToGmvRate - left.summary.refundToGmvRate)
+        .slice(0, 18);
+      const byScale = [...eligible]
+        .sort((left, right) => right.summary.gmv - left.summary.gmv)
+        .slice(0, 6);
+      const candidates = [...new Map([...byRate, ...byScale].map((product) => [product.productId, product])).values()];
+
+      return candidates
+        .map((product): RiskAlertItem => {
+          const rate = product.summary.refundToGmvRate;
+          const severity: RiskAlertItem['severity'] = rate >= 0.2 ? 'critical' : rate >= 0.1 ? 'warning' : 'normal';
+          const alerts: ProductAlert[] = [{
+            type: 'bad_review',
+            severity: severity === 'critical' ? 'critical' : 'warning',
+            message: severity === 'critical'
+              ? `同期退款金额占比 ${(rate * 100).toFixed(1)}%,优先核查售后与履约记录`
+              : severity === 'warning'
+                ? `同期退款金额占比 ${(rate * 100).toFixed(1)}%,进入重点观察`
+                : `当前周期退款金额占比 ${(rate * 100).toFixed(1)}%`,
+          }];
+          return {
+            asin: product.productId,
+            title: product.title,
+            photo: product.detail?.imageUrl || '',
+            severity,
+            negativeRate: Math.round(rate * 1000) / 10,
+            avgStar: product.summary.gmv,
+            totalReviews: product.summary.refundAmount,
+            analyzableReviews: product.summary.soldUnits,
+            ratingsCount: product.summary.refundOrders,
+            negativeRateBasis: 'reviews',
+            alerts,
+            topNegativeKeyword: '',
+            productSku: product.model || product.productId,
+            sellerSku: product.productId,
+            site: dataset.platform,
+            userAccount: dataset.caseName,
+            riskKind: 'refund',
+            gmv: product.summary.gmv,
+            refundAmount: product.summary.refundAmount,
+            refundOrders: product.summary.refundOrders,
+            soldUnits: product.summary.soldUnits,
+            conversionRate: product.summary.conversionRate,
+            analysisStart: dataset.source.dateRange.start,
+            analysisEnd: dataset.source.dateRange.end,
+            evidenceBoundary: '退款金额与成交金额为同周期汇总,不代表同一订单批次的退款率。',
+          };
+        })
+        .sort((left, right) => {
+          const severityOrder = { critical: 0, warning: 1, normal: 2 } as const;
+          return severityOrder[left.severity] - severityOrder[right.severity]
+            || (right.refundAmount || 0) - (left.refundAmount || 0)
+            || right.negativeRate - left.negativeRate;
+        });
+    }));
   }
 
   getScenarioComplaints(_filters?: FilterOptions): Observable<ScenarioComplaintCluster[]> {
@@ -88,12 +178,12 @@ export class DomesticMonitoringViewService {
   }
 
   getCategoryProductRows(
-    _source: 'own' | 'competitor' = 'own',
+    source: 'own' | 'competitor' = 'own',
     _filters?: FilterOptions,
   ): Observable<Map<string, any[]>> {
     return this.dataset.dataset$.pipe(map((dataset) => {
       const groups = new Map<string, any[]>();
-      dataset.products.forEach((product) => {
+      dataset.products.filter((product) => product.role === source).forEach((product) => {
         const category = product.category2 || product.category1 || '未分类';
         const rows = groups.get(category) ?? [];
         rows.push({
@@ -116,7 +206,7 @@ export class DomesticMonitoringViewService {
   getCategoryVocDensity(_filters?: FilterOptions): Observable<CategoryVocDensityItem[]> {
     return this.dataset.dataset$.pipe(map((dataset) => {
       const groups = new Map<string, DomesticProduct[]>();
-      dataset.products.forEach((product) => {
+      dataset.products.filter((product) => product.role === 'own').forEach((product) => {
         const category = product.category2 || product.category1 || '未分类';
         groups.set(category, [...(groups.get(category) ?? []), product]);
       });

+ 73 - 0
src/app/core/services/domestic-review-insight.service.ts

@@ -0,0 +1,73 @@
+import { Injectable } from '@angular/core';
+import { DomesticReview } from '../models/domestic.models';
+import { ReviewDataUtils } from '../../../modules/shared/services/review-data-utils';
+
+export interface DomesticReviewTopicSignal {
+  id: string;
+  label: string;
+  description: string;
+  count: number;
+  share: number;
+  sample: DomesticReview | null;
+}
+
+export interface DomesticCompetitorReviewReference {
+  rawCount: number;
+  meaningfulCount: number;
+  placeholderCount: number;
+  negativeCount: number;
+  meaningful: DomesticReview[];
+  negative: DomesticReview[];
+  topics: DomesticReviewTopicSignal[];
+}
+
+interface TopicRule {
+  id: string;
+  label: string;
+  description: string;
+  pattern: RegExp;
+}
+
+@Injectable({ providedIn: 'root' })
+export class DomesticReviewInsightService {
+  private readonly rules: TopicRule[] = [
+    { id: 'performance', label: '功率与加热表现', description: '功率标称、加热速度、断电或火力体验', pattern: /功率|瓦|加热|火力|断电|跳闸|煮得久|豆子还是生/i },
+    { id: 'quality', label: '质量与耐用性', description: '质量、损坏、松动或短期失效', pattern: /质量|垃圾|烂|差劲|损坏|坏了|松动|不行|黑心/i },
+    { id: 'service', label: '售后与保修体验', description: '客服响应、补偿、维修和保修成本', pattern: /客服|售后|服务态度|补偿|道歉|保修|维修|运费|处理问题/i },
+    { id: 'safety', label: '安全与密封风险', description: '漏油、漏电、裸露线头或着火隐患', pattern: /漏油|漏电|油缸|穿窿|着火|线头裸|安全/i },
+    { id: 'usability', label: '易用与清洁负担', description: '清洗、体积、操作和产出体验', pattern: /清洗|笨重|不好用|麻烦|不实用|份量少|稀/i },
+    { id: 'noise', label: '噪声与经营干扰', description: '设备噪声及其对门店顾客的影响', pattern: /声音|噪音|轰轰|顾客影响/i },
+  ];
+
+  build(reviews: DomesticReview[] | null | undefined): DomesticCompetitorReviewReference {
+    const raw = reviews ?? [];
+    const meaningful = ReviewDataUtils.filterMeaningfulReviews(raw);
+    const negative = meaningful
+      .filter((review) => ReviewDataUtils.classifySentiment(review.rating) === 'negative')
+      .sort((left, right) => ReviewDataUtils.parseReviewDate(right) - ReviewDataUtils.parseReviewDate(left));
+    const topics = this.rules
+      .map((rule): DomesticReviewTopicSignal => {
+        const matched = negative.filter((review) => rule.pattern.test(review.content || ''));
+        return {
+          id: rule.id,
+          label: rule.label,
+          description: rule.description,
+          count: matched.length,
+          share: negative.length ? matched.length / negative.length : 0,
+          sample: matched[0] ?? null,
+        };
+      })
+      .filter((topic) => topic.count > 0)
+      .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, 'zh-CN'));
+
+    return {
+      rawCount: raw.length,
+      meaningfulCount: meaningful.length,
+      placeholderCount: Math.max(0, raw.length - meaningful.length),
+      negativeCount: negative.length,
+      meaningful,
+      negative,
+      topics,
+    };
+  }
+}

+ 170 - 0
src/app/core/services/saas-platform.service.spec.ts

@@ -0,0 +1,170 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import {
+  CursorPage,
+  SaasAnalysisRun,
+  SaasInsightDecision,
+  SaasPlatformService,
+} from './saas-platform.service';
+
+describe('SaasPlatformService', () => {
+  let service: SaasPlatformService;
+  let http: HttpTestingController;
+
+  const workspaceId = 'workspace/alpha';
+  const workspaceUrl = '/api/saas/workspaces/workspace%2Falpha';
+  const analysis: SaasAnalysisRun = {
+    id: 'analysis/001',
+    workspaceId,
+    analysisType: 'voc_insight',
+    targetKind: 'workspace',
+    targetKey: workspaceId,
+    status: 'completed',
+    input: { reviewCount: 40 },
+    result: { insightCount: 9 },
+    evidenceCount: 15,
+    requestedBy: 'user-001',
+    errorSummary: null,
+    requestedAt: '2026-08-06T08:00:00.000Z',
+    startedAt: '2026-08-06T08:00:01.000Z',
+    completedAt: '2026-08-06T08:00:05.000Z',
+  };
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+    });
+
+    service = TestBed.inject(SaasPlatformService);
+    http = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    http.verify();
+  });
+
+  it('gets analysis runs with paging filters', () => {
+    const response: CursorPage<SaasAnalysisRun> = { items: [analysis], nextCursor: 'cursor-next' };
+    let actual: typeof response | undefined;
+
+    service.analyses(workspaceId, 'completed', 'cursor/current', 10).subscribe((value) => {
+      actual = value;
+    });
+
+    const request = http.expectOne((candidate) => candidate.url === `${workspaceUrl}/analyses`);
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('status')).toBe('completed');
+    expect(request.request.params.get('cursor')).toBe('cursor/current');
+    expect(request.request.params.get('limit')).toBe('10');
+    request.flush(response);
+
+    expect(actual).toEqual(response);
+  });
+
+  it('creates a VOC insight analysis run', () => {
+    const payload = {
+      analysisType: 'voc_insight' as const,
+      targetKind: 'workspace' as const,
+      targetKey: workspaceId,
+      input: { reviewIds: ['review-001', 'review-002'] },
+    };
+    const response = { analysis };
+    let actual: typeof response | undefined;
+
+    service.createAnalysis(workspaceId, payload).subscribe((value) => {
+      actual = value;
+    });
+
+    const request = http.expectOne(`${workspaceUrl}/analyses`);
+    expect(request.request.method).toBe('POST');
+    expect(request.request.body).toEqual(payload);
+    request.flush(response);
+
+    expect(actual).toEqual(response);
+  });
+
+  it('updates an analysis run using an encoded analysis id', () => {
+    const patch = {
+      status: 'completed' as const,
+      result: { insightCount: 9, evidenceCoverage: 0.38 },
+      evidenceCount: 15,
+      errorSummary: null,
+      completedAt: '2026-08-06T08:00:05.000Z',
+    };
+    const response = { analysis };
+    let actual: typeof response | undefined;
+
+    service.updateAnalysis(workspaceId, analysis.id, patch).subscribe((value) => {
+      actual = value;
+    });
+
+    const request = http.expectOne(`${workspaceUrl}/analyses/analysis%2F001`);
+    expect(request.request.method).toBe('PATCH');
+    expect(request.request.body).toEqual(patch);
+    request.flush(response);
+
+    expect(actual).toEqual(response);
+  });
+
+  it('creates an action with its source insight, evidence, and validation metric', () => {
+    const payload = {
+      title: 'Resolve recurring sealing failures',
+      description: 'Validate the proposed packaging change against VOC evidence.',
+      priority: 'high' as const,
+      sourceAnalysisId: analysis.id,
+      sourceInsightId: 'insight-009',
+      sourceDecisionId: 'decision-009',
+      sourceKind: 'insight' as const,
+      creationKey: 'workspace/alpha|analysis/001|insight-009|decision-009',
+      evidenceIds: ['review-001', 'review-002'],
+      validationMetric: 'negative_review_rate decreases from 24% to 12% within 30 days',
+    };
+
+    service.createAction(workspaceId, payload).subscribe();
+
+    const request = http.expectOne(`${workspaceUrl}/actions`);
+    expect(request.request.method).toBe('POST');
+    expect(request.request.body).toEqual(payload);
+    expect(request.request.body.sourceAnalysisId).toBe(analysis.id);
+    expect(request.request.body.sourceInsightId).toBe('insight-009');
+    expect(request.request.body.sourceDecisionId).toBe('decision-009');
+    expect(request.request.body.sourceKind).toBe('insight');
+    expect(request.request.body.creationKey).toContain('decision-009');
+    expect(request.request.body.evidenceIds).toEqual(['review-001', 'review-002']);
+    expect(request.request.body.validationMetric).toBe(
+      'negative_review_rate decreases from 24% to 12% within 30 days',
+    );
+    request.flush({ action: {} });
+  });
+
+  it('lists current insight decisions for an analysis', () => {
+    const response: CursorPage<SaasInsightDecision> = { items: [], nextCursor: null };
+
+    service.insightDecisions(workspaceId, analysis.id, 'insight-009', true).subscribe();
+
+    const request = http.expectOne((candidate) => candidate.url === `${workspaceUrl}/insight-decisions`);
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('analysisId')).toBe(analysis.id);
+    expect(request.request.params.get('insightId')).toBe('insight-009');
+    expect(request.request.params.get('currentOnly')).toBe('true');
+    expect(request.request.params.get('limit')).toBe('100');
+    request.flush(response);
+  });
+
+  it('creates a grounded insight decision', () => {
+    const payload = {
+      sourceAnalysisId: analysis.id,
+      sourceInsightId: 'insight-009',
+      decision: 'confirmed' as const,
+      reviewedEvidenceIds: ['review-001', 'review-002'],
+      comment: 'Evidence is consistent across the reviewed samples.',
+    };
+
+    service.createInsightDecision(workspaceId, payload).subscribe();
+
+    const request = http.expectOne(`${workspaceUrl}/insight-decisions`);
+    expect(request.request.method).toBe('POST');
+    expect(request.request.body).toEqual(payload);
+    request.flush({ decision: {} });
+  });
+});

+ 91 - 0
src/app/core/services/saas-platform.service.ts

@@ -29,9 +29,31 @@ export interface CursorPage<T> {
   nextCursor: string | null;
 }
 
+export type SaasAnalysisStatus = 'pending' | 'processing' | 'completed' | 'partial' | 'failed' | 'cancelled';
+
+export interface SaasAnalysisRun {
+  id: string;
+  workspaceId: string;
+  analysisType: 'voice' | 'pain_point' | 'feature' | 'scenario' | 'risk' | 'report' | 'voc_insight';
+  targetKind: 'workspace' | 'category' | 'product';
+  targetKey: string;
+  status: SaasAnalysisStatus;
+  input: Record<string, unknown>;
+  result: Record<string, unknown> | null;
+  evidenceCount: number;
+  requestedBy: string;
+  errorSummary: string | null;
+  requestedAt: string;
+  startedAt: string | null;
+  completedAt: string | null;
+}
+
 export interface SaasActionItem {
   id: string;
   workspaceId: string;
+  sourceDecisionId?: string | null;
+  sourceKind?: 'insight' | 'raw_feedback' | 'rule_action';
+  creationKey?: string | null;
   actionType: 'general' | 'experience' | 'product' | 'strategy' | 'data_quality';
   title: string;
   description: string;
@@ -40,6 +62,29 @@ export interface SaasActionItem {
   productKey: string | null;
   assigneeUserId: string | null;
   dueAt: string | null;
+  sourceAnalysisId?: string | null;
+  sourceInsightId?: string | null;
+  evidenceIds?: string[];
+  validationMetric?: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export type SaasInsightDecisionType = 'confirmed' | 'rejected' | 'needs_more_evidence';
+
+export interface SaasInsightDecision {
+  id: string;
+  workspaceId: string;
+  sourceAnalysisId: string;
+  sourceInsightId: string;
+  decision: SaasInsightDecisionType;
+  reviewedEvidenceIds: string[];
+  comment: string;
+  decidedBy: string;
+  decidedAt: string;
+  version: number;
+  supersedesId: string | null;
+  isCurrent: boolean;
   createdAt: string;
   updatedAt: string;
 }
@@ -71,6 +116,52 @@ export class SaasPlatformService {
     });
   }
 
+  analyses(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<SaasAnalysisRun>> {
+    return this.http.get<CursorPage<SaasAnalysisRun>>(`${this.workspaceUrl(workspaceId)}/analyses`, {
+      params: this.pageParams(status, cursor, limit),
+    });
+  }
+
+  createAnalysis(
+    workspaceId: string,
+    analysis: Pick<SaasAnalysisRun, 'analysisType' | 'targetKind' | 'targetKey' | 'input'>,
+  ): Observable<{ analysis: SaasAnalysisRun }> {
+    return this.http.post<{ analysis: SaasAnalysisRun }>(`${this.workspaceUrl(workspaceId)}/analyses`, analysis);
+  }
+
+  updateAnalysis(
+    workspaceId: string,
+    analysisId: string,
+    patch: Partial<Pick<SaasAnalysisRun, 'status' | 'result' | 'evidenceCount' | 'errorSummary' | 'startedAt' | 'completedAt'>>,
+  ): Observable<{ analysis: SaasAnalysisRun }> {
+    return this.http.patch<{ analysis: SaasAnalysisRun }>(`${this.workspaceUrl(workspaceId)}/analyses/${encodeURIComponent(analysisId)}`, patch);
+  }
+
+  insightDecisions(
+    workspaceId: string,
+    analysisId = '',
+    insightId = '',
+    currentOnly = true,
+    cursor = '',
+    limit = 100,
+  ): Observable<CursorPage<SaasInsightDecision>> {
+    let params = new HttpParams().set('limit', limit).set('currentOnly', currentOnly);
+    if (analysisId) params = params.set('analysisId', analysisId);
+    if (insightId) params = params.set('insightId', insightId);
+    if (cursor) params = params.set('cursor', cursor);
+    return this.http.get<CursorPage<SaasInsightDecision>>(`${this.workspaceUrl(workspaceId)}/insight-decisions`, { params });
+  }
+
+  createInsightDecision(
+    workspaceId: string,
+    decision: Pick<SaasInsightDecision, 'sourceAnalysisId' | 'sourceInsightId' | 'decision' | 'reviewedEvidenceIds' | 'comment'>,
+  ): Observable<{ decision: SaasInsightDecision }> {
+    return this.http.post<{ decision: SaasInsightDecision }>(
+      `${this.workspaceUrl(workspaceId)}/insight-decisions`,
+      decision,
+    );
+  }
+
   createAction(
     workspaceId: string,
     action: Pick<SaasActionItem, 'title'> & Partial<Omit<SaasActionItem, 'id' | 'workspaceId' | 'title' | 'createdAt' | 'updatedAt'>>,

+ 111 - 0
src/app/core/services/workbench-context.service.spec.ts

@@ -0,0 +1,111 @@
+import { Component } from '@angular/core';
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Router, provideRouter } from '@angular/router';
+import { WorkbenchContextService } from './workbench-context.service';
+
+@Component({
+  standalone: true,
+  template: '',
+})
+class TestRouteComponent {}
+
+describe('WorkbenchContextService', () => {
+  let router: Router;
+  let service: WorkbenchContextService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        provideRouter([
+          { path: 'feedback-inbox', component: TestRouteComponent },
+          { path: '**', component: TestRouteComponent },
+        ]),
+      ],
+    });
+
+    router = TestBed.inject(Router);
+    service = TestBed.inject(WorkbenchContextService);
+  });
+
+  it('hydrates every supported context dimension from the URL', fakeAsync(() => {
+    void router.navigateByUrl(
+      '/feedback-inbox?workspaceId=workspace-1&platform=jd&shopId=shop-1&categoryId=cat-1' +
+      '&productId=sku-1&dateFrom=2026-07-01&dateTo=2026-07-31&comparePeriod=previous-year',
+    );
+    tick();
+
+    expect(service.snapshot).toEqual({
+      workspaceId: 'workspace-1',
+      platform: 'jd',
+      shopId: 'shop-1',
+      categoryId: 'cat-1',
+      productId: 'sku-1',
+      dateFrom: '2026-07-01',
+      dateTo: '2026-07-31',
+      comparisonPeriod: 'previous-year',
+    });
+  }));
+
+  it('updates context params without discarding unrelated page state', fakeAsync(() => {
+    void router.navigateByUrl('/feedback-inbox?view=unread&page=3');
+    tick();
+
+    service.updateContext({ workspaceId: 'workspace-1', platform: 'jd', shopId: 'shop-1' });
+    tick();
+
+    const params = router.parseUrl(router.url).queryParams;
+    expect(params['view']).toBe('unread');
+    expect(params['page']).toBe('3');
+    expect(params['workspaceId']).toBe('workspace-1');
+    expect(params['platform']).toBe('jd');
+    expect(params['shopId']).toBe('shop-1');
+  }));
+
+  it('clears operational filters while retaining workspace and platform scope', fakeAsync(() => {
+    void router.navigateByUrl(
+      '/feedback-inbox?workspaceId=workspace-1&platform=jd&shopId=shop-1&categoryId=cat-1' +
+      '&productId=sku-1&dateFrom=2026-07-01&dateTo=2026-07-31&comparePeriod=previous-period',
+    );
+    tick();
+
+    service.clearFilters();
+    tick();
+
+    expect(service.snapshot.workspaceId).toBe('workspace-1');
+    expect(service.snapshot.platform).toBe('jd');
+    expect(service.hasActiveFilters()).toBeFalse();
+    const params = router.parseUrl(router.url).queryParams;
+    expect(params['shopId']).toBeUndefined();
+    expect(params['categoryId']).toBeUndefined();
+    expect(params['productId']).toBeUndefined();
+    expect(params['dateFrom']).toBeUndefined();
+    expect(params['dateTo']).toBeUndefined();
+    expect(params['comparePeriod']).toBeUndefined();
+  }));
+
+  it('ignores incomplete or invalid URL date ranges', fakeAsync(() => {
+    void router.navigateByUrl(
+      '/feedback-inbox?dateFrom=2026-07-31&dateTo=2026-07-01&comparePeriod=unsupported',
+    );
+    tick();
+
+    expect(service.snapshot.dateFrom).toBe('');
+    expect(service.snapshot.dateTo).toBe('');
+    expect(service.snapshot.comparisonPeriod).toBe('none');
+  }));
+
+  it('publishes refresh requests and caller-supplied availability metadata', () => {
+    let refreshCount = 0;
+    service.refreshRequested$.subscribe(() => refreshCount++);
+
+    service.requestRefresh();
+    service.setAvailability('stale', '2026-08-07T02:30:00+08:00', '上游批次超过 SLA');
+
+    expect(refreshCount).toBe(1);
+    expect(service.availabilitySnapshot).toEqual({
+      status: 'stale',
+      lastUpdated: '2026-08-06T18:30:00.000Z',
+      detail: '上游批次超过 SLA',
+    });
+  });
+});

+ 248 - 0
src/app/core/services/workbench-context.service.ts

@@ -0,0 +1,248 @@
+import { DestroyRef, Injectable, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { NavigationEnd, Params, Router } from '@angular/router';
+import { BehaviorSubject, Subject, distinctUntilChanged, filter, map } from 'rxjs';
+
+export type WorkbenchAvailabilityStatus = 'ready' | 'stale' | 'partial' | 'error';
+export type WorkbenchComparisonPeriod = 'none' | 'previous-period' | 'previous-year';
+
+export interface WorkbenchContext {
+  workspaceId: string;
+  platform: string;
+  shopId: string;
+  categoryId: string;
+  productId: string;
+  dateFrom: string;
+  dateTo: string;
+  comparisonPeriod: WorkbenchComparisonPeriod;
+}
+
+export interface WorkbenchAvailability {
+  status: WorkbenchAvailabilityStatus | null;
+  lastUpdated: string | null;
+  detail: string;
+}
+
+export interface WorkbenchContextOption {
+  value: string;
+  label: string;
+  disabled?: boolean;
+}
+
+export const WORKBENCH_QUERY_PARAM_KEYS = {
+  workspaceId: 'workspaceId',
+  platform: 'platform',
+  shopId: 'shopId',
+  categoryId: 'categoryId',
+  productId: 'productId',
+  dateFrom: 'dateFrom',
+  dateTo: 'dateTo',
+  comparisonPeriod: 'comparePeriod',
+} as const;
+
+const EMPTY_CONTEXT: WorkbenchContext = {
+  workspaceId: '',
+  platform: '',
+  shopId: '',
+  categoryId: '',
+  productId: '',
+  dateFrom: '',
+  dateTo: '',
+  comparisonPeriod: 'none',
+};
+
+const EMPTY_AVAILABILITY: WorkbenchAvailability = {
+  status: null,
+  lastUpdated: null,
+  detail: '',
+};
+
+const COMPARISON_PERIODS = new Set<WorkbenchComparisonPeriod>([
+  'none',
+  'previous-period',
+  'previous-year',
+]);
+
+@Injectable({ providedIn: 'root' })
+export class WorkbenchContextService {
+  private readonly router = inject(Router);
+  private readonly destroyRef = inject(DestroyRef);
+  private readonly contextSubject = new BehaviorSubject<WorkbenchContext>(EMPTY_CONTEXT);
+  private readonly availabilitySubject = new BehaviorSubject<WorkbenchAvailability>(EMPTY_AVAILABILITY);
+  private readonly refreshSubject = new Subject<void>();
+
+  readonly context$ = this.contextSubject.asObservable().pipe(
+    distinctUntilChanged((previous, current) => this.contextEquals(previous, current)),
+  );
+  readonly availability$ = this.availabilitySubject.asObservable().pipe(
+    distinctUntilChanged((previous, current) =>
+      previous.status === current.status &&
+      previous.lastUpdated === current.lastUpdated &&
+      previous.detail === current.detail,
+    ),
+  );
+  readonly refreshRequested$ = this.refreshSubject.asObservable();
+  readonly hasActiveFilters$ = this.context$.pipe(map((context) => this.hasActiveFilters(context)));
+
+  constructor() {
+    this.readContextFromUrl();
+    this.router.events.pipe(
+      filter((event): event is NavigationEnd => event instanceof NavigationEnd),
+      takeUntilDestroyed(this.destroyRef),
+    ).subscribe(() => this.readContextFromUrl());
+  }
+
+  get snapshot(): WorkbenchContext {
+    return this.contextSubject.value;
+  }
+
+  get availabilitySnapshot(): WorkbenchAvailability {
+    return this.availabilitySubject.value;
+  }
+
+  updateContext(patch: Partial<WorkbenchContext>, replaceUrl = true): void {
+    const context = this.normalizeContext({ ...this.snapshot, ...patch });
+    if (this.contextEquals(context, this.snapshot)) return;
+
+    this.contextSubject.next(context);
+    this.writeContextToUrl(context, replaceUrl);
+  }
+
+  clearFilters(replaceUrl = true): void {
+    this.updateContext({
+      shopId: '',
+      categoryId: '',
+      productId: '',
+      dateFrom: '',
+      dateTo: '',
+      comparisonPeriod: 'none',
+    }, replaceUrl);
+  }
+
+  requestRefresh(): void {
+    this.refreshSubject.next();
+  }
+
+  setAvailability(
+    status: WorkbenchAvailabilityStatus | null,
+    lastUpdated: string | Date | null,
+    detail = '',
+  ): void {
+    this.availabilitySubject.next({
+      status,
+      lastUpdated: this.normalizeTimestamp(lastUpdated),
+      detail: detail.trim(),
+    });
+  }
+
+  hasActiveFilters(context: WorkbenchContext = this.snapshot): boolean {
+    return Boolean(
+      context.shopId ||
+      context.categoryId ||
+      context.productId ||
+      context.dateFrom ||
+      context.dateTo ||
+      context.comparisonPeriod !== 'none'
+    );
+  }
+
+  private readContextFromUrl(): void {
+    const queryParams = this.router.parseUrl(this.router.url).queryParams;
+    const context = this.normalizeContext({
+      workspaceId: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.workspaceId),
+      platform: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.platform),
+      shopId: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.shopId),
+      categoryId: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.categoryId),
+      productId: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.productId),
+      dateFrom: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.dateFrom),
+      dateTo: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.dateTo),
+      comparisonPeriod: this.readParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.comparisonPeriod),
+    });
+
+    if (!this.contextEquals(context, this.snapshot)) this.contextSubject.next(context);
+  }
+
+  private writeContextToUrl(context: WorkbenchContext, replaceUrl: boolean): void {
+    const urlTree = this.router.parseUrl(this.router.url);
+    const queryParams: Params = { ...urlTree.queryParams };
+
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.workspaceId, context.workspaceId);
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.platform, context.platform);
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.shopId, context.shopId);
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.categoryId, context.categoryId);
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.productId, context.productId);
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.dateFrom, context.dateFrom);
+    this.assignParam(queryParams, WORKBENCH_QUERY_PARAM_KEYS.dateTo, context.dateTo);
+    this.assignParam(
+      queryParams,
+      WORKBENCH_QUERY_PARAM_KEYS.comparisonPeriod,
+      context.comparisonPeriod === 'none' ? '' : context.comparisonPeriod,
+    );
+
+    urlTree.queryParams = queryParams;
+    void this.router.navigateByUrl(urlTree, { replaceUrl });
+  }
+
+  private normalizeContext(value: Partial<Record<keyof WorkbenchContext, unknown>>): WorkbenchContext {
+    const dateFrom = this.normalizeDate(value.dateFrom);
+    const dateTo = this.normalizeDate(value.dateTo);
+    const hasCompleteRange = Boolean(dateFrom && dateTo && dateFrom <= dateTo);
+
+    return {
+      workspaceId: this.normalizeId(value.workspaceId),
+      platform: this.normalizeId(value.platform),
+      shopId: this.normalizeId(value.shopId),
+      categoryId: this.normalizeId(value.categoryId),
+      productId: this.normalizeId(value.productId),
+      dateFrom: hasCompleteRange ? dateFrom : '',
+      dateTo: hasCompleteRange ? dateTo : '',
+      comparisonPeriod: this.normalizeComparison(value.comparisonPeriod),
+    };
+  }
+
+  private normalizeId(value: unknown): string {
+    return typeof value === 'string' ? value.trim() : '';
+  }
+
+  private normalizeDate(value: unknown): string {
+    if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return '';
+    const [year, month, day] = value.split('-').map(Number);
+    const date = new Date(year, month - 1, day);
+    return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
+      ? value
+      : '';
+  }
+
+  private normalizeComparison(value: unknown): WorkbenchComparisonPeriod {
+    return typeof value === 'string' && COMPARISON_PERIODS.has(value as WorkbenchComparisonPeriod)
+      ? value as WorkbenchComparisonPeriod
+      : 'none';
+  }
+
+  private normalizeTimestamp(value: string | Date | null): string | null {
+    if (!value) return null;
+    const date = value instanceof Date ? value : new Date(value);
+    return Number.isNaN(date.getTime()) ? null : date.toISOString();
+  }
+
+  private readParam(params: Params, key: string): string {
+    const value = params[key];
+    return Array.isArray(value) ? String(value[0] ?? '') : String(value ?? '');
+  }
+
+  private assignParam(params: Params, key: string, value: string): void {
+    if (value) params[key] = value;
+    else delete params[key];
+  }
+
+  private contextEquals(left: WorkbenchContext, right: WorkbenchContext): boolean {
+    return left.workspaceId === right.workspaceId &&
+      left.platform === right.platform &&
+      left.shopId === right.shopId &&
+      left.categoryId === right.categoryId &&
+      left.productId === right.productId &&
+      left.dateFrom === right.dateFrom &&
+      left.dateTo === right.dateTo &&
+      left.comparisonPeriod === right.comparisonPeriod;
+  }
+}

+ 535 - 0
src/modules/ai-voc-insight/ai-voc-insight.component.html

@@ -0,0 +1,535 @@
+<section class="ai-voc-page">
+  <header class="page-header">
+    <div class="title-group">
+      <span class="title-icon" aria-hidden="true">
+        <lucide-icon [img]="icons.brain" [size]="20" [strokeWidth]="1.8"></lucide-icon>
+      </span>
+      <div>
+        <h1>AI VOC 洞察工作台</h1>
+        <p>以真实评价证据生成可审计、可验证、可执行的用户洞察</p>
+      </div>
+    </div>
+
+    <div class="gateway-status" [attr.data-state]="gatewayState">
+      <span class="gateway-icon" aria-hidden="true">
+        @if (gatewayState === 'loading') {
+          <lucide-icon class="spin" [img]="icons.loader" [size]="16" [strokeWidth]="2"></lucide-icon>
+        } @else if (gatewayState === 'ready') {
+          <lucide-icon [img]="icons.wifi" [size]="16" [strokeWidth]="2"></lucide-icon>
+        } @else {
+          <lucide-icon [img]="icons.wifiOff" [size]="16" [strokeWidth]="2"></lucide-icon>
+        }
+      </span>
+      <span class="gateway-copy">
+        <strong>{{ gatewayLabel }}</strong>
+        <small>{{ gatewayStatus?.defaultModel || gatewayError || '等待网关配置' }}</small>
+      </span>
+      @if (gatewayState === 'error') {
+        <button type="button" class="icon-button" title="重新检测网关" aria-label="重新检测 AI 网关" (click)="loadGatewayStatus()">
+          <lucide-icon [img]="icons.refresh" [size]="15" [strokeWidth]="2"></lucide-icon>
+        </button>
+      }
+    </div>
+  </header>
+
+  @if (persistenceNotice || analysisHistoryError) {
+    <section
+      class="persistence-banner"
+      [class.is-error]="!!analysisHistoryError"
+      [attr.role]="analysisHistoryError ? 'alert' : 'status'"
+      aria-live="polite">
+      <lucide-icon [img]="analysisHistoryError ? icons.alert : icons.check" [size]="14" [strokeWidth]="2"></lucide-icon>
+      <span>{{ analysisHistoryError || persistenceNotice }}</span>
+      @if (analysisHistoryError) {
+        <button type="button" class="text-button" (click)="loadAnalysisHistory()">重新加载历史</button>
+      }
+    </section>
+  }
+
+  @if (datasetLoading) {
+    <section class="full-state" aria-live="polite">
+      <lucide-icon class="spin" [img]="icons.loader" [size]="24" [strokeWidth]="1.8"></lucide-icon>
+      <h2>正在准备 VOC 证据</h2>
+      <p>加载评价、商品归属与主题信号。</p>
+    </section>
+  } @else if (datasetError) {
+    <section class="full-state state-error" role="alert">
+      <lucide-icon [img]="icons.circleAlert" [size]="24" [strokeWidth]="1.8"></lucide-icon>
+      <h2>数据准备失败</h2>
+      <p>{{ datasetError }}</p>
+      <button type="button" class="secondary-button" (click)="reloadDataset()">
+        <lucide-icon [img]="icons.refresh" [size]="16" [strokeWidth]="2"></lucide-icon>
+        重新加载
+      </button>
+    </section>
+  } @else if (context) {
+    <div class="workbench">
+      <aside class="evidence-rail" aria-label="洞察输入与证据概览">
+        <section class="rail-section filter-section">
+          <div class="section-heading">
+            <div>
+              <span class="section-kicker">分析任务</span>
+              <h2>证据筛选</h2>
+            </div>
+            <lucide-icon [img]="icons.gauge" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+          </div>
+
+          <div class="filter-group">
+            <span class="filter-label">分析范围</span>
+            <div class="segmented-control" role="group" aria-label="分析范围">
+              @for (option of scopeOptions; track option.value) {
+                <button
+                  type="button"
+                  [class.is-active]="filters.scope === option.value"
+                  [attr.aria-pressed]="filters.scope === option.value"
+                  (click)="filters.scope = option.value; onFiltersChanged()">
+                  {{ option.label }}
+                </button>
+              }
+            </div>
+          </div>
+
+          <label class="select-field">
+            <span>情感倾向</span>
+            <select [(ngModel)]="filters.sentiment" (ngModelChange)="onFiltersChanged()">
+              @for (option of sentimentOptions; track option.value) {
+                <option [ngValue]="option.value">{{ option.label }}</option>
+              }
+            </select>
+          </label>
+
+          <div class="filter-group">
+            <span class="filter-label">输入样本数</span>
+            <div class="sample-options" role="group" aria-label="输入样本数">
+              @for (limit of sampleLimits; track limit) {
+                <button
+                  type="button"
+                  [class.is-active]="filters.sampleLimit === limit"
+                  [attr.aria-pressed]="filters.sampleLimit === limit"
+                  (click)="filters.sampleLimit = limit; onFiltersChanged()">
+                  {{ limit }}
+                </button>
+              }
+            </div>
+          </div>
+
+          <button type="button" class="generate-button" [disabled]="!canGenerate" (click)="generate()">
+            @if (generation.loading) {
+              <lucide-icon class="spin" [img]="icons.loader" [size]="17" [strokeWidth]="2"></lucide-icon>
+              <span>生成中</span>
+            } @else {
+              <lucide-icon [img]="icons.sparkles" [size]="17" [strokeWidth]="2"></lucide-icon>
+              <span>{{ generateButtonLabel }}</span>
+            }
+          </button>
+          @if (!gatewayLoading && !gatewayStatus?.configured) {
+            <p class="button-hint">当前使用可追溯规则摘要;配置 AI 网关后可生成语义洞察。</p>
+          }
+        </section>
+
+        <section class="rail-section coverage-section">
+          <div class="section-heading compact-heading">
+            <div>
+              <span class="section-kicker">输入审计</span>
+              <h2>证据覆盖</h2>
+            </div>
+            <lucide-icon [img]="icons.database" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+          </div>
+          <dl class="coverage-grid">
+            <div>
+              <dt>有效评价</dt>
+              <dd>{{ preparedContext.totalMeaningfulCount }}</dd>
+            </div>
+            <div>
+              <dt>筛选命中</dt>
+              <dd>{{ preparedContext.matchingCount }}</dd>
+            </div>
+            <div>
+              <dt>本次输入</dt>
+              <dd>{{ preparedContext.evidence.length }}</dd>
+            </div>
+            <div>
+              <dt>主题数量</dt>
+              <dd>{{ preparedContext.topics.length }}</dd>
+            </div>
+          </dl>
+          <div class="source-summary">
+            <span><i class="source-own"></i>本品 {{ preparedContext.sourceCounts.own || 0 }}</span>
+            <span><i class="source-competitor"></i>竞品 {{ preparedContext.sourceCounts.competitor || 0 }}</span>
+            <span><i class="source-unknown"></i>待确认 {{ preparedContext.sourceCounts.unknown || 0 }}</span>
+          </div>
+        </section>
+
+        <section class="rail-section topic-section">
+          <div class="section-heading compact-heading">
+            <div>
+              <span class="section-kicker">样本构成</span>
+              <h2>主题分布</h2>
+            </div>
+            <lucide-icon [img]="icons.chart" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+          </div>
+          @if (preparedContext.topics.length) {
+            <div class="topic-list">
+              @for (topic of preparedContext.topics; track topic.id) {
+                <div class="topic-row">
+                  <div class="topic-label">
+                    <span>{{ topic.label }}</span>
+                    <strong>{{ topic.count }}</strong>
+                  </div>
+                  <div class="topic-track" aria-hidden="true">
+                    <span [style.width.%]="topic.share * 100"></span>
+                  </div>
+                </div>
+              }
+            </div>
+          } @else {
+            <p class="rail-empty">当前筛选范围没有可分析主题。</p>
+          }
+        </section>
+
+        <section class="rail-section evidence-section">
+          <div class="section-heading compact-heading">
+            <div>
+              <span class="section-kicker">原始样本</span>
+              <h2>输入证据</h2>
+            </div>
+            <span class="section-count">{{ preparedContext.evidence.length }}</span>
+          </div>
+          <div class="evidence-list">
+            @for (evidence of preparedContext.evidence.slice(0, 8); track evidence.id) {
+              <a
+                class="evidence-preview"
+                [routerLink]="['/feedback-inbox']"
+                [queryParams]="{ reviewId: evidence.id }"
+                [attr.data-sentiment]="evidence.sentiment">
+                <span class="evidence-preview-meta">
+                  <strong>{{ evidence.topicLabel }}</strong>
+                  <em>{{ sentimentLabel(evidence.sentiment) }}</em>
+                </span>
+                <span class="evidence-preview-copy">{{ evidence.review.content }}</span>
+                <small>{{ evidence.sourceLabel }} · {{ evidence.review.rating || '-' }} 星</small>
+              </a>
+            }
+          </div>
+        </section>
+
+        <section class="rail-section history-section">
+          <div class="section-heading compact-heading">
+            <div>
+              <span class="section-kicker">持久化记录</span>
+              <h2>分析历史</h2>
+            </div>
+            <lucide-icon [img]="icons.activity" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+          </div>
+
+          @if (analysisHistoryLoading) {
+            <div class="rail-loading">
+              <lucide-icon class="spin" [img]="icons.loader" [size]="16" [strokeWidth]="2"></lucide-icon>
+              <span>正在加载最近分析</span>
+            </div>
+          } @else if (analysisHistory.length) {
+            <div class="history-list">
+              @for (run of analysisHistory; track run.id) {
+                <button
+                  type="button"
+                  class="history-card"
+                  [class.is-active]="isHistoryActive(run)"
+                  [disabled]="!historyCanRestore(run)"
+                  (click)="historyCanRestore(run) && restoreHistoryRun(run)">
+                  <span class="history-card-top">
+                    <strong>{{ historyStatusLabel(run) }}</strong>
+                    <em>{{ formatDate(run.requestedAt) }}</em>
+                  </span>
+                  <span class="history-card-title">{{ historyLabel(run) }}</span>
+                  <small>{{ historySummary(run) }}</small>
+                  @if (historyCanRestore(run)) {
+                    <span class="history-card-action">点击恢复结果</span>
+                  } @else {
+                    <span class="history-card-action muted">仅可查看记录</span>
+                  }
+                </button>
+              }
+            </div>
+          } @else {
+            <p class="rail-empty">暂无 VOC 分析历史,生成一次结果后会自动保存。</p>
+          }
+        </section>
+      </aside>
+
+      <main class="insight-main">
+        @if (generation.loading) {
+          <section class="generation-state" aria-live="polite">
+            <span class="state-visual">
+              <lucide-icon class="spin" [img]="icons.brain" [size]="25" [strokeWidth]="1.7"></lucide-icon>
+            </span>
+            <h2>AI 正在分析 {{ preparedContext.evidence.length }} 条评价证据</h2>
+            <p>{{ generationProgress }}</p>
+            <div class="progress-line" aria-hidden="true"><span></span></div>
+            <div class="analysis-steps" aria-hidden="true">
+              <span class="is-complete">证据归类</span>
+              <span class="is-active">需求推断</span>
+              <span>机会校验</span>
+            </div>
+          </section>
+        } @else if (generation.error) {
+          <section class="generation-state error-state" role="alert">
+            <span class="state-visual">
+              <lucide-icon [img]="icons.circleAlert" [size]="25" [strokeWidth]="1.8"></lucide-icon>
+            </span>
+            <h2>洞察生成未完成</h2>
+            <p>{{ generation.error }}</p>
+            <button type="button" class="secondary-button" [disabled]="!canGenerate" (click)="generate()">
+              <lucide-icon [img]="icons.refresh" [size]="16" [strokeWidth]="2"></lucide-icon>
+              重新生成
+            </button>
+          </section>
+        } @else if (generation.result) {
+          <header class="result-header">
+            <div class="result-heading">
+              <span class="eyebrow" [attr.data-mode]="insightResult.mode">
+                <lucide-icon [img]="insightResult.mode === 'ai' ? icons.sparkles : icons.risk" [size]="14" [strokeWidth]="2"></lucide-icon>
+                {{ insightResult.mode === 'ai' ? 'AI 分析完成' : '规则摘要 · 待验证' }}
+              </span>
+              <h2>{{ insightResult.headline }}</h2>
+              <p>{{ insightResult.executiveSummary }}</p>
+            </div>
+            <dl class="result-metrics">
+              <div><dt>洞察</dt><dd>{{ insightResult.insights.length }}</dd></div>
+              <div><dt>引用证据</dt><dd>{{ resultCoverage }}%</dd></div>
+              <div><dt>样本</dt><dd>{{ insightResult.sampleCount }}</dd></div>
+            </dl>
+          </header>
+
+          <div class="result-toolbar">
+            <span>生成于 {{ formatDate(insightResult.generatedAt) }}</span>
+            <button type="button" class="text-button" [disabled]="!canGenerate" (click)="generate()">
+              <lucide-icon [img]="icons.refresh" [size]="14" [strokeWidth]="2"></lucide-icon>
+              重新生成
+            </button>
+          </div>
+
+          <section class="insight-list" aria-label="AI 洞察结果">
+            @for (insight of insightResult.insights; track insight.id; let index = $index) {
+              <article class="insight-card" [attr.data-severity]="insight.severity">
+                <header class="insight-card-header">
+                  <div class="insight-index">{{ index + 1 | number: '2.0-0' }}</div>
+                  <div class="insight-title">
+                    <div class="badge-row">
+                      <span class="severity-badge" [attr.data-severity]="insight.severity">{{ severityLabel(insight.severity) }}优先级</span>
+                      <span class="confidence-badge" [attr.data-confidence]="insight.confidence">{{ confidenceLabel(insight.confidence) }}</span>
+                      <span class="opportunity-badge" [attr.data-status]="insight.opportunityStatus">{{ opportunityLabel(insight.opportunityStatus) }}</span>
+                    </div>
+                    <h3>{{ insight.title }}</h3>
+                  </div>
+                  <button
+                    type="button"
+                    class="action-button"
+                    [disabled]="isActionCreated(insight) || !canCreateAction(insight)"
+                    (click)="openAction(insight)">
+                    @if (isActionCreated(insight)) {
+                      <lucide-icon [img]="icons.check" [size]="15" [strokeWidth]="2"></lucide-icon>
+                    } @else {
+                      <lucide-icon [img]="icons.clipboard" [size]="15" [strokeWidth]="2"></lucide-icon>
+                    }
+                    {{ actionButtonLabel(insight) }}
+                  </button>
+                </header>
+
+                <div class="insight-body">
+                  <section class="finding-block">
+                    <span class="field-name"><lucide-icon [img]="icons.file" [size]="14" [strokeWidth]="1.9"></lucide-icon>证据发现</span>
+                    <p>{{ insight.finding }}</p>
+                  </section>
+                  <section>
+                    <span class="field-name"><lucide-icon [img]="icons.quote" [size]="14" [strokeWidth]="1.9"></lucide-icon>用户需要</span>
+                    <p>{{ insight.userNeed }}</p>
+                  </section>
+                  <section>
+                    <span class="field-name"><lucide-icon [img]="icons.idea" [size]="14" [strokeWidth]="1.9"></lucide-icon>建议动作</span>
+                    <p>{{ insight.recommendation }}</p>
+                  </section>
+                  <section>
+                    <span class="field-name"><lucide-icon [img]="icons.target" [size]="14" [strokeWidth]="1.9"></lucide-icon>验证指标</span>
+                    <p>{{ insight.validationMetric }}</p>
+                  </section>
+                </div>
+
+                <footer class="evidence-citations">
+                  <span class="citation-label">证据引用 {{ insight.evidenceCount }}</span>
+                  <div class="citation-links">
+                    @for (evidenceId of insight.evidenceIds; track evidenceId) {
+                      <a [routerLink]="['/feedback-inbox']" [queryParams]="{ reviewId: evidenceId }" [title]="'查看原始评价 ' + evidenceId">
+                        {{ evidenceId }}
+                        <lucide-icon [img]="icons.arrow" [size]="12" [strokeWidth]="2"></lucide-icon>
+                      </a>
+                    }
+                  </div>
+                </footer>
+
+                <section class="decision-panel" [attr.data-decision]="decisionFor(insight)?.decision || 'pending'">
+                  <header class="decision-panel-header">
+                    <div>
+                      <span class="field-name">人工决策</span>
+                      <strong>
+                        @if (decisionFor(insight); as decision) {
+                          {{ decisionLabel(decision.decision) }} · v{{ decision.version }}
+                        } @else {
+                          等待证据审阅
+                        }
+                      </strong>
+                    </div>
+                    <span class="review-progress">
+                      已阅 {{ reviewedEvidenceCount(insight) }} / {{ insight.evidenceIds.length }}
+                    </span>
+                  </header>
+
+                  @if (decisionHistoryLoading) {
+                    <div class="decision-state">正在同步人工决策记录…</div>
+                  } @else if (decisionHistoryError) {
+                    <div class="decision-state is-error">{{ decisionHistoryError }}</div>
+                  }
+
+                  @if (decisionFor(insight); as decision) {
+                    @if (!isReviewingDecision(insight)) {
+                      <div class="decision-summary">
+                        <p>{{ decision.comment }}</p>
+                        <span>{{ decision.decidedBy }} · {{ formatDate(decision.decidedAt) }} · {{ decision.id }}</span>
+                        <button type="button" class="text-button" (click)="beginDecisionRevision(insight)">重新评审</button>
+                      </div>
+                    }
+                  }
+
+                  @if (isReviewingDecision(insight)) {
+                    <div class="decision-evidence-list" aria-label="已审阅证据">
+                      @for (evidenceId of insight.evidenceIds; track evidenceId) {
+                        <div class="decision-evidence-item">
+                          <label>
+                            <input
+                              type="checkbox"
+                              [checked]="isEvidenceReviewed(insight, evidenceId)"
+                              (change)="toggleEvidenceReview(insight, evidenceId, $any($event.target).checked)" />
+                            <span>{{ evidenceId }}</span>
+                          </label>
+                          <a [routerLink]="['/feedback-inbox']" [queryParams]="{ reviewId: evidenceId }">查看证据</a>
+                        </div>
+                      }
+                    </div>
+
+                    <label class="decision-comment">
+                      <span>判断理由</span>
+                      <textarea
+                        rows="3"
+                        maxlength="1000"
+                        placeholder="记录证据边界、判断依据和需要补充的信息"
+                        [(ngModel)]="decisionComments[insight.id]"
+                        [ngModelOptions]="{ standalone: true }"></textarea>
+                    </label>
+
+                    @if (decisionErrors[insight.id]) {
+                      <div class="decision-state is-error">{{ decisionErrors[insight.id] }}</div>
+                    }
+
+                    <div class="decision-actions">
+                      @if (insightResult.mode === 'ai') {
+                        <button
+                          type="button"
+                          class="decision-button is-confirm"
+                          [disabled]="!canSubmitDecision(insight, 'confirmed')"
+                          (click)="submitDecision(insight, 'confirmed')">确认洞察</button>
+                        <button
+                          type="button"
+                          class="decision-button is-reject"
+                          [disabled]="!canSubmitDecision(insight, 'rejected')"
+                          (click)="submitDecision(insight, 'rejected')">驳回</button>
+                      }
+                      <button
+                        type="button"
+                        class="decision-button is-evidence"
+                        [disabled]="!canSubmitDecision(insight, 'needs_more_evidence')"
+                        (click)="submitDecision(insight, 'needs_more_evidence')">需要补证据</button>
+                      @if (decisionFor(insight)) {
+                        <button type="button" class="text-button" (click)="cancelDecisionRevision(insight)">取消</button>
+                      }
+                    </div>
+                  }
+                </section>
+              </article>
+            }
+          </section>
+
+          <div class="audit-grid">
+            <section class="audit-section risk-section">
+              <header>
+                <lucide-icon [img]="icons.risk" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+                <div><span>决策边界</span><h3>风险与限制</h3></div>
+              </header>
+              @if (insightResult.risks.length) {
+                <ul>
+                  @for (risk of insightResult.risks; track risk) { <li>{{ risk }}</li> }
+                </ul>
+              } @else {
+                <p class="audit-empty">本轮未返回额外风险项,仍需人工复核证据边界。</p>
+              }
+            </section>
+            <section class="audit-section question-section">
+              <header>
+                <lucide-icon [img]="icons.activity" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+                <div><span>下一轮分析</span><h3>待追问问题</h3></div>
+              </header>
+              @if (insightResult.followUpQuestions.length) {
+                <ol>
+                  @for (question of insightResult.followUpQuestions; track question) { <li>{{ question }}</li> }
+                </ol>
+              } @else {
+                <p class="audit-empty">当前没有补充问题。</p>
+              }
+            </section>
+          </div>
+        } @else {
+          <section class="empty-workspace">
+            <div class="empty-heading">
+              <span class="state-visual">
+                <lucide-icon [img]="icons.sparkles" [size]="25" [strokeWidth]="1.7"></lucide-icon>
+              </span>
+              <h2>从评价证据生成结构化洞察</h2>
+              <p>当前已准备 {{ preparedContext.evidence.length }} 条样本。生成结果会明确发现、用户需要、机会状态、建议动作与验证指标。</p>
+            </div>
+            <div class="workflow-preview" aria-label="洞察工作流">
+              <div><span>01</span><strong>评价原声</strong><small>保留可回溯证据 ID</small></div>
+              <lucide-icon [img]="icons.arrow" [size]="17" [strokeWidth]="1.8"></lucide-icon>
+              <div><span>02</span><strong>用户需要</strong><small>区分事实与推断</small></div>
+              <lucide-icon [img]="icons.arrow" [size]="17" [strokeWidth]="1.8"></lucide-icon>
+              <div><span>03</span><strong>验证行动</strong><small>绑定指标进入执行</small></div>
+            </div>
+            @if (!preparedContext.evidence.length) {
+              <div class="inline-warning">
+                <lucide-icon [img]="icons.alert" [size]="16" [strokeWidth]="2"></lucide-icon>
+                当前筛选没有有效评价,请调整分析范围或情感倾向。
+              </div>
+            }
+          </section>
+        }
+      </main>
+    </div>
+  }
+</section>
+
+@if (selectedInsight; as insight) {
+  <app-action-create-panel
+    [workspaceId]="workspaceId"
+    [productKey]="productKeyFor(insight)"
+    [suggestedTitle]="actionTitle(insight)"
+    [suggestedDescription]="actionDescription(insight)"
+    [sourceLabel]="'AI VOC 洞察 · ' + insight.evidenceCount + ' 条证据'"
+    [sourceAnalysisId]="activeAnalysisId || null"
+    [sourceInsightId]="insight.id"
+    [sourceDecisionId]="decisionFor(insight)?.id || null"
+    [sourceKind]="'insight'"
+    [creationKey]="actionCreationKey(insight)"
+    [actionType]="actionTypeFor(insight)"
+    [evidenceIds]="insight.evidenceIds"
+    [validationMetric]="insight.validationMetric"
+    (created)="onActionCreated($event)"
+    (cancelled)="cancelAction()"
+  ></app-action-create-panel>
+}

+ 1302 - 0
src/modules/ai-voc-insight/ai-voc-insight.component.scss

@@ -0,0 +1,1302 @@
+:host {
+  display: block;
+  min-width: 0;
+  color: #1f2937;
+  letter-spacing: 0;
+}
+
+button,
+select {
+  font: inherit;
+  letter-spacing: 0;
+}
+
+button {
+  cursor: pointer;
+}
+
+.ai-voc-page {
+  width: 100%;
+  padding: 24px;
+}
+
+.page-header,
+.title-group,
+.gateway-status,
+.section-heading,
+.gateway-icon,
+.generate-button,
+.secondary-button,
+.text-button,
+.action-button,
+.badge-row,
+.field-name,
+.audit-section header,
+.evidence-preview-meta,
+.source-summary,
+.citation-links a,
+.eyebrow {
+  display: flex;
+  align-items: center;
+}
+
+.page-header {
+  justify-content: space-between;
+  gap: 20px;
+  margin-bottom: 18px;
+}
+
+.title-group {
+  min-width: 0;
+  gap: 12px;
+}
+
+.title-icon,
+.state-visual {
+  display: grid;
+  place-items: center;
+  flex: 0 0 auto;
+  border-radius: 8px;
+  color: #35536b;
+  background: #edf3f6;
+}
+
+.title-icon {
+  width: 36px;
+  height: 36px;
+}
+
+.page-header h1 {
+  color: #18212b;
+  font-size: 22px;
+  font-weight: 650;
+  line-height: 1.25;
+}
+
+.page-header p {
+  margin-top: 3px;
+  color: #6b7280;
+  font-size: 13px;
+  line-height: 1.45;
+}
+
+.gateway-status {
+  min-width: 210px;
+  gap: 9px;
+  padding: 8px 9px;
+  border: 1px solid #dfe4e8;
+  border-radius: 7px;
+  background: #ffffff;
+}
+
+.gateway-icon {
+  justify-content: center;
+  width: 30px;
+  height: 30px;
+  border-radius: 6px;
+  color: #64748b;
+  background: #f1f5f9;
+}
+
+.gateway-status[data-state='ready'] .gateway-icon {
+  color: #26705a;
+  background: #eaf6f0;
+}
+
+.gateway-status[data-state='error'] .gateway-icon,
+.gateway-status[data-state='unconfigured'] .gateway-icon {
+  color: #9b6230;
+  background: #fff6e9;
+}
+
+.gateway-copy {
+  display: grid;
+  min-width: 0;
+  flex: 1;
+  gap: 2px;
+}
+
+.gateway-copy strong {
+  color: #2d3742;
+  font-size: 12px;
+  font-weight: 650;
+}
+
+.gateway-copy small {
+  max-width: 190px;
+  overflow: hidden;
+  color: #7b8590;
+  font-size: 10px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.icon-button {
+  display: grid;
+  place-items: center;
+  flex: 0 0 auto;
+  width: 28px;
+  height: 28px;
+  border: 1px solid #dfe4e8;
+  border-radius: 5px;
+  color: #56616c;
+  background: #ffffff;
+}
+
+.persistence-banner {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 14px;
+  padding: 10px 12px;
+  border: 1px solid #d9e7dd;
+  border-radius: 7px;
+  color: #315a49;
+  background: #f4fbf7;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.persistence-banner.is-error {
+  border-color: #e7d1c7;
+  color: #8a5547;
+  background: #fff6f2;
+}
+
+.persistence-banner .text-button {
+  margin-left: auto;
+}
+
+.workbench {
+  display: grid;
+  grid-template-columns: minmax(280px, 330px) minmax(0, 1fr);
+  min-height: calc(100dvh - 164px);
+  overflow: hidden;
+  border: 1px solid #dfe4e8;
+  border-radius: 8px;
+  background: #ffffff;
+  box-shadow: 0 3px 14px rgba(24, 33, 43, 0.04);
+}
+
+.evidence-rail {
+  min-width: 0;
+  border-right: 1px solid #e4e9ed;
+  background: #fafbfc;
+}
+
+.rail-section {
+  padding: 18px;
+  border-bottom: 1px solid #e4e9ed;
+}
+
+.filter-section {
+  background: #ffffff;
+}
+
+.section-heading {
+  justify-content: space-between;
+  gap: 12px;
+  color: #74808b;
+}
+
+.section-heading > div {
+  display: grid;
+  gap: 3px;
+}
+
+.section-kicker {
+  color: #8a949e;
+  font-size: 10px;
+  font-weight: 650;
+  line-height: 1.2;
+  text-transform: uppercase;
+}
+
+.section-heading h2 {
+  color: #27313b;
+  font-size: 14px;
+  font-weight: 650;
+  line-height: 1.35;
+}
+
+.compact-heading {
+  margin-bottom: 14px;
+}
+
+.filter-group,
+.select-field {
+  display: grid;
+  gap: 7px;
+  margin-top: 16px;
+}
+
+.filter-label,
+.select-field > span {
+  color: #68737e;
+  font-size: 11px;
+  font-weight: 600;
+}
+
+.segmented-control,
+.sample-options {
+  display: grid;
+  gap: 3px;
+  padding: 3px;
+  border: 1px solid #dde3e7;
+  border-radius: 6px;
+  background: #f5f7f8;
+}
+
+.segmented-control {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.sample-options {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.segmented-control button,
+.sample-options button {
+  min-width: 0;
+  height: 30px;
+  border-radius: 4px;
+  color: #66717c;
+  background: transparent;
+  font-size: 11px;
+  font-weight: 600;
+}
+
+.segmented-control button.is-active,
+.sample-options button.is-active {
+  color: #263946;
+  background: #ffffff;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
+}
+
+.select-field select {
+  width: 100%;
+  height: 36px;
+  padding: 0 30px 0 10px;
+  border: 1px solid #d9e0e5;
+  border-radius: 6px;
+  color: #34404b;
+  background: #ffffff;
+  font-size: 12px;
+}
+
+.generate-button,
+.secondary-button,
+.action-button {
+  justify-content: center;
+  gap: 7px;
+  min-height: 36px;
+  padding: 0 14px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 650;
+}
+
+.generate-button {
+  width: 100%;
+  margin-top: 18px;
+  color: #ffffff;
+  background: #315c50;
+}
+
+.generate-button:hover:not(:disabled) {
+  background: #264b41;
+}
+
+.generate-button:disabled {
+  cursor: default;
+  color: #73857e;
+  background: #e7eeeb;
+}
+
+.button-hint,
+.rail-empty {
+  margin-top: 8px;
+  color: #8a949e;
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.coverage-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  margin: 0;
+  border: 1px solid #e1e6e9;
+  border-radius: 6px;
+  background: #ffffff;
+}
+
+.coverage-grid > div {
+  display: grid;
+  gap: 3px;
+  padding: 11px 12px;
+  border-right: 1px solid #e7ebee;
+  border-bottom: 1px solid #e7ebee;
+}
+
+.coverage-grid > div:nth-child(2n) { border-right: 0; }
+.coverage-grid > div:nth-last-child(-n + 2) { border-bottom: 0; }
+
+.coverage-grid dt {
+  color: #7b8590;
+  font-size: 10px;
+}
+
+.coverage-grid dd {
+  margin: 0;
+  color: #25313c;
+  font-size: 17px;
+  font-weight: 680;
+}
+
+.source-summary {
+  flex-wrap: wrap;
+  gap: 8px 12px;
+  margin-top: 12px;
+  color: #68737d;
+  font-size: 10px;
+}
+
+.source-summary span {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.source-summary i {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+}
+
+.source-own { background: #557b91; }
+.source-competitor { background: #9a7756; }
+.source-unknown { background: #a2aab2; }
+
+.topic-list {
+  display: grid;
+  gap: 12px;
+}
+
+.topic-label {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 5px;
+  color: #58636e;
+  font-size: 10px;
+}
+
+.topic-label span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.topic-label strong {
+  color: #35414c;
+  font-weight: 650;
+}
+
+.topic-track {
+  height: 5px;
+  overflow: hidden;
+  border-radius: 3px;
+  background: #e8ecef;
+}
+
+.topic-track span {
+  display: block;
+  min-width: 3px;
+  height: 100%;
+  border-radius: inherit;
+  background: #68889a;
+}
+
+.section-count {
+  padding: 2px 7px;
+  border-radius: 4px;
+  color: #61707a;
+  background: #edf1f3;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.evidence-list {
+  display: grid;
+  gap: 8px;
+}
+
+.evidence-preview {
+  display: grid;
+  min-width: 0;
+  gap: 5px;
+  padding: 10px;
+  border: 1px solid #e2e7ea;
+  border-left: 3px solid #aeb8c0;
+  border-radius: 6px;
+  color: inherit;
+  background: #ffffff;
+  text-decoration: none;
+}
+
+.evidence-preview[data-sentiment='negative'] { border-left-color: #c96a62; }
+.evidence-preview[data-sentiment='positive'] { border-left-color: #5d9277; }
+
+.evidence-preview:hover {
+  border-color: #aebbc4;
+  border-left-color: #557b91;
+  background: #f8fafb;
+}
+
+.evidence-preview-meta {
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.evidence-preview-meta strong {
+  overflow: hidden;
+  color: #35414b;
+  font-size: 10px;
+  font-weight: 650;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.evidence-preview-meta em {
+  flex: 0 0 auto;
+  color: #7b8590;
+  font-size: 9px;
+  font-style: normal;
+}
+
+.evidence-preview-copy {
+  display: -webkit-box;
+  overflow: hidden;
+  color: #606b75;
+  font-size: 10px;
+  line-height: 1.5;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.evidence-preview small {
+  color: #929aa2;
+  font-size: 9px;
+}
+
+.rail-loading {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+  color: #77828d;
+  font-size: 11px;
+  line-height: 1.4;
+}
+
+.history-list {
+  display: grid;
+  gap: 10px;
+}
+
+.history-card {
+  display: grid;
+  gap: 6px;
+  padding: 11px 12px;
+  border: 1px solid #dfe5e8;
+  border-left: 3px solid #9aa8b1;
+  border-radius: 7px;
+  color: inherit;
+  background: #ffffff;
+  text-align: left;
+}
+
+.history-card:hover:not(:disabled) {
+  border-color: #bac5cb;
+  border-left-color: #5f8397;
+  background: #fafcfd;
+}
+
+.history-card.is-active {
+  border-color: #99b6a9;
+  border-left-color: #3d7b63;
+  background: #f3fbf7;
+}
+
+.history-card:disabled {
+  cursor: default;
+  opacity: 0.88;
+}
+
+.history-card-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.history-card-top strong {
+  color: #34414b;
+  font-size: 11px;
+  font-weight: 650;
+}
+
+.history-card-top em {
+  color: #8a949e;
+  font-size: 9px;
+  font-style: normal;
+  white-space: nowrap;
+}
+
+.history-card-title {
+  color: #22303a;
+  font-size: 12px;
+  font-weight: 650;
+  line-height: 1.45;
+}
+
+.history-card small,
+.history-card-action {
+  color: #71808d;
+  font-size: 10px;
+  line-height: 1.45;
+}
+
+.history-card-action {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  font-weight: 650;
+}
+
+.history-card-action.muted {
+  color: #98a2ab;
+}
+
+.insight-main {
+  min-width: 0;
+  background: #ffffff;
+}
+
+.full-state,
+.generation-state,
+.empty-workspace {
+  display: grid;
+  place-items: center;
+  align-content: center;
+  min-height: min(640px, calc(100dvh - 164px));
+  padding: 48px 30px;
+  color: #75808a;
+  text-align: center;
+}
+
+.full-state {
+  border: 1px solid #dfe4e8;
+  border-radius: 8px;
+  background: #ffffff;
+}
+
+.full-state h2,
+.generation-state h2,
+.empty-workspace h2 {
+  margin-top: 15px;
+  color: #27323d;
+  font-size: 18px;
+  font-weight: 650;
+  line-height: 1.35;
+}
+
+.full-state p,
+.generation-state p,
+.empty-workspace p {
+  max-width: 590px;
+  margin-top: 7px;
+  color: #6e7983;
+  font-size: 12px;
+  line-height: 1.65;
+}
+
+.state-visual {
+  width: 48px;
+  height: 48px;
+}
+
+.state-error,
+.error-state {
+  color: #a24d48;
+}
+
+.secondary-button {
+  margin-top: 18px;
+  border: 1px solid #d5dde2;
+  color: #415663;
+  background: #ffffff;
+}
+
+.secondary-button:hover:not(:disabled) {
+  border-color: #9fadb7;
+  background: #f8fafb;
+}
+
+.progress-line {
+  width: min(420px, 82%);
+  height: 4px;
+  margin-top: 22px;
+  overflow: hidden;
+  border-radius: 3px;
+  background: #e8ecef;
+}
+
+.progress-line span {
+  display: block;
+  width: 36%;
+  height: 100%;
+  border-radius: inherit;
+  background: #557b91;
+  animation: progress 1.35s ease-in-out infinite;
+}
+
+.analysis-steps {
+  display: flex;
+  gap: 24px;
+  margin-top: 14px;
+  color: #9aa2aa;
+  font-size: 10px;
+}
+
+.analysis-steps span {
+  position: relative;
+}
+
+.analysis-steps span:not(:last-child)::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  left: calc(100% + 7px);
+  width: 10px;
+  height: 1px;
+  background: #d3d9dd;
+}
+
+.analysis-steps .is-complete { color: #3f765f; }
+.analysis-steps .is-active { color: #3e6174; font-weight: 650; }
+
+.workflow-preview {
+  display: grid;
+  grid-template-columns: minmax(130px, 1fr) auto minmax(130px, 1fr) auto minmax(130px, 1fr);
+  align-items: center;
+  gap: 14px;
+  width: min(680px, 100%);
+  margin-top: 30px;
+}
+
+.workflow-preview > div {
+  display: grid;
+  min-height: 102px;
+  align-content: center;
+  gap: 5px;
+  padding: 14px;
+  border: 1px solid #e0e5e8;
+  border-radius: 7px;
+  background: #fafbfc;
+}
+
+.workflow-preview > lucide-icon { color: #9aa4ac; }
+.workflow-preview span { color: #84909a; font-size: 10px; font-weight: 700; }
+.workflow-preview strong { color: #35414b; font-size: 13px; font-weight: 650; }
+.workflow-preview small { color: #7a858f; font-size: 10px; line-height: 1.4; }
+
+.inline-warning {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  max-width: 560px;
+  margin-top: 22px;
+  padding: 9px 12px;
+  border: 1px solid #ecd8b7;
+  border-radius: 6px;
+  color: #7a5d34;
+  background: #fff9ef;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.result-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 28px;
+  padding: 25px 28px 22px;
+  border-bottom: 1px solid #e5e9ec;
+}
+
+.result-heading {
+  min-width: 0;
+  max-width: 760px;
+}
+
+.eyebrow {
+  gap: 5px;
+  color: #416a5d;
+  font-size: 10px;
+  font-weight: 700;
+  text-transform: uppercase;
+}
+
+.eyebrow[data-mode='deterministic'] {
+  color: #7a5c27;
+}
+
+.result-heading h2 {
+  margin-top: 8px;
+  color: #18232e;
+  font-size: 21px;
+  font-weight: 680;
+  line-height: 1.35;
+}
+
+.result-heading p {
+  margin-top: 8px;
+  color: #5f6b76;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.result-metrics {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(68px, 1fr));
+  flex: 0 0 auto;
+  margin: 0;
+  border: 1px solid #e0e5e8;
+  border-radius: 7px;
+  background: #fafbfc;
+}
+
+.result-metrics div {
+  display: grid;
+  gap: 3px;
+  padding: 11px 13px;
+  border-right: 1px solid #e4e8eb;
+  text-align: center;
+}
+
+.result-metrics div:last-child { border-right: 0; }
+.result-metrics dt { color: #7c8690; font-size: 9px; }
+.result-metrics dd { margin: 0; color: #293641; font-size: 15px; font-weight: 680; }
+
+.result-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  min-height: 42px;
+  padding: 0 28px;
+  border-bottom: 1px solid #edf0f2;
+  color: #8a939b;
+  background: #fafbfc;
+  font-size: 10px;
+}
+
+.text-button {
+  gap: 5px;
+  color: #496779;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.insight-list {
+  display: grid;
+  gap: 14px;
+  padding: 20px 28px;
+}
+
+.insight-card {
+  position: relative;
+  overflow: hidden;
+  border: 1px solid #dfe5e8;
+  border-radius: 8px;
+  background: #ffffff;
+}
+
+.insight-card::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: #8c9aa4;
+}
+
+.insight-card[data-severity='critical']::before { background: #ba514c; }
+.insight-card[data-severity='high']::before { background: #cc7661; }
+.insight-card[data-severity='medium']::before { background: #bb9348; }
+.insight-card[data-severity='low']::before { background: #698a78; }
+
+.insight-card-header {
+  display: grid;
+  grid-template-columns: 34px minmax(0, 1fr) auto;
+  gap: 13px;
+  align-items: start;
+  padding: 18px 18px 16px 20px;
+  border-bottom: 1px solid #e8ecef;
+}
+
+.insight-index {
+  display: grid;
+  place-items: center;
+  width: 34px;
+  height: 34px;
+  border-radius: 6px;
+  color: #536571;
+  background: #edf2f4;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.insight-title { min-width: 0; }
+
+.badge-row {
+  flex-wrap: wrap;
+  gap: 5px;
+}
+
+.severity-badge,
+.confidence-badge,
+.opportunity-badge {
+  display: inline-flex;
+  align-items: center;
+  min-height: 20px;
+  padding: 0 7px;
+  border-radius: 4px;
+  font-size: 9px;
+  font-weight: 650;
+  white-space: nowrap;
+}
+
+.severity-badge { color: #6f5933; background: #faf2df; }
+.severity-badge[data-severity='critical'] { color: #973d39; background: #fff0ef; }
+.severity-badge[data-severity='high'] { color: #9a553e; background: #fff2ed; }
+.severity-badge[data-severity='low'] { color: #3e6b56; background: #edf7f2; }
+.confidence-badge { color: #456579; background: #edf3f6; }
+.confidence-badge[data-confidence='low'] { color: #745f36; background: #f8f2e5; }
+.opportunity-badge { color: #596672; background: #eef1f3; }
+.opportunity-badge[data-status='supported'] { color: #32674f; background: #eaf6ef; }
+.opportunity-badge[data-status='partially_supported'] { color: #566b37; background: #f1f5e7; }
+.opportunity-badge[data-status='needs_validation'] { color: #79602e; background: #fbf3df; }
+.opportunity-badge[data-status='not_supported'] { color: #7d4b4b; background: #f8eded; }
+
+.insight-title h3 {
+  margin-top: 7px;
+  color: #202c37;
+  font-size: 16px;
+  font-weight: 670;
+  line-height: 1.4;
+}
+
+.action-button {
+  min-height: 32px;
+  border: 1px solid #cbd6dc;
+  color: #355a6c;
+  background: #ffffff;
+  white-space: nowrap;
+}
+
+.action-button:hover:not(:disabled) {
+  border-color: #8299a6;
+  background: #f5f8f9;
+}
+
+.action-button:disabled {
+  cursor: default;
+  border-color: #d5e3dc;
+  color: #3e745b;
+  background: #eff7f3;
+}
+
+.insight-body {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.insight-body section {
+  min-width: 0;
+  padding: 16px 18px;
+  border-right: 1px solid #edf0f2;
+  border-bottom: 1px solid #edf0f2;
+}
+
+.insight-body section:nth-child(2n) { border-right: 0; }
+.insight-body section:nth-last-child(-n + 2) { border-bottom: 0; }
+
+.field-name {
+  gap: 6px;
+  color: #73808a;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.insight-body p {
+  margin-top: 7px;
+  color: #3e4a55;
+  font-size: 12px;
+  line-height: 1.65;
+  overflow-wrap: anywhere;
+}
+
+.finding-block {
+  background: #fafbfc;
+}
+
+.evidence-citations {
+  display: grid;
+  grid-template-columns: 92px minmax(0, 1fr);
+  gap: 10px;
+  padding: 12px 18px 14px;
+  border-top: 1px solid #e8ecef;
+  background: #fafbfc;
+}
+
+.citation-label {
+  padding-top: 4px;
+  color: #77828c;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.citation-links {
+  flex-wrap: wrap;
+  gap: 5px;
+  min-width: 0;
+}
+
+.citation-links a {
+  max-width: 230px;
+  gap: 4px;
+  min-height: 23px;
+  padding: 0 7px;
+  overflow: hidden;
+  border: 1px solid #dce3e7;
+  border-radius: 4px;
+  color: #4d6878;
+  background: #ffffff;
+  font-family: Consolas, 'SFMono-Regular', monospace;
+  font-size: 9px;
+  text-decoration: none;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.citation-links a:hover {
+  border-color: #9eafb8;
+  color: #2f5265;
+}
+
+.citation-links lucide-icon { flex: 0 0 auto; }
+
+.decision-panel {
+  display: grid;
+  gap: 13px;
+  padding: 16px 18px 18px;
+  border-top: 1px solid #dfe5e8;
+  background: #f7f9f8;
+}
+
+.decision-panel[data-decision='confirmed'] { border-top-color: #b8d4c4; background: #f4f9f6; }
+.decision-panel[data-decision='rejected'] { border-top-color: #dfc4c1; background: #fbf6f5; }
+.decision-panel[data-decision='needs_more_evidence'] { border-top-color: #ded0ad; background: #fbf9f2; }
+
+.decision-panel-header,
+.decision-panel-header > div,
+.decision-summary,
+.decision-comment {
+  display: grid;
+}
+
+.decision-panel-header {
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 12px;
+  align-items: start;
+}
+
+.decision-panel-header > div { gap: 4px; }
+
+.decision-panel-header strong {
+  color: #273640;
+  font-size: 13px;
+  font-weight: 680;
+}
+
+.review-progress {
+  padding: 4px 7px;
+  border: 1px solid #d7e0e4;
+  border-radius: 4px;
+  color: #60717c;
+  background: #ffffff;
+  font-size: 9px;
+  font-weight: 650;
+  white-space: nowrap;
+}
+
+.decision-state {
+  color: #657681;
+  font-size: 10px;
+}
+
+.decision-state.is-error { color: #9a4f4a; }
+
+.decision-summary {
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 7px 16px;
+  align-items: center;
+}
+
+.decision-summary p {
+  grid-column: 1 / -1;
+  color: #3d4c55;
+  font-size: 11px;
+  line-height: 1.6;
+  overflow-wrap: anywhere;
+}
+
+.decision-summary span {
+  color: #75818a;
+  font-size: 9px;
+  overflow-wrap: anywhere;
+}
+
+.decision-evidence-list {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  border: 1px solid #dfe5e8;
+  border-radius: 6px;
+  background: #ffffff;
+}
+
+.decision-evidence-item {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 8px;
+  align-items: center;
+  min-width: 0;
+  padding: 9px 10px;
+  border-right: 1px solid #edf0f2;
+  border-bottom: 1px solid #edf0f2;
+}
+
+.decision-evidence-item:nth-child(2n) { border-right: 0; }
+.decision-evidence-item:nth-last-child(-n + 2) { border-bottom: 0; }
+
+.decision-evidence-item label {
+  display: flex;
+  gap: 7px;
+  align-items: center;
+  min-width: 0;
+  color: #465965;
+  font-family: Consolas, 'SFMono-Regular', monospace;
+  font-size: 9px;
+}
+
+.decision-evidence-item input {
+  width: 14px;
+  height: 14px;
+  flex: 0 0 auto;
+  accent-color: #4f7865;
+}
+
+.decision-evidence-item label span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.decision-evidence-item a {
+  color: #526f7f;
+  font-size: 9px;
+  text-decoration: none;
+  white-space: nowrap;
+}
+
+.decision-comment { gap: 6px; }
+
+.decision-comment > span {
+  color: #63737e;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.decision-comment textarea {
+  width: 100%;
+  min-height: 76px;
+  resize: vertical;
+  border: 1px solid #cfd9de;
+  border-radius: 6px;
+  padding: 9px 10px;
+  color: #2f3e47;
+  background: #ffffff;
+  font: inherit;
+  font-size: 11px;
+  line-height: 1.55;
+}
+
+.decision-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 7px;
+  align-items: center;
+}
+
+.decision-button {
+  min-height: 30px;
+  padding: 0 11px;
+  border: 1px solid #cbd5da;
+  color: #43545f;
+  background: #ffffff;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.decision-button.is-confirm { border-color: #a9c8b7; color: #35644d; background: #f1f8f4; }
+.decision-button.is-reject { border-color: #d8b8b4; color: #874843; background: #fbf3f2; }
+.decision-button.is-evidence { border-color: #d5c18d; color: #745d27; background: #fbf7e9; }
+.decision-button:disabled { opacity: .46; cursor: default; }
+
+.audit-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+  padding: 0 28px 28px;
+}
+
+.audit-section {
+  padding: 17px 18px;
+  border: 1px solid #dfe5e8;
+  border-radius: 8px;
+  background: #ffffff;
+}
+
+.audit-section header {
+  align-items: flex-start;
+  gap: 9px;
+  color: #697b87;
+}
+
+.audit-section header > div {
+  display: grid;
+  gap: 2px;
+}
+
+.audit-section header span {
+  color: #8a949d;
+  font-size: 9px;
+  text-transform: uppercase;
+}
+
+.audit-section h3 {
+  color: #34404b;
+  font-size: 13px;
+  font-weight: 650;
+}
+
+.audit-section ul,
+.audit-section ol {
+  display: grid;
+  gap: 8px;
+  margin: 13px 0 0;
+  padding-left: 19px;
+  color: #56616b;
+  font-size: 11px;
+  line-height: 1.6;
+}
+
+.risk-section { background: #fffdfa; }
+.question-section { background: #fafcfd; }
+.audit-empty { margin-top: 12px; color: #7c8790; font-size: 11px; line-height: 1.55; }
+
+button:focus-visible,
+select:focus-visible,
+a:focus-visible,
+input:focus-visible,
+textarea:focus-visible {
+  outline: 2px solid #53788c;
+  outline-offset: 2px;
+}
+
+.spin { animation: spin 850ms linear infinite; }
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+@keyframes progress {
+  0% { transform: translateX(-110%); }
+  100% { transform: translateX(380%); }
+}
+
+@media (max-width: 1100px) {
+  .ai-voc-page { padding: 20px; }
+  .workbench { grid-template-columns: minmax(260px, 300px) minmax(0, 1fr); }
+  .result-header { display: grid; }
+  .result-metrics { width: fit-content; }
+  .insight-card-header { grid-template-columns: 34px minmax(0, 1fr); }
+  .action-button { grid-column: 2; width: fit-content; }
+}
+
+@media (max-width: 820px) {
+  .ai-voc-page { padding: 16px; }
+  .workbench { display: block; min-height: 0; overflow: visible; }
+  .evidence-rail { border-right: 0; border-bottom: 1px solid #e4e9ed; }
+  .evidence-section { display: none; }
+  .insight-main { min-height: 520px; }
+  .full-state,
+  .generation-state,
+  .empty-workspace { min-height: 520px; }
+}
+
+@media (max-width: 600px) {
+  .page-header { align-items: flex-start; }
+  .page-header p { display: none; }
+  .page-header h1 { font-size: 19px; }
+  .title-icon { width: 34px; height: 34px; }
+  .gateway-status { min-width: 0; padding: 5px; }
+  .gateway-icon { width: 30px; height: 30px; }
+  .gateway-copy { display: none; }
+  .rail-section { padding: 16px; }
+  .result-header { padding: 20px 17px 18px; }
+  .result-heading h2 { font-size: 18px; }
+  .result-metrics { width: 100%; }
+  .result-toolbar { padding: 0 17px; }
+  .insight-list { padding: 14px; }
+  .insight-card-header { grid-template-columns: 30px minmax(0, 1fr); padding: 15px 14px 14px 17px; }
+  .insight-index { width: 30px; height: 30px; }
+  .action-button { grid-column: 1 / -1; width: 100%; }
+  .insight-body { grid-template-columns: 1fr; }
+  .insight-body section,
+  .insight-body section:nth-child(2n),
+  .insight-body section:nth-last-child(-n + 2) { border-right: 0; border-bottom: 1px solid #edf0f2; }
+  .insight-body section:last-child { border-bottom: 0; }
+  .evidence-citations { grid-template-columns: 1fr; }
+  .citation-links a { max-width: 100%; }
+  .decision-panel { padding: 15px 14px 17px; }
+  .decision-panel-header { grid-template-columns: 1fr; }
+  .review-progress { width: fit-content; }
+  .decision-summary { grid-template-columns: 1fr; }
+  .decision-evidence-list { grid-template-columns: 1fr; }
+  .decision-evidence-item,
+  .decision-evidence-item:nth-child(2n),
+  .decision-evidence-item:nth-last-child(-n + 2) { border-right: 0; border-bottom: 1px solid #edf0f2; }
+  .decision-evidence-item:last-child { border-bottom: 0; }
+  .decision-button { flex: 1 1 130px; }
+  .audit-grid { grid-template-columns: 1fr; padding: 0 14px 18px; }
+  .workflow-preview { grid-template-columns: 1fr; width: 100%; }
+  .workflow-preview > lucide-icon { transform: rotate(90deg); }
+  .workflow-preview > div { min-height: 82px; }
+  .analysis-steps { gap: 16px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .spin,
+  .progress-line span { animation-duration: 1.8s; }
+}

+ 210 - 0
src/modules/ai-voc-insight/ai-voc-insight.component.spec.ts

@@ -0,0 +1,210 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { EMPTY, of } from 'rxjs';
+import { AiGatewayService } from '../../app/core/services/ai-gateway.service';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import {
+  SaasActionItem,
+  SaasInsightDecision,
+  SaasPlatformService,
+} from '../../app/core/services/saas-platform.service';
+import { ActionCreatePanelComponent } from '../shared/components/action-create-panel/action-create-panel.component';
+import { AiVocInsightItem } from './ai-voc-insight.models';
+import { AiVocInsightComponent } from './ai-voc-insight.component';
+import { AiVocInsightService } from './ai-voc-insight.service';
+
+describe('AiVocInsightComponent action source mapping', () => {
+  let fixture: ComponentFixture<AiVocInsightComponent>;
+  let component: AiVocInsightComponent;
+  let saas: jasmine.SpyObj<SaasPlatformService>;
+
+  const insight: AiVocInsightItem = {
+    id: 'insight-42',
+    title: 'Recurring issue',
+    finding: 'The issue appears in grounded evidence.',
+    userNeed: 'Users need a reliable resolution.',
+    severity: 'high',
+    confidence: 'medium',
+    opportunityStatus: 'supported',
+    evidenceIds: ['evidence-1', 'evidence-2'],
+    evidenceCount: 2,
+    recommendation: 'Validate the proposed product change.',
+    validationMetric: 'Issue recurrence falls below 10%.',
+  };
+
+  const confirmedDecision: SaasInsightDecision = {
+    id: 'decision-42',
+    workspaceId: 'demashi',
+    sourceAnalysisId: 'analysis-42',
+    sourceInsightId: insight.id,
+    decision: 'confirmed',
+    reviewedEvidenceIds: [...insight.evidenceIds],
+    comment: 'The evidence supports a tracked action.',
+    decidedBy: 'analyst-1',
+    decidedAt: '2026-08-07T01:00:00.000Z',
+    version: 1,
+    supersedesId: null,
+    isCurrent: true,
+    createdAt: '2026-08-07T01:00:00.000Z',
+    updatedAt: '2026-08-07T01:00:00.000Z',
+  };
+
+  beforeEach(async () => {
+    saas = jasmine.createSpyObj<SaasPlatformService>('SaasPlatformService', [
+      'analyses',
+      'insightDecisions',
+      'createInsightDecision',
+      'createAction',
+    ]);
+    saas.analyses.and.returnValue(of({ items: [], nextCursor: null }));
+    saas.insightDecisions.and.returnValue(of({ items: [], nextCursor: null }));
+    saas.createInsightDecision.and.returnValue(of({ decision: confirmedDecision }));
+    saas.createAction.and.returnValue(of({ action: {} as SaasActionItem }));
+
+    await TestBed.configureTestingModule({
+      imports: [AiVocInsightComponent],
+      providers: [
+        {
+          provide: DomesticDatasetService,
+          useValue: { dataset$: EMPTY, refresh: jasmine.createSpy('refresh') },
+        },
+        {
+          provide: AiGatewayService,
+          useValue: { status: () => of({ configured: false }) },
+        },
+        {
+          provide: AiVocInsightService,
+          useValue: { prepare: jasmine.createSpy('prepare') },
+        },
+        { provide: SaasPlatformService, useValue: saas },
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(AiVocInsightComponent);
+    component = fixture.componentInstance;
+  });
+
+  afterEach(() => {
+    sessionStorage.removeItem('saas-voc:ai-insight:active:demashi');
+    fixture.destroy();
+  });
+
+  function actionPanel(): ActionCreatePanelComponent | null {
+    const debugElement = fixture.debugElement.query(By.directive(ActionCreatePanelComponent));
+    return (debugElement?.componentInstance as ActionCreatePanelComponent | undefined) ?? null;
+  }
+
+  it('maps run, insight, evidence, and validation fields into the created ActionItem', () => {
+    component.activeAnalysisId = 'analysis-42';
+    component.decisionsByInsight.set(insight.id, confirmedDecision);
+    component.selectedInsight = insight;
+    fixture.detectChanges();
+
+    const panel = actionPanel();
+    expect(panel).not.toBeNull();
+    panel?.submit();
+
+    expect(saas.createAction).toHaveBeenCalledOnceWith('demashi', jasmine.objectContaining({
+      sourceAnalysisId: 'analysis-42',
+      sourceInsightId: 'insight-42',
+      sourceDecisionId: 'decision-42',
+      sourceKind: 'insight',
+      creationKey: 'demashi|analysis-42|insight-42|decision-42',
+      evidenceIds: ['evidence-1', 'evidence-2'],
+      validationMetric: 'Issue recurrence falls below 10%.',
+    }));
+  });
+
+  it('does not open an action creator without an approved decision and analysis run', () => {
+    component.activeAnalysisId = '';
+    component.openAction(insight);
+    fixture.detectChanges();
+
+    expect(actionPanel()).toBeNull();
+    expect(component.selectedInsight).toBeNull();
+    expect(saas.createAction).not.toHaveBeenCalled();
+  });
+
+  it('does not render an action creator or issue a request when no insight is selected', () => {
+    component.activeAnalysisId = 'analysis-42';
+    component.selectedInsight = null;
+    fixture.detectChanges();
+
+    expect(actionPanel()).toBeNull();
+    expect(saas.createAction).not.toHaveBeenCalled();
+  });
+
+  it('restores the remembered completed run after returning from an evidence route', () => {
+    const run = {
+      id: 'analysis-return-1',
+      workspaceId: 'demashi',
+      analysisType: 'voc_insight' as const,
+      targetKind: 'workspace' as const,
+      targetKey: 'demashi',
+      input: {
+        filters: { scope: 'all', sentiment: 'all', sampleLimit: 20 },
+        evidenceIds: ['evidence-1', 'evidence-2'],
+      },
+      requestedBy: 'tester',
+      status: 'completed' as const,
+      result: {
+        mode: 'ai' as const,
+        executiveSummary: 'Summary',
+        headline: 'Headline',
+        filters: { scope: 'all' as const, sentiment: 'all' as const, sampleLimit: 20 },
+        insights: [insight],
+        risks: [],
+        followUpQuestions: [],
+        sampleCount: 2,
+        evidenceCoverage: 1,
+        generatedAt: '2026-08-06T10:00:00.000Z',
+      },
+      evidenceCount: 2,
+      errorSummary: null,
+      requestedAt: '2026-08-06T10:00:00.000Z',
+      startedAt: '2026-08-06T10:00:00.000Z',
+      completedAt: '2026-08-06T10:00:01.000Z',
+    };
+    sessionStorage.setItem('saas-voc:ai-insight:active:demashi', run.id);
+    saas.analyses.and.returnValue(of({ items: [run], nextCursor: null }));
+
+    fixture.detectChanges();
+
+    expect(component.activeAnalysisId).toBe(run.id);
+    expect(component.selectedHistoryId).toBe(run.id);
+    expect(component.generation.result?.headline).toBe('Headline');
+    expect(saas.insightDecisions).toHaveBeenCalledOnceWith('demashi', run.id, '', true);
+    sessionStorage.removeItem('saas-voc:ai-insight:active:demashi');
+  });
+
+  it('requires all evidence and a reason before saving a decision', () => {
+    component.activeAnalysisId = 'analysis-42';
+    component.generation.result = {
+      mode: 'ai',
+      executiveSummary: 'Summary',
+      headline: 'Headline',
+      filters: { scope: 'all', sentiment: 'all', sampleLimit: 20 },
+      insights: [insight],
+      risks: [],
+      followUpQuestions: [],
+      sampleCount: 2,
+      evidenceCoverage: 1,
+      generatedAt: '2026-08-07T01:00:00.000Z',
+    };
+
+    component.toggleEvidenceReview(insight, 'evidence-1', true);
+    component.decisionComments[insight.id] = 'Reviewed the cited evidence.';
+    expect(component.canSubmitDecision(insight, 'confirmed')).toBeFalse();
+
+    component.toggleEvidenceReview(insight, 'evidence-2', true);
+    component.submitDecision(insight, 'confirmed');
+
+    expect(saas.createInsightDecision).toHaveBeenCalledOnceWith('demashi', {
+      sourceAnalysisId: 'analysis-42',
+      sourceInsightId: insight.id,
+      decision: 'confirmed',
+      reviewedEvidenceIds: insight.evidenceIds,
+      comment: 'Reviewed the cited evidence.',
+    });
+  });
+});

+ 978 - 0
src/modules/ai-voc-insight/ai-voc-insight.component.ts

@@ -0,0 +1,978 @@
+import { CommonModule } from '@angular/common';
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  DestroyRef,
+  OnInit,
+  inject,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+import { RouterLink } from '@angular/router';
+import {
+  Activity,
+  AlertTriangle,
+  ArrowRight,
+  BarChart3,
+  BrainCircuit,
+  CheckCircle2,
+  CircleAlert,
+  ClipboardPlus,
+  Database,
+  FileText,
+  Gauge,
+  Lightbulb,
+  LoaderCircle,
+  LucideAngularModule,
+  MessageSquareQuote,
+  RefreshCw,
+  ShieldAlert,
+  Sparkles,
+  Target,
+  Wifi,
+  WifiOff,
+} from 'lucide-angular';
+import { Subscription, of } from 'rxjs';
+import { DomesticDataset } from '../../app/core/models/domestic.models';
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+import {
+  AiGatewayService,
+  AiGatewayStatus,
+} from '../../app/core/services/ai-gateway.service';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import {
+  SaasActionItem,
+  SaasAnalysisRun,
+  SaasInsightDecision,
+  SaasInsightDecisionType,
+  SaasPlatformService,
+} from '../../app/core/services/saas-platform.service';
+import { ActionCreatePanelComponent } from '../shared/components/action-create-panel/action-create-panel.component';
+import {
+  AiVocConfidence,
+  AiVocEvidenceReview,
+  AiVocGenerationState,
+  AiVocInsightFilters,
+  AiVocInsightItem,
+  AiVocOpportunityStatus,
+  AiVocPreparedContext,
+  AiVocSentiment,
+  AiVocSeverity,
+  AiVocScope,
+} from './ai-voc-insight.models';
+import { AiVocInsightService } from './ai-voc-insight.service';
+
+@Component({
+  selector: 'app-ai-voc-insight',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    RouterLink,
+    LucideAngularModule,
+    ActionCreatePanelComponent,
+  ],
+  templateUrl: './ai-voc-insight.component.html',
+  styleUrl: './ai-voc-insight.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AiVocInsightComponent implements OnInit {
+  private readonly datasetService = inject(DomesticDatasetService);
+  private readonly gatewayService = inject(AiGatewayService);
+  private readonly insightService = inject(AiVocInsightService);
+  private readonly saasService = inject(SaasPlatformService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+  private generationSubscription?: Subscription;
+  private dataset: DomesticDataset | null = null;
+
+  readonly icons = {
+    activity: Activity,
+    alert: AlertTriangle,
+    arrow: ArrowRight,
+    chart: BarChart3,
+    brain: BrainCircuit,
+    check: CheckCircle2,
+    circleAlert: CircleAlert,
+    clipboard: ClipboardPlus,
+    database: Database,
+    file: FileText,
+    gauge: Gauge,
+    idea: Lightbulb,
+    loader: LoaderCircle,
+    quote: MessageSquareQuote,
+    refresh: RefreshCw,
+    risk: ShieldAlert,
+    sparkles: Sparkles,
+    target: Target,
+    wifi: Wifi,
+    wifiOff: WifiOff,
+  };
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly scopeOptions: ReadonlyArray<{ value: AiVocScope; label: string }> = [
+    { value: 'all', label: '全部评价' },
+    { value: 'own', label: '本品评价' },
+    { value: 'competitor', label: '竞品评价' },
+  ];
+  readonly sentimentOptions: ReadonlyArray<{ value: AiVocSentiment; label: string }> = [
+    { value: 'all', label: '全部倾向' },
+    { value: 'negative', label: '负向评价' },
+    { value: 'neutral', label: '中性评价' },
+    { value: 'positive', label: '正向评价' },
+  ];
+  readonly sampleLimits = [20, 40, 60, 80] as const;
+
+  filters: AiVocInsightFilters = {
+    scope: 'all',
+    sentiment: 'all',
+    sampleLimit: 40,
+  };
+  context: AiVocPreparedContext | null = null;
+  generation: AiVocGenerationState = this.emptyGeneration();
+  gatewayStatus: AiGatewayStatus | null = null;
+  gatewayLoading = true;
+  gatewayError = '';
+  datasetLoading = true;
+  datasetError = '';
+  selectedInsight: AiVocInsightItem | null = null;
+  readonly createdActionIds = new Set<string>();
+  readonly decisionsByInsight = new Map<string, SaasInsightDecision>();
+  readonly reviewedEvidenceByInsight = new Map<string, Set<string>>();
+  readonly decisionSubmittingIds = new Set<string>();
+  readonly decisionEditingIds = new Set<string>();
+  readonly decisionComments: Record<string, string> = {};
+  readonly decisionErrors: Record<string, string> = {};
+  decisionHistoryLoading = false;
+  decisionHistoryError = '';
+  analysisHistory: SaasAnalysisRun[] = [];
+  analysisHistoryLoading = true;
+  analysisHistoryError = '';
+  persistenceNotice = '';
+  activeAnalysisId = '';
+  selectedHistoryId = '';
+  private readonly analysisHistoryLimit = 20;
+  private readonly activeAnalysisStorageKey = `saas-voc:ai-insight:active:${this.workspaceId}`;
+  private finalizedAnalysisId = '';
+  private analysisProcessingReady = false;
+  private generationRunId = 0;
+  private pendingAnalysisFinalization:
+    | {
+        runId: number;
+        kind: 'completed' | 'failed';
+        result?: NonNullable<AiVocGenerationState['result']>;
+        errorSummary?: string;
+      }
+    | null = null;
+
+  ngOnInit(): void {
+    this.loadGatewayStatus();
+    this.loadAnalysisHistory();
+    this.datasetService.dataset$
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: (dataset) => {
+          this.dataset = dataset;
+          this.datasetLoading = false;
+          this.datasetError = '';
+          this.prepareContext();
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          this.datasetLoading = false;
+          this.datasetError = 'VOC 数据加载失败,请检查数据服务后重试。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  get canGenerate(): boolean {
+    return !!this.context?.evidence.length
+      && !this.gatewayLoading
+      && !this.generation.loading;
+  }
+
+  get generateButtonLabel(): string {
+    return this.gatewayStatus?.configured ? '生成 AI 洞察' : '生成规则摘要';
+  }
+
+  get gatewayState(): 'loading' | 'ready' | 'unconfigured' | 'error' {
+    if (this.gatewayLoading) return 'loading';
+    if (this.gatewayError) return 'error';
+    return this.gatewayStatus?.configured ? 'ready' : 'unconfigured';
+  }
+
+  get gatewayLabel(): string {
+    const labels = {
+      loading: '检测中',
+      ready: '网关已就绪',
+      unconfigured: '网关未配置',
+      error: '状态获取失败',
+    } as const;
+    return labels[this.gatewayState];
+  }
+
+  get resultCoverage(): number {
+    return Math.round((this.generation.result?.evidenceCoverage ?? 0) * 100);
+  }
+
+  get preparedContext(): AiVocPreparedContext {
+    return this.context as AiVocPreparedContext;
+  }
+
+  get insightResult(): NonNullable<AiVocGenerationState['result']> {
+    return this.generation.result as NonNullable<AiVocGenerationState['result']>;
+  }
+
+  get generationProgress(): string {
+    const received = this.generation.progressText.trim().length;
+    return received
+      ? `正在接收并校验结构化结果,已接收 ${received} 个字符`
+      : '正在建立证据链并生成待验证洞察';
+  }
+
+  onFiltersChanged(): void {
+    this.generationSubscription?.unsubscribe();
+    this.generationRunId += 1;
+    this.pendingAnalysisFinalization = null;
+    this.activeAnalysisId = '';
+    this.finalizedAnalysisId = '';
+    this.analysisProcessingReady = false;
+    this.generation = this.emptyGeneration();
+    this.selectedInsight = null;
+    this.selectedHistoryId = '';
+    this.persistenceNotice = '';
+    this.prepareContext();
+  }
+
+  reloadDataset(): void {
+    this.datasetLoading = true;
+    this.datasetError = '';
+    this.datasetService.refresh();
+    this.cdr.markForCheck();
+  }
+
+  loadGatewayStatus(): void {
+    this.gatewayLoading = true;
+    this.gatewayError = '';
+    this.gatewayService.status()
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: (status) => {
+          this.gatewayStatus = status;
+          this.gatewayLoading = false;
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          this.gatewayStatus = null;
+          this.gatewayLoading = false;
+          this.gatewayError = 'AI 网关状态接口暂不可用。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  loadAnalysisHistory(): void {
+    this.analysisHistoryLoading = true;
+    this.analysisHistoryError = '';
+
+    this.saasService.analyses(this.workspaceId, '', '', this.analysisHistoryLimit)
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: (page) => {
+          this.analysisHistory = this.normalizeAnalysisHistory(page.items);
+          this.analysisHistoryLoading = false;
+          const rememberedId = this.readRememberedAnalysisId();
+          const rememberedRun = rememberedId
+            ? this.analysisHistory.find((run) => run.id === rememberedId)
+            : undefined;
+          if (rememberedRun && this.isRestorableStatus(rememberedRun.status) && rememberedRun.result) {
+            this.restoreHistoryRun(rememberedRun);
+          }
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          this.analysisHistory = [];
+          this.analysisHistoryLoading = false;
+          this.analysisHistoryError = '分析历史加载失败,当前页仍可继续生成洞察。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private loadInsightDecisions(analysisId: string): void {
+    this.resetDecisionState();
+    if (!analysisId) return;
+
+    this.decisionHistoryLoading = true;
+    this.saasService.insightDecisions(this.workspaceId, analysisId, '', true)
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: (page) => {
+          for (const decision of page.items) {
+            this.decisionsByInsight.set(decision.sourceInsightId, decision);
+            this.reviewedEvidenceByInsight.set(
+              decision.sourceInsightId,
+              new Set(decision.reviewedEvidenceIds),
+            );
+          }
+          this.decisionHistoryLoading = false;
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          this.decisionHistoryLoading = false;
+          this.decisionHistoryError = '人工决策记录加载失败,请刷新后重试。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  generate(): void {
+    const context = this.context;
+    if (!context || !this.canGenerate) return;
+
+    const runId = ++this.generationRunId;
+    const startedAt = new Date().toISOString();
+    this.generationSubscription?.unsubscribe();
+    this.pendingAnalysisFinalization = null;
+    this.activeAnalysisId = '';
+    this.clearRememberedAnalysisId();
+    this.finalizedAnalysisId = '';
+    this.analysisProcessingReady = false;
+    this.selectedHistoryId = '';
+    this.persistenceNotice = '';
+    this.generation = {
+      loading: true,
+      complete: false,
+      progressText: '',
+      result: null,
+      error: '',
+    };
+    this.selectedInsight = null;
+    this.resetDecisionState();
+    this.cdr.markForCheck();
+
+    this.persistAnalysisStart(context, runId, startedAt);
+    const generation$ = this.gatewayStatus?.configured
+      ? this.insightService.generate(context)
+      : of<AiVocGenerationState>({
+          loading: false,
+          complete: true,
+          progressText: '',
+          result: this.insightService.buildDeterministicResult(context),
+          error: '',
+        });
+    this.generationSubscription = generation$
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: (state) => {
+          if (runId !== this.generationRunId) return;
+          this.generation = state;
+          if (state.complete && state.result) {
+            this.queueAnalysisCompletion(runId, state.result);
+          } else if (state.complete && state.error) {
+            this.queueAnalysisFailure(runId, state.error);
+          }
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          if (runId !== this.generationRunId) return;
+          this.generation = {
+            loading: false,
+            complete: true,
+            progressText: '',
+            result: null,
+            error: 'AI 洞察生成失败,请确认网关连接后重试。',
+          };
+          this.queueAnalysisFailure(runId, this.generation.error);
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  openAction(insight: AiVocInsightItem): void {
+    if (this.createdActionIds.has(insight.id) || !this.canCreateAction(insight)) return;
+    this.selectedInsight = insight;
+  }
+
+  decisionFor(insight: AiVocInsightItem): SaasInsightDecision | null {
+    return this.decisionsByInsight.get(insight.id) ?? null;
+  }
+
+  decisionLabel(decision: SaasInsightDecisionType): string {
+    const labels: Record<SaasInsightDecisionType, string> = {
+      confirmed: '已确认',
+      rejected: '已驳回',
+      needs_more_evidence: '需要补证据',
+    };
+    return labels[decision];
+  }
+
+  isReviewingDecision(insight: AiVocInsightItem): boolean {
+    return !this.decisionFor(insight) || this.decisionEditingIds.has(insight.id);
+  }
+
+  beginDecisionRevision(insight: AiVocInsightItem): void {
+    const decision = this.decisionFor(insight);
+    if (decision) {
+      this.decisionComments[insight.id] = decision.comment;
+      this.reviewedEvidenceByInsight.set(insight.id, new Set(decision.reviewedEvidenceIds));
+    }
+    this.decisionErrors[insight.id] = '';
+    this.decisionEditingIds.add(insight.id);
+  }
+
+  cancelDecisionRevision(insight: AiVocInsightItem): void {
+    this.decisionEditingIds.delete(insight.id);
+    this.decisionErrors[insight.id] = '';
+  }
+
+  isEvidenceReviewed(insight: AiVocInsightItem, evidenceId: string): boolean {
+    return this.reviewedEvidenceByInsight.get(insight.id)?.has(evidenceId) ?? false;
+  }
+
+  toggleEvidenceReview(insight: AiVocInsightItem, evidenceId: string, checked: boolean): void {
+    const reviewed = new Set(this.reviewedEvidenceByInsight.get(insight.id) ?? []);
+    if (checked) reviewed.add(evidenceId);
+    else reviewed.delete(evidenceId);
+    this.reviewedEvidenceByInsight.set(insight.id, reviewed);
+    this.decisionErrors[insight.id] = '';
+  }
+
+  reviewedEvidenceCount(insight: AiVocInsightItem): number {
+    return this.reviewedEvidenceByInsight.get(insight.id)?.size ?? 0;
+  }
+
+  canSubmitDecision(insight: AiVocInsightItem, decision: SaasInsightDecisionType): boolean {
+    if (!this.activeAnalysisId || this.decisionSubmittingIds.has(insight.id)) return false;
+    if (this.generation.result?.mode === 'deterministic' && decision !== 'needs_more_evidence') return false;
+    const reviewed = this.reviewedEvidenceByInsight.get(insight.id);
+    return insight.evidenceIds.length > 0
+      && insight.evidenceIds.every((id) => reviewed?.has(id))
+      && !!this.decisionComments[insight.id]?.trim();
+  }
+
+  submitDecision(insight: AiVocInsightItem, decision: SaasInsightDecisionType): void {
+    if (!this.canSubmitDecision(insight, decision)) return;
+    const analysisId = this.activeAnalysisId;
+    this.decisionSubmittingIds.add(insight.id);
+    this.decisionErrors[insight.id] = '';
+
+    this.saasService.createInsightDecision(this.workspaceId, {
+      sourceAnalysisId: analysisId,
+      sourceInsightId: insight.id,
+      decision,
+      reviewedEvidenceIds: insight.evidenceIds.filter((id) => this.isEvidenceReviewed(insight, id)),
+      comment: this.decisionComments[insight.id].trim(),
+    }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
+      next: ({ decision: saved }) => {
+        this.decisionsByInsight.set(insight.id, saved);
+        this.reviewedEvidenceByInsight.set(insight.id, new Set(saved.reviewedEvidenceIds));
+        this.decisionEditingIds.delete(insight.id);
+        this.decisionSubmittingIds.delete(insight.id);
+        this.cdr.markForCheck();
+      },
+      error: () => {
+        this.decisionErrors[insight.id] = '决策保存失败,请检查证据和分析状态后重试。';
+        this.decisionSubmittingIds.delete(insight.id);
+        this.cdr.markForCheck();
+      },
+    });
+  }
+
+  canCreateAction(insight: AiVocInsightItem): boolean {
+    const decision = this.decisionFor(insight)?.decision;
+    return decision === 'confirmed' || decision === 'needs_more_evidence';
+  }
+
+  actionButtonLabel(insight: AiVocInsightItem): string {
+    if (this.isActionCreated(insight)) return '已进入行动中心';
+    const decision = this.decisionFor(insight)?.decision;
+    if (decision === 'confirmed') return '创建正式行动';
+    if (decision === 'needs_more_evidence') return '创建补证据任务';
+    if (decision === 'rejected') return '洞察已驳回';
+    return '审阅后创建行动';
+  }
+
+  actionTypeFor(insight: AiVocInsightItem): SaasActionItem['actionType'] {
+    return this.decisionFor(insight)?.decision === 'needs_more_evidence' ? 'data_quality' : 'general';
+  }
+
+  actionCreationKey(insight: AiVocInsightItem): string | null {
+    const decision = this.decisionFor(insight);
+    if (!decision || !this.activeAnalysisId) return null;
+    return [this.workspaceId, this.activeAnalysisId, insight.id, decision.id].join('|');
+  }
+
+  cancelAction(): void {
+    this.selectedInsight = null;
+  }
+
+  onActionCreated(_: SaasActionItem): void {
+    if (this.selectedInsight) this.createdActionIds.add(this.selectedInsight.id);
+    window.setTimeout(() => {
+      this.selectedInsight = null;
+      this.cdr.markForCheck();
+    }, 700);
+  }
+
+  isActionCreated(insight: AiVocInsightItem): boolean {
+    return this.createdActionIds.has(insight.id);
+  }
+
+  evidenceFor(insight: AiVocInsightItem): AiVocEvidenceReview[] {
+    const ids = new Set(insight.evidenceIds);
+    return (this.context?.evidence ?? []).filter((item) => ids.has(item.id));
+  }
+
+  firstEvidence(insight: AiVocInsightItem): AiVocEvidenceReview | null {
+    const firstId = insight.evidenceIds[0];
+    return this.context?.evidence.find((item) => item.id === firstId) ?? null;
+  }
+
+  productKeyFor(insight: AiVocInsightItem): string {
+    return this.firstEvidence(insight)?.productKey ?? '';
+  }
+
+  actionTitle(insight: AiVocInsightItem): string {
+    const prefix = this.actionTypeFor(insight) === 'data_quality' ? '补充证据' : insight.title;
+    return `${prefix}:${insight.recommendation}`.slice(0, 120);
+  }
+
+  actionDescription(insight: AiVocInsightItem): string {
+    return [
+      `洞察发现:${insight.finding}`,
+      `用户需要:${insight.userNeed}`,
+      `建议动作:${insight.recommendation}`,
+      `验证指标:${insight.validationMetric}`,
+      `证据 ID:${insight.evidenceIds.join('、')}`,
+    ].join('\n');
+  }
+
+  severityLabel(value: AiVocSeverity): string {
+    return ({ critical: '紧急', high: '高', medium: '中', low: '低' } as const)[value];
+  }
+
+  confidenceLabel(value: AiVocConfidence): string {
+    return ({ high: '高置信', medium: '中置信', low: '低置信' } as const)[value];
+  }
+
+  opportunityLabel(value: AiVocOpportunityStatus): string {
+    return ({
+      supported: '证据支持',
+      partially_supported: '部分支持',
+      needs_validation: '待验证',
+      not_supported: '暂不支持',
+    } as const)[value];
+  }
+
+  sentimentLabel(value: Exclude<AiVocSentiment, 'all'>): string {
+    return ({ negative: '负向', neutral: '中性', positive: '正向' } as const)[value];
+  }
+
+  formatDate(value: string): string {
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return value || '-';
+    return new Intl.DateTimeFormat('zh-CN', {
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      hour12: false,
+    }).format(date);
+  }
+
+  trackEvidence(_: number, evidence: AiVocEvidenceReview): string {
+    return evidence.id;
+  }
+
+  restoreHistoryRun(run: SaasAnalysisRun): void {
+    if (!this.isVocInsightAnalysis(run) || !this.isRestorableStatus(run.status) || !run.result) return;
+
+    const restoredFilters = this.readAnalysisFilters(this.readAnalysisInput(run.input)?.['filters']);
+    if (restoredFilters) {
+      this.filters = restoredFilters;
+      this.prepareContext();
+    }
+
+    const restoredResult = this.readAnalysisResult(run.result);
+    if (!restoredResult) {
+      this.generation = this.emptyGeneration();
+      this.selectedInsight = null;
+      this.resetDecisionState();
+      this.selectedHistoryId = '';
+      this.activeAnalysisId = '';
+      this.finalizedAnalysisId = '';
+      this.clearRememberedAnalysisId();
+      this.persistenceNotice = '已找到历史记录,但结果结构不完整,暂时不能恢复。';
+      this.cdr.markForCheck();
+      return;
+    }
+
+    this.generation = {
+      loading: false,
+      complete: true,
+      progressText: '',
+      result: restoredResult,
+      error: '',
+    };
+    this.selectedInsight = null;
+    this.selectedHistoryId = run.id;
+    this.activeAnalysisId = run.id;
+    this.rememberActiveAnalysisId(run.id);
+    this.loadInsightDecisions(run.id);
+    this.finalizedAnalysisId = run.id;
+    this.persistenceNotice = `已恢复 ${this.formatDate(run.requestedAt)} 的分析结果。`;
+    this.cdr.markForCheck();
+  }
+
+  historyLabel(run: SaasAnalysisRun): string {
+    const input = this.readAnalysisInput(run.input);
+    const filters = input ? this.readAnalysisFilters(input['filters']) : null;
+    const scope = filters?.scope ?? 'all';
+    const sentiment = filters?.sentiment ?? 'all';
+    const sampleCount = input?.['sampleCount'];
+    const sampleLimit = filters?.sampleLimit ?? (typeof sampleCount === 'number' ? sampleCount : 0);
+
+    return [
+      scope === 'all' ? '全部范围' : scope === 'own' ? '本品' : '竞品',
+      sentiment === 'all' ? '全部情感' : sentiment === 'negative' ? '负向' : sentiment === 'neutral' ? '中性' : '正向',
+      sampleLimit ? `样本 ${sampleLimit}` : '样本未知',
+    ].join(' · ');
+  }
+
+  historySummary(run: SaasAnalysisRun): string {
+    const input = this.readAnalysisInput(run.input);
+    const topicsValue = input?.['topics'];
+    const evidenceIdsValue = input?.['evidenceIds'];
+    const topics = Array.isArray(topicsValue) ? topicsValue : [];
+    const evidenceIds = Array.isArray(evidenceIdsValue) ? evidenceIdsValue.length : run.evidenceCount;
+    return `证据 ${evidenceIds} · 主题 ${topics.length}`;
+  }
+
+  historyCanRestore(run: SaasAnalysisRun): boolean {
+    return this.isVocInsightAnalysis(run) && this.isRestorableStatus(run.status) && !!run.result;
+  }
+
+  historyStatusLabel(run: SaasAnalysisRun): string {
+    return ({
+      pending: '待处理',
+      processing: '生成中',
+      completed: '已完成',
+      partial: '部分完成',
+      failed: '失败',
+      cancelled: '已取消',
+    } as const)[run.status];
+  }
+
+  isHistoryActive(run: SaasAnalysisRun): boolean {
+    return this.selectedHistoryId === run.id || this.activeAnalysisId === run.id;
+  }
+
+  private persistAnalysisStart(context: AiVocPreparedContext, runId: number, startedAt: string): void {
+    this.saasService.createAnalysis(this.workspaceId, {
+      analysisType: 'voc_insight',
+      targetKind: 'workspace',
+      targetKey: this.workspaceId,
+      input: this.buildAnalysisInput(context),
+    })
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: ({ analysis }) => {
+          if (runId !== this.generationRunId) return;
+          this.activeAnalysisId = analysis.id;
+          this.rememberActiveAnalysisId(analysis.id);
+          this.analysisProcessingReady = false;
+          this.analysisHistory = this.upsertAnalysisHistory({
+            ...analysis,
+            status: 'processing',
+            startedAt: analysis.startedAt ?? startedAt,
+            evidenceCount: analysis.evidenceCount || context.evidence.length,
+          });
+          this.updateAnalysisProcessing(analysis.id, startedAt, runId);
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          if (runId !== this.generationRunId) return;
+          this.persistenceNotice = '分析记录保存失败,本次洞察生成仍会继续。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private updateAnalysisProcessing(analysisId: string, startedAt: string, runId: number): void {
+    this.saasService.updateAnalysis(this.workspaceId, analysisId, {
+      status: 'processing',
+      startedAt,
+    })
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: ({ analysis }) => {
+          if (runId !== this.generationRunId && analysis.id !== this.activeAnalysisId) return;
+          this.analysisProcessingReady = true;
+          this.analysisHistory = this.upsertAnalysisHistory(analysis);
+          this.flushPendingAnalysisFinalization(runId, analysis.id);
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          if (runId !== this.generationRunId) return;
+          this.persistenceNotice = '分析记录已创建,但处理状态尚未同步完成。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private queueAnalysisCompletion(runId: number, result: NonNullable<AiVocGenerationState['result']>): void {
+    this.pendingAnalysisFinalization = { runId, kind: 'completed', result };
+    const analysisId = this.currentAnalysisId(runId);
+    if (analysisId) {
+      this.commitAnalysisCompletion(analysisId, result, runId);
+    }
+  }
+
+  private queueAnalysisFailure(runId: number, errorSummary: string): void {
+    this.pendingAnalysisFinalization = { runId, kind: 'failed', errorSummary };
+    const analysisId = this.currentAnalysisId(runId);
+    if (analysisId) {
+      this.commitAnalysisFailure(analysisId, errorSummary, runId);
+    }
+  }
+
+  private flushPendingAnalysisFinalization(runId: number, analysisId: string): void {
+    const pending = this.pendingAnalysisFinalization;
+    if (!pending || pending.runId !== runId) return;
+    this.pendingAnalysisFinalization = null;
+
+    if (pending.kind === 'completed' && pending.result) {
+      this.commitAnalysisCompletion(analysisId, pending.result, runId);
+      return;
+    }
+
+    if (pending.kind === 'failed') {
+      this.commitAnalysisFailure(analysisId, pending.errorSummary ?? 'AI 洞察生成失败。', runId);
+    }
+  }
+
+  private commitAnalysisCompletion(
+    analysisId: string,
+    result: NonNullable<AiVocGenerationState['result']>,
+    runId: number,
+  ): void {
+    if (this.finalizedAnalysisId === analysisId) return;
+    this.finalizedAnalysisId = analysisId;
+    this.saasService.updateAnalysis(this.workspaceId, analysisId, {
+      status: result.mode === 'deterministic' ? 'partial' : 'completed',
+      result: this.serializeAnalysisResult(result),
+      evidenceCount: new Set(result.insights.flatMap((item) => item.evidenceIds)).size,
+      completedAt: result.generatedAt ?? new Date().toISOString(),
+    })
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: ({ analysis }) => {
+          if (runId !== this.generationRunId && analysis.id !== this.activeAnalysisId) return;
+          this.analysisHistory = this.upsertAnalysisHistory(analysis);
+          this.persistenceNotice = result.mode === 'deterministic'
+            ? '规则摘要已保存为部分完成,需人工复核后再转行动。'
+            : 'AI 洞察结果已保存到分析历史。';
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          if (runId !== this.generationRunId) return;
+          this.persistenceNotice = '洞察已生成,但历史结果尚未保存完成。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private commitAnalysisFailure(analysisId: string, errorSummary: string, runId: number): void {
+    if (this.finalizedAnalysisId === analysisId) return;
+    this.finalizedAnalysisId = analysisId;
+    this.saasService.updateAnalysis(this.workspaceId, analysisId, {
+      status: 'failed',
+      errorSummary,
+      completedAt: new Date().toISOString(),
+    })
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: ({ analysis }) => {
+          if (runId !== this.generationRunId && analysis.id !== this.activeAnalysisId) return;
+          this.analysisHistory = this.upsertAnalysisHistory(analysis);
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          if (runId !== this.generationRunId) return;
+          this.persistenceNotice = '生成失败状态尚未同步到分析历史。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private currentAnalysisId(runId: number): string | null {
+    return this.generationRunId === runId && this.analysisProcessingReady
+      ? this.activeAnalysisId || null
+      : null;
+  }
+
+  private buildAnalysisInput(context: AiVocPreparedContext): Record<string, unknown> {
+    return {
+      filters: context.filters,
+      evidenceIds: context.evidence.map((item) => item.id),
+      topics: context.topics.map((topic) => ({
+        id: topic.id,
+        label: topic.label,
+        count: topic.count,
+        share: topic.share,
+      })),
+      sampleCount: context.evidence.length,
+      sourceCounts: context.sourceCounts,
+      sentimentCounts: context.sentimentCounts,
+      generatedBy: 'ai-voc-insight',
+    };
+  }
+
+  private serializeAnalysisResult(result: NonNullable<AiVocGenerationState['result']>): Record<string, unknown> {
+    return result as unknown as Record<string, unknown>;
+  }
+
+  private readAnalysisInput(input: Record<string, unknown>): Record<string, unknown> | null {
+    return input && typeof input === 'object' ? input : null;
+  }
+
+  private readAnalysisFilters(input: unknown): AiVocInsightFilters | null {
+    if (!input || typeof input !== 'object') return null;
+    const record = input as Record<string, unknown>;
+    const scope = record['scope'];
+    const sentiment = record['sentiment'];
+    const sampleLimit = record['sampleLimit'];
+
+    if (!this.isAiVocScope(scope) || !this.isAiVocSentiment(sentiment) || typeof sampleLimit !== 'number') return null;
+    return {
+      scope,
+      sentiment,
+      sampleLimit,
+    };
+  }
+
+  private readAnalysisResult(result: Record<string, unknown>): NonNullable<AiVocGenerationState['result']> | null {
+    if (!result || typeof result !== 'object') return null;
+    const mode = result['mode'];
+    const filters = this.readAnalysisFilters(result['filters']);
+    if (
+      (mode !== 'ai' && mode !== 'deterministic')
+      || !filters
+      || !this.isReadableAnalysisText(result['headline'])
+      || !this.isReadableAnalysisText(result['executiveSummary'])
+      || typeof result['generatedAt'] !== 'string'
+      || typeof result['sampleCount'] !== 'number'
+      || typeof result['evidenceCoverage'] !== 'number'
+      || !Array.isArray(result['insights'])
+      || !Array.isArray(result['risks'])
+      || !Array.isArray(result['followUpQuestions'])
+      || !this.hasReadableInsightText(result['insights'])
+    ) return null;
+    return result as unknown as NonNullable<AiVocGenerationState['result']>;
+  }
+
+  private isReadableAnalysisText(value: unknown): value is string {
+    if (typeof value !== 'string' || !value.trim()) return false;
+    const text = value.trim();
+    const corruptionCount = (text.match(/[?\uFFFD]/g) ?? []).length;
+    return corruptionCount < 3 || corruptionCount / text.length < 0.15;
+  }
+
+  private hasReadableInsightText(value: unknown[]): boolean {
+    return value.every((item) => {
+      if (!item || typeof item !== 'object') return false;
+      const record = item as Record<string, unknown>;
+      return ['title', 'finding', 'userNeed', 'recommendation', 'validationMetric']
+        .every((key) => this.isReadableAnalysisText(record[key]));
+    });
+  }
+
+  private isVocInsightAnalysis(run: SaasAnalysisRun): boolean {
+    return run.analysisType === 'voc_insight' && run.targetKind === 'workspace' && run.targetKey === this.workspaceId;
+  }
+
+  private rememberActiveAnalysisId(analysisId: string): void {
+    try {
+      sessionStorage.setItem(this.activeAnalysisStorageKey, analysisId);
+    } catch {
+      // Session storage can be unavailable in restricted browser contexts.
+    }
+  }
+
+  private readRememberedAnalysisId(): string {
+    try {
+      return sessionStorage.getItem(this.activeAnalysisStorageKey) ?? '';
+    } catch {
+      return '';
+    }
+  }
+
+  private clearRememberedAnalysisId(): void {
+    try {
+      sessionStorage.removeItem(this.activeAnalysisStorageKey);
+    } catch {
+      // Session storage can be unavailable in restricted browser contexts.
+    }
+  }
+
+  private resetDecisionState(): void {
+    this.decisionsByInsight.clear();
+    this.reviewedEvidenceByInsight.clear();
+    this.decisionSubmittingIds.clear();
+    this.decisionEditingIds.clear();
+    for (const key of Object.keys(this.decisionComments)) delete this.decisionComments[key];
+    for (const key of Object.keys(this.decisionErrors)) delete this.decisionErrors[key];
+    this.decisionHistoryLoading = false;
+    this.decisionHistoryError = '';
+  }
+
+  private isRestorableStatus(status: SaasAnalysisRun['status']): boolean {
+    return status === 'completed' || status === 'partial';
+  }
+
+  private isAiVocScope(value: unknown): value is AiVocScope {
+    return value === 'all' || value === 'own' || value === 'competitor';
+  }
+
+  private isAiVocSentiment(value: unknown): value is AiVocSentiment {
+    return value === 'all' || value === 'negative' || value === 'neutral' || value === 'positive';
+  }
+
+  private upsertAnalysisHistory(run: SaasAnalysisRun): SaasAnalysisRun[] {
+    if (!this.isVocInsightAnalysis(run)) return this.analysisHistory;
+    const items = this.analysisHistory.filter((item) => item.id !== run.id);
+    items.unshift(run);
+    return this.normalizeAnalysisHistory(items);
+  }
+
+  private normalizeAnalysisHistory(items: SaasAnalysisRun[]): SaasAnalysisRun[] {
+    return items
+      .filter((item) => this.isVocInsightAnalysis(item))
+      .sort((left, right) => this.dateValue(right.requestedAt) - this.dateValue(left.requestedAt))
+      .slice(0, this.analysisHistoryLimit);
+  }
+
+  private dateValue(value: string | null): number {
+    const date = new Date(value ?? '');
+    return Number.isNaN(date.getTime()) ? 0 : date.getTime();
+  }
+
+  private prepareContext(): void {
+    if (!this.dataset) return;
+    this.context = this.insightService.prepare(this.dataset, this.filters);
+    this.cdr.markForCheck();
+  }
+
+  private emptyGeneration(): AiVocGenerationState {
+    return {
+      loading: false,
+      complete: false,
+      progressText: '',
+      result: null,
+      error: '',
+    };
+  }
+}

+ 81 - 0
src/modules/ai-voc-insight/ai-voc-insight.models.ts

@@ -0,0 +1,81 @@
+import { DomesticReview } from '../../app/core/models/domestic.models';
+
+export type AiVocScope = 'all' | 'own' | 'competitor';
+export type AiVocSentiment = 'all' | 'negative' | 'neutral' | 'positive';
+export type AiVocConfidence = 'low' | 'medium' | 'high';
+export type AiVocSeverity = 'critical' | 'high' | 'medium' | 'low';
+export type AiVocOpportunityStatus = 'supported' | 'partially_supported' | 'needs_validation' | 'not_supported';
+
+export interface AiVocInsightFilters {
+  scope: AiVocScope;
+  sentiment: AiVocSentiment;
+  sampleLimit: number;
+}
+
+export interface AiVocEvidenceReview {
+  id: string;
+  review: DomesticReview;
+  productKey: string;
+  productId: string;
+  productTitle: string;
+  brand: string;
+  sourceRole: Exclude<AiVocScope, 'all'> | 'unknown';
+  sourceLabel: string;
+  sentiment: Exclude<AiVocSentiment, 'all'>;
+  topicId: string;
+  topicLabel: string;
+  dateLabel: string;
+}
+
+export interface AiVocTopicSummary {
+  id: string;
+  label: string;
+  count: number;
+  share: number;
+}
+
+export interface AiVocPreparedContext {
+  filters: AiVocInsightFilters;
+  evidence: AiVocEvidenceReview[];
+  totalMeaningfulCount: number;
+  matchingCount: number;
+  sourceCounts: Record<'own' | 'competitor' | 'unknown', number>;
+  sentimentCounts: Record<Exclude<AiVocSentiment, 'all'>, number>;
+  topics: AiVocTopicSummary[];
+  generatedAt: string;
+}
+
+export interface AiVocInsightItem {
+  id: string;
+  title: string;
+  finding: string;
+  userNeed: string;
+  severity: AiVocSeverity;
+  confidence: AiVocConfidence;
+  opportunityStatus: AiVocOpportunityStatus;
+  evidenceIds: string[];
+  evidenceCount: number;
+  recommendation: string;
+  validationMetric: string;
+}
+
+export interface AiVocInsightResult {
+  mode: 'ai' | 'deterministic';
+  headline: string;
+  executiveSummary: string;
+  generatedAt: string;
+  filters: AiVocInsightFilters;
+  sampleCount: number;
+  evidenceCoverage: number;
+  insights: AiVocInsightItem[];
+  risks: string[];
+  followUpQuestions: string[];
+}
+
+export interface AiVocGenerationState {
+  loading: boolean;
+  complete: boolean;
+  progressText: string;
+  result: AiVocInsightResult | null;
+  error: string;
+}

+ 368 - 0
src/modules/ai-voc-insight/ai-voc-insight.service.spec.ts

@@ -0,0 +1,368 @@
+import { TestBed } from '@angular/core/testing';
+import { firstValueFrom, of } from 'rxjs';
+import {
+  DomesticDataset,
+  DomesticProduct,
+  DomesticReview,
+} from '../../app/core/models/domestic.models';
+import { DomesticReviewInsightService } from '../../app/core/services/domestic-review-insight.service';
+import { AiAnalysisService, AiStreamState } from '../shared/services/ai-analysis.service';
+import { AiVocInsightFilters } from './ai-voc-insight.models';
+import { AiVocInsightService } from './ai-voc-insight.service';
+
+describe('AiVocInsightService', () => {
+  let service: AiVocInsightService;
+  let ai: jasmine.SpyObj<AiAnalysisService>;
+
+  const defaultFilters: AiVocInsightFilters = {
+    scope: 'all',
+    sentiment: 'all',
+    sampleLimit: 40,
+  };
+
+  beforeEach(() => {
+    ai = jasmine.createSpyObj<AiAnalysisService>('AiAnalysisService', ['streamAnalysis']);
+    TestBed.configureTestingModule({
+      providers: [
+        AiVocInsightService,
+        DomesticReviewInsightService,
+        { provide: AiAnalysisService, useValue: ai },
+      ],
+    });
+    service = TestBed.inject(AiVocInsightService);
+  });
+
+  it('filters reviews without meaningful text before preparing evidence', () => {
+    const context = service.prepare(createDataset([
+      review('valid', 'own-product', 5, 'Useful review text', '2026-01-03'),
+      review('empty', 'own-product', 1, '', '2026-01-02'),
+      review('punctuation', 'own-product', 2, '!!!', '2026-01-01'),
+    ]), defaultFilters);
+
+    expect(context.totalMeaningfulCount).toBe(1);
+    expect(context.matchingCount).toBe(1);
+    expect(context.evidence.map((item) => item.id)).toEqual(['valid']);
+  });
+
+  it('applies scope, sentiment, and the clamped sample limit', () => {
+    const ownNegative = Array.from({ length: 11 }, (_, index) =>
+      review(`own-negative-${index}`, 'own-product', 1, `Issue ${index}`, `2026-01-${String(index + 1).padStart(2, '0')}`),
+    );
+    const context = service.prepare(createDataset([
+      ...ownNegative,
+      review('own-positive', 'own-product', 5, 'Works well', '2026-01-20'),
+      review('competitor-negative', 'competitor-product', 1, 'Competitor issue', '2026-01-21'),
+    ]), {
+      scope: 'own',
+      sentiment: 'negative',
+      sampleLimit: 2,
+    });
+
+    expect(context.filters.sampleLimit).toBe(10);
+    expect(context.matchingCount).toBe(11);
+    expect(context.evidence.length).toBe(10);
+    expect(context.evidence.every((item) => item.sourceRole === 'own')).toBeTrue();
+    expect(context.evidence.every((item) => item.sentiment === 'negative')).toBeTrue();
+  });
+
+  it('orders negative evidence first and uses descending date within a sentiment', () => {
+    const context = service.prepare(createDataset([
+      review('positive-newest', 'own-product', 5, 'Positive', '2026-04-04'),
+      review('negative-old', 'own-product', 1, 'Old issue', '2026-01-01'),
+      review('neutral', 'own-product', 3, 'Neutral', '2026-03-03'),
+      review('negative-new', 'own-product', 2, 'New issue', '2026-02-02'),
+    ]), defaultFilters);
+
+    expect(context.evidence.map((item) => item.id)).toEqual([
+      'negative-new',
+      'negative-old',
+      'neutral',
+      'positive-newest',
+    ]);
+  });
+
+  it('summarizes topics, sources, and sentiments from sampled evidence', () => {
+    const context = service.prepare(createDataset([
+      review('own-negative-1', 'own-product', 1, 'First issue', '2026-01-04'),
+      review('own-negative-2', 'own-product', 2, 'Second issue', '2026-01-03'),
+      review('competitor-neutral', 'competitor-product', 3, 'Average result', '2026-01-02'),
+      review('competitor-positive', 'competitor-product', 5, 'Strong result', '2026-01-01'),
+    ]), defaultFilters);
+
+    expect(context.sourceCounts).toEqual(jasmine.objectContaining({ own: 2, competitor: 2 }));
+    expect(context.sentimentCounts).toEqual(jasmine.objectContaining({
+      negative: 2,
+      neutral: 1,
+      positive: 1,
+    }));
+    expect(context.topics.map(({ id, count, share }) => ({ id, count, share }))).toEqual(jasmine.arrayWithExactContents([
+      { id: 'unclassified-negative', count: 2, share: 0.5 },
+      { id: 'neutral-feedback', count: 1, share: 0.25 },
+      { id: 'positive-feedback', count: 1, share: 0.25 },
+    ]));
+  });
+
+  it('returns an error without calling AI when there is no evidence', async () => {
+    const state = await firstValueFrom(service.generate(service.prepare(createDataset([]), defaultFilters)));
+
+    expect(ai.streamAnalysis).not.toHaveBeenCalled();
+    expect(state.complete).toBeTrue();
+    expect(state.result).toBeNull();
+    expect(state.error).toBeTruthy();
+  });
+
+  it('redacts PII only in the AI prompt while preserving the source context and evidence ID', async () => {
+    const rawContent = [
+      '邮箱 alice.test+voc@example.com,手机号 13900139000。',
+      '订单号:ORDER-2026-0088,账号=buyer_888;',
+      '收货地址:北京市朝阳区建国路88号;联系人:张三。',
+      '功率 2200W,延迟 120ms,吞吐 123456789 ops,证据 ID EV-13800138000。',
+    ].join('');
+    const evidenceId = 'evidence-13800138000';
+    const dataset = createDataset([
+      review(evidenceId, 'own-product', 1, rawContent, '2026-01-01'),
+    ]);
+    const context = service.prepare(dataset, defaultFilters);
+    ai.streamAnalysis.and.returnValue(completedAiResponse({
+      insights: [{ title: 'Grounded finding', evidenceIds: [evidenceId] }],
+    }));
+
+    await firstValueFrom(service.generate(context));
+
+    const prompt = ai.streamAnalysis.calls.mostRecent().args[0];
+    expect(prompt).not.toContain('alice.test+voc@example.com');
+    expect(prompt).not.toContain('13900139000');
+    expect(prompt).not.toContain('ORDER-2026-0088');
+    expect(prompt).not.toContain('buyer_888');
+    expect(prompt).not.toContain('北京市朝阳区建国路88号');
+    expect(prompt).not.toContain('张三');
+    expect(prompt).toContain('[邮箱已脱敏]');
+    expect(prompt).toContain('[手机号已脱敏]');
+    expect(prompt).toContain('订单号:[订单号已脱敏]');
+    expect(prompt).toContain('账号=[账号已脱敏]');
+    expect(prompt).toContain('收货地址:[收货地址已脱敏]');
+    expect(prompt).toContain('联系人:[联系人已脱敏]');
+    expect(prompt).toContain('功率 2200W');
+    expect(prompt).toContain('延迟 120ms');
+    expect(prompt).toContain('吞吐 123456789 ops');
+    expect(prompt).toContain('证据 ID EV-13800138000');
+    expect(prompt).toContain(`\"evidenceId\":\"${evidenceId}\"`);
+    expect(context.evidence[0].review.content).toBe(rawContent);
+    expect(dataset.reviews?.[0].content).toBe(rawContent);
+  });
+
+  it('builds a deterministic fallback whose evidence IDs all come from the input context', () => {
+    const context = service.prepare(createDataset([
+      review('rule-evidence-1', 'own-product', 1, 'Battery drains quickly', '2026-01-01'),
+      review('rule-evidence-2', 'own-product', 2, 'Battery life is short', '2026-01-02'),
+      review('rule-evidence-3', 'own-product', 5, 'Works well overall', '2026-01-03'),
+    ]), defaultFilters);
+
+    const result = service.buildDeterministicResult(context);
+    const inputEvidenceIds = new Set(context.evidence.map((item) => item.id));
+
+    expect(result?.mode).toBe('deterministic');
+    expect(result?.insights.length).toBeGreaterThan(0);
+    result?.insights.forEach((insight) => {
+      expect(insight.evidenceIds.every((id) => inputEvidenceIds.has(id))).toBeTrue();
+    });
+  });
+
+  it('marks deterministic insights as needing validation when the sample is insufficient', () => {
+    const context = service.prepare(createDataset([
+      review('small-sample-evidence', 'own-product', 1, 'Battery drains quickly', '2026-01-01'),
+    ]), defaultFilters);
+
+    const result = service.buildDeterministicResult(context);
+
+    expect(result?.sampleCount).toBe(1);
+    expect(result?.insights.length).toBeGreaterThan(0);
+    expect(result?.insights.every((insight) => insight.opportunityStatus === 'needs_validation')).toBeTrue();
+  });
+
+  it('removes invalid evidence IDs returned by AI', async () => {
+    const context = service.prepare(createDataset([
+      review('evidence-1', 'own-product', 1, 'Issue one', '2026-01-01'),
+      review('evidence-2', 'own-product', 2, 'Issue two', '2026-01-02'),
+    ]), defaultFilters);
+    ai.streamAnalysis.and.returnValue(completedAiResponse({
+      insights: [{ title: 'Finding', evidenceIds: ['missing-id', 'evidence-1'] }],
+    }));
+
+    const state = await firstValueFrom(service.generate(context));
+
+    expect(state.error).toBe('');
+    expect(state.result?.insights[0].evidenceIds).toEqual(['evidence-1']);
+    expect(state.result?.insights[0].evidenceCount).toBe(1);
+  });
+
+  it('falls back to a deterministic result when AI supplies no valid evidence references', async () => {
+    const context = service.prepare(createDataset([
+      review('evidence-1', 'own-product', 1, 'Issue one', '2026-01-01'),
+    ]), defaultFilters);
+    ai.streamAnalysis.and.returnValue(completedAiResponse({
+      insights: [{ title: 'Unsupported finding', evidenceIds: ['missing-id'] }],
+    }));
+
+    const state = await firstValueFrom(service.generate(context));
+
+    expect(state.complete).toBeTrue();
+    expect(state.result?.mode).toBe('deterministic');
+    expect(state.result?.insights.every((insight) => insight.opportunityStatus === 'needs_validation')).toBeTrue();
+    expect(state.error).toBe('');
+  });
+
+  it('falls back to a deterministic result when the AI stream reports an error', async () => {
+    const context = service.prepare(createDataset([
+      review('evidence-1', 'own-product', 1, 'Issue one', '2026-01-01'),
+    ]), defaultFilters);
+    ai.streamAnalysis.and.returnValue(of<AiStreamState>({
+      text: '',
+      delta: '',
+      loading: false,
+      complete: true,
+      error: 'gateway unavailable',
+    }));
+
+    const state = await firstValueFrom(service.generate(context));
+
+    expect(state.result?.mode).toBe('deterministic');
+    expect(state.result?.insights[0].evidenceIds).toEqual(['evidence-1']);
+    expect(state.error).toBe('');
+  });
+
+  it('computes evidence coverage and confidence from valid references instead of the AI claim', async () => {
+    const context = service.prepare(createDataset([
+      review('evidence-1', 'own-product', 1, 'Issue one', '2026-01-01'),
+      review('evidence-2', 'own-product', 1, 'Issue two', '2026-01-02'),
+      review('evidence-3', 'own-product', 1, 'Issue three', '2026-01-03'),
+      review('evidence-4', 'own-product', 1, 'Issue four', '2026-01-04'),
+    ]), defaultFilters);
+    ai.streamAnalysis.and.returnValue(completedAiResponse({
+      insights: [{
+        title: 'Grounded finding',
+        confidence: 'high',
+        evidenceIds: ['evidence-1', 'evidence-2', 'evidence-3'],
+      }],
+    }));
+
+    const state = await firstValueFrom(service.generate(context));
+
+    expect(state.result?.evidenceCoverage).toBe(0.75);
+    expect(state.result?.insights[0].evidenceCount).toBe(3);
+    expect(state.result?.insights[0].confidence).toBe('medium');
+  });
+
+  it('parses a valid JSON object when the model appends extra text', async () => {
+    const context = service.prepare(createDataset([
+      review('evidence-1', 'own-product', 1, 'Issue one', '2026-01-01'),
+    ]), defaultFilters);
+    ai.streamAnalysis.and.returnValue(of<AiStreamState>({
+      text: `${JSON.stringify({ insights: [{ title: 'Grounded', evidenceIds: ['evidence-1'] }] })}\n\n补充说明`,
+      delta: '',
+      loading: false,
+      complete: true,
+    }));
+
+    const state = await firstValueFrom(service.generate(context));
+
+    expect(state.error).toBe('');
+    expect(state.result?.insights[0].evidenceIds).toEqual(['evidence-1']);
+  });
+
+  function completedAiResponse(payload: unknown) {
+    return of<AiStreamState>({
+      text: JSON.stringify(payload),
+      delta: '',
+      loading: false,
+      complete: true,
+    });
+  }
+
+  function review(
+    reviewId: string,
+    productId: string,
+    rating: number,
+    content: string,
+    reviewDate: string,
+  ): DomesticReview {
+    return { reviewId, productId, rating, content, reviewDate };
+  }
+
+  function createDataset(reviews: DomesticReview[]): DomesticDataset {
+    return {
+      schemaVersion: 1,
+      generatedAt: '2026-08-06T00:00:00.000Z',
+      caseName: 'AI VOC service test',
+      platform: 'test-platform',
+      source: {
+        sourceFile: 'fixture.json',
+        sourceHash: 'fixture-hash',
+        dateRange: { start: '2026-01-01', end: '2026-12-31' },
+      },
+      summary: {
+        metricRows: 0,
+        metricProducts: 2,
+        mappingRows: 0,
+        relations: 0,
+        uniqueCompetitorProducts: 1,
+        category2Count: 0,
+        category3Count: 0,
+        reviewCount: reviews.length,
+      },
+      dailyTotals: [],
+      products: [
+        createProduct('own-product', 'own'),
+        createProduct('competitor-product', 'competitor'),
+      ],
+      mappingGroups: [],
+      relations: [],
+      reviews,
+      quality: {
+        orphanMappings: [],
+        mappingsWithoutCompetitor: [],
+        brandWithoutProductId: [],
+      },
+    };
+  }
+
+  function createProduct(productId: string, role: DomesticProduct['role']): DomesticProduct {
+    return {
+      platform: 'test-platform',
+      productId,
+      productKey: `test-platform:${productId}`,
+      asin: productId,
+      role,
+      brand: role === 'own' ? 'Own Brand' : 'Competitor Brand',
+      title: `${role} product`,
+      model: '',
+      category1: '',
+      category2: '',
+      category3: '',
+      source: 'test',
+      relationCount: 0,
+      summary: {
+        gmv: 0,
+        soldUnits: 0,
+        transactionOrders: 0,
+        transactionCustomers: 0,
+        impressions: 0,
+        clicks: 0,
+        views: 0,
+        visitors: 0,
+        cartUnits: 0,
+        orderAmount: 0,
+        orderUnits: 0,
+        orderCount: 0,
+        refundAmount: 0,
+        refundUnits: 0,
+        refundOrders: 0,
+        conversionRate: 0,
+        clickThroughRate: 0,
+        averageUnitPrice: 0,
+        refundToGmvRate: 0,
+      },
+      trend: [],
+    };
+  }
+});

+ 391 - 0
src/modules/ai-voc-insight/ai-voc-insight.service.ts

@@ -0,0 +1,391 @@
+import { Injectable, inject } from '@angular/core';
+import { Observable, catchError, map, of } from 'rxjs';
+import { DomesticDataset, DomesticReview } from '../../app/core/models/domestic.models';
+import { DomesticReviewInsightService } from '../../app/core/services/domestic-review-insight.service';
+import { AiAnalysisService } from '../shared/services/ai-analysis.service';
+import { ReviewDataUtils } from '../shared/services/review-data-utils';
+import {
+  AiVocConfidence,
+  AiVocEvidenceReview,
+  AiVocGenerationState,
+  AiVocInsightFilters,
+  AiVocInsightItem,
+  AiVocInsightResult,
+  AiVocOpportunityStatus,
+  AiVocPreparedContext,
+  AiVocSentiment,
+  AiVocSeverity,
+} from './ai-voc-insight.models';
+
+const TOPIC_COPY: Record<string, string> = {
+  performance: '功率与加热表现',
+  quality: '质量与耐用性',
+  service: '售后与保修',
+  safety: '安全与密封风险',
+  usability: '易用与清洁负担',
+  noise: '噪声与经营干扰',
+};
+
+const SCOPE_LABELS = {
+  all: '全部评价',
+  own: '本品评价',
+  competitor: '竞品评价',
+} as const;
+
+const PREFIXED_PII_REDACTIONS = [
+  { pattern: /(订单号\s*(?:[::=]|为(?:是)?)\s*)[^,,。;;!?!?\r\n]+/g, placeholder: '[订单号已脱敏]' },
+  { pattern: /(账号\s*(?:[::=]|为(?:是)?)\s*)[^,,。;;!?!?\r\n]+/g, placeholder: '[账号已脱敏]' },
+  { pattern: /(收货地址\s*(?:[::=]|为(?:是)?)\s*)[^,,。;;!?!?\r\n]+/g, placeholder: '[收货地址已脱敏]' },
+  { pattern: /(联系人\s*(?:[::=]|为(?:是)?)\s*)[^,,。;;!?!?\r\n]+/g, placeholder: '[联系人已脱敏]' },
+] as const;
+
+const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
+const MAINLAND_MOBILE_PATTERN = /(^|[^A-Za-z0-9_-])((?:(?:\+?86|0086)[ -]?)?1[3-9]\d(?:[ -]?\d){8})(?![A-Za-z0-9_-])/g;
+
+@Injectable({ providedIn: 'root' })
+export class AiVocInsightService {
+  private readonly ai = inject(AiAnalysisService);
+  private readonly reviewInsight = inject(DomesticReviewInsightService);
+
+  prepare(dataset: DomesticDataset, filters: AiVocInsightFilters): AiVocPreparedContext {
+    const products = new Map(dataset.products.map((product) => [product.productId, product]));
+    const meaningful = ReviewDataUtils.filterMeaningfulReviews(dataset.reviews ?? []);
+    const scoped = meaningful.filter((review) => {
+      const role = products.get(review.productId)?.role ?? 'unknown';
+      if (filters.scope !== 'all' && role !== filters.scope) return false;
+      return filters.sentiment === 'all' || this.sentiment(review.rating) === filters.sentiment;
+    });
+    const ordered = [...scoped].sort((left, right) => {
+      const sentimentDelta = this.sentimentPriority(left.rating) - this.sentimentPriority(right.rating);
+      if (sentimentDelta) return sentimentDelta;
+      return ReviewDataUtils.parseReviewDate(right) - ReviewDataUtils.parseReviewDate(left);
+    });
+    const evidence = ordered.slice(0, this.clampSampleLimit(filters.sampleLimit)).map((review) => {
+      const product = products.get(review.productId);
+      const sourceRole = product?.role ?? 'unknown';
+      const topic = this.reviewInsight.build([review]).topics[0];
+      const sentiment = this.sentiment(review.rating);
+      return {
+        id: review.reviewId || `${review.productId}:${ReviewDataUtils.parseReviewDate(review)}`,
+        review,
+        productKey: product?.productKey || `${dataset.platform}:${review.productId}`,
+        productId: review.productId,
+        productTitle: product?.title || review.productId,
+        brand: product?.brand || '',
+        sourceRole,
+        sourceLabel: sourceRole === 'own' ? '本品评价' : sourceRole === 'competitor' ? '竞品评价' : '来源待确认',
+        sentiment,
+        topicId: topic?.id || this.fallbackTopicId(sentiment),
+        topicLabel: topic ? TOPIC_COPY[topic.id] || topic.label : this.fallbackTopicLabel(sentiment),
+        dateLabel: review.reviewDate || '',
+      } satisfies AiVocEvidenceReview;
+    });
+
+    return {
+      filters: { ...filters, sampleLimit: this.clampSampleLimit(filters.sampleLimit) },
+      evidence,
+      totalMeaningfulCount: meaningful.length,
+      matchingCount: scoped.length,
+      sourceCounts: this.sourceCounts(evidence),
+      sentimentCounts: this.sentimentCounts(evidence),
+      topics: this.topicSummary(evidence),
+      generatedAt: new Date().toISOString(),
+    };
+  }
+
+  generate(context: AiVocPreparedContext): Observable<AiVocGenerationState> {
+    if (!context.evidence.length) {
+      return new Observable((subscriber) => {
+        subscriber.next({
+          loading: false,
+          complete: true,
+          progressText: '',
+          result: null,
+          error: '当前筛选范围没有可用于分析的有效评价。',
+        });
+        subscriber.complete();
+      });
+    }
+
+    return this.ai.streamAnalysis(
+      this.buildPrompt(context),
+      this.systemPrompt(),
+      { temperature: 0.2, thinking: true, outputMode: 'json', applyOutputStyle: false },
+    ).pipe(map((state): AiVocGenerationState => {
+      if (state.error) {
+        return this.deterministicState(context, state.text, state.error);
+      }
+      if (!state.complete) {
+        return { loading: true, complete: false, progressText: state.text, result: null, error: '' };
+      }
+      try {
+        return {
+          loading: false,
+          complete: true,
+          progressText: state.text,
+          result: this.parseResult(state.text, context),
+          error: '',
+        };
+      } catch (error) {
+        return this.deterministicState(
+          context,
+          state.text,
+          error instanceof Error ? error.message : 'AI 洞察结果解析失败。',
+        );
+      }
+    }), catchError((error) => of(this.deterministicState(
+      context,
+      '',
+      error instanceof Error ? error.message : 'AI 洞察请求失败。',
+    ))));
+  }
+
+  private deterministicState(
+    context: AiVocPreparedContext,
+    progressText: string,
+    errorSummary: string,
+  ): AiVocGenerationState {
+    const result = this.buildDeterministicResult(context);
+    return result
+      ? { loading: false, complete: true, progressText, result, error: '' }
+      : { loading: false, complete: true, progressText, result: null, error: errorSummary };
+  }
+
+  buildDeterministicResult(context: AiVocPreparedContext): AiVocInsightResult | null {
+    if (!context.evidence.length) return null;
+    const groups = new Map<string, AiVocEvidenceReview[]>();
+    context.evidence.forEach((item) => {
+      const group = groups.get(item.topicId) ?? [];
+      group.push(item);
+      groups.set(item.topicId, group);
+    });
+    const insights = [...groups.entries()]
+      .sort((left, right) => right[1].length - left[1].length)
+      .slice(0, 8)
+      .map(([topicId, evidence], index): AiVocInsightItem => ({
+        id: `rule-${topicId}-${index + 1}`,
+        title: `${evidence[0].topicLabel}:规则摘要`,
+        finding: `当前输入中有 ${evidence.length} 条评价落在“${evidence[0].topicLabel}”,该结果只代表筛选样本中的可见信号。`,
+        userNeed: '需要补充同类商品和更多时间段评价,确认问题是否具有普遍性。',
+        severity: evidence.some((item) => item.sentiment === 'negative') ? evidence.length >= 3 ? 'high' : 'medium' : 'low',
+        confidence: this.confidence(evidence.length),
+        opportunityStatus: 'needs_validation',
+        evidenceIds: evidence.slice(0, 8).map((item) => item.id),
+        evidenceCount: Math.min(8, evidence.length),
+        recommendation: '保留为待验证议题,补充目标商品样本后再进入正式行动排期。',
+        validationMetric: '同主题有效评价数量、复现率与人工确认结果',
+      }));
+    const referenced = new Set(insights.flatMap((item) => item.evidenceIds));
+    return {
+      mode: 'deterministic',
+      headline: '规则摘要:当前评价中的主要用户信号',
+      executiveSummary: 'AI 网关当前未完成生成,以下内容由稳定主题规则从真实评价样本汇总,全部标记为待验证,不替代人工判断。',
+      generatedAt: new Date().toISOString(),
+      filters: context.filters,
+      sampleCount: context.evidence.length,
+      evidenceCoverage: context.evidence.length ? referenced.size / context.evidence.length : 0,
+      insights,
+      risks: ['规则摘要只反映当前输入样本,不能外推为总体问题率。', '主题命中来自固定关键词规则,需要人工复核主题边界。'],
+      followUpQuestions: ['该主题在本品评价中是否同样出现?', '补充更多商品和时间段后,主题排序是否稳定?'],
+    };
+  }
+
+  private buildPrompt(context: AiVocPreparedContext): string {
+    const payload = context.evidence.map((item) => ({
+      evidenceId: item.id,
+      source: item.sourceLabel,
+      productId: item.productId,
+      productTitle: item.productTitle,
+      brand: item.brand,
+      rating: item.review.rating,
+      date: item.dateLabel,
+      topic: item.topicLabel,
+      content: this.redactReviewContent(item.review.content),
+    }));
+    return [
+      `分析范围:${SCOPE_LABELS[context.filters.scope]},匹配 ${context.matchingCount} 条,本次输入 ${context.evidence.length} 条。`,
+      '请只输出一个合法 JSON 对象,不要输出 Markdown 或解释文字。',
+      '输出结构:',
+      '{"headline":"一句话判断","executiveSummary":"100字内摘要","insights":[{"id":"insight-1","title":"标题","finding":"证据支持的发现","userNeed":"用户未满足需求","severity":"critical|high|medium|low","opportunityStatus":"supported|partially_supported|needs_validation|not_supported","evidenceIds":["真实 evidenceId"],"recommendation":"建议动作","validationMetric":"验证指标"}],"risks":["风险"],"followUpQuestions":["下一步问题"]}',
+      '约束:每条 insight 必须引用 1-8 个输入中的 evidenceId;不要改写或虚构 ID;样本不足时使用 needs_validation;保留不支持的方向作为审计记录;不要编造销量、金额、比例或用户原话。',
+      `评价证据:${JSON.stringify(payload)}`,
+    ].join('\n\n');
+  }
+
+  private redactReviewContent(content: string): string {
+    let redacted = String(content ?? '');
+    PREFIXED_PII_REDACTIONS.forEach(({ pattern, placeholder }) => {
+      redacted = redacted.replace(pattern, `$1${placeholder}`);
+    });
+    redacted = redacted.replace(EMAIL_PATTERN, '[邮箱已脱敏]');
+    return redacted.replace(MAINLAND_MOBILE_PATTERN, '$1[手机号已脱敏]');
+  }
+
+  private systemPrompt(): string {
+    return [
+      '你是国内电商 VOC 洞察分析师。你的任务是把评价证据转成可审计的用户需求、机会判断和验证动作。',
+      '证据链必须是:评价原声 -> 痛点/需求 -> 未满足需求 -> 机会状态 -> 验证动作。',
+      '区分事实、推断和建议;只有当前输入评价可以作为事实证据。',
+      '机会状态必须混合使用 supported、partially_supported、needs_validation、not_supported,避免所有结论都被写成确定机会。',
+    ].join('\n');
+  }
+
+  private parseResult(raw: string, context: AiVocPreparedContext): AiVocInsightResult {
+    const parsed = this.parseJson(raw);
+    const validEvidenceIds = new Set(context.evidence.map((item) => item.id));
+    const insights = (Array.isArray(parsed?.['insights']) ? parsed['insights'] : [])
+      .map((item: unknown, index: number) => this.normalizeInsight(item, index, validEvidenceIds))
+      .filter((item: AiVocInsightItem | null): item is AiVocInsightItem => item !== null);
+    if (!insights.length) {
+      throw new Error('AI 返回的洞察没有可验证的评价引用,请重新生成。');
+    }
+    const referenced = new Set(insights.flatMap((item) => item.evidenceIds));
+    return {
+      mode: 'ai',
+      headline: this.text(parsed?.['headline'], 'AI VOC 洞察'),
+      executiveSummary: this.text(parsed?.['executiveSummary'], '已生成基于当前评价样本的证据化洞察。'),
+      generatedAt: new Date().toISOString(),
+      filters: context.filters,
+      sampleCount: context.evidence.length,
+      evidenceCoverage: context.evidence.length ? referenced.size / context.evidence.length : 0,
+      insights,
+      risks: this.stringArray(parsed?.['risks']).slice(0, 6),
+      followUpQuestions: this.stringArray(parsed?.['followUpQuestions']).slice(0, 6),
+    };
+  }
+
+  private normalizeInsight(value: unknown, index: number, validIds: Set<string>): AiVocInsightItem | null {
+    const item = value && typeof value === 'object' ? value as Record<string, unknown> : {};
+    const evidenceIds = this.stringArray(item['evidenceIds']).filter((id) => validIds.has(id)).slice(0, 8);
+    if (!evidenceIds.length) return null;
+    return {
+      id: this.text(item['id'], `insight-${index + 1}`),
+      title: this.text(item['title'], `洞察 ${index + 1}`),
+      finding: this.text(item['finding'], ''),
+      userNeed: this.text(item['userNeed'], ''),
+      severity: this.enumValue(item['severity'], ['critical', 'high', 'medium', 'low'], 'medium'),
+      confidence: this.confidence(evidenceIds.length),
+      opportunityStatus: this.enumValue(
+        item['opportunityStatus'],
+        ['supported', 'partially_supported', 'needs_validation', 'not_supported'],
+        'needs_validation',
+      ),
+      evidenceIds,
+      evidenceCount: evidenceIds.length,
+      recommendation: this.text(item['recommendation'], '补充同类评价并进行人工复核。'),
+      validationMetric: this.text(item['validationMetric'], '同类有效评价数量与问题复现率'),
+    };
+  }
+
+  private parseJson(raw: string): Record<string, any> {
+    const normalized = String(raw || '').replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
+    const candidates: Record<string, any>[] = [];
+    for (let start = 0; start < normalized.length; start += 1) {
+      if (normalized[start] !== '{') continue;
+      let depth = 0;
+      let inString = false;
+      let escaped = false;
+      for (let index = start; index < normalized.length; index += 1) {
+        const character = normalized[index];
+        if (escaped) {
+          escaped = false;
+          continue;
+        }
+        if (character === '\\' && inString) {
+          escaped = true;
+          continue;
+        }
+        if (character === '"') {
+          inString = !inString;
+          continue;
+        }
+        if (inString) continue;
+        if (character === '{') depth += 1;
+        if (character === '}') depth -= 1;
+        if (depth === 0) {
+          try {
+            const candidate = JSON.parse(normalized.slice(start, index + 1));
+            if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
+              candidates.push(candidate);
+            }
+          } catch {
+            // Keep scanning in case the model prefixed a second complete JSON object.
+          }
+          break;
+        }
+      }
+    }
+    const insightPayload = candidates.find((candidate) => Array.isArray(candidate['insights']));
+    if (insightPayload) return insightPayload;
+    if (candidates[0]) return candidates[0];
+    throw new Error('AI 未返回可解析的 JSON 洞察。');
+  }
+
+  private sentiment(rating: number): Exclude<AiVocSentiment, 'all'> {
+    if (rating > 0 && rating <= 2) return 'negative';
+    if (rating === 3) return 'neutral';
+    return 'positive';
+  }
+
+  private sentimentPriority(rating: number): number {
+    const sentiment = this.sentiment(rating);
+    return sentiment === 'negative' ? 0 : sentiment === 'neutral' ? 1 : 2;
+  }
+
+  private fallbackTopicId(sentiment: Exclude<AiVocSentiment, 'all'>): string {
+    return sentiment === 'negative' ? 'unclassified-negative' : sentiment === 'neutral' ? 'neutral-feedback' : 'positive-feedback';
+  }
+
+  private fallbackTopicLabel(sentiment: Exclude<AiVocSentiment, 'all'>): string {
+    return sentiment === 'negative' ? '未归类低评分' : sentiment === 'neutral' ? '中评体验' : '高分反馈';
+  }
+
+  private topicSummary(evidence: AiVocEvidenceReview[]) {
+    const counts = new Map<string, { label: string; count: number }>();
+    evidence.forEach((item) => {
+      const current = counts.get(item.topicId) ?? { label: item.topicLabel, count: 0 };
+      current.count += 1;
+      counts.set(item.topicId, current);
+    });
+    return [...counts.entries()]
+      .map(([id, item]) => ({ id, label: item.label, count: item.count, share: evidence.length ? item.count / evidence.length : 0 }))
+      .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, 'zh-CN'));
+  }
+
+  private sourceCounts(evidence: AiVocEvidenceReview[]): Record<'own' | 'competitor' | 'unknown', number> {
+    const counts = { own: 0, competitor: 0, unknown: 0 };
+    evidence.forEach((item) => counts[item.sourceRole] += 1);
+    return counts;
+  }
+
+  private sentimentCounts(evidence: AiVocEvidenceReview[]): Record<'negative' | 'neutral' | 'positive', number> {
+    const counts = { negative: 0, neutral: 0, positive: 0 };
+    evidence.forEach((item) => counts[item.sentiment] += 1);
+    return counts;
+  }
+
+  private confidence(count: number): AiVocConfidence {
+    if (count >= 8) return 'high';
+    if (count >= 3) return 'medium';
+    return 'low';
+  }
+
+  private clampSampleLimit(value: number): number {
+    const normalized = Number.isFinite(value) ? Math.round(value) : 40;
+    return Math.max(10, Math.min(80, normalized));
+  }
+
+  private text(value: unknown, fallback: string): string {
+    return typeof value === 'string' && value.trim() ? value.trim() : fallback;
+  }
+
+  private stringArray(value: unknown): string[] {
+    return Array.isArray(value)
+      ? value.filter((item): item is string => typeof item === 'string' && !!item.trim()).map((item) => item.trim())
+      : [];
+  }
+
+  private enumValue<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
+    return typeof value === 'string' && allowed.includes(value as T) ? value as T : fallback;
+  }
+}

+ 126 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-components.spec.ts

@@ -0,0 +1,126 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import {
+  SaasAnalysisRun,
+  SaasInsightDecision,
+  SaasPlatformService,
+} from '../../../app/core/services/saas-platform.service';
+import { AnalysisRunDetailComponent } from './analysis-run-detail.component';
+import { AnalysisRunListComponent } from './analysis-run-list.component';
+
+describe('Analysis run center', () => {
+  const completedRun: SaasAnalysisRun = {
+    id: 'run-001',
+    workspaceId: 'demashi',
+    analysisType: 'voc_insight',
+    targetKind: 'workspace',
+    targetKey: 'demashi',
+    status: 'completed',
+    input: {
+      filters: { scope: 'competitor', sentiment: 'negative', sampleLimit: 40 },
+      sampleCount: 12,
+      evidenceIds: ['review-1', 'review-2'],
+      sourceCounts: { competitor: 12 },
+      topics: [{ id: 'quality', label: '质量与耐用性', count: 8, share: 0.67 }],
+      generatedBy: 'ai-voc-insight',
+    },
+    result: {
+      mode: 'ai',
+      headline: '耐用性是当前主要负向主题',
+      executiveSummary: '样本中的负向反馈主要集中在耐用性。',
+      generatedAt: '2026-08-07T03:01:00.000Z',
+      sampleCount: 12,
+      evidenceCoverage: 0.5,
+      insights: [{
+        id: 'insight-1',
+        title: '耐用性风险',
+        finding: '两条证据指向重复故障。',
+        userNeed: '更稳定的使用寿命。',
+        severity: 'high',
+        confidence: 'medium',
+        opportunityStatus: 'needs_validation',
+        recommendation: '补充本品样本后复核。',
+        validationMetric: '同主题评价复现率',
+        evidenceIds: ['review-1'],
+      }],
+      risks: [],
+      followUpQuestions: [],
+    },
+    evidenceCount: 2,
+    requestedBy: 'analyst-1',
+    errorSummary: null,
+    requestedAt: '2026-08-07T03:00:00.000Z',
+    startedAt: '2026-08-07T03:00:10.000Z',
+    completedAt: '2026-08-07T03:01:00.000Z',
+  };
+
+  const decision: SaasInsightDecision = {
+    id: 'decision-1',
+    workspaceId: 'demashi',
+    sourceAnalysisId: completedRun.id,
+    sourceInsightId: 'insight-1',
+    decision: 'needs_more_evidence',
+    reviewedEvidenceIds: ['review-1'],
+    comment: '补充本品评价后复核。',
+    decidedBy: 'reviewer-1',
+    decidedAt: '2026-08-07T03:05:00.000Z',
+    version: 1,
+    supersedesId: null,
+    isCurrent: true,
+    createdAt: '2026-08-07T03:05:00.000Z',
+    updatedAt: '2026-08-07T03:05:00.000Z',
+  };
+
+  let service: jasmine.SpyObj<SaasPlatformService>;
+
+  beforeEach(() => {
+    service = jasmine.createSpyObj<SaasPlatformService>('SaasPlatformService', ['analyses', 'insightDecisions']);
+    service.analyses.and.returnValue(of({ items: [completedRun], nextCursor: null }));
+    service.insightDecisions.and.returnValue(of({ items: [decision], nextCursor: null }));
+  });
+
+  it('renders the real run fields and opens the detail view', async () => {
+    await TestBed.configureTestingModule({
+      imports: [AnalysisRunListComponent],
+      providers: [{ provide: SaasPlatformService, useValue: service }],
+    }).compileComponents();
+
+    const fixture = TestBed.createComponent(AnalysisRunListComponent);
+    fixture.detectChanges();
+
+    const text = fixture.nativeElement.textContent as string;
+    expect(service.analyses).toHaveBeenCalledWith('demashi', '', '', 50);
+    expect(text).toContain('AI VOC 洞察');
+    expect(text).toMatch(/12\s*\/\s*2/);
+    expect(text).toContain('AI 生成');
+
+    const detailButton = fixture.nativeElement.querySelector('.row-action') as HTMLButtonElement;
+    detailButton.click();
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('app-analysis-run-detail')).not.toBeNull();
+  });
+
+  it('renders evidence, human decisions and explicit missing fields', async () => {
+    await TestBed.configureTestingModule({
+      imports: [AnalysisRunDetailComponent],
+      providers: [{ provide: SaasPlatformService, useValue: service }],
+    }).compileComponents();
+
+    const fixture: ComponentFixture<AnalysisRunDetailComponent> = TestBed.createComponent(AnalysisRunDetailComponent);
+    fixture.componentRef.setInput('run', completedRun);
+    fixture.componentRef.setInput('workspaceId', 'demashi');
+    fixture.detectChanges();
+
+    expect(service.insightDecisions).toHaveBeenCalledWith('demashi', completedRun.id, '', false);
+    expect(fixture.nativeElement.textContent).toContain('模型 / Prompt 版本');
+    expect(fixture.nativeElement.textContent).toContain('未记录');
+
+    const evidenceTab = fixture.nativeElement.querySelector('.detail-tabs button:nth-child(2)') as HTMLButtonElement;
+    evidenceTab.click();
+    fixture.detectChanges();
+    const text = fixture.nativeElement.textContent as string;
+    expect(text).toContain('review-1');
+    expect(text).toContain('需补充证据');
+    expect(text).toContain('评价原文');
+  });
+});

+ 241 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.html

@@ -0,0 +1,241 @@
+<ng-container *ngIf="run as currentRun">
+  <button class="drawer-backdrop" type="button" aria-label="关闭运行详情" (click)="close()"></button>
+  <aside #runDetail class="run-detail" role="dialog" aria-modal="true" aria-labelledby="run-detail-title">
+    <header class="detail-header">
+      <div class="detail-title">
+        <span class="title-icon" aria-hidden="true">
+          <lucide-icon [img]="icons.activity" [size]="18"></lucide-icon>
+        </span>
+        <div>
+          <span>{{ typeLabel() }}</span>
+          <h2 id="run-detail-title">{{ targetLabel() }}</h2>
+          <code>{{ currentRun.id }}</code>
+        </div>
+      </div>
+      <button class="close-button" type="button" title="关闭" aria-label="关闭运行详情" (click)="close()">
+        <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
+      </button>
+    </header>
+
+    <div class="detail-status">
+      <span class="status-badge" [attr.data-tone]="statusTone()">
+        <span class="status-dot"></span>{{ statusLabel() }}
+      </span>
+      <span>{{ targetKindLabel() }}</span>
+      <span>证据 {{ currentRun.evidenceCount | number }}</span>
+      <span>{{ modeLabel() }}</span>
+    </div>
+
+    <nav class="detail-tabs" aria-label="运行详情视图">
+      <button type="button" [class.is-active]="activeTab === 'summary'" (click)="setTab('summary')">概览</button>
+      <button type="button" [class.is-active]="activeTab === 'evidence'" (click)="setTab('evidence')">
+        证据与决策 <span>{{ evidenceReferences.length }}</span>
+      </button>
+      <button type="button" [class.is-active]="activeTab === 'timeline'" (click)="setTab('timeline')">运行记录</button>
+    </nav>
+
+    <div class="detail-body" *ngIf="activeTab === 'summary'">
+      <section class="detail-section" aria-labelledby="metadata-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.database" [size]="16"></lucide-icon>
+          <h3 id="metadata-title">运行元数据</h3>
+        </div>
+        <dl class="definition-grid">
+          <div><dt>分析类型</dt><dd>{{ typeLabel() }}</dd></div>
+          <div><dt>目标类型</dt><dd>{{ targetKindLabel() }}</dd></div>
+          <div><dt>目标键</dt><dd>{{ targetLabel() }}</dd></div>
+          <div><dt>请求人</dt><dd>{{ currentRun.requestedBy || '未记录' }}</dd></div>
+          <div><dt>请求时间</dt><dd>{{ formatDate(currentRun.requestedAt) }}</dd></div>
+          <div><dt>完成时间</dt><dd [class.is-missing]="!currentRun.completedAt">{{ formatDate(currentRun.completedAt) }}</dd></div>
+          <div><dt>错误摘要</dt><dd [class.is-error]="currentRun.errorSummary">{{ currentRun.errorSummary || '无' }}</dd></div>
+          <div><dt>模型 / Prompt 版本</dt><dd class="is-missing">未记录</dd></div>
+        </dl>
+      </section>
+
+      <section class="detail-section" *ngIf="inputView as input" aria-labelledby="input-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.gauge" [size]="16"></lucide-icon>
+          <h3 id="input-title">输入范围</h3>
+        </div>
+        <dl class="definition-grid is-four-columns">
+          <div><dt>评价范围</dt><dd>{{ scopeLabel() }}</dd></div>
+          <div><dt>情感筛选</dt><dd>{{ sentimentLabel() }}</dd></div>
+          <div><dt>样本上限</dt><dd>{{ number(input.sampleLimit) }}</dd></div>
+          <div><dt>实际样本</dt><dd>{{ number(input.sampleCount) }}</dd></div>
+          <div><dt>输入证据 ID</dt><dd>{{ input.evidenceIds.length || '未记录' }}</dd></div>
+          <div><dt>生成入口</dt><dd [class.is-missing]="!input.generatedBy">{{ input.generatedBy || '未记录' }}</dd></div>
+          <div><dt>时间范围</dt><dd class="is-missing">未记录</dd></div>
+          <div><dt>店铺 / 商品筛选</dt><dd class="is-missing">未记录</dd></div>
+        </dl>
+
+        <div class="compact-grid">
+          <div class="compact-group">
+            <h4>来源分布</h4>
+            <div class="key-value-list" *ngIf="input.sourceCounts.length; else missingSources">
+              <div *ngFor="let item of input.sourceCounts">
+                <span>{{ sourceLabel(item.label) }}</span><strong>{{ item.value | number }}</strong>
+              </div>
+            </div>
+            <ng-template #missingSources><p class="missing-copy">来源分布未记录</p></ng-template>
+          </div>
+          <div class="compact-group">
+            <h4>输入主题</h4>
+            <div class="key-value-list" *ngIf="input.topics.length; else missingTopics">
+              <div *ngFor="let topic of input.topics">
+                <span>{{ topic.label || topic.id }}</span><strong>{{ number(topic.count) }}</strong>
+              </div>
+            </div>
+            <ng-template #missingTopics><p class="missing-copy">输入主题未记录</p></ng-template>
+          </div>
+        </div>
+      </section>
+
+      <section class="detail-section" aria-labelledby="result-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.brain" [size]="16"></lucide-icon>
+          <h3 id="result-title">结果摘要</h3>
+        </div>
+
+        <ng-container *ngIf="resultView as result; else missingResult">
+          <div class="result-heading">
+            <div>
+              <span>{{ modeLabel() }} · 生成时间 {{ formatDate(result.generatedAt) }}</span>
+              <h4>{{ result.headline || '结果标题未记录' }}</h4>
+            </div>
+            <div class="coverage-value">
+              <span>证据覆盖</span>
+              <strong>{{ percentage(result.evidenceCoverage) }}</strong>
+            </div>
+          </div>
+          <p class="executive-summary" [class.is-missing]="!result.executiveSummary">
+            {{ result.executiveSummary || '结果摘要未记录' }}
+          </p>
+
+          <div class="insight-list" *ngIf="result.insights.length; else missingInsights">
+            <article class="insight-item" *ngFor="let insight of result.insights">
+              <header>
+                <div>
+                  <span>{{ insight.id || '洞察 ID 未记录' }}</span>
+                  <h4>{{ insight.title || '洞察标题未记录' }}</h4>
+                </div>
+                <div class="insight-labels">
+                  <span>{{ severityLabel(insight.severity) }}</span>
+                  <span>{{ opportunityLabel(insight.opportunityStatus) }}</span>
+                </div>
+              </header>
+              <dl class="insight-fields">
+                <div><dt>发现</dt><dd>{{ insight.finding || '未记录' }}</dd></div>
+                <div><dt>用户需求</dt><dd>{{ insight.userNeed || '未记录' }}</dd></div>
+                <div><dt>建议动作</dt><dd>{{ insight.recommendation || '未记录' }}</dd></div>
+                <div><dt>验证指标</dt><dd>{{ insight.validationMetric || '未记录' }}</dd></div>
+              </dl>
+              <footer>引用证据 {{ insight.evidenceIds.length }} 条 · 置信度 {{ insight.confidence || '未记录' }}</footer>
+            </article>
+          </div>
+          <ng-template #missingInsights><p class="missing-state">结果已保存,但洞察明细未记录。</p></ng-template>
+        </ng-container>
+
+        <ng-template #missingResult>
+          <div class="missing-state">
+            <lucide-icon [img]="icons.alert" [size]="18"></lucide-icon>
+            <strong>结果未记录</strong>
+            <span>当前运行的 result 字段为空,页面没有生成替代结论。</span>
+          </div>
+        </ng-template>
+      </section>
+    </div>
+
+    <div class="detail-body" *ngIf="activeTab === 'evidence'">
+      <div class="contract-notice">
+        <lucide-icon [img]="icons.evidence" [size]="16"></lucide-icon>
+        <span>运行契约只保存证据 ID,评价原文、商品标题和快照内容未随运行记录保存。</span>
+      </div>
+
+      <section class="detail-section" aria-labelledby="evidence-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.quote" [size]="16"></lucide-icon>
+          <h3 id="evidence-title">证据引用</h3>
+          <span class="section-count">{{ evidenceReferences.length }}</span>
+        </div>
+        <div class="evidence-table-wrap" *ngIf="evidenceReferences.length; else missingEvidence">
+          <table class="evidence-table">
+            <thead><tr><th>证据 ID</th><th>关联洞察</th><th>证据原文</th></tr></thead>
+            <tbody>
+              <tr *ngFor="let evidence of evidenceReferences; trackBy: trackEvidence">
+                <td><code>{{ evidence.id }}</code></td>
+                <td>{{ evidence.insightTitles.length ? evidence.insightTitles.join('、') : '未被结果洞察引用' }}</td>
+                <td class="is-missing">未记录</td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+        <ng-template #missingEvidence><p class="missing-state">输入和结果中均未记录证据 ID。</p></ng-template>
+      </section>
+
+      <section class="detail-section" aria-labelledby="decision-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.userCheck" [size]="16"></lucide-icon>
+          <h3 id="decision-title">人工决策</h3>
+          <span class="section-count">{{ decisions.length }}</span>
+        </div>
+
+        <div class="inline-loading" *ngIf="decisionsLoading">
+          <lucide-icon class="is-spinning" [img]="icons.loader" [size]="16"></lucide-icon>
+          正在加载人工决策
+        </div>
+        <p class="inline-error" *ngIf="!decisionsLoading && decisionsError">{{ decisionsError }}</p>
+
+        <div class="decision-list" *ngIf="!decisionsLoading && !decisionsError && decisions.length">
+          <article *ngFor="let decision of decisions; trackBy: trackDecision">
+            <header>
+              <span class="decision-badge" [attr.data-decision]="decision.decision">{{ decisionLabel(decision) }}</span>
+              <time [attr.datetime]="decision.decidedAt">{{ formatDate(decision.decidedAt) }}</time>
+            </header>
+            <dl>
+              <div><dt>洞察 ID</dt><dd><code>{{ decision.sourceInsightId }}</code></dd></div>
+              <div><dt>决策人</dt><dd>{{ decision.decidedBy || '未记录' }}</dd></div>
+              <div><dt>审阅证据</dt><dd>{{ decision.reviewedEvidenceIds.length ? decision.reviewedEvidenceIds.join('、') : '未记录' }}</dd></div>
+              <div><dt>意见</dt><dd>{{ decision.comment || '未记录' }}</dd></div>
+            </dl>
+          </article>
+        </div>
+        <p class="missing-state" *ngIf="!decisionsLoading && !decisionsError && !decisions.length">当前运行没有人工决策记录。</p>
+      </section>
+    </div>
+
+    <div class="detail-body" *ngIf="activeTab === 'timeline'">
+      <div class="contract-notice">
+        <lucide-icon [img]="icons.clock" [size]="16"></lucide-icon>
+        <span>后端未提供独立运行事件流;当前时间线由状态和三个时间字段还原。</span>
+      </div>
+
+      <section class="detail-section" aria-labelledby="duration-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.timer" [size]="16"></lucide-icon>
+          <h3 id="duration-title">运行耗时</h3>
+        </div>
+        <dl class="duration-grid">
+          <div><dt>排队耗时</dt><dd>{{ formatDuration(durations.queueMs) }}</dd></div>
+          <div><dt>执行耗时</dt><dd>{{ formatDuration(durations.executionMs) }}</dd></div>
+          <div><dt>总耗时</dt><dd>{{ formatDuration(durations.totalMs) }}</dd></div>
+        </dl>
+      </section>
+
+      <section class="detail-section" aria-labelledby="timeline-title">
+        <div class="section-heading">
+          <lucide-icon [img]="icons.activity" [size]="16"></lucide-icon>
+          <h3 id="timeline-title">运行时间线</h3>
+        </div>
+        <ol class="timeline-list">
+          <li *ngFor="let item of timeline; trackBy: trackTimeline" [attr.data-tone]="item.tone" [class.is-missing]="item.missing">
+            <span class="timeline-marker"></span>
+            <div>
+              <header><strong>{{ item.title }}</strong><time>{{ formatDate(item.time) }}</time></header>
+              <p>{{ item.description }}</p>
+            </div>
+          </li>
+        </ol>
+      </section>
+    </div>
+  </aside>
+</ng-container>

+ 447 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.scss

@@ -0,0 +1,447 @@
+:host {
+  position: fixed;
+  z-index: 1200;
+  inset: 0;
+  display: block;
+  letter-spacing: 0;
+}
+
+button {
+  font: inherit;
+  letter-spacing: 0;
+  cursor: pointer;
+}
+
+.drawer-backdrop {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  border: 0;
+  background: rgba(17, 24, 39, 0.34);
+  backdrop-filter: blur(1px);
+}
+
+.run-detail {
+  position: absolute;
+  top: 0;
+  right: 0;
+  display: grid;
+  grid-template-rows: auto auto auto minmax(0, 1fr);
+  width: min(780px, calc(100vw - 40px));
+  height: 100dvh;
+  overflow: hidden;
+  color: #27313b;
+  background: #ffffff;
+  box-shadow: -16px 0 42px rgba(15, 23, 42, 0.16);
+}
+
+.detail-header,
+.detail-title,
+.detail-status,
+.detail-tabs,
+.section-heading,
+.result-heading,
+.insight-item header,
+.insight-labels,
+.contract-notice,
+.decision-list article > header,
+.timeline-list header,
+.inline-loading {
+  display: flex;
+  align-items: center;
+}
+
+.detail-header {
+  justify-content: space-between;
+  gap: 18px;
+  min-height: 82px;
+  padding: 15px 20px;
+  border-bottom: 1px solid #e4e9ed;
+}
+
+.detail-title {
+  min-width: 0;
+  gap: 11px;
+}
+
+.title-icon {
+  display: grid;
+  width: 34px;
+  height: 34px;
+  place-items: center;
+  flex: 0 0 auto;
+  border-radius: 7px;
+  color: #35536b;
+  background: #edf3f6;
+}
+
+.detail-title > div {
+  display: grid;
+  min-width: 0;
+  gap: 2px;
+}
+
+.detail-title span {
+  color: #78838d;
+  font-size: 10px;
+  font-weight: 650;
+  text-transform: uppercase;
+}
+
+.detail-title h2 {
+  max-width: 560px;
+  margin: 0;
+  overflow: hidden;
+  color: #18212b;
+  font-size: 17px;
+  font-weight: 650;
+  line-height: 1.3;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.detail-title code {
+  overflow: hidden;
+  color: #8a949e;
+  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+  font-size: 9px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.close-button {
+  display: grid;
+  width: 32px;
+  height: 32px;
+  place-items: center;
+  flex: 0 0 auto;
+  border: 1px solid #dfe4e8;
+  border-radius: 5px;
+  color: #66727d;
+  background: #ffffff;
+}
+
+.close-button:hover { color: #1f2937; background: #f6f8f9; }
+
+.detail-status {
+  min-height: 38px;
+  gap: 0;
+  padding: 0 20px;
+  overflow-x: auto;
+  border-bottom: 1px solid #e7ebee;
+  color: #697580;
+  background: #fafbfc;
+  font-size: 10px;
+  white-space: nowrap;
+}
+
+.detail-status > span {
+  padding: 0 12px;
+  border-right: 1px solid #e1e6e9;
+}
+
+.detail-status > span:first-child { padding-left: 0; }
+.status-badge { gap: 6px; font-weight: 650; }
+.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
+.status-badge[data-tone='info'] { color: #1d4ed8; }
+.status-badge[data-tone='info'] .status-dot { background: #3b82f6; }
+.status-badge[data-tone='success'] { color: #047857; }
+.status-badge[data-tone='success'] .status-dot { background: #10b981; }
+.status-badge[data-tone='warning'] { color: #a16207; }
+.status-badge[data-tone='warning'] .status-dot { background: #eab308; }
+.status-badge[data-tone='danger'] { color: #b91c1c; }
+.status-badge[data-tone='danger'] .status-dot { background: #ef4444; }
+
+.detail-tabs {
+  min-height: 45px;
+  gap: 4px;
+  padding: 0 20px;
+  border-bottom: 1px solid #dfe4e8;
+  background: #ffffff;
+}
+
+.detail-tabs button {
+  position: relative;
+  height: 45px;
+  padding: 0 13px;
+  border: 0;
+  color: #68737e;
+  background: transparent;
+  font-size: 11px;
+  font-weight: 600;
+}
+
+.detail-tabs button::after {
+  position: absolute;
+  right: 10px;
+  bottom: -1px;
+  left: 10px;
+  height: 2px;
+  background: transparent;
+  content: '';
+}
+
+.detail-tabs button.is-active { color: #274b65; }
+.detail-tabs button.is-active::after { background: #466b84; }
+.detail-tabs button span { margin-left: 4px; color: #8a949e; font-size: 9px; }
+
+.detail-body {
+  min-width: 0;
+  overflow-y: auto;
+  background: #f7f9fa;
+}
+
+.detail-section {
+  padding: 20px;
+  border-bottom: 1px solid #e2e7ea;
+  background: #ffffff;
+}
+
+.detail-section + .detail-section { margin-top: 10px; border-top: 1px solid #e2e7ea; }
+
+.section-heading {
+  gap: 8px;
+  margin-bottom: 15px;
+  color: #667886;
+}
+
+.section-heading h3 {
+  margin: 0;
+  color: #27313b;
+  font-size: 13px;
+  font-weight: 650;
+}
+
+.section-count {
+  min-width: 20px;
+  margin-left: auto;
+  padding: 2px 6px;
+  border-radius: 9px;
+  color: #66727d;
+  background: #eef2f4;
+  font-size: 9px;
+  text-align: center;
+}
+
+.definition-grid,
+.duration-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  margin: 0;
+  border-top: 1px solid #e8ecef;
+  border-left: 1px solid #e8ecef;
+}
+
+.definition-grid.is-four-columns { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+
+.definition-grid > div,
+.duration-grid > div {
+  min-width: 0;
+  padding: 10px 12px;
+  border-right: 1px solid #e8ecef;
+  border-bottom: 1px solid #e8ecef;
+}
+
+dt {
+  margin-bottom: 5px;
+  color: #86919b;
+  font-size: 9px;
+  font-weight: 600;
+  text-transform: uppercase;
+}
+
+dd {
+  min-width: 0;
+  margin: 0;
+  overflow-wrap: anywhere;
+  color: #34404b;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.is-missing { color: #8a949e !important; }
+.is-error { color: #a33f3f !important; }
+
+.compact-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+  margin-top: 16px;
+}
+
+.compact-group {
+  min-width: 0;
+  padding-top: 12px;
+  border-top: 1px solid #e8ecef;
+}
+
+.compact-group h4 {
+  margin: 0 0 8px;
+  color: #65717c;
+  font-size: 10px;
+  font-weight: 650;
+}
+
+.key-value-list { display: grid; gap: 7px; }
+.key-value-list > div { display: flex; justify-content: space-between; gap: 14px; color: #68737e; font-size: 10px; }
+.key-value-list strong { color: #34404b; font-weight: 650; }
+.missing-copy { margin: 0; color: #8a949e; font-size: 10px; }
+
+.result-heading {
+  justify-content: space-between;
+  gap: 18px;
+  padding: 14px;
+  border: 1px solid #dfe6e9;
+  border-radius: 6px 6px 0 0;
+  background: #f7fafb;
+}
+
+.result-heading > div:first-child { min-width: 0; }
+.result-heading span { color: #78838d; font-size: 9px; }
+.result-heading h4 { margin: 4px 0 0; color: #22313d; font-size: 14px; font-weight: 650; line-height: 1.4; }
+
+.coverage-value {
+  display: grid;
+  min-width: 76px;
+  gap: 2px;
+  text-align: right;
+}
+
+.coverage-value strong { color: #294d68; font-size: 18px; font-weight: 650; }
+
+.executive-summary {
+  margin: 0;
+  padding: 13px 14px;
+  border: 1px solid #dfe6e9;
+  border-top: 0;
+  color: #4b5863;
+  font-size: 11px;
+  line-height: 1.7;
+}
+
+.insight-list { display: grid; gap: 10px; margin-top: 12px; }
+
+.insight-item {
+  border: 1px solid #dfe5e8;
+  border-radius: 6px;
+  background: #ffffff;
+}
+
+.insight-item > header {
+  justify-content: space-between;
+  gap: 14px;
+  padding: 11px 12px;
+  border-bottom: 1px solid #e8ecef;
+  background: #fafbfc;
+}
+
+.insight-item header > div:first-child { min-width: 0; }
+.insight-item header span { color: #89939d; font-size: 9px; }
+.insight-item h4 { margin: 3px 0 0; color: #2f3b46; font-size: 12px; font-weight: 650; }
+
+.insight-labels { flex: 0 0 auto; gap: 5px; }
+.insight-labels span { padding: 3px 6px; border: 1px solid #dde4e7; border-radius: 4px; color: #65717c !important; background: #ffffff; }
+
+.insight-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
+.insight-fields > div { padding: 10px 12px; border-right: 1px solid #edf0f2; border-bottom: 1px solid #edf0f2; }
+.insight-item footer { padding: 8px 12px; color: #7e8993; font-size: 9px; }
+
+.missing-state {
+  display: grid;
+  min-height: 96px;
+  place-items: center;
+  align-content: center;
+  gap: 5px;
+  margin: 0;
+  padding: 16px;
+  color: #87919b;
+  background: #fafbfc;
+  font-size: 10px;
+  text-align: center;
+}
+
+.missing-state strong { color: #65717c; font-size: 11px; }
+
+.contract-notice {
+  gap: 8px;
+  margin: 12px 20px 0;
+  padding: 10px 12px;
+  border: 1px solid #d9e2e7;
+  border-radius: 6px;
+  color: #536979;
+  background: #f2f7f9;
+  font-size: 10px;
+  line-height: 1.45;
+}
+
+.evidence-table-wrap { overflow-x: auto; border: 1px solid #e1e6e9; }
+.evidence-table { width: 100%; min-width: 620px; border-collapse: collapse; table-layout: fixed; }
+.evidence-table th { height: 34px; padding: 0 10px; border-bottom: 1px solid #e1e6e9; color: #7b8590; background: #f7f9fa; font-size: 9px; text-align: left; }
+.evidence-table th:first-child { width: 220px; }
+.evidence-table th:last-child { width: 110px; }
+.evidence-table td { padding: 9px 10px; border-bottom: 1px solid #edf0f2; color: #4b5863; font-size: 10px; line-height: 1.45; vertical-align: top; }
+.evidence-table code,
+.decision-list code { color: #335870; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; overflow-wrap: anywhere; }
+
+.inline-loading { justify-content: center; gap: 7px; min-height: 80px; color: #7b8590; font-size: 10px; }
+.inline-error { padding: 12px; color: #a33f3f; background: #fff7f7; font-size: 10px; }
+
+.decision-list { display: grid; gap: 9px; }
+.decision-list article { border: 1px solid #dfe5e8; border-radius: 6px; }
+.decision-list article > header { justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #e8ecef; background: #fafbfc; }
+.decision-list time { color: #86919b; font-size: 9px; }
+.decision-badge { padding: 3px 6px; border-radius: 4px; color: #52606b; background: #edf1f3; font-size: 9px; font-weight: 650; }
+.decision-badge[data-decision='confirmed'] { color: #047857; background: #ecfdf5; }
+.decision-badge[data-decision='rejected'] { color: #b91c1c; background: #fef2f2; }
+.decision-badge[data-decision='needs_more_evidence'] { color: #a16207; background: #fefce8; }
+.decision-list dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
+.decision-list dl > div { padding: 9px 10px; border-right: 1px solid #edf0f2; border-bottom: 1px solid #edf0f2; }
+
+.duration-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.duration-grid dd { color: #294d68; font-size: 16px; font-weight: 650; font-variant-numeric: tabular-nums; }
+
+.timeline-list { margin: 0; padding: 0; list-style: none; }
+.timeline-list li { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 10px; padding-bottom: 20px; }
+.timeline-list li:not(:last-child)::before { position: absolute; top: 13px; bottom: -2px; left: 5px; width: 1px; background: #dbe2e6; content: ''; }
+.timeline-marker { position: relative; z-index: 1; width: 11px; height: 11px; margin-top: 2px; border: 3px solid #ffffff; border-radius: 50%; background: #94a3b8; box-shadow: 0 0 0 1px #94a3b8; }
+.timeline-list li[data-tone='info'] .timeline-marker { background: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
+.timeline-list li[data-tone='success'] .timeline-marker { background: #10b981; box-shadow: 0 0 0 1px #10b981; }
+.timeline-list li[data-tone='warning'] .timeline-marker { background: #eab308; box-shadow: 0 0 0 1px #eab308; }
+.timeline-list li[data-tone='danger'] .timeline-marker { background: #ef4444; box-shadow: 0 0 0 1px #ef4444; }
+.timeline-list li.is-missing .timeline-marker { background: #ffffff; box-shadow: 0 0 0 1px #c7d0d6; }
+.timeline-list header { justify-content: space-between; gap: 12px; }
+.timeline-list strong { color: #34404b; font-size: 11px; font-weight: 650; }
+.timeline-list time { color: #87919b; font-size: 9px; }
+.timeline-list p { margin: 4px 0 0; color: #737e88; font-size: 10px; line-height: 1.5; }
+
+button:focus-visible { outline: 2px solid rgba(37, 99, 235, 0.35); outline-offset: 2px; }
+.is-spinning { animation: spin 0.85s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 760px) {
+  .run-detail { width: 100vw; }
+  .detail-header,
+  .detail-section { padding-right: 16px; padding-left: 16px; }
+  .detail-status,
+  .detail-tabs { padding-right: 16px; padding-left: 16px; }
+  .contract-notice { margin-right: 16px; margin-left: 16px; }
+  .definition-grid.is-four-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .insight-fields,
+  .decision-list dl { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 460px) {
+  .detail-title h2 { max-width: calc(100vw - 112px); font-size: 15px; }
+  .detail-tabs button { padding: 0 8px; }
+  .definition-grid,
+  .definition-grid.is-four-columns,
+  .compact-grid,
+  .duration-grid { grid-template-columns: 1fr; }
+  .result-heading { align-items: flex-start; }
+  .insight-item > header { align-items: flex-start; flex-direction: column; }
+  .timeline-list header { align-items: flex-start; flex-direction: column; gap: 2px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .is-spinning { animation: none; }
+}

+ 307 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.ts

@@ -0,0 +1,307 @@
+import { CommonModule } from '@angular/common';
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  DestroyRef,
+  AfterViewInit,
+  ElementRef,
+  EventEmitter,
+  HostListener,
+  Input,
+  OnChanges,
+  OnDestroy,
+  Output,
+  SimpleChanges,
+  ViewChild,
+  inject,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+  Activity,
+  AlertCircle,
+  BrainCircuit,
+  CheckCircle2,
+  Clock3,
+  Database,
+  FileSearch,
+  Gauge,
+  LoaderCircle,
+  MessageSquareQuote,
+  Timer,
+  UserCheck,
+  X,
+  LucideAngularModule,
+} from 'lucide-angular';
+import { EMPTY, expand, map, reduce } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import {
+  SaasAnalysisRun,
+  SaasInsightDecision,
+  SaasPlatformService,
+} from '../../../app/core/services/saas-platform.service';
+import {
+  AnalysisRunDurationView,
+  AnalysisRunEvidenceReferenceView,
+  AnalysisRunInputView,
+  AnalysisRunResultView,
+  AnalysisRunTimelineItem,
+  analysisModeLabel,
+  analysisRunStatusLabel,
+  analysisRunStatusTone,
+  analysisRunTypeLabel,
+  analysisScopeLabel,
+  analysisSentimentLabel,
+  analysisTargetKindLabel,
+  analysisTargetLabel,
+  buildAnalysisDurations,
+  buildAnalysisTimeline,
+  buildEvidenceReferences,
+  formatAnalysisDate,
+  formatDuration,
+  insightDecisionLabel,
+  insightSeverityLabel,
+  opportunityStatusLabel,
+  readAnalysisRunInput,
+  readAnalysisRunResult,
+} from './analysis-run.models';
+
+type AnalysisRunDetailTab = 'summary' | 'evidence' | 'timeline';
+
+@Component({
+  selector: 'app-analysis-run-detail',
+  standalone: true,
+  imports: [CommonModule, LucideAngularModule],
+  templateUrl: './analysis-run-detail.component.html',
+  styleUrl: './analysis-run-detail.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AnalysisRunDetailComponent implements OnChanges, AfterViewInit, OnDestroy {
+  private readonly saasService = inject(SaasPlatformService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+  private requestVersion = 0;
+  private readonly returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+
+  @ViewChild('runDetail') private runDetail?: ElementRef<HTMLElement>;
+
+  @Input() run: SaasAnalysisRun | null = null;
+  @Input() workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  @Output() readonly closed = new EventEmitter<void>();
+
+  readonly icons = {
+    activity: Activity,
+    alert: AlertCircle,
+    brain: BrainCircuit,
+    check: CheckCircle2,
+    clock: Clock3,
+    database: Database,
+    evidence: FileSearch,
+    gauge: Gauge,
+    loader: LoaderCircle,
+    quote: MessageSquareQuote,
+    timer: Timer,
+    userCheck: UserCheck,
+    close: X,
+  };
+
+  activeTab: AnalysisRunDetailTab = 'summary';
+  inputView: AnalysisRunInputView | null = null;
+  resultView: AnalysisRunResultView | null = null;
+  evidenceReferences: AnalysisRunEvidenceReferenceView[] = [];
+  timeline: AnalysisRunTimelineItem[] = [];
+  durations: AnalysisRunDurationView = { queueMs: null, executionMs: null, totalMs: null };
+  decisions: SaasInsightDecision[] = [];
+  decisionsLoading = false;
+  decisionsError = '';
+
+  ngAfterViewInit(): void {
+    queueMicrotask(() => this.runDetail?.nativeElement.querySelector<HTMLElement>('[aria-label="关闭运行详情"]')?.focus());
+  }
+
+  ngOnDestroy(): void {
+    queueMicrotask(() => this.returnFocus?.focus());
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (!changes['run'] && !changes['workspaceId']) return;
+    this.activeTab = 'summary';
+    this.rebuildView();
+    this.loadDecisions();
+  }
+
+  @HostListener('document:keydown', ['$event'])
+  handleKeyboard(event: KeyboardEvent): void {
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      this.close();
+      return;
+    }
+    if (event.key === 'Tab') this.keepFocusInDrawer(event);
+  }
+
+  close(): void {
+    this.closed.emit();
+  }
+
+  setTab(tab: AnalysisRunDetailTab): void {
+    this.activeTab = tab;
+  }
+
+  statusLabel(): string {
+    return this.run ? analysisRunStatusLabel(this.run.status) : '未记录';
+  }
+
+  statusTone(): string {
+    return this.run ? analysisRunStatusTone(this.run.status) : 'neutral';
+  }
+
+  typeLabel(): string {
+    return this.run ? analysisRunTypeLabel(this.run.analysisType) : '未记录';
+  }
+
+  targetKindLabel(): string {
+    return this.run ? analysisTargetKindLabel(this.run.targetKind) : '未记录';
+  }
+
+  targetLabel(): string {
+    return this.run ? analysisTargetLabel(this.run) : '未记录';
+  }
+
+  modeLabel(): string {
+    return analysisModeLabel(this.resultView?.mode ?? null);
+  }
+
+  scopeLabel(): string {
+    return analysisScopeLabel(this.inputView?.scope ?? null);
+  }
+
+  sentimentLabel(): string {
+    return analysisSentimentLabel(this.inputView?.sentiment ?? null);
+  }
+
+  severityLabel(value: string | null): string {
+    return insightSeverityLabel(value);
+  }
+
+  opportunityLabel(value: string | null): string {
+    return opportunityStatusLabel(value);
+  }
+
+  decisionLabel(decision: SaasInsightDecision): string {
+    return insightDecisionLabel(decision.decision);
+  }
+
+  formatDate(value: string | null): string {
+    return formatAnalysisDate(value);
+  }
+
+  formatDuration(value: number | null): string {
+    return formatDuration(value);
+  }
+
+  percentage(value: number | null): string {
+    if (value === null || !Number.isFinite(value)) return '未记录';
+    return `${Math.round(value * 100)}%`;
+  }
+
+  number(value: number | null): string {
+    return value === null ? '未记录' : value.toLocaleString('zh-CN');
+  }
+
+  sourceLabel(value: string): string {
+    return ({ own: '本品', competitor: '竞品', unknown: '来源待确认' } as Record<string, string>)[value] ?? value;
+  }
+
+  trackEvidence(_: number, item: AnalysisRunEvidenceReferenceView): string {
+    return item.id;
+  }
+
+  trackDecision(_: number, item: SaasInsightDecision): string {
+    return item.id;
+  }
+
+  trackTimeline(_: number, item: AnalysisRunTimelineItem): string {
+    return item.key;
+  }
+
+  private rebuildView(): void {
+    if (!this.run) {
+      this.inputView = null;
+      this.resultView = null;
+      this.evidenceReferences = [];
+      this.timeline = [];
+      this.durations = { queueMs: null, executionMs: null, totalMs: null };
+      return;
+    }
+    this.inputView = readAnalysisRunInput(this.run);
+    this.resultView = readAnalysisRunResult(this.run);
+    this.evidenceReferences = buildEvidenceReferences(this.inputView, this.resultView);
+    this.timeline = buildAnalysisTimeline(this.run);
+    this.durations = buildAnalysisDurations(this.run);
+  }
+
+  private loadDecisions(): void {
+    const run = this.run;
+    const workspaceId = this.workspaceId.trim();
+    const version = ++this.requestVersion;
+    this.decisions = [];
+    this.decisionsError = '';
+    if (!run || !workspaceId) {
+      this.decisionsLoading = false;
+      return;
+    }
+
+    this.decisionsLoading = true;
+    const request = (cursor: string) => cursor
+      ? this.saasService.insightDecisions(workspaceId, run.id, '', false, cursor, 100)
+      : this.saasService.insightDecisions(workspaceId, run.id, '', false);
+    request('')
+      .pipe(
+        expand((page) => page.nextCursor ? request(page.nextCursor) : EMPTY),
+        map((page) => page.items),
+        reduce((items, pageItems) => [...items, ...pageItems], [] as SaasInsightDecision[]),
+        takeUntilDestroyed(this.destroyRef),
+      )
+      .subscribe({
+        next: (items) => {
+          if (version !== this.requestVersion) return;
+          this.decisions = items;
+          this.decisionsLoading = false;
+          this.decisionsError = '';
+          this.cdr.markForCheck();
+        },
+        error: (error: unknown) => {
+          if (version !== this.requestVersion) return;
+          this.decisionsLoading = false;
+          this.decisionsError = this.errorMessage(error);
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private keepFocusInDrawer(event: KeyboardEvent): void {
+    const root = this.runDetail?.nativeElement;
+    if (!root) return;
+    const focusable = Array.from(root.querySelectorAll<HTMLElement>(
+      'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
+    ));
+    if (!focusable.length) return;
+    const first = focusable[0];
+    const last = focusable[focusable.length - 1];
+    if (event.shiftKey && document.activeElement === first) {
+      event.preventDefault();
+      last.focus();
+    } else if (!event.shiftKey && document.activeElement === last) {
+      event.preventDefault();
+      first.focus();
+    }
+  }
+
+  private errorMessage(error: unknown): string {
+    if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
+      return `人工决策加载失败:${error.message}`;
+    }
+    return '人工决策加载失败,请稍后重试。';
+  }
+}

+ 140 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.html

@@ -0,0 +1,140 @@
+<section class="analysis-runs-page" aria-labelledby="analysis-runs-title">
+  <header class="page-header">
+    <div class="title-group">
+      <span class="title-icon" aria-hidden="true">
+        <lucide-icon [img]="icons.brain" [size]="19"></lucide-icon>
+      </span>
+      <div>
+        <h1 id="analysis-runs-title">AI 分析运行中心</h1>
+        <p>最近更新:{{ loadedAt ? formatDate(loadedAt) : '尚未加载' }}</p>
+      </div>
+    </div>
+    <button class="icon-button" type="button" title="刷新运行列表" aria-label="刷新运行列表"
+      [disabled]="loading" (click)="refresh()">
+      <lucide-icon [img]="loading ? icons.loader : icons.refresh" [size]="16"
+        [class.is-spinning]="loading"></lucide-icon>
+    </button>
+  </header>
+
+  <div class="run-summary" aria-label="当前已加载运行统计">
+    <div>
+      <span>已加载</span>
+      <strong>{{ runs.length }}</strong>
+    </div>
+    <div>
+      <span class="summary-icon is-info"><lucide-icon [img]="icons.activity" [size]="14"></lucide-icon></span>
+      <span>待处理 / 运行中</span>
+      <strong>{{ processingCount }}</strong>
+    </div>
+    <div>
+      <span class="summary-icon is-success"><lucide-icon [img]="icons.check" [size]="14"></lucide-icon></span>
+      <span>完成 / 部分完成</span>
+      <strong>{{ completedCount }}</strong>
+    </div>
+    <div>
+      <span class="summary-icon is-danger"><lucide-icon [img]="icons.alert" [size]="14"></lucide-icon></span>
+      <span>失败</span>
+      <strong>{{ failedCount }}</strong>
+    </div>
+  </div>
+
+  <div class="run-toolbar">
+    <label class="search-field">
+      <lucide-icon [img]="icons.search" [size]="15" aria-hidden="true"></lucide-icon>
+      <span class="sr-only">搜索运行</span>
+      <input type="search" [(ngModel)]="searchTerm" placeholder="搜索目标、类型、请求人或运行 ID" />
+    </label>
+    <label class="filter-field">
+      <span>状态</span>
+      <select [(ngModel)]="statusFilter" (change)="applyStatusFilter()">
+        <option *ngFor="let option of statusOptions" [ngValue]="option.value">{{ option.label }}</option>
+      </select>
+    </label>
+    <span class="result-count">{{ visibleRuns.length }} 条可见记录</span>
+  </div>
+
+  <div class="state-block" *ngIf="loading">
+    <lucide-icon class="is-spinning" [img]="icons.loader" [size]="22"></lucide-icon>
+    <strong>正在加载分析运行</strong>
+  </div>
+
+  <div class="state-block is-error" *ngIf="!loading && error">
+    <lucide-icon [img]="icons.alert" [size]="22"></lucide-icon>
+    <strong>{{ error }}</strong>
+    <button type="button" (click)="refresh()">重试</button>
+  </div>
+
+  <div class="run-table-wrap" *ngIf="!loading && !error && visibleRuns.length">
+    <table class="run-table">
+      <thead>
+        <tr>
+          <th>状态</th>
+          <th>分析类型</th>
+          <th>目标对象</th>
+          <th class="number-cell">样本 / 证据</th>
+          <th>模式</th>
+          <th>请求 / 完成时间</th>
+          <th>错误</th>
+          <th><span class="sr-only">查看详情</span></th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr *ngFor="let run of visibleRuns; trackBy: trackRun" [class.is-selected]="selectedRun?.id === run.id">
+          <td>
+            <span class="status-badge" [attr.data-tone]="statusTone(run)">
+              <span class="status-dot"></span>{{ statusLabel(run) }}
+            </span>
+          </td>
+          <td>
+            <strong class="primary-cell">{{ typeLabel(run) }}</strong>
+            <span class="secondary-cell">{{ run.analysisType }}</span>
+          </td>
+          <td>
+            <button class="target-button" type="button" (click)="selectRun(run)">
+              <span>{{ targetLabel(run) }}</span>
+              <small>{{ targetKindLabel(run) }}</small>
+            </button>
+          </td>
+          <td class="number-cell">
+            <strong>{{ sampleCount(run) }}</strong>
+            <span>/ {{ run.evidenceCount | number }}</span>
+          </td>
+          <td>{{ modeLabel(run) }}</td>
+          <td>
+            <time class="primary-cell" [attr.datetime]="run.requestedAt">{{ formatDate(run.requestedAt) }}</time>
+            <time class="secondary-cell" *ngIf="run.completedAt; else missingCompletion"
+              [attr.datetime]="run.completedAt">{{ formatDate(run.completedAt) }}</time>
+            <ng-template #missingCompletion><span class="secondary-cell is-missing">完成时间未记录</span></ng-template>
+          </td>
+          <td>
+            <span class="error-cell" [class.is-present]="run.errorSummary">{{ run.errorSummary || '无' }}</span>
+          </td>
+          <td class="action-cell">
+            <button class="row-action" type="button" title="查看运行详情" [attr.aria-label]="'查看运行 ' + run.id"
+              (click)="selectRun(run)">
+              <lucide-icon [img]="icons.eye" [size]="15"></lucide-icon>
+              <lucide-icon [img]="icons.chevron" [size]="14"></lucide-icon>
+            </button>
+          </td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
+
+  <div class="state-block" *ngIf="!loading && !error && !visibleRuns.length">
+    <lucide-icon [img]="icons.clock" [size]="22"></lucide-icon>
+    <strong>{{ runs.length ? '没有匹配当前搜索的运行' : '当前筛选下没有分析运行' }}</strong>
+  </div>
+
+  <footer class="table-footer" *ngIf="!loading && !error && runs.length">
+    <span>{{ nextCursor ? '仍有更多运行记录' : '已到达当前记录末尾' }}</span>
+    <button type="button" *ngIf="nextCursor" [disabled]="loadingMore" (click)="loadMore()">
+      <lucide-icon [img]="loadingMore ? icons.loader : icons.chevron" [size]="15"
+        [class.is-spinning]="loadingMore"></lucide-icon>
+      {{ loadingMore ? '加载中' : '加载更多' }}
+    </button>
+  </footer>
+</section>
+
+<app-analysis-run-detail *ngIf="selectedRun" [run]="selectedRun" [workspaceId]="workspaceId"
+  (closed)="closeDetail()"></app-analysis-run-detail>

+ 415 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.scss

@@ -0,0 +1,415 @@
+:host {
+  display: block;
+  min-width: 0;
+  color: #1f2937;
+  letter-spacing: 0;
+}
+
+button,
+input,
+select {
+  font: inherit;
+  letter-spacing: 0;
+}
+
+button {
+  cursor: pointer;
+}
+
+.analysis-runs-page {
+  width: 100%;
+  padding: 24px;
+}
+
+.page-header,
+.title-group,
+.run-summary,
+.run-summary > div,
+.run-toolbar,
+.search-field,
+.filter-field,
+.status-badge,
+.table-footer,
+.table-footer button,
+.row-action {
+  display: flex;
+  align-items: center;
+}
+
+.page-header {
+  justify-content: space-between;
+  gap: 20px;
+  margin-bottom: 16px;
+}
+
+.title-group {
+  min-width: 0;
+  gap: 12px;
+}
+
+.title-icon {
+  display: grid;
+  width: 36px;
+  height: 36px;
+  place-items: center;
+  flex: 0 0 auto;
+  border-radius: 7px;
+  color: #35536b;
+  background: #edf3f6;
+}
+
+h1 {
+  margin: 0;
+  color: #18212b;
+  font-size: 22px;
+  font-weight: 650;
+  line-height: 1.25;
+}
+
+.title-group p {
+  margin: 3px 0 0;
+  color: #77818b;
+  font-size: 11px;
+}
+
+.icon-button,
+.row-action {
+  justify-content: center;
+  border: 1px solid #dfe4e8;
+  border-radius: 5px;
+  color: #56616c;
+  background: #ffffff;
+}
+
+.icon-button {
+  width: 34px;
+  height: 34px;
+}
+
+.icon-button:hover:not(:disabled),
+.row-action:hover,
+.table-footer button:hover:not(:disabled) {
+  border-color: #bac5ce;
+  color: #22313d;
+  background: #f7f9fa;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible {
+  outline: 2px solid rgba(37, 99, 235, 0.35);
+  outline-offset: 2px;
+}
+
+.run-summary {
+  min-height: 48px;
+  overflow-x: auto;
+  border: 1px solid #dfe4e8;
+  border-radius: 7px 7px 0 0;
+  background: #ffffff;
+}
+
+.run-summary > div {
+  min-width: 160px;
+  gap: 8px;
+  padding: 0 16px;
+  border-right: 1px solid #e7ebee;
+  color: #68737e;
+  font-size: 11px;
+  white-space: nowrap;
+}
+
+.run-summary > div:first-child {
+  min-width: 120px;
+}
+
+.run-summary strong {
+  margin-left: auto;
+  color: #27313b;
+  font-size: 15px;
+  font-weight: 650;
+}
+
+.summary-icon {
+  display: grid;
+  width: 24px;
+  height: 24px;
+  place-items: center;
+  border-radius: 5px;
+  color: #64748b;
+  background: #f1f5f9;
+}
+
+.summary-icon.is-info { color: #2563eb; background: #eff6ff; }
+.summary-icon.is-success { color: #047857; background: #ecfdf5; }
+.summary-icon.is-danger { color: #b91c1c; background: #fef2f2; }
+
+.run-toolbar {
+  min-height: 54px;
+  gap: 12px;
+  padding: 9px 12px;
+  border: 1px solid #dfe4e8;
+  border-top: 0;
+  background: #fafbfc;
+}
+
+.search-field {
+  width: min(380px, 100%);
+  height: 34px;
+  gap: 8px;
+  padding: 0 10px;
+  border: 1px solid #d8dee3;
+  border-radius: 5px;
+  color: #7b8791;
+  background: #ffffff;
+}
+
+.search-field:focus-within {
+  border-color: #9eb3c3;
+}
+
+.search-field input {
+  width: 100%;
+  min-width: 0;
+  border: 0;
+  outline: 0;
+  color: #29333d;
+  background: transparent;
+  font-size: 12px;
+}
+
+.filter-field {
+  height: 34px;
+  gap: 7px;
+  padding-left: 10px;
+  border: 1px solid #d8dee3;
+  border-radius: 5px;
+  color: #77818b;
+  background: #ffffff;
+  font-size: 11px;
+}
+
+.filter-field select {
+  height: 100%;
+  padding: 0 28px 0 4px;
+  border: 0;
+  outline: 0;
+  color: #27313b;
+  background: transparent;
+  font-size: 12px;
+}
+
+.result-count {
+  margin-left: auto;
+  color: #7b8590;
+  font-size: 11px;
+  white-space: nowrap;
+}
+
+.run-table-wrap {
+  overflow-x: auto;
+  border: 1px solid #dfe4e8;
+  border-top: 0;
+  background: #ffffff;
+}
+
+.run-table {
+  width: 100%;
+  min-width: 1120px;
+  border-collapse: collapse;
+  table-layout: fixed;
+}
+
+.run-table th {
+  height: 36px;
+  padding: 0 12px;
+  border-bottom: 1px solid #e4e9ed;
+  color: #7b8590;
+  background: #f7f9fa;
+  font-size: 10px;
+  font-weight: 650;
+  text-align: left;
+  text-transform: uppercase;
+}
+
+.run-table th:nth-child(1) { width: 112px; }
+.run-table th:nth-child(2) { width: 142px; }
+.run-table th:nth-child(3) { width: 190px; }
+.run-table th:nth-child(4) { width: 110px; }
+.run-table th:nth-child(5) { width: 100px; }
+.run-table th:nth-child(6) { width: 202px; }
+.run-table th:nth-child(7) { width: 190px; }
+.run-table th:nth-child(8) { width: 58px; }
+
+.run-table td {
+  height: 58px;
+  padding: 8px 12px;
+  border-bottom: 1px solid #edf0f2;
+  color: #46515c;
+  font-size: 11px;
+  vertical-align: middle;
+}
+
+.run-table tbody tr:hover,
+.run-table tbody tr.is-selected {
+  background: #f8fafb;
+}
+
+.run-table tbody tr.is-selected {
+  box-shadow: inset 3px 0 #54758c;
+}
+
+.primary-cell,
+.secondary-cell {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.primary-cell {
+  color: #27313b;
+  font-size: 11px;
+  font-weight: 620;
+}
+
+.secondary-cell {
+  margin-top: 3px;
+  color: #89939d;
+  font-size: 10px;
+}
+
+.secondary-cell.is-missing,
+.error-cell {
+  color: #8a949e;
+}
+
+.status-badge {
+  width: max-content;
+  gap: 6px;
+  color: #56616c;
+  font-weight: 600;
+}
+
+.status-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #94a3b8;
+}
+
+.status-badge[data-tone='info'] { color: #1d4ed8; }
+.status-badge[data-tone='info'] .status-dot { background: #3b82f6; }
+.status-badge[data-tone='success'] { color: #047857; }
+.status-badge[data-tone='success'] .status-dot { background: #10b981; }
+.status-badge[data-tone='warning'] { color: #a16207; }
+.status-badge[data-tone='warning'] .status-dot { background: #eab308; }
+.status-badge[data-tone='danger'] { color: #b91c1c; }
+.status-badge[data-tone='danger'] .status-dot { background: #ef4444; }
+
+.target-button {
+  display: grid;
+  width: 100%;
+  min-width: 0;
+  gap: 3px;
+  padding: 0;
+  border: 0;
+  color: #294d68;
+  background: transparent;
+  text-align: left;
+}
+
+.target-button span,
+.target-button small {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.target-button span { font-size: 11px; font-weight: 620; }
+.target-button small { color: #8a949e; font-size: 10px; }
+
+.number-cell {
+  font-variant-numeric: tabular-nums;
+  text-align: right !important;
+}
+
+.number-cell strong { color: #27313b; }
+.number-cell span { color: #8a949e; }
+
+.error-cell {
+  display: -webkit-box;
+  overflow: hidden;
+  line-height: 1.4;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.error-cell.is-present { color: #a33f3f; }
+.action-cell { text-align: right; }
+.row-action { width: 36px; height: 28px; gap: 0; }
+
+.state-block {
+  display: grid;
+  min-height: 240px;
+  place-items: center;
+  align-content: center;
+  gap: 10px;
+  border: 1px solid #dfe4e8;
+  border-top: 0;
+  color: #75808b;
+  background: #ffffff;
+}
+
+.state-block strong {
+  max-width: 520px;
+  font-size: 12px;
+  font-weight: 600;
+  text-align: center;
+}
+
+.state-block.is-error { color: #9b4646; }
+
+.state-block button,
+.table-footer button {
+  height: 30px;
+  padding: 0 12px;
+  border: 1px solid #d8dee3;
+  border-radius: 5px;
+  color: #34414c;
+  background: #ffffff;
+  font-size: 11px;
+}
+
+.table-footer {
+  justify-content: space-between;
+  min-height: 46px;
+  padding: 8px 12px;
+  border: 1px solid #dfe4e8;
+  border-top: 0;
+  border-radius: 0 0 7px 7px;
+  color: #7b8590;
+  background: #fafbfc;
+  font-size: 11px;
+}
+
+.table-footer button { gap: 6px; }
+
+.is-spinning { animation: spin 0.85s linear infinite; }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 720px) {
+  .analysis-runs-page { padding: 16px; }
+  h1 { font-size: 19px; }
+  .run-summary > div { min-width: 148px; }
+  .run-toolbar { align-items: stretch; flex-wrap: wrap; }
+  .search-field { width: 100%; }
+  .filter-field { flex: 1; }
+  .result-count { align-self: center; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .is-spinning { animation: none; }
+}

+ 245 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.ts

@@ -0,0 +1,245 @@
+import { CommonModule } from '@angular/common';
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  DestroyRef,
+  EventEmitter,
+  Input,
+  OnChanges,
+  OnInit,
+  Output,
+  SimpleChanges,
+  inject,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+import {
+  Activity,
+  AlertCircle,
+  BrainCircuit,
+  CheckCircle2,
+  ChevronRight,
+  Clock3,
+  Eye,
+  LoaderCircle,
+  RefreshCw,
+  Search,
+  LucideAngularModule,
+} from 'lucide-angular';
+import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import {
+  SaasAnalysisRun,
+  SaasPlatformService,
+} from '../../../app/core/services/saas-platform.service';
+import { AnalysisRunDetailComponent } from './analysis-run-detail.component';
+import {
+  ANALYSIS_RUN_STATUS_OPTIONS,
+  AnalysisRunStatusFilter,
+  analysisModeLabel,
+  analysisRunStatusLabel,
+  analysisRunStatusTone,
+  analysisRunTypeLabel,
+  analysisTargetKindLabel,
+  analysisTargetLabel,
+  formatAnalysisDate,
+  readAnalysisRunInput,
+  readAnalysisRunResult,
+} from './analysis-run.models';
+
+@Component({
+  selector: 'app-analysis-run-list',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule, AnalysisRunDetailComponent],
+  templateUrl: './analysis-run-list.component.html',
+  styleUrl: './analysis-run-list.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AnalysisRunListComponent implements OnInit, OnChanges {
+  private readonly saasService = inject(SaasPlatformService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+  private initialized = false;
+  private requestVersion = 0;
+
+  @Input() workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  @Output() readonly runSelected = new EventEmitter<SaasAnalysisRun>();
+
+  readonly icons = {
+    activity: Activity,
+    alert: AlertCircle,
+    brain: BrainCircuit,
+    check: CheckCircle2,
+    chevron: ChevronRight,
+    clock: Clock3,
+    eye: Eye,
+    loader: LoaderCircle,
+    refresh: RefreshCw,
+    search: Search,
+  };
+  readonly statusOptions = ANALYSIS_RUN_STATUS_OPTIONS;
+
+  runs: SaasAnalysisRun[] = [];
+  statusFilter: AnalysisRunStatusFilter = '';
+  searchTerm = '';
+  loading = true;
+  loadingMore = false;
+  error = '';
+  nextCursor: string | null = null;
+  selectedRun: SaasAnalysisRun | null = null;
+  loadedAt: string | null = null;
+
+  ngOnInit(): void {
+    this.initialized = true;
+    this.loadRuns(true);
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (this.initialized && changes['workspaceId']) this.loadRuns(true);
+  }
+
+  get visibleRuns(): SaasAnalysisRun[] {
+    const query = this.searchTerm.trim().toLocaleLowerCase('zh-CN');
+    if (!query) return this.runs;
+    return this.runs.filter((run) => [
+      run.id,
+      run.targetKey,
+      run.requestedBy,
+      run.errorSummary ?? '',
+      analysisRunTypeLabel(run.analysisType),
+      analysisTargetKindLabel(run.targetKind),
+    ].some((value) => value.toLocaleLowerCase('zh-CN').includes(query)));
+  }
+
+  get processingCount(): number {
+    return this.runs.filter((run) => run.status === 'pending' || run.status === 'processing').length;
+  }
+
+  get completedCount(): number {
+    return this.runs.filter((run) => run.status === 'completed' || run.status === 'partial').length;
+  }
+
+  get failedCount(): number {
+    return this.runs.filter((run) => run.status === 'failed').length;
+  }
+
+  applyStatusFilter(): void {
+    this.loadRuns(true);
+  }
+
+  refresh(): void {
+    this.loadRuns(true);
+  }
+
+  loadMore(): void {
+    if (!this.nextCursor || this.loadingMore) return;
+    this.loadRuns(false);
+  }
+
+  selectRun(run: SaasAnalysisRun): void {
+    this.selectedRun = run;
+    this.runSelected.emit(run);
+  }
+
+  closeDetail(): void {
+    this.selectedRun = null;
+  }
+
+  trackRun(_: number, run: SaasAnalysisRun): string {
+    return run.id;
+  }
+
+  statusLabel(run: SaasAnalysisRun): string {
+    return analysisRunStatusLabel(run.status);
+  }
+
+  statusTone(run: SaasAnalysisRun): string {
+    return analysisRunStatusTone(run.status);
+  }
+
+  typeLabel(run: SaasAnalysisRun): string {
+    return analysisRunTypeLabel(run.analysisType);
+  }
+
+  targetKindLabel(run: SaasAnalysisRun): string {
+    return analysisTargetKindLabel(run.targetKind);
+  }
+
+  targetLabel(run: SaasAnalysisRun): string {
+    return analysisTargetLabel(run);
+  }
+
+  sampleCount(run: SaasAnalysisRun): string {
+    const input = readAnalysisRunInput(run);
+    const result = readAnalysisRunResult(run);
+    const value = input.sampleCount ?? result?.sampleCount ?? null;
+    return value === null ? '未记录' : value.toLocaleString('zh-CN');
+  }
+
+  modeLabel(run: SaasAnalysisRun): string {
+    return analysisModeLabel(readAnalysisRunResult(run)?.mode ?? null);
+  }
+
+  formatDate(value: string | null): string {
+    return formatAnalysisDate(value);
+  }
+
+  private loadRuns(reset: boolean): void {
+    const requestVersion = ++this.requestVersion;
+    if (!this.workspaceId.trim()) {
+      this.runs = [];
+      this.loading = false;
+      this.loadingMore = false;
+      this.error = '工作空间 ID 未记录,运行列表未请求。';
+      this.cdr.markForCheck();
+      return;
+    }
+
+    const cursor = reset ? '' : this.nextCursor ?? '';
+    if (reset) {
+      this.loading = true;
+      this.error = '';
+      this.nextCursor = null;
+    } else {
+      this.loadingMore = true;
+    }
+
+    this.saasService.analyses(this.workspaceId, this.statusFilter, cursor, 50)
+      .pipe(takeUntilDestroyed(this.destroyRef))
+      .subscribe({
+        next: (page) => {
+          if (requestVersion !== this.requestVersion) return;
+          this.runs = reset ? page.items : this.mergeRuns(this.runs, page.items);
+          this.nextCursor = page.nextCursor;
+          this.loadedAt = new Date().toISOString();
+          this.loading = false;
+          this.loadingMore = false;
+          this.error = '';
+          if (this.selectedRun) {
+            this.selectedRun = this.runs.find((run) => run.id === this.selectedRun?.id) ?? this.selectedRun;
+          }
+          this.cdr.markForCheck();
+        },
+        error: (error: unknown) => {
+          if (requestVersion !== this.requestVersion) return;
+          this.loading = false;
+          this.loadingMore = false;
+          this.error = this.errorMessage(error);
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private mergeRuns(current: SaasAnalysisRun[], incoming: SaasAnalysisRun[]): SaasAnalysisRun[] {
+    const byId = new Map(current.map((run) => [run.id, run]));
+    incoming.forEach((run) => byId.set(run.id, run));
+    return [...byId.values()];
+  }
+
+  private errorMessage(error: unknown): string {
+    if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
+      return `分析运行加载失败:${error.message}`;
+    }
+    return '分析运行加载失败,请检查数据服务后重试。';
+  }
+}

+ 364 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run.models.ts

@@ -0,0 +1,364 @@
+import {
+  SaasAnalysisRun,
+  SaasAnalysisStatus,
+  SaasInsightDecisionType,
+} from '../../../app/core/services/saas-platform.service';
+
+export type AnalysisRunStatusFilter = '' | SaasAnalysisStatus;
+export type AnalysisRunTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
+
+export interface AnalysisRunTopicView {
+  id: string;
+  label: string | null;
+  count: number | null;
+  share: number | null;
+}
+
+export interface AnalysisRunInputView {
+  scope: string | null;
+  sentiment: string | null;
+  sampleLimit: number | null;
+  sampleCount: number | null;
+  evidenceIds: string[];
+  sourceCounts: Array<{ label: string; value: number }>;
+  topics: AnalysisRunTopicView[];
+  generatedBy: string | null;
+}
+
+export interface AnalysisRunInsightView {
+  key: string;
+  id: string | null;
+  title: string | null;
+  finding: string | null;
+  userNeed: string | null;
+  severity: string | null;
+  confidence: string | null;
+  opportunityStatus: string | null;
+  recommendation: string | null;
+  validationMetric: string | null;
+  evidenceIds: string[];
+}
+
+export interface AnalysisRunResultView {
+  mode: string | null;
+  headline: string | null;
+  executiveSummary: string | null;
+  generatedAt: string | null;
+  sampleCount: number | null;
+  evidenceCoverage: number | null;
+  insights: AnalysisRunInsightView[];
+  risks: string[];
+  followUpQuestions: string[];
+}
+
+export interface AnalysisRunEvidenceReferenceView {
+  id: string;
+  insightKeys: string[];
+  insightTitles: string[];
+}
+
+export interface AnalysisRunTimelineItem {
+  key: 'requested' | 'started' | 'completed' | 'status';
+  title: string;
+  time: string | null;
+  description: string;
+  tone: AnalysisRunTone;
+  missing: boolean;
+}
+
+export interface AnalysisRunDurationView {
+  queueMs: number | null;
+  executionMs: number | null;
+  totalMs: number | null;
+}
+
+export const ANALYSIS_RUN_STATUS_OPTIONS: ReadonlyArray<{
+  value: AnalysisRunStatusFilter;
+  label: string;
+}> = [
+  { value: '', label: '全部状态' },
+  { value: 'pending', label: '待处理' },
+  { value: 'processing', label: '运行中' },
+  { value: 'completed', label: '已完成' },
+  { value: 'partial', label: '部分完成' },
+  { value: 'failed', label: '失败' },
+  { value: 'cancelled', label: '已取消' },
+];
+
+export function analysisRunStatusLabel(status: SaasAnalysisStatus): string {
+  return ({
+    pending: '待处理',
+    processing: '运行中',
+    completed: '已完成',
+    partial: '部分完成',
+    failed: '失败',
+    cancelled: '已取消',
+  } as const)[status];
+}
+
+export function analysisRunStatusTone(status: SaasAnalysisStatus): AnalysisRunTone {
+  return ({
+    pending: 'neutral',
+    processing: 'info',
+    completed: 'success',
+    partial: 'warning',
+    failed: 'danger',
+    cancelled: 'neutral',
+  } as const)[status];
+}
+
+export function analysisRunTypeLabel(type: SaasAnalysisRun['analysisType']): string {
+  return ({
+    voice: '声音分类',
+    pain_point: '痛点洞察',
+    feature: '功能分析',
+    scenario: '场景分析',
+    risk: '风险识别',
+    report: '洞察报告',
+    voc_insight: 'AI VOC 洞察',
+  } as const)[type];
+}
+
+export function analysisTargetKindLabel(kind: SaasAnalysisRun['targetKind']): string {
+  return ({
+    workspace: '工作空间',
+    category: '类目',
+    product: '商品',
+  } as const)[kind];
+}
+
+export function analysisTargetLabel(run: SaasAnalysisRun): string {
+  const key = run.targetKey.trim();
+  return key || '目标键未记录';
+}
+
+export function analysisModeLabel(mode: string | null): string {
+  if (mode === 'ai') return 'AI 生成';
+  if (mode === 'deterministic') return '规则摘要';
+  return mode?.trim() || '未记录';
+}
+
+export function analysisScopeLabel(scope: string | null): string {
+  if (scope === 'all') return '全部评价';
+  if (scope === 'own') return '本品评价';
+  if (scope === 'competitor') return '竞品评价';
+  return scope?.trim() || '未记录';
+}
+
+export function analysisSentimentLabel(sentiment: string | null): string {
+  if (sentiment === 'all') return '全部倾向';
+  if (sentiment === 'negative') return '负向评价';
+  if (sentiment === 'neutral') return '中性评价';
+  if (sentiment === 'positive') return '正向评价';
+  return sentiment?.trim() || '未记录';
+}
+
+export function insightDecisionLabel(decision: SaasInsightDecisionType): string {
+  return ({
+    confirmed: '已确认',
+    rejected: '已驳回',
+    needs_more_evidence: '需补充证据',
+  } as const)[decision];
+}
+
+export function insightSeverityLabel(severity: string | null): string {
+  return ({
+    critical: '严重',
+    high: '高',
+    medium: '中',
+    low: '低',
+  } as Record<string, string>)[severity ?? ''] ?? severity?.trim() ?? '未记录';
+}
+
+export function opportunityStatusLabel(status: string | null): string {
+  return ({
+    supported: '证据支持',
+    partially_supported: '部分支持',
+    needs_validation: '待验证',
+    not_supported: '暂不支持',
+  } as Record<string, string>)[status ?? ''] ?? status?.trim() ?? '未记录';
+}
+
+export function formatAnalysisDate(value: string | null): string {
+  if (!value) return '未记录';
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return `无效时间:${value}`;
+  return new Intl.DateTimeFormat('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false,
+  }).format(date);
+}
+
+export function formatDuration(value: number | null): string {
+  if (value === null || value < 0 || !Number.isFinite(value)) return '未记录';
+  if (value < 1_000) return `${Math.round(value)} 毫秒`;
+  const seconds = Math.floor(value / 1_000);
+  if (seconds < 60) return `${seconds} 秒`;
+  const minutes = Math.floor(seconds / 60);
+  const remainingSeconds = seconds % 60;
+  if (minutes < 60) return `${minutes} 分 ${remainingSeconds} 秒`;
+  const hours = Math.floor(minutes / 60);
+  return `${hours} 小时 ${minutes % 60} 分`;
+}
+
+export function readAnalysisRunInput(run: SaasAnalysisRun): AnalysisRunInputView {
+  const input = isRecord(run.input) ? run.input : {};
+  const filters = readRecord(input['filters']);
+  const sourceCounts = readRecord(input['sourceCounts']);
+  const topics = readArray(input['topics'])
+    .filter(isRecord)
+    .map((topic, index): AnalysisRunTopicView => ({
+      id: readString(topic['id']) ?? `topic-${index + 1}`,
+      label: readString(topic['label']),
+      count: readNumber(topic['count']),
+      share: readNumber(topic['share']),
+    }));
+
+  return {
+    scope: readString(filters['scope']),
+    sentiment: readString(filters['sentiment']),
+    sampleLimit: readNumber(filters['sampleLimit']),
+    sampleCount: readNumber(input['sampleCount']),
+    evidenceIds: readStringArray(input['evidenceIds']),
+    sourceCounts: Object.entries(sourceCounts)
+      .filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]))
+      .map(([label, value]) => ({ label, value })),
+    topics,
+    generatedBy: readString(input['generatedBy']),
+  };
+}
+
+export function readAnalysisRunResult(run: SaasAnalysisRun): AnalysisRunResultView | null {
+  if (!isRecord(run.result)) return null;
+  const result = run.result;
+  const insights = readArray(result['insights'])
+    .filter(isRecord)
+    .map((insight, index): AnalysisRunInsightView => ({
+      key: readString(insight['id']) ?? `insight-${index + 1}`,
+      id: readString(insight['id']),
+      title: readString(insight['title']),
+      finding: readString(insight['finding']),
+      userNeed: readString(insight['userNeed']),
+      severity: readString(insight['severity']),
+      confidence: readString(insight['confidence']),
+      opportunityStatus: readString(insight['opportunityStatus']),
+      recommendation: readString(insight['recommendation']),
+      validationMetric: readString(insight['validationMetric']),
+      evidenceIds: readStringArray(insight['evidenceIds']),
+    }));
+
+  return {
+    mode: readString(result['mode']),
+    headline: readString(result['headline']),
+    executiveSummary: readString(result['executiveSummary']),
+    generatedAt: readString(result['generatedAt']),
+    sampleCount: readNumber(result['sampleCount']),
+    evidenceCoverage: readNumber(result['evidenceCoverage']),
+    insights,
+    risks: readStringArray(result['risks']),
+    followUpQuestions: readStringArray(result['followUpQuestions']),
+  };
+}
+
+export function buildEvidenceReferences(
+  input: AnalysisRunInputView,
+  result: AnalysisRunResultView | null,
+): AnalysisRunEvidenceReferenceView[] {
+  const references = new Map<string, AnalysisRunEvidenceReferenceView>();
+  input.evidenceIds.forEach((id) => references.set(id, { id, insightKeys: [], insightTitles: [] }));
+  result?.insights.forEach((insight) => {
+    insight.evidenceIds.forEach((id) => {
+      const reference = references.get(id) ?? { id, insightKeys: [], insightTitles: [] };
+      if (!reference.insightKeys.includes(insight.key)) reference.insightKeys.push(insight.key);
+      const title = insight.title ?? insight.id ?? '标题未记录';
+      if (!reference.insightTitles.includes(title)) reference.insightTitles.push(title);
+      references.set(id, reference);
+    });
+  });
+  return [...references.values()];
+}
+
+export function buildAnalysisTimeline(run: SaasAnalysisRun): AnalysisRunTimelineItem[] {
+  const statusTone = analysisRunStatusTone(run.status);
+  return [
+    {
+      key: 'requested',
+      title: '已提交分析请求',
+      time: run.requestedAt || null,
+      description: `请求人:${run.requestedBy.trim() || '未记录'}`,
+      tone: 'neutral',
+      missing: !run.requestedAt,
+    },
+    {
+      key: 'started',
+      title: run.startedAt ? '开始处理' : '开始时间未记录',
+      time: run.startedAt,
+      description: run.startedAt ? '运行进入 processing 阶段。' : '后端没有保存 startedAt。',
+      tone: run.startedAt ? 'info' : 'neutral',
+      missing: !run.startedAt,
+    },
+    {
+      key: 'completed',
+      title: run.completedAt ? '处理结束' : '完成时间未记录',
+      time: run.completedAt,
+      description: run.completedAt ? `结束状态:${analysisRunStatusLabel(run.status)}` : '当前记录没有 completedAt。',
+      tone: run.completedAt ? statusTone : 'neutral',
+      missing: !run.completedAt,
+    },
+    {
+      key: 'status',
+      title: `当前状态:${analysisRunStatusLabel(run.status)}`,
+      time: run.completedAt ?? run.startedAt ?? run.requestedAt ?? null,
+      description: run.errorSummary?.trim() || '未记录额外状态说明。',
+      tone: statusTone,
+      missing: false,
+    },
+  ];
+}
+
+export function buildAnalysisDurations(run: SaasAnalysisRun): AnalysisRunDurationView {
+  const requested = dateValue(run.requestedAt);
+  const started = dateValue(run.startedAt);
+  const completed = dateValue(run.completedAt);
+  return {
+    queueMs: requested !== null && started !== null ? started - requested : null,
+    executionMs: started !== null && completed !== null ? completed - started : null,
+    totalMs: requested !== null && completed !== null ? completed - requested : null,
+  };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === 'object' && !Array.isArray(value);
+}
+
+function readRecord(value: unknown): Record<string, unknown> {
+  return isRecord(value) ? value : {};
+}
+
+function readArray(value: unknown): unknown[] {
+  return Array.isArray(value) ? value : [];
+}
+
+function readString(value: unknown): string | null {
+  return typeof value === 'string' && value.trim() ? value : null;
+}
+
+function readNumber(value: unknown): number | null {
+  return typeof value === 'number' && Number.isFinite(value) ? value : null;
+}
+
+function readStringArray(value: unknown): string[] {
+  if (!Array.isArray(value)) return [];
+  return value.filter((item): item is string => typeof item === 'string' && !!item.trim());
+}
+
+function dateValue(value: string | null): number | null {
+  if (!value) return null;
+  const date = new Date(value);
+  return Number.isNaN(date.getTime()) ? null : date.getTime();
+}

+ 571 - 0
src/modules/data-center/data-center.component.html

@@ -0,0 +1,571 @@
+<main class="data-center" *ngIf="vm$ | async as vm">
+  <header class="command-bar">
+    <div class="command-title">
+      <div class="title-line">
+        <lucide-icon [img]="icons.database" [size]="22" [strokeWidth]="1.9"></lucide-icon>
+        <h1>数据中台</h1>
+      </div>
+      <div class="context-line" aria-label="当前数据上下文">
+        <span><lucide-icon [img]="icons.store" [size]="14"></lucide-icon>{{ workspaceId }}</span>
+        <span>{{ platform | uppercase }}</span>
+        <span>{{ vm.dataset.source.dateRange.start }} 至 {{ vm.dataset.source.dateRange.end }}</span>
+        <span>更新于 {{ dateTime(vm.dataset.generatedAt) }}</span>
+      </div>
+    </div>
+
+    <div class="command-actions">
+      <button class="icon-button" type="button" title="刷新数据" aria-label="刷新数据" [disabled]="refreshing" (click)="refreshAll()">
+        <lucide-icon [img]="icons.refresh" [size]="17" [class.spinning]="refreshing"></lucide-icon>
+      </button>
+      <button class="secondary-button" type="button" (click)="configureSource()">
+        <lucide-icon [img]="icons.settings" [size]="16"></lucide-icon>
+        接入配置
+      </button>
+      <button class="primary-button" type="button" (click)="openSync(vm.dataset)">
+        <lucide-icon [img]="icons.play" [size]="16"></lucide-icon>
+        新建同步
+      </button>
+    </div>
+  </header>
+
+  <section class="health-band" [class.health-green]="vm.statusTone === 'green'" [class.health-amber]="vm.statusTone === 'amber'" [class.health-red]="vm.statusTone === 'red'">
+    <div class="health-main">
+      <lucide-icon [img]="vm.statusTone === 'green' ? icons.check : icons.alert" [size]="20"></lucide-icon>
+      <div>
+        <strong>{{ vm.statusTitle }}</strong>
+        <p>{{ vm.statusMessage }}</p>
+      </div>
+    </div>
+    <div class="health-facts">
+      <span><b>{{ vm.readyDomains }}</b>/{{ vm.coverage.length }} 业务域就绪</span>
+      <span><b>{{ vm.qualityFailed }}</b> 项阻断</span>
+      <span><b>{{ vm.activeJobs }}</b> 个运行任务</span>
+      <span>快照 {{ vm.sourceAgeLabel }}</span>
+    </div>
+  </section>
+
+  <div class="warning-row" *ngIf="vm.warnings.length">
+    <lucide-icon [img]="icons.alert" [size]="16"></lucide-icon>
+    <span>{{ vm.warnings.join(';') }},当前保留可用数据并显示接口异常。</span>
+  </div>
+  <div class="warning-row" *ngIf="actionError">
+    <lucide-icon [img]="icons.failed" [size]="16"></lucide-icon>
+    <span>{{ actionError }}</span>
+  </div>
+
+  <nav class="view-tabs" role="tablist" aria-label="数据中台视图">
+    <button
+      *ngFor="let tab of tabs"
+      type="button"
+      role="tab"
+      [attr.aria-selected]="activeView === tab.id"
+      [class.active]="activeView === tab.id"
+      (click)="setView(tab.id)"
+    >
+      {{ tab.label }}
+    </button>
+  </nav>
+
+  <section class="view-content" *ngIf="activeView === 'overview'">
+    <div class="metric-strip" aria-label="数据总览指标">
+      <div class="metric-item">
+        <span>平台商品</span>
+        <strong>{{ number(vm.ownProducts + vm.competitorProducts) }}</strong>
+        <small>本品 {{ number(vm.ownProducts) }} · 竞品 {{ number(vm.competitorProducts) }}</small>
+      </div>
+      <div class="metric-item">
+        <span>商品日明细</span>
+        <strong>{{ number(vm.dataset.summary.metricRows) }}</strong>
+        <small>{{ vm.dateDays }} 天经营周期</small>
+      </div>
+      <div class="metric-item metric-risk">
+        <span>评价证据</span>
+        <strong>{{ number(vm.ownReviews + vm.competitorReviews) }}</strong>
+        <small>本品 {{ number(vm.ownReviews) }} · 竞品 {{ number(vm.competitorReviews) }}</small>
+      </div>
+      <div class="metric-item">
+        <span>最近导入成功率</span>
+        <strong>{{ vm.latestImport ? percent(vm.importSuccessRate) : '--' }}</strong>
+        <small>{{ vm.latestImport ? number(vm.latestImport.successRows) + ' / ' + number(vm.latestImport.totalRows) + ' 行' : '暂无导入记录' }}</small>
+      </div>
+    </div>
+
+    <section class="workspace-section">
+      <div class="section-heading">
+        <div>
+          <h2>国内电商业务对象覆盖</h2>
+          <p>按业务对象与数据粒度审视能力,聚合指标不替代订单、售后或履约明细。</p>
+        </div>
+        <span class="section-count">{{ vm.readyDomains }}/{{ vm.coverage.length }} 已就绪</span>
+      </div>
+      <div class="table-scroll">
+        <table class="data-table coverage-table">
+          <thead>
+            <tr>
+              <th>业务域</th>
+              <th>核心对象</th>
+              <th>标准粒度</th>
+              <th>接入状态</th>
+              <th>覆盖率</th>
+              <th>当前证据</th>
+              <th>下一缺口</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr *ngFor="let domain of vm.coverage">
+              <td><strong>{{ domain.label }}</strong></td>
+              <td>{{ domain.objectLabel }}</td>
+              <td><code>{{ domain.grain }}</code></td>
+              <td><span class="status-tag" [class]="'status-tag ' + domain.state">{{ coverageStateLabel(domain.state) }}</span></td>
+              <td>
+                <div class="coverage-cell">
+                  <span class="coverage-track" aria-hidden="true"><i [style.width.%]="domain.coverage * 100"></i></span>
+                  <b>{{ percent(domain.coverage) }}</b>
+                </div>
+              </td>
+              <td>{{ domain.evidence }}</td>
+              <td class="gap-cell">{{ domain.gap }}</td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <div class="overview-lower">
+      <section class="workspace-section priority-section">
+        <div class="section-heading compact-heading">
+          <div>
+            <h2>治理优先队列</h2>
+            <p>先处理阻断洞察可信度的缺口,再扩展新业务域。</p>
+          </div>
+          <button class="text-button" type="button" (click)="setView('quality')">查看全部规则</button>
+        </div>
+        <div class="priority-list">
+          <div class="priority-row" *ngFor="let rule of vm.rules">
+            <span class="severity-mark" [class]="rule.state"></span>
+            <div class="priority-copy">
+              <strong>{{ rule.name }}</strong>
+              <span>{{ rule.result }} · 门槛 {{ rule.threshold }}</span>
+            </div>
+            <span class="affected">影响 {{ number(rule.affected) }}</span>
+            <button class="row-action" *ngIf="rule.action" type="button" (click)="runRuleAction(rule)">处理</button>
+          </div>
+        </div>
+      </section>
+
+      <section class="workspace-section run-section">
+        <div class="section-heading compact-heading">
+          <div>
+            <h2>最近运行</h2>
+            <p>数据源、导入与同步任务的最新状态。</p>
+          </div>
+          <button class="text-button" type="button" (click)="setView('sources')">进入运行中心</button>
+        </div>
+        <dl class="run-facts">
+          <div>
+            <dt>数据源</dt>
+            <dd>{{ vm.sources.length ? sourceStatusLabel(vm.sources[0].status) : '未读取' }}</dd>
+            <span>{{ vm.sources.length ? sourceKindLabel(vm.sources[0].kind) : '等待接入配置' }}</span>
+          </div>
+          <div>
+            <dt>标准化导入</dt>
+            <dd>{{ vm.latestImport ? importStatusLabel(vm.latestImport.status) : '无记录' }}</dd>
+            <span>{{ vm.latestImport ? dateTime(vm.latestImport.completedAt) : '尚未完成导入' }}</span>
+          </div>
+          <div>
+            <dt>同步队列</dt>
+            <dd>{{ vm.jobs.length }} 个任务</dd>
+            <span>{{ vm.activeJobs ? vm.activeJobs + ' 个正在运行' : '当前队列空闲' }}</span>
+          </div>
+          <div>
+            <dt>质量门禁</dt>
+            <dd>{{ vm.qualityPassed }} 通过 / {{ vm.qualityFailed }} 阻断</dd>
+            <span>{{ vm.qualityWarnings }} 项需要关注</span>
+          </div>
+        </dl>
+      </section>
+    </div>
+  </section>
+
+  <section class="view-content" *ngIf="activeView === 'sources'">
+    <section class="workspace-section">
+      <div class="section-heading">
+        <div>
+          <h2>数据源</h2>
+          <p>统一管理平台连接、凭证托管方式和最近检查时间。</p>
+        </div>
+        <button class="secondary-button compact" type="button" (click)="configureSource()">
+          <lucide-icon [img]="icons.plug" [size]="15"></lucide-icon>
+          管理接入
+        </button>
+      </div>
+      <div class="table-scroll">
+        <table class="data-table source-table">
+          <thead><tr><th>数据源</th><th>平台</th><th>连接状态</th><th>凭证托管</th><th>最近检查</th><th>操作</th></tr></thead>
+          <tbody>
+            <tr class="interactive-row" *ngFor="let source of vm.sources" tabindex="0" (click)="openSourceDetails(source)" (keydown.enter)="openSourceDetails(source)">
+              <td><strong>{{ sourceKindLabel(source.kind) }}</strong><small class="cell-meta">{{ source.id }}</small></td>
+              <td>{{ source.platform | uppercase }}</td>
+              <td><span class="status-tag" [class.ready]="source.status === 'active' || source.status === 'configured'" [class.missing]="source.status === 'error'">{{ sourceStatusLabel(source.status) }}</span></td>
+              <td>{{ credentialStorageLabel(source.credentialStorage) }}</td>
+              <td>{{ dateTime(source.lastCheckedAt) }}</td>
+              <td><button class="row-action" type="button" (click)="$event.stopPropagation(); openSourceDetails(source)">查看详情</button></td>
+            </tr>
+            <tr *ngIf="!vm.sources.length"><td colspan="6" class="empty-cell">尚未读取到数据源。</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <section class="workspace-section">
+      <div class="section-heading">
+        <div>
+          <h2>同步任务</h2>
+          <p>按平台商品 ID 拉取商品与评价,失败任务可重试,待执行任务可取消。</p>
+        </div>
+        <div class="section-tools">
+          <select [(ngModel)]="jobStatus" aria-label="筛选同步任务状态">
+            <option *ngFor="let status of jobStatuses" [ngValue]="status.value">{{ status.label }}</option>
+          </select>
+          <button class="primary-button compact" type="button" (click)="openSync(vm.dataset)">
+            <lucide-icon [img]="icons.play" [size]="15"></lucide-icon>
+            新建同步
+          </button>
+        </div>
+      </div>
+      <div class="table-scroll">
+        <table class="data-table jobs-table">
+          <thead><tr><th>任务</th><th>范围</th><th>商品数</th><th>状态</th><th>进度</th><th>请求时间</th><th>操作</th></tr></thead>
+          <tbody>
+            <tr class="interactive-row" *ngFor="let job of filteredJobs(vm.jobs)" tabindex="0" (click)="openJobDetails(job)" (keydown.enter)="openJobDetails(job)">
+              <td><strong>{{ job.id }}</strong><small class="cell-meta">尝试 {{ job.attempts }}/{{ job.maxAttempts }}</small></td>
+              <td>{{ job.scopes.join(' + ') }}</td>
+              <td>{{ job.productIds.length }}</td>
+              <td><span class="status-tag" [class.ready]="job.status === 'completed'" [class.partial]="job.status === 'partial' || job.status === 'pending' || job.status === 'processing'" [class.missing]="job.status === 'failed'">{{ jobStatusLabel(job.status) }}</span></td>
+              <td>
+                <div class="coverage-cell compact-progress">
+                  <span class="coverage-track"><i [style.width.%]="job.progress"></i></span>
+                  <b>{{ job.progress }}%</b>
+                </div>
+                <small class="error-summary" *ngIf="job.errorSummary">{{ job.errorSummary }}</small>
+              </td>
+              <td>{{ dateTime(job.requestedAt) }}</td>
+              <td>
+                <div class="row-actions">
+                  <button class="icon-button small" type="button" title="查看任务详情" aria-label="查看任务详情" (click)="$event.stopPropagation(); openJobDetails(job)">
+                    <lucide-icon [img]="icons.history" [size]="15"></lucide-icon>
+                  </button>
+                  <button class="icon-button small" *ngIf="job.status === 'failed' || job.status === 'partial'" type="button" title="重试任务" aria-label="重试任务" [disabled]="busyJobs.has(job.id)" (click)="$event.stopPropagation(); retryJob(job)">
+                    <lucide-icon [img]="icons.retry" [size]="15"></lucide-icon>
+                  </button>
+                  <button class="icon-button small danger" *ngIf="job.status === 'pending'" type="button" title="取消任务" aria-label="取消任务" [disabled]="busyJobs.has(job.id)" (click)="$event.stopPropagation(); cancelJob(job)">
+                    <lucide-icon [img]="icons.close" [size]="15"></lucide-icon>
+                  </button>
+                </div>
+              </td>
+            </tr>
+            <tr *ngIf="!filteredJobs(vm.jobs).length"><td colspan="7" class="empty-cell">当前筛选下没有同步任务。</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <section class="workspace-section">
+      <div class="section-heading">
+        <div>
+          <h2>导入批次</h2>
+          <p>记录标准化快照来源、行级处理结果和完成时间。</p>
+        </div>
+      </div>
+      <div class="table-scroll">
+        <table class="data-table">
+          <thead><tr><th>批次</th><th>来源</th><th>文件</th><th>处理结果</th><th>状态</th><th>完成时间</th></tr></thead>
+          <tbody>
+            <tr *ngFor="let batch of vm.imports">
+              <td><strong>{{ batch.id }}</strong></td>
+              <td>{{ sourceKindLabel(batch.sourceKind) }}</td>
+              <td>{{ batch.sourceFile || '--' }}</td>
+              <td>{{ number(batch.successRows) }} 成功 / {{ number(batch.failedRows) }} 失败</td>
+              <td><span class="status-tag" [class.ready]="batch.status === 'completed'" [class.partial]="batch.status === 'partial' || batch.status === 'processing'" [class.missing]="batch.status === 'failed'">{{ importStatusLabel(batch.status) }}</span></td>
+              <td>{{ dateTime(batch.completedAt) }}</td>
+            </tr>
+            <tr *ngIf="!vm.imports.length"><td colspan="6" class="empty-cell">暂无导入批次。</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+  </section>
+
+  <section class="view-content" *ngIf="activeView === 'quality'">
+    <div class="quality-summary">
+      <div><strong>{{ vm.qualityPassed }}</strong><span>通过</span></div>
+      <div class="quality-warning"><strong>{{ vm.qualityWarnings }}</strong><span>关注</span></div>
+      <div class="quality-failed"><strong>{{ vm.qualityFailed }}</strong><span>阻断</span></div>
+      <p>阻断项直接影响洞察可信度;关注项表示数据可用,但粒度、覆盖或时效尚未达到运营标准。</p>
+    </div>
+
+    <section class="workspace-section">
+      <div class="section-heading">
+        <div>
+          <h2>质量门禁</h2>
+          <p>覆盖完整性、结构一致性、新鲜度和业务可追溯性。</p>
+        </div>
+      </div>
+      <div class="table-scroll">
+        <table class="data-table quality-table">
+          <thead><tr><th>领域</th><th>规则</th><th>当前结果</th><th>门槛</th><th>影响对象</th><th>结论</th><th>操作</th></tr></thead>
+          <tbody>
+            <tr *ngFor="let rule of vm.rules">
+              <td>{{ rule.domain }}</td>
+              <td><strong>{{ rule.name }}</strong></td>
+              <td>{{ rule.result }}</td>
+              <td>{{ rule.threshold }}</td>
+              <td>{{ number(rule.affected) }}</td>
+              <td><span class="status-tag" [class]="'status-tag ' + rule.state">{{ ruleStateLabel(rule.state) }}</span></td>
+              <td><button class="row-action" type="button" *ngIf="rule.action" (click)="runRuleAction(rule)">处理</button><span *ngIf="!rule.action">--</span></td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+
+    <section class="semantic-band">
+      <div>
+        <lucide-icon [img]="icons.layers" [size]="19"></lucide-icon>
+        <strong>聚合指标</strong>
+        <p>商品日 GMV、订单数、退款额用于趋势与经营对比,不能回溯单笔交易。</p>
+      </div>
+      <div>
+        <lucide-icon [img]="icons.shopping" [size]="19"></lucide-icon>
+        <strong>完整业务实体</strong>
+        <p>订单、子单、支付、发货、签收、售后单、库存流水需要独立主键与状态历史。</p>
+      </div>
+      <div>
+        <lucide-icon [img]="icons.star" [size]="19"></lucide-icon>
+        <strong>VOC 证据</strong>
+        <p>评价、追评、商家回复和售后原因应保留来源、时间、商品归属与可回溯证据。</p>
+      </div>
+    </section>
+  </section>
+
+  <section class="view-content" *ngIf="activeView === 'dictionary'">
+    <section class="workspace-section">
+      <div class="section-heading dictionary-heading">
+        <div>
+          <h2>国内电商指标口径</h2>
+          <p>统一指标定义、计算公式、数据粒度和来源可用性。</p>
+        </div>
+        <div class="section-tools dictionary-tools">
+          <label class="search-field">
+            <lucide-icon [img]="icons.search" [size]="15"></lucide-icon>
+            <input [(ngModel)]="dictionarySearch" type="search" placeholder="搜索指标、定义或公式" aria-label="搜索指标口径">
+          </label>
+          <select [(ngModel)]="dictionaryDomain" aria-label="筛选指标领域">
+            <option value="">全部领域</option>
+            <option *ngFor="let domain of dictionaryDomains()" [value]="domain">{{ domain }}</option>
+          </select>
+        </div>
+      </div>
+      <div class="table-scroll">
+        <table class="data-table dictionary-table">
+          <thead><tr><th>领域</th><th>指标</th><th>业务定义</th><th>计算口径</th><th>粒度</th><th>来源</th><th>可用性</th></tr></thead>
+          <tbody>
+            <tr *ngFor="let metric of filteredDictionary()">
+              <td>{{ metric.domain }}</td>
+              <td><strong>{{ metric.name }}</strong><code class="metric-key">{{ metric.key }}</code></td>
+              <td>{{ metric.definition }}</td>
+              <td>{{ metric.formula }}</td>
+              <td><code>{{ metric.grain }}</code></td>
+              <td>{{ metric.source }}</td>
+              <td><span class="status-tag" [class.ready]="metric.availability === 'available'" [class.partial]="metric.availability === 'partial'" [class.missing]="metric.availability === 'missing'">{{ availabilityLabel(metric.availability) }}</span></td>
+            </tr>
+            <tr *ngIf="!filteredDictionary().length"><td colspan="7" class="empty-cell">没有匹配的指标口径。</td></tr>
+          </tbody>
+        </table>
+      </div>
+    </section>
+  </section>
+
+  <button class="drawer-backdrop" type="button" aria-label="关闭侧栏" *ngIf="drawerOpen()" (click)="closeActiveDrawer()"></button>
+
+  <aside *ngIf="selectedJob" class="detail-drawer open" role="dialog" aria-modal="true" aria-labelledby="job-detail-title">
+    <ng-container *ngIf="selectedJob as job">
+      <div class="drawer-header">
+        <div>
+          <h2 id="job-detail-title">同步任务详情</h2>
+          <p>{{ job.id }}</p>
+        </div>
+        <button class="icon-button" type="button" title="关闭" aria-label="关闭任务详情" (click)="closeJobDetails()">
+          <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
+        </button>
+      </div>
+      <div class="drawer-body detail-body">
+        <div class="detail-status-line">
+          <span class="status-tag" [class.ready]="job.status === 'completed'" [class.partial]="job.status === 'partial' || job.status === 'pending' || job.status === 'processing'" [class.missing]="job.status === 'failed'">{{ jobStatusLabel(job.status) }}</span>
+          <span>尝试 {{ job.attempts }}/{{ job.maxAttempts }}</span>
+        </div>
+
+        <section class="drawer-section progress-section" aria-label="任务进度">
+          <div class="drawer-section-heading"><h3>任务进度</h3><strong>{{ job.progress }}%</strong></div>
+          <span class="detail-progress-track"><i [style.width.%]="job.progress"></i></span>
+          <p>{{ job.productIds.length }} 个商品 · {{ job.scopes.length }} 个同步范围</p>
+        </section>
+
+        <div class="detail-error" *ngIf="job.errorSummary">
+          <lucide-icon [img]="icons.alert" [size]="17"></lucide-icon>
+          <div><strong>错误摘要</strong><p>{{ job.errorSummary }}</p></div>
+        </div>
+
+        <section class="drawer-section">
+          <div class="drawer-section-heading"><h3>运行信息</h3></div>
+          <dl class="detail-list">
+            <div><dt>平台</dt><dd>{{ job.platform | uppercase }}</dd></div>
+            <div><dt>请求时间</dt><dd>{{ dateTime(job.requestedAt) }}</dd></div>
+            <div><dt>开始时间</dt><dd>{{ dateTime(job.startedAt) }}</dd></div>
+            <div><dt>完成时间</dt><dd>{{ dateTime(job.completedAt) }}</dd></div>
+          </dl>
+          <div class="detail-subsection">
+            <span class="detail-label">同步范围</span>
+            <div class="detail-chips"><span *ngFor="let scope of job.scopes">{{ scopeLabel(scope) }}</span></div>
+          </div>
+          <div class="detail-subsection">
+            <span class="detail-label">平台商品 ID</span>
+            <div class="product-id-list"><code *ngFor="let productId of job.productIds">{{ productId }}</code></div>
+          </div>
+        </section>
+
+        <section class="drawer-section event-section">
+          <div class="drawer-section-heading">
+            <div><h3>事件时间线</h3><p>按服务端记录顺序展示。</p></div>
+            <button class="icon-button small" type="button" title="刷新事件" aria-label="刷新任务事件" [disabled]="jobEventsLoading" (click)="reloadJobEvents()">
+              <lucide-icon [img]="icons.refresh" [size]="15" [class.spinning]="jobEventsLoading"></lucide-icon>
+            </button>
+          </div>
+          <div class="drawer-loading" *ngIf="jobEventsLoading"><span></span>正在读取事件</div>
+          <div class="drawer-inline-error" *ngIf="!jobEventsLoading && jobEventsError">
+            <p>{{ jobEventsError }}</p>
+            <button class="text-button" type="button" (click)="reloadJobEvents()">重新加载</button>
+          </div>
+          <ol class="event-timeline" *ngIf="!jobEventsLoading && !jobEventsError && jobEvents.length">
+            <li *ngFor="let event of jobEvents" [class]="'event-' + event.level">
+              <span class="event-marker">
+                <lucide-icon *ngIf="event.level === 'info'" [img]="icons.check" [size]="13"></lucide-icon>
+                <lucide-icon *ngIf="event.level !== 'info'" [img]="icons.alert" [size]="13"></lucide-icon>
+              </span>
+              <div class="event-content">
+                <div class="event-heading"><strong>{{ eventTypeLabel(event.type) }}</strong><time>{{ dateTime(event.createdAt) }}</time></div>
+                <p>{{ event.message }}</p>
+                <div class="event-details" *ngIf="eventDetailEntries(event) as details">
+                  <span *ngFor="let detail of details"><b>{{ detail.label }}</b>{{ detail.value }}</span>
+                </div>
+                <small>{{ eventLevelLabel(event.level) }} · {{ event.type }}</small>
+              </div>
+            </li>
+          </ol>
+          <div class="drawer-empty" *ngIf="!jobEventsLoading && !jobEventsError && !jobEvents.length">
+            <lucide-icon [img]="icons.history" [size]="19"></lucide-icon>
+            <p>任务尚未记录事件。</p>
+          </div>
+        </section>
+      </div>
+      <div class="drawer-footer">
+        <button class="secondary-button" type="button" (click)="closeJobDetails()">关闭</button>
+        <button class="secondary-button danger-text" *ngIf="job.status === 'pending'" type="button" [disabled]="busyJobs.has(job.id)" (click)="cancelJob(job)">取消任务</button>
+        <button class="primary-button" *ngIf="job.status === 'failed' || job.status === 'partial'" type="button" [disabled]="busyJobs.has(job.id)" (click)="retryJob(job)">
+          <lucide-icon [img]="icons.retry" [size]="16"></lucide-icon>重试任务
+        </button>
+      </div>
+    </ng-container>
+  </aside>
+
+  <aside *ngIf="selectedSource" class="detail-drawer open" role="dialog" aria-modal="true" aria-labelledby="source-detail-title">
+    <ng-container *ngIf="selectedSource as source">
+      <div class="drawer-header">
+        <div>
+          <h2 id="source-detail-title">数据源详情</h2>
+          <p>{{ sourceKindLabel(source.kind) }} · {{ source.id }}</p>
+        </div>
+        <button class="icon-button" type="button" title="关闭" aria-label="关闭数据源详情" (click)="closeSourceDetails()">
+          <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
+        </button>
+      </div>
+      <div class="drawer-body detail-body">
+        <div class="detail-status-line">
+          <span class="status-tag" [class.ready]="source.status === 'active' || source.status === 'configured'" [class.missing]="source.status === 'error'">{{ sourceStatusLabel(source.status) }}</span>
+          <span>{{ source.platform | uppercase }} · {{ workspaceId }}</span>
+        </div>
+
+        <section class="drawer-section">
+          <div class="drawer-section-heading"><h3>授权与连接</h3></div>
+          <dl class="detail-list source-detail-list">
+            <div><dt>授权托管</dt><dd>{{ credentialStorageLabel(source.credentialStorage) }}</dd></div>
+            <div><dt>最近检查</dt><dd>{{ dateTime(source.lastCheckedAt) }}</dd></div>
+            <div><dt>授权账号</dt><dd><span class="building-label">待建设</span></dd></div>
+            <div><dt>授权范围</dt><dd><span class="building-label">待建设</span></dd></div>
+            <div><dt>授权到期时间</dt><dd><span class="building-label">待建设</span></dd></div>
+            <div><dt>店铺绑定</dt><dd><span class="building-label">待建设</span></dd></div>
+          </dl>
+        </section>
+
+        <section class="drawer-section">
+          <div class="drawer-section-heading"><div><h3>连接器数据域</h3><p>以当前真实同步范围为准。</p></div></div>
+          <div class="domain-list">
+            <div *ngFor="let domain of connectorDomains">
+              <div><strong>{{ domain.label }}</strong><p>{{ domain.description }}</p></div>
+              <span class="status-tag" [class.ready]="domain.state === 'supported'" [class.missing]="domain.state === 'building'">{{ domain.state === 'supported' ? '已支持' : '待建设' }}</span>
+            </div>
+          </div>
+        </section>
+
+        <section class="next-action-band">
+          <lucide-icon [img]="source.status === 'error' ? icons.alert : icons.check" [size]="18"></lucide-icon>
+          <div><span>下一步动作</span><strong>{{ sourceNextAction(source).title }}</strong><p>{{ sourceNextAction(source).description }}</p></div>
+        </section>
+      </div>
+      <div class="drawer-footer">
+        <button class="secondary-button" type="button" (click)="configureSource()">
+          <lucide-icon [img]="icons.settings" [size]="16"></lucide-icon>管理接入
+        </button>
+        <button class="primary-button" *ngIf="source.status === 'active' || source.status === 'configured'" type="button" (click)="openSyncFromSource(vm.dataset)">
+          <lucide-icon [img]="icons.play" [size]="16"></lucide-icon>新建同步
+        </button>
+      </div>
+    </ng-container>
+  </aside>
+
+  <aside *ngIf="syncOpen" class="sync-drawer open" role="dialog" aria-modal="true" aria-labelledby="sync-drawer-title">
+    <div class="drawer-header">
+      <div>
+        <h2 id="sync-drawer-title">新建平台同步</h2>
+        <p>{{ platform | uppercase }} · {{ workspaceId }}</p>
+      </div>
+      <button class="icon-button" type="button" title="关闭" aria-label="关闭" [disabled]="syncing" (click)="closeSync()">
+        <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
+      </button>
+    </div>
+    <div class="drawer-body">
+      <label class="field-label" for="sync-products">平台商品 ID</label>
+      <textarea id="sync-products" [(ngModel)]="syncProductIds" rows="8" placeholder="每行一个商品 ID,最多 100 个"></textarea>
+      <small class="field-help">支持换行、逗号或空格分隔;系统会自动去重。</small>
+
+      <fieldset class="scope-fieldset">
+        <legend>同步范围</legend>
+        <label><input type="checkbox" [(ngModel)]="syncProductScope"><span>商品主数据</span></label>
+        <label><input type="checkbox" [(ngModel)]="syncReviewScope"><span>商品评价</span></label>
+      </fieldset>
+
+      <div class="sync-note">
+        <lucide-icon [img]="icons.clock" [size]="17"></lucide-icon>
+        <p>任务进入后台队列后可在“接入与同步”查看进度。商品与评价会分别记录结果,部分成功的任务可重试。</p>
+      </div>
+      <p class="form-error" *ngIf="syncError">{{ syncError }}</p>
+    </div>
+    <div class="drawer-footer">
+      <button class="secondary-button" type="button" [disabled]="syncing" (click)="closeSync()">取消</button>
+      <button class="primary-button" type="button" [disabled]="syncing" (click)="submitSync()">
+        <lucide-icon [img]="icons.play" [size]="16"></lucide-icon>
+        {{ syncing ? '正在创建' : '创建任务' }}
+      </button>
+    </div>
+  </aside>
+</main>

+ 1635 - 0
src/modules/data-center/data-center.component.scss

@@ -0,0 +1,1635 @@
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.data-center {
+  --dc-text: #182230;
+  --dc-muted: #667085;
+  --dc-subtle: #98a2b3;
+  --dc-border: #e4e7ec;
+  --dc-border-strong: #d0d5dd;
+  --dc-surface: #ffffff;
+  --dc-canvas: #f7f8fa;
+  --dc-primary: #2563eb;
+  min-height: 100%;
+  padding: 22px 24px 72px;
+  background: var(--dc-canvas);
+  color: var(--dc-text);
+}
+
+.command-bar,
+.section-heading,
+.command-actions,
+.context-line,
+.health-main,
+.health-facts,
+.section-tools,
+.row-actions,
+.drawer-header,
+.drawer-footer {
+  display: flex;
+  align-items: center;
+}
+
+.command-bar {
+  min-height: 66px;
+  justify-content: space-between;
+  gap: 24px;
+  padding: 0 0 18px;
+}
+
+.command-title {
+  min-width: 0;
+}
+
+.title-line {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.title-line lucide-icon {
+  display: inline-flex;
+  color: #344054;
+}
+
+.title-line h1 {
+  font-size: 21px;
+  font-weight: 700;
+  line-height: 1.25;
+  letter-spacing: 0;
+}
+
+.context-line {
+  flex-wrap: wrap;
+  gap: 6px 18px;
+  margin-top: 8px;
+  color: var(--dc-muted);
+  font-size: 12px;
+}
+
+.context-line span {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  white-space: nowrap;
+}
+
+.context-line span:not(:last-child)::after {
+  position: absolute;
+  right: -10px;
+  color: #d0d5dd;
+  content: '/';
+}
+
+.command-actions,
+.section-tools,
+.drawer-footer {
+  gap: 8px;
+}
+
+.primary-button,
+.secondary-button,
+.icon-button,
+.text-button,
+.row-action {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  min-height: 36px;
+  border: 1px solid transparent;
+  border-radius: 6px;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1;
+  letter-spacing: 0;
+  transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
+}
+
+.primary-button {
+  padding: 0 14px;
+  background: var(--dc-primary);
+  color: #ffffff;
+}
+
+.primary-button:hover:not(:disabled) {
+  background: #1d4ed8;
+}
+
+.secondary-button {
+  padding: 0 13px;
+  border-color: var(--dc-border-strong);
+  background: #ffffff;
+  color: #344054;
+}
+
+.secondary-button:hover:not(:disabled),
+.icon-button:hover:not(:disabled) {
+  border-color: #98a2b3;
+  background: #f9fafb;
+}
+
+.primary-button.compact,
+.secondary-button.compact {
+  min-height: 32px;
+  padding: 0 11px;
+  font-size: 12px;
+}
+
+.icon-button {
+  width: 36px;
+  min-width: 36px;
+  padding: 0;
+  border-color: var(--dc-border-strong);
+  background: #ffffff;
+  color: #475467;
+}
+
+.icon-button.small {
+  width: 30px;
+  min-width: 30px;
+  min-height: 30px;
+}
+
+.icon-button.danger {
+  color: #b42318;
+}
+
+button:disabled {
+  cursor: not-allowed;
+  opacity: 0.55;
+}
+
+.health-band {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 28px;
+  min-height: 68px;
+  padding: 13px 16px;
+  border: 1px solid #b2ddff;
+  border-left-width: 4px;
+  background: #eff8ff;
+}
+
+.health-main {
+  min-width: 0;
+  gap: 11px;
+}
+
+.health-main lucide-icon {
+  display: inline-flex;
+  flex: 0 0 auto;
+  color: #175cd3;
+}
+
+.health-main strong {
+  display: block;
+  color: #1849a9;
+  font-size: 13px;
+}
+
+.health-main p {
+  margin-top: 3px;
+  color: #344054;
+  font-size: 12px;
+}
+
+.health-facts {
+  flex: 0 0 auto;
+  gap: 0;
+}
+
+.health-facts span {
+  padding: 0 14px;
+  border-left: 1px solid rgba(52, 64, 84, 0.16);
+  color: #475467;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.health-facts span:first-child {
+  border-left: 0;
+}
+
+.health-facts b {
+  color: #101828;
+  font-variant-numeric: tabular-nums;
+}
+
+.health-green {
+  border-color: #abefc6;
+  background: #ecfdf3;
+}
+
+.health-green .health-main lucide-icon,
+.health-green .health-main strong {
+  color: #067647;
+}
+
+.health-amber {
+  border-color: #fedf89;
+  background: #fffaeb;
+}
+
+.health-amber .health-main lucide-icon,
+.health-amber .health-main strong {
+  color: #b54708;
+}
+
+.health-red {
+  border-color: #fecdca;
+  background: #fef3f2;
+}
+
+.health-red .health-main lucide-icon,
+.health-red .health-main strong {
+  color: #b42318;
+}
+
+.warning-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 38px;
+  margin-top: 10px;
+  padding: 8px 12px;
+  border: 1px solid #fedf89;
+  background: #fffcf5;
+  color: #93370d;
+  font-size: 12px;
+}
+
+.warning-row lucide-icon {
+  display: inline-flex;
+  flex: 0 0 auto;
+}
+
+.view-tabs {
+  display: flex;
+  align-items: flex-end;
+  gap: 24px;
+  min-height: 53px;
+  margin-top: 12px;
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.view-tabs button {
+  position: relative;
+  min-height: 52px;
+  padding: 0 2px;
+  color: var(--dc-muted);
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.view-tabs button::after {
+  position: absolute;
+  right: 0;
+  bottom: -1px;
+  left: 0;
+  height: 2px;
+  background: transparent;
+  content: '';
+}
+
+.view-tabs button:hover,
+.view-tabs button.active {
+  color: #175cd3;
+}
+
+.view-tabs button.active::after {
+  background: var(--dc-primary);
+}
+
+.view-content {
+  display: grid;
+  gap: 16px;
+  padding-top: 16px;
+}
+
+.metric-strip {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  border: 1px solid var(--dc-border);
+  background: var(--dc-surface);
+}
+
+.metric-item {
+  min-width: 0;
+  min-height: 112px;
+  padding: 17px 18px;
+  border-left: 1px solid var(--dc-border);
+}
+
+.metric-item:first-child {
+  border-left: 0;
+}
+
+.metric-item span,
+.metric-item small {
+  display: block;
+}
+
+.metric-item span {
+  color: var(--dc-muted);
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.metric-item strong {
+  display: block;
+  margin-top: 7px;
+  color: #101828;
+  font-size: 25px;
+  font-weight: 700;
+  line-height: 1.2;
+  font-variant-numeric: tabular-nums;
+}
+
+.metric-item small {
+  margin-top: 5px;
+  overflow: hidden;
+  color: var(--dc-muted);
+  font-size: 11px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.metric-risk strong {
+  color: #b42318;
+}
+
+.workspace-section {
+  min-width: 0;
+  overflow: hidden;
+  border: 1px solid var(--dc-border);
+  background: var(--dc-surface);
+}
+
+.section-heading {
+  min-height: 66px;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 13px 16px;
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.section-heading h2 {
+  color: #101828;
+  font-size: 14px;
+  font-weight: 700;
+}
+
+.section-heading p {
+  margin-top: 4px;
+  color: var(--dc-muted);
+  font-size: 11px;
+}
+
+.compact-heading {
+  min-height: 62px;
+}
+
+.section-count {
+  color: #475467;
+  font-size: 12px;
+  font-weight: 600;
+  white-space: nowrap;
+}
+
+.table-scroll {
+  width: 100%;
+  overflow-x: auto;
+}
+
+.data-table {
+  width: 100%;
+  min-width: 860px;
+  border-collapse: collapse;
+  table-layout: auto;
+}
+
+.data-table th,
+.data-table td {
+  padding: 11px 14px;
+  border-bottom: 1px solid #eaecf0;
+  color: #475467;
+  font-size: 12px;
+  line-height: 1.45;
+  text-align: left;
+  vertical-align: middle;
+}
+
+.data-table th {
+  height: 38px;
+  padding-top: 8px;
+  padding-bottom: 8px;
+  background: #f9fafb;
+  color: #667085;
+  font-size: 11px;
+  font-weight: 600;
+  white-space: nowrap;
+}
+
+.data-table tbody tr:last-child td {
+  border-bottom: 0;
+}
+
+.data-table tbody tr:hover td {
+  background: #fcfcfd;
+}
+
+.data-table strong {
+  color: #182230;
+  font-weight: 600;
+}
+
+.data-table code {
+  padding: 2px 5px;
+  border-radius: 4px;
+  background: #f2f4f7;
+  color: #475467;
+  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+  font-size: 10px;
+  letter-spacing: 0;
+}
+
+.coverage-table {
+  min-width: 1160px;
+}
+
+.coverage-table th:nth-child(1) { width: 118px; }
+.coverage-table th:nth-child(2) { width: 150px; }
+.coverage-table th:nth-child(3) { width: 140px; }
+.coverage-table th:nth-child(4) { width: 105px; }
+.coverage-table th:nth-child(5) { width: 170px; }
+.coverage-table th:nth-child(6) { width: 210px; }
+
+.gap-cell {
+  max-width: 330px;
+  color: #7a2e0e !important;
+}
+
+.status-tag {
+  display: inline-flex;
+  align-items: center;
+  min-height: 22px;
+  padding: 0 7px;
+  border: 1px solid #d0d5dd;
+  border-radius: 5px;
+  background: #f9fafb;
+  color: #475467;
+  font-size: 10px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.status-tag.ready,
+.status-tag.passed {
+  border-color: #abefc6;
+  background: #ecfdf3;
+  color: #067647;
+}
+
+.status-tag.partial,
+.status-tag.warning {
+  border-color: #fedf89;
+  background: #fffaeb;
+  color: #b54708;
+}
+
+.status-tag.missing,
+.status-tag.failed {
+  border-color: #fecdca;
+  background: #fef3f2;
+  color: #b42318;
+}
+
+.coverage-cell {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 130px;
+}
+
+.coverage-track {
+  display: block;
+  width: 94px;
+  height: 5px;
+  overflow: hidden;
+  border-radius: 3px;
+  background: #eaecf0;
+}
+
+.coverage-track i {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: #2e90fa;
+}
+
+.coverage-cell b {
+  min-width: 34px;
+  color: #344054;
+  font-size: 11px;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.overview-lower {
+  display: grid;
+  grid-template-columns: minmax(0, 1.35fr) minmax(330px, 0.65fr);
+  gap: 16px;
+  align-items: start;
+}
+
+.text-button,
+.row-action {
+  min-height: 28px;
+  padding: 0;
+  color: #175cd3;
+}
+
+.text-button:hover,
+.row-action:hover {
+  color: #1849a9;
+  text-decoration: underline;
+}
+
+.priority-list {
+  display: grid;
+}
+
+.priority-row {
+  display: grid;
+  grid-template-columns: 8px minmax(0, 1fr) auto 42px;
+  gap: 10px;
+  align-items: center;
+  min-height: 54px;
+  padding: 8px 15px;
+  border-bottom: 1px solid #eaecf0;
+}
+
+.priority-row:last-child {
+  border-bottom: 0;
+}
+
+.severity-mark {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #12b76a;
+}
+
+.severity-mark.warning {
+  background: #f79009;
+}
+
+.severity-mark.failed {
+  background: #f04438;
+}
+
+.priority-copy {
+  min-width: 0;
+}
+
+.priority-copy strong,
+.priority-copy span {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.priority-copy strong {
+  color: #344054;
+  font-size: 12px;
+}
+
+.priority-copy span,
+.affected {
+  margin-top: 2px;
+  color: var(--dc-muted);
+  font-size: 10px;
+}
+
+.affected {
+  margin: 0;
+  white-space: nowrap;
+}
+
+.run-facts {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  margin: 0;
+}
+
+.run-facts > div {
+  min-height: 94px;
+  padding: 14px 16px;
+  border-right: 1px solid #eaecf0;
+  border-bottom: 1px solid #eaecf0;
+}
+
+.run-facts > div:nth-child(even) {
+  border-right: 0;
+}
+
+.run-facts > div:nth-last-child(-n + 2) {
+  border-bottom: 0;
+}
+
+.run-facts dt,
+.run-facts span {
+  color: var(--dc-muted);
+  font-size: 10px;
+}
+
+.run-facts dd {
+  margin: 6px 0 2px;
+  color: #182230;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.cell-meta,
+.metric-key,
+.error-summary {
+  display: block;
+  margin-top: 3px;
+  color: var(--dc-subtle);
+  font-size: 10px;
+}
+
+.metric-key {
+  width: fit-content;
+}
+
+.error-summary {
+  max-width: 240px;
+  overflow: hidden;
+  color: #b42318;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.jobs-table {
+  min-width: 980px;
+}
+
+.source-table {
+  min-width: 820px;
+}
+
+.interactive-row {
+  cursor: pointer;
+}
+
+.interactive-row:focus-visible {
+  outline: 2px solid #84adff;
+  outline-offset: -2px;
+}
+
+.interactive-row:focus-visible td {
+  background: #eff8ff;
+}
+
+.compact-progress {
+  min-width: 120px;
+}
+
+.compact-progress .coverage-track {
+  width: 74px;
+}
+
+.section-tools select,
+.dictionary-tools select,
+.search-field,
+.drawer-body textarea {
+  border: 1px solid var(--dc-border-strong);
+  border-radius: 6px;
+  background: #ffffff;
+  color: #344054;
+}
+
+.section-tools select,
+.dictionary-tools select {
+  min-width: 124px;
+  height: 34px;
+  padding: 0 30px 0 10px;
+  font-size: 12px;
+}
+
+.row-actions {
+  min-height: 30px;
+}
+
+.empty-cell {
+  height: 72px;
+  color: var(--dc-subtle) !important;
+  text-align: center !important;
+}
+
+.quality-summary {
+  display: grid;
+  grid-template-columns: repeat(3, 120px) minmax(0, 1fr);
+  align-items: stretch;
+  border: 1px solid var(--dc-border);
+  background: #ffffff;
+}
+
+.quality-summary > div {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-height: 86px;
+  padding: 13px 18px;
+  border-right: 1px solid var(--dc-border);
+}
+
+.quality-summary strong {
+  color: #067647;
+  font-size: 24px;
+  line-height: 1.1;
+  font-variant-numeric: tabular-nums;
+}
+
+.quality-summary span {
+  margin-top: 5px;
+  color: var(--dc-muted);
+  font-size: 11px;
+}
+
+.quality-summary .quality-warning strong {
+  color: #b54708;
+}
+
+.quality-summary .quality-failed strong {
+  color: #b42318;
+}
+
+.quality-summary > p {
+  align-self: center;
+  padding: 0 22px;
+  color: #475467;
+  font-size: 12px;
+  line-height: 1.7;
+}
+
+.quality-table {
+  min-width: 920px;
+}
+
+.semantic-band {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  border: 1px solid var(--dc-border);
+  background: #ffffff;
+}
+
+.semantic-band > div {
+  display: grid;
+  grid-template-columns: 25px minmax(0, 1fr);
+  column-gap: 9px;
+  min-height: 110px;
+  padding: 18px;
+  border-left: 1px solid var(--dc-border);
+}
+
+.semantic-band > div:first-child {
+  border-left: 0;
+}
+
+.semantic-band lucide-icon {
+  display: inline-flex;
+  grid-row: 1 / span 2;
+  color: #475467;
+}
+
+.semantic-band strong {
+  color: #344054;
+  font-size: 12px;
+}
+
+.semantic-band p {
+  margin-top: 5px;
+  color: var(--dc-muted);
+  font-size: 11px;
+  line-height: 1.6;
+}
+
+.dictionary-heading {
+  min-height: 76px;
+}
+
+.dictionary-tools {
+  min-width: 460px;
+}
+
+.search-field {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  gap: 7px;
+  min-width: 260px;
+  height: 34px;
+  padding: 0 10px;
+  color: #667085;
+}
+
+.search-field input {
+  width: 100%;
+  min-width: 0;
+  border: 0;
+  background: transparent;
+  color: #344054;
+  font-size: 12px;
+}
+
+.dictionary-table {
+  min-width: 1220px;
+}
+
+.dictionary-table th:nth-child(1) { width: 70px; }
+.dictionary-table th:nth-child(2) { width: 150px; }
+.dictionary-table th:nth-child(3) { width: 280px; }
+.dictionary-table th:nth-child(4) { width: 210px; }
+.dictionary-table th:nth-child(5) { width: 150px; }
+.dictionary-table th:nth-child(6) { width: 140px; }
+
+.drawer-backdrop {
+  position: fixed;
+  z-index: 1190;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  border: 0;
+  background: rgba(16, 24, 40, 0.34);
+  cursor: default;
+}
+
+.sync-drawer,
+.detail-drawer {
+  position: fixed;
+  z-index: 1200;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr) auto;
+  width: min(440px, 100vw);
+  border-left: 1px solid var(--dc-border-strong);
+  background: #ffffff;
+  box-shadow: -12px 0 34px rgba(16, 24, 40, 0.12);
+  transform: translateX(102%);
+  transition: transform 180ms ease;
+}
+
+.detail-drawer {
+  width: min(520px, 100vw);
+}
+
+.sync-drawer.open,
+.detail-drawer.open {
+  transform: translateX(0);
+}
+
+.drawer-header {
+  justify-content: space-between;
+  gap: 16px;
+  min-height: 76px;
+  padding: 14px 18px;
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.drawer-header h2 {
+  color: #101828;
+  font-size: 16px;
+}
+
+.drawer-header p {
+  margin-top: 4px;
+  color: var(--dc-muted);
+  font-size: 11px;
+}
+
+.drawer-body {
+  overflow-y: auto;
+  padding: 20px 18px;
+}
+
+.detail-body {
+  padding: 0;
+}
+
+.detail-status-line {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  min-height: 54px;
+  padding: 10px 18px;
+  border-bottom: 1px solid var(--dc-border);
+  background: #f9fafb;
+  color: var(--dc-muted);
+  font-size: 11px;
+}
+
+.drawer-section {
+  padding: 18px;
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.drawer-section-heading {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 14px;
+}
+
+.drawer-section-heading h3 {
+  color: #182230;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.drawer-section-heading p,
+.progress-section > p {
+  margin-top: 4px;
+  color: var(--dc-muted);
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.drawer-section-heading > strong {
+  color: #344054;
+  font-size: 14px;
+  font-variant-numeric: tabular-nums;
+}
+
+.detail-progress-track {
+  display: block;
+  overflow: hidden;
+  width: 100%;
+  height: 7px;
+  border-radius: 4px;
+  background: #eaecf0;
+}
+
+.detail-progress-track i {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: #2563eb;
+}
+
+.detail-error,
+.drawer-inline-error,
+.next-action-band {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  padding: 14px 18px;
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.detail-error {
+  background: #fff5f4;
+  color: #b42318;
+}
+
+.detail-error lucide-icon,
+.next-action-band > lucide-icon {
+  display: inline-flex;
+  flex: 0 0 auto;
+  margin-top: 1px;
+}
+
+.detail-error strong,
+.detail-error p,
+.next-action-band span,
+.next-action-band strong,
+.next-action-band p {
+  display: block;
+}
+
+.detail-error strong,
+.next-action-band span {
+  font-size: 10px;
+  font-weight: 600;
+}
+
+.detail-error p,
+.next-action-band p {
+  margin-top: 4px;
+  font-size: 11px;
+  line-height: 1.55;
+}
+
+.detail-list {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  margin: 0;
+  border-top: 1px solid var(--dc-border);
+  border-left: 1px solid var(--dc-border);
+}
+
+.detail-list > div {
+  min-width: 0;
+  padding: 11px 12px;
+  border-right: 1px solid var(--dc-border);
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.detail-list dt,
+.detail-label {
+  color: var(--dc-muted);
+  font-size: 10px;
+}
+
+.detail-list dd {
+  overflow-wrap: anywhere;
+  margin-top: 5px;
+  color: #344054;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.building-label {
+  color: #b54708;
+  font-weight: 600;
+}
+
+.detail-subsection {
+  margin-top: 16px;
+}
+
+.detail-chips,
+.product-id-list,
+.event-details {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-top: 7px;
+}
+
+.detail-chips span,
+.event-details span {
+  padding: 4px 7px;
+  border: 1px solid var(--dc-border);
+  border-radius: 4px;
+  background: #f9fafb;
+  color: #475467;
+  font-size: 10px;
+}
+
+.event-details b {
+  margin-right: 5px;
+  color: #344054;
+}
+
+.product-id-list {
+  max-height: 118px;
+  overflow-y: auto;
+}
+
+.product-id-list code {
+  padding: 3px 6px;
+  border: 1px solid #eaecf0;
+  background: #f9fafb;
+  color: #344054;
+  font-size: 10px;
+}
+
+.event-section {
+  border-bottom: 0;
+}
+
+.drawer-loading,
+.drawer-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  min-height: 94px;
+  color: var(--dc-muted);
+  font-size: 11px;
+}
+
+.drawer-loading span {
+  width: 14px;
+  height: 14px;
+  border: 2px solid #d0d5dd;
+  border-top-color: #2563eb;
+  border-radius: 50%;
+  animation: spin 900ms linear infinite;
+}
+
+.drawer-empty {
+  flex-direction: column;
+  border: 1px dashed var(--dc-border-strong);
+  background: #f9fafb;
+}
+
+.drawer-inline-error {
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #fecdca;
+  background: #fff5f4;
+  color: #b42318;
+}
+
+.drawer-inline-error p {
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+.event-timeline {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.event-timeline li {
+  position: relative;
+  display: grid;
+  grid-template-columns: 24px minmax(0, 1fr);
+  gap: 10px;
+  padding-bottom: 18px;
+}
+
+.event-timeline li:not(:last-child)::before {
+  position: absolute;
+  top: 21px;
+  bottom: -1px;
+  left: 11px;
+  width: 1px;
+  background: #d0d5dd;
+  content: '';
+}
+
+.event-marker {
+  position: relative;
+  z-index: 1;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border: 1px solid #abefc6;
+  border-radius: 50%;
+  background: #ecfdf3;
+  color: #067647;
+}
+
+.event-warning .event-marker {
+  border-color: #fedf89;
+  background: #fffaeb;
+  color: #b54708;
+}
+
+.event-error .event-marker {
+  border-color: #fecdca;
+  background: #fef3f2;
+  color: #b42318;
+}
+
+.event-content {
+  min-width: 0;
+  padding-top: 2px;
+}
+
+.event-heading {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.event-heading strong {
+  color: #344054;
+  font-size: 12px;
+}
+
+.event-heading time,
+.event-content > small {
+  color: var(--dc-subtle);
+  font-size: 9px;
+}
+
+.event-content > p {
+  margin-top: 4px;
+  color: #475467;
+  font-size: 11px;
+  line-height: 1.55;
+  overflow-wrap: anywhere;
+}
+
+.event-content > small {
+  display: block;
+  margin-top: 7px;
+}
+
+.domain-list {
+  border-top: 1px solid var(--dc-border);
+}
+
+.domain-list > div {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 14px;
+  min-height: 62px;
+  padding: 10px 0;
+  border-bottom: 1px solid var(--dc-border);
+}
+
+.domain-list strong {
+  color: #344054;
+  font-size: 12px;
+}
+
+.domain-list p {
+  margin-top: 3px;
+  color: var(--dc-muted);
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.domain-list .status-tag {
+  flex: 0 0 auto;
+}
+
+.next-action-band {
+  margin: 0;
+  background: #eff8ff;
+  color: #175cd3;
+}
+
+.next-action-band strong {
+  margin-top: 3px;
+  color: #1849a9;
+  font-size: 12px;
+}
+
+.next-action-band p {
+  color: #475467;
+}
+
+.danger-text {
+  border-color: #fecdca;
+  color: #b42318;
+}
+
+.field-label,
+.scope-fieldset legend {
+  display: block;
+  margin-bottom: 7px;
+  color: #344054;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.drawer-body textarea {
+  display: block;
+  width: 100%;
+  min-height: 154px;
+  padding: 10px 12px;
+  resize: vertical;
+  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.drawer-body textarea:focus,
+.search-field:focus-within,
+.section-tools select:focus,
+.dictionary-tools select:focus {
+  border-color: #84adff;
+  box-shadow: 0 0 0 3px rgba(46, 144, 250, 0.12);
+}
+
+.field-help {
+  display: block;
+  margin-top: 6px;
+  color: var(--dc-muted);
+  font-size: 10px;
+}
+
+.scope-fieldset {
+  display: grid;
+  gap: 10px;
+  margin: 22px 0 0;
+  padding: 0;
+  border: 0;
+}
+
+.scope-fieldset label {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  min-height: 34px;
+  color: #344054;
+  font-size: 12px;
+}
+
+.scope-fieldset input {
+  width: 16px;
+  height: 16px;
+  accent-color: var(--dc-primary);
+}
+
+.sync-note {
+  display: flex;
+  align-items: flex-start;
+  gap: 9px;
+  margin-top: 22px;
+  padding: 12px;
+  border: 1px solid #b2ddff;
+  background: #eff8ff;
+  color: #344054;
+}
+
+.sync-note lucide-icon {
+  display: inline-flex;
+  flex: 0 0 auto;
+  color: #175cd3;
+}
+
+.sync-note p,
+.form-error {
+  font-size: 11px;
+  line-height: 1.6;
+}
+
+.form-error {
+  margin-top: 12px;
+  color: #b42318;
+}
+
+.drawer-footer {
+  justify-content: flex-end;
+  min-height: 68px;
+  padding: 12px 18px;
+  border-top: 1px solid var(--dc-border);
+}
+
+.spinning {
+  animation: spin 900ms linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .sync-drawer,
+  .detail-drawer,
+  .primary-button,
+  .secondary-button,
+  .icon-button {
+    transition: none;
+  }
+
+  .spinning {
+    animation: none;
+  }
+
+  .drawer-loading span {
+    animation: none;
+  }
+}
+
+@media (max-width: 1180px) {
+  .health-band {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .health-facts {
+    width: 100%;
+    justify-content: space-between;
+  }
+
+  .health-facts span:first-child {
+    padding-left: 0;
+  }
+
+  .overview-lower {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 900px) {
+  .data-center {
+    padding-right: 16px;
+    padding-left: 16px;
+  }
+
+  .command-bar {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 14px;
+  }
+
+  .command-actions {
+    width: 100%;
+  }
+
+  .command-actions .primary-button,
+  .command-actions .secondary-button {
+    flex: 1;
+  }
+
+  .metric-strip {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .metric-item:nth-child(3) {
+    border-left: 0;
+    border-top: 1px solid var(--dc-border);
+  }
+
+  .metric-item:nth-child(4) {
+    border-top: 1px solid var(--dc-border);
+  }
+
+  .quality-summary {
+    grid-template-columns: repeat(3, 1fr);
+  }
+
+  .quality-summary > p {
+    grid-column: 1 / -1;
+    padding: 14px 18px;
+    border-top: 1px solid var(--dc-border);
+  }
+
+  .semantic-band {
+    grid-template-columns: 1fr;
+  }
+
+  .semantic-band > div {
+    border-top: 1px solid var(--dc-border);
+    border-left: 0;
+  }
+
+  .semantic-band > div:first-child {
+    border-top: 0;
+  }
+
+  .dictionary-heading {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .dictionary-tools {
+    min-width: 0;
+    width: 100%;
+  }
+}
+
+@media (max-width: 620px) {
+  .data-center {
+    padding: 16px 12px 54px;
+  }
+
+  .title-line h1 {
+    font-size: 19px;
+  }
+
+  .context-line {
+    gap: 5px 14px;
+  }
+
+  .context-line span:not(:last-child)::after {
+    right: -8px;
+  }
+
+  .command-actions .secondary-button {
+    display: none;
+  }
+
+  .health-facts {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px 0;
+  }
+
+  .health-facts span,
+  .health-facts span:first-child {
+    padding: 0 8px;
+    border-left: 0;
+  }
+
+  .health-facts span:nth-child(even) {
+    border-left: 1px solid rgba(52, 64, 84, 0.16);
+  }
+
+  .view-tabs {
+    gap: 0;
+    overflow-x: auto;
+  }
+
+  .view-tabs button {
+    flex: 0 0 25%;
+    min-width: 76px;
+    font-size: 12px;
+  }
+
+  .metric-strip {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .metric-item {
+    min-height: 100px;
+    padding: 14px 12px;
+  }
+
+  .metric-item strong {
+    font-size: 21px;
+  }
+
+  .metric-item small {
+    white-space: normal;
+  }
+
+  .section-heading {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .compact-heading .text-button {
+    margin-top: -6px;
+  }
+
+  .priority-row {
+    grid-template-columns: 8px minmax(0, 1fr) 38px;
+  }
+
+  .priority-row .affected {
+    display: none;
+  }
+
+  .run-facts {
+    grid-template-columns: 1fr;
+  }
+
+  .run-facts > div,
+  .run-facts > div:nth-child(even),
+  .run-facts > div:nth-last-child(-n + 2) {
+    min-height: 82px;
+    border-right: 0;
+    border-bottom: 1px solid #eaecf0;
+  }
+
+  .run-facts > div:last-child {
+    border-bottom: 0;
+  }
+
+  .section-tools,
+  .dictionary-tools {
+    align-items: stretch;
+    flex-direction: column;
+    width: 100%;
+  }
+
+  .section-tools select,
+  .dictionary-tools select,
+  .search-field {
+    min-width: 0;
+    width: 100%;
+  }
+
+  .quality-summary {
+    grid-template-columns: repeat(3, 1fr);
+  }
+
+  .quality-summary > div {
+    min-height: 76px;
+    padding: 12px;
+  }
+
+  .quality-summary strong {
+    font-size: 21px;
+  }
+
+  .detail-list {
+    grid-template-columns: 1fr;
+  }
+
+  .event-heading {
+    align-items: flex-start;
+    flex-direction: column;
+    gap: 3px;
+  }
+
+  .detail-drawer .drawer-footer {
+    flex-wrap: wrap;
+  }
+}

+ 12 - 0
src/modules/data-center/data-center.component.spec.ts

@@ -0,0 +1,12 @@
+import { TestBed } from '@angular/core/testing';
+import { DataCenterComponent } from './data-center.component';
+
+describe('DataCenterComponent', () => {
+  it('compiles the standalone component and its detail drawer templates', async () => {
+    await TestBed.configureTestingModule({
+      imports: [DataCenterComponent],
+    }).compileComponents();
+
+    expect(DataCenterComponent).toBeTruthy();
+  });
+});

+ 812 - 0
src/modules/data-center/data-center.component.ts

@@ -0,0 +1,812 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import {
+  AlertTriangle,
+  BookOpen,
+  CheckCircle2,
+  CircleX,
+  Clock3,
+  Database,
+  FileClock,
+  Gauge,
+  History,
+  Layers3,
+  LucideAngularModule,
+  PackageSearch,
+  Play,
+  PlugZap,
+  RefreshCw,
+  RotateCcw,
+  Search,
+  Settings,
+  ShoppingCart,
+  Star,
+  Store,
+  Upload,
+  X,
+} from 'lucide-angular';
+import {
+  BehaviorSubject,
+  EMPTY,
+  Observable,
+  catchError,
+  combineLatest,
+  expand,
+  forkJoin,
+  map,
+  of,
+  reduce,
+  shareReplay,
+  switchMap,
+  tap,
+} from 'rxjs';
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+import { DomesticDataset, DomesticProduct } from '../../app/core/models/domestic.models';
+import { DomesticDatasetService, buildDomesticReviewScope } from '../../app/core/services/domestic-dataset.service';
+import {
+  DataCenterImportBatch,
+  DataCenterPage,
+  DataCenterJobEvent,
+  DataCenterJobStatus,
+  DataCenterService,
+  DataCenterSource,
+  DataCenterSyncJob,
+  DataCenterSyncScope,
+} from './data-center.service';
+
+type DataCenterView = 'overview' | 'sources' | 'quality' | 'dictionary';
+type CoverageState = 'ready' | 'partial' | 'missing';
+type RuleState = 'passed' | 'warning' | 'failed';
+
+interface CoverageDomain {
+  id: string;
+  label: string;
+  objectLabel: string;
+  grain: string;
+  state: CoverageState;
+  coverage: number;
+  evidence: string;
+  gap: string;
+}
+
+interface QualityRule {
+  id: string;
+  domain: string;
+  name: string;
+  state: RuleState;
+  result: string;
+  threshold: string;
+  affected: number;
+  action: 'products' | 'reviews' | 'competitors' | 'settings' | '';
+}
+
+interface MetricDefinition {
+  domain: string;
+  key: string;
+  name: string;
+  definition: string;
+  formula: string;
+  grain: string;
+  source: string;
+  availability: 'available' | 'partial' | 'missing';
+}
+
+interface ConnectorDomain {
+  key: string;
+  label: string;
+  description: string;
+  state: 'supported' | 'building';
+}
+
+interface EventDetailEntry {
+  key: string;
+  label: string;
+  value: string;
+}
+
+interface DataCenterApiState {
+  sources: DataCenterSource[];
+  imports: DataCenterImportBatch[];
+  jobs: DataCenterSyncJob[];
+  warnings: string[];
+}
+
+interface DataCenterViewModel {
+  dataset: DomesticDataset;
+  sources: DataCenterSource[];
+  imports: DataCenterImportBatch[];
+  jobs: DataCenterSyncJob[];
+  warnings: string[];
+  coverage: CoverageDomain[];
+  rules: QualityRule[];
+  ownProducts: number;
+  competitorProducts: number;
+  ownReviews: number;
+  competitorReviews: number;
+  dateDays: number;
+  sourceAgeHours: number;
+  sourceAgeLabel: string;
+  sourceFreshnessState: RuleState;
+  latestImport: DataCenterImportBatch | null;
+  importSuccessRate: number;
+  readyDomains: number;
+  qualityPassed: number;
+  qualityWarnings: number;
+  qualityFailed: number;
+  activeJobs: number;
+  statusTone: 'green' | 'amber' | 'red';
+  statusTitle: string;
+  statusMessage: string;
+}
+
+export const DOMESTIC_METRIC_DICTIONARY: MetricDefinition[] = [
+  { domain: '交易', key: 'gmv', name: '成交金额', definition: '统计周期内已成交商品金额汇总,不等同于结算到账。', formula: '商品日成交金额求和', grain: '商品 × 日', source: '经营日报', availability: 'available' },
+  { domain: '交易', key: 'transactionOrders', name: '成交订单', definition: '统计周期内达到成交口径的订单数量。', formula: '商品日成交订单数求和', grain: '商品 × 日', source: '经营日报', availability: 'available' },
+  { domain: '下单', key: 'orderAmount', name: '下单金额', definition: '用户提交订单时的金额汇总,包含后续未成交部分。', formula: '商品日下单金额求和', grain: '商品 × 日', source: '经营日报', availability: 'available' },
+  { domain: '下单', key: 'orderCount', name: '下单单量', definition: '用户提交的订单数量,与成交订单分开统计。', formula: '商品日下单单量求和', grain: '商品 × 日', source: '经营日报', availability: 'available' },
+  { domain: '流量', key: 'impressions', name: '搜索曝光', definition: '商品在搜索场景被展示的次数。', formula: '商品日搜索曝光求和', grain: '商品 × 日', source: '经营日报', availability: 'available' },
+  { domain: '流量', key: 'clickThroughRate', name: '搜索点击率', definition: '搜索曝光转化为搜索点击的比例。', formula: '搜索点击 ÷ 搜索曝光', grain: '周期聚合', source: '经营日报', availability: 'available' },
+  { domain: '转化', key: 'conversionRate', name: '成交单转化率', definition: '商品访客转化为成交订单的比例。', formula: '成交订单 ÷ 商品访客', grain: '周期聚合', source: '经营日报', availability: 'available' },
+  { domain: '退款', key: 'refundAmount', name: '退款金额', definition: '统计周期内已记录的退款金额,不代表完整售后单状态。', formula: '商品日退款金额求和', grain: '商品 × 日', source: '经营日报', availability: 'available' },
+  { domain: '退款', key: 'refundToGmvRate', name: '退款金额占比', definition: '退款金额相对于成交金额的比例。', formula: '退款金额 ÷ 成交金额', grain: '周期聚合', source: '经营日报', availability: 'available' },
+  { domain: '商品', key: 'productId', name: '平台商品 ID', definition: '平台侧商品唯一标识;当前京东场景按商品 ID 对齐。', formula: 'platform + productId', grain: '商品', source: '商品主数据', availability: 'available' },
+  { domain: '店铺', key: 'shopId', name: '平台店铺 ID', definition: '平台内稳定店铺标识,用于隔离授权、商品、订单和经营数据。', formula: 'platform + shopId', grain: '店铺', source: '店铺授权维表', availability: 'partial' },
+  { domain: '订单', key: 'platformOrderId', name: '平台订单 ID', definition: '订单事实主键;主订单、子订单与商品行需要分别保存。', formula: 'platform + shopId + orderId', grain: '订单 / 子单', source: '订单明细', availability: 'missing' },
+  { domain: '履约', key: 'waybillNo', name: '运单号', definition: '承载发货、揽收、运输、签收和异常轨迹的履约标识。', formula: 'platform + logisticsCompany + waybillNo', grain: '履约单 / 运单', source: '物流接口', availability: 'missing' },
+  { domain: '评价', key: 'reviewId', name: '评价 ID', definition: '平台评价证据唯一标识,用于洞察回溯和人工审阅。', formula: 'platform + reviewId', grain: '评价', source: '评价采集', availability: 'partial' },
+  { domain: '售后', key: 'aftersaleId', name: '售后单 ID', definition: '退货、退款、换货等售后流程主键。', formula: '平台售后单号', grain: '售后单', source: '售后明细', availability: 'missing' },
+  { domain: '库存', key: 'availableStock', name: '可售库存', definition: '店铺仓维度当前可售库存。', formula: '现货 - 锁定 - 不可售', grain: '店铺 × SKU × 仓', source: '库存接口', availability: 'missing' },
+  { domain: '广告', key: 'adSpend', name: '广告消耗', definition: '付费流量计划在统计周期内的消耗。', formula: '广告计划日消耗求和', grain: '计划 × 日', source: '广告投放接口', availability: 'missing' },
+  { domain: '结算', key: 'settlementNetAmount', name: '结算净额', definition: '订单结算收入扣除退款、佣金、服务费、优惠分摊等费用后的净额。', formula: '应收 - 退款 - 佣金 - 服务费 - 费用分摊', grain: '结算批次 × 子单', source: '账单 / 对账接口', availability: 'missing' },
+];
+
+@Component({
+  selector: 'app-data-center',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './data-center.component.html',
+  styleUrls: ['./data-center.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class DataCenterComponent {
+  private readonly datasetService = inject(DomesticDatasetService);
+  private readonly dataCenter = inject(DataCenterService);
+  private readonly router = inject(Router);
+  private readonly route = inject(ActivatedRoute);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly host: ElementRef<HTMLElement> = inject(ElementRef);
+  private readonly refreshTrigger = new BehaviorSubject<void>(undefined);
+  private drawerReturnFocus: HTMLElement | null = null;
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly platform = RUNTIME_CONFIG.domesticPlatform;
+  readonly icons = {
+    alert: AlertTriangle,
+    book: BookOpen,
+    check: CheckCircle2,
+    clock: Clock3,
+    close: X,
+    database: Database,
+    failed: CircleX,
+    gauge: Gauge,
+    history: History,
+    import: Upload,
+    layers: Layers3,
+    package: PackageSearch,
+    play: Play,
+    plug: PlugZap,
+    refresh: RefreshCw,
+    retry: RotateCcw,
+    search: Search,
+    settings: Settings,
+    shopping: ShoppingCart,
+    star: Star,
+    store: Store,
+    task: FileClock,
+  };
+  readonly tabs: Array<{ id: DataCenterView; label: string }> = [
+    { id: 'overview', label: '数据总览' },
+    { id: 'sources', label: '接入与同步' },
+    { id: 'quality', label: '质量规则' },
+    { id: 'dictionary', label: '口径字典' },
+  ];
+  readonly jobStatuses: Array<{ value: DataCenterJobStatus | ''; label: string }> = [
+    { value: '', label: '全部状态' },
+    { value: 'pending', label: '待执行' },
+    { value: 'processing', label: '执行中' },
+    { value: 'completed', label: '已完成' },
+    { value: 'partial', label: '部分完成' },
+    { value: 'failed', label: '失败' },
+    { value: 'cancelled', label: '已取消' },
+  ];
+  readonly connectorDomains: ConnectorDomain[] = [
+    { key: 'product', label: '商品主数据', description: '支持按平台商品 ID 同步商品信息。', state: 'supported' },
+    { key: 'reviews', label: '商品评价', description: '支持按平台商品 ID 同步评价证据。', state: 'supported' },
+    { key: 'orders', label: '订单与子单', description: '订单状态、支付和商品行明细待接入。', state: 'building' },
+    { key: 'aftersale', label: '退款与售后', description: '售后单、原因和退货物流待接入。', state: 'building' },
+    { key: 'inventory', label: '库存与履约', description: '库存快照、仓库和物流事件待接入。', state: 'building' },
+    { key: 'settlement', label: '对账与结算', description: '账单、费用项和结算批次待接入。', state: 'building' },
+  ];
+
+  activeView: DataCenterView = 'overview';
+  refreshing = false;
+  syncOpen = false;
+  syncing = false;
+  syncError = '';
+  syncProductIds = '';
+  syncProductScope = true;
+  syncReviewScope = true;
+  dictionarySearch = '';
+  dictionaryDomain = '';
+  jobStatus: DataCenterJobStatus | '' = '';
+  actionError = '';
+  selectedJob: DataCenterSyncJob | null = null;
+  jobEvents: DataCenterJobEvent[] = [];
+  jobEventsLoading = false;
+  jobEventsError = '';
+  selectedSource: DataCenterSource | null = null;
+  readonly busyJobs = new Set<string>();
+
+  private readonly operations$ = this.refreshTrigger.pipe(
+    switchMap(() => forkJoin({
+      sources: this.dataCenter.dataSources(this.workspaceId).pipe(
+        map((response) => ({ items: response.items, warning: '' })),
+        catchError(() => of({ items: [] as DataCenterSource[], warning: '数据源状态读取失败' })),
+      ),
+      imports: this.collectPages((cursor) => this.dataCenter.imports(this.workspaceId, 50, cursor)).pipe(
+        map((items) => ({ items, warning: '' })),
+        catchError(() => of({ items: [] as DataCenterImportBatch[], warning: '导入批次读取失败' })),
+      ),
+      jobs: this.collectPages((cursor) => this.dataCenter.jobs(this.workspaceId, '', 50, cursor)).pipe(
+        map((items) => ({ items, warning: '' })),
+        catchError(() => of({ items: [] as DataCenterSyncJob[], warning: '同步任务读取失败' })),
+      ),
+    })),
+    map(({ sources, imports, jobs }): DataCenterApiState => ({
+      sources: sources.items,
+      imports: imports.items,
+      jobs: jobs.items,
+      warnings: [sources.warning, imports.warning, jobs.warning].filter(Boolean),
+    })),
+    tap(() => {
+      this.refreshing = false;
+      this.cdr.markForCheck();
+    }),
+    shareReplay({ bufferSize: 1, refCount: false }),
+  );
+
+  readonly vm$ = combineLatest([this.datasetService.dataset$, this.operations$]).pipe(
+    map(([dataset, operations]) => this.buildViewModel(dataset, operations)),
+    shareReplay({ bufferSize: 1, refCount: false }),
+  );
+
+  constructor() {
+    this.route.queryParamMap.subscribe((params) => {
+      const view = params.get('view');
+      if (view && this.tabs.some((tab) => tab.id === view)) this.activeView = view as DataCenterView;
+      this.cdr.markForCheck();
+    });
+  }
+
+  private collectPages<T>(request: (cursor: string) => Observable<DataCenterPage<T>>): Observable<T[]> {
+    return request('').pipe(
+      expand((page) => page.nextCursor ? request(page.nextCursor) : EMPTY),
+      map((page) => page.items),
+      reduce((items, pageItems) => [...items, ...pageItems], [] as T[]),
+    );
+  }
+
+  setView(view: DataCenterView): void {
+    this.activeView = view;
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: { view: view === 'overview' ? null : view },
+      queryParamsHandling: 'merge',
+      replaceUrl: true,
+    });
+  }
+
+  refreshAll(): void {
+    this.refreshing = true;
+    this.actionError = '';
+    this.datasetService.refresh();
+    this.refreshTrigger.next();
+  }
+
+  openSync(dataset: DomesticDataset): void {
+    this.rememberDrawerTrigger();
+    if (!this.syncProductIds) {
+      this.syncProductIds = dataset.products
+        .filter((product) => product.role === 'own')
+        .slice(0, 3)
+        .map((product) => product.productId)
+        .join('\n');
+    }
+    this.selectedJob = null;
+    this.selectedSource = null;
+    this.syncError = '';
+    this.syncOpen = true;
+    this.focusActiveDrawer();
+  }
+
+  closeSync(): void {
+    if (this.syncing) return;
+    this.syncOpen = false;
+    this.syncError = '';
+    this.restoreDrawerFocus();
+  }
+
+  openJobDetails(job: DataCenterSyncJob): void {
+    this.rememberDrawerTrigger();
+    this.syncOpen = false;
+    this.selectedSource = null;
+    this.selectedJob = job;
+    this.loadJobEvents(job.id);
+    this.focusActiveDrawer();
+  }
+
+  closeJobDetails(): void {
+    this.selectedJob = null;
+    this.jobEvents = [];
+    this.jobEventsError = '';
+    this.jobEventsLoading = false;
+    this.restoreDrawerFocus();
+  }
+
+  reloadJobEvents(): void {
+    if (this.selectedJob) this.loadJobEvents(this.selectedJob.id);
+  }
+
+  openSourceDetails(source: DataCenterSource): void {
+    this.rememberDrawerTrigger();
+    this.syncOpen = false;
+    this.selectedJob = null;
+    this.jobEvents = [];
+    this.jobEventsError = '';
+    this.selectedSource = source;
+    this.focusActiveDrawer();
+  }
+
+  closeSourceDetails(): void {
+    this.selectedSource = null;
+    this.restoreDrawerFocus();
+  }
+
+  openSyncFromSource(dataset: DomesticDataset): void {
+    const returnFocus = this.drawerReturnFocus;
+    this.selectedSource = null;
+    this.openSync(dataset);
+    this.drawerReturnFocus = returnFocus;
+  }
+
+  drawerOpen(): boolean {
+    return this.syncOpen || Boolean(this.selectedJob) || Boolean(this.selectedSource);
+  }
+
+  closeActiveDrawer(): void {
+    if (this.syncOpen) {
+      this.closeSync();
+      return;
+    }
+    if (this.selectedJob) this.closeJobDetails();
+    if (this.selectedSource) this.closeSourceDetails();
+  }
+
+  @HostListener('document:keydown', ['$event'])
+  handleDrawerKeyboard(event: KeyboardEvent): void {
+    if (!this.drawerOpen()) return;
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      this.closeActiveDrawer();
+      return;
+    }
+    if (event.key === 'Tab') this.keepFocusInDrawer(event);
+  }
+
+  submitSync(): void {
+    const productIds = [...new Set(this.syncProductIds.split(/[\s,,]+/).map((item) => item.trim()).filter(Boolean))];
+    const scopes: DataCenterSyncScope[] = [];
+    if (this.syncProductScope) scopes.push('product');
+    if (this.syncReviewScope) scopes.push('reviews');
+    if (!productIds.length) {
+      this.syncError = '至少填写一个平台商品 ID。';
+      return;
+    }
+    if (productIds.length > 100) {
+      this.syncError = '单次同步最多支持 100 个商品。';
+      return;
+    }
+    if (!scopes.length) {
+      this.syncError = '至少选择一个同步范围。';
+      return;
+    }
+    this.syncing = true;
+    this.syncError = '';
+    this.dataCenter.enqueueSync(this.workspaceId, productIds, scopes).subscribe({
+      next: () => {
+        this.syncing = false;
+        this.syncOpen = false;
+        this.restoreDrawerFocus();
+        this.setView('sources');
+        this.refreshTrigger.next();
+        this.cdr.markForCheck();
+      },
+      error: (error) => {
+        this.syncing = false;
+        this.syncError = this.apiError(error, '同步任务创建失败。');
+        this.cdr.markForCheck();
+      },
+    });
+  }
+
+  retryJob(job: DataCenterSyncJob): void {
+    this.runJobAction(job, () => this.dataCenter.retryJob(job.id));
+  }
+
+  cancelJob(job: DataCenterSyncJob): void {
+    this.runJobAction(job, () => this.dataCenter.cancelJob(job.id));
+  }
+
+  configureSource(): void {
+    this.router.navigateByUrl('/system/api');
+  }
+
+  runRuleAction(rule: QualityRule): void {
+    const routes: Record<Exclude<QualityRule['action'], ''>, string> = {
+      products: '/voc-insight/products',
+      reviews: '/feedback-inbox',
+      competitors: '/domestic/competitors',
+      settings: '/system/api',
+    };
+    if (rule.action) this.router.navigateByUrl(routes[rule.action]);
+  }
+
+  filteredJobs(jobs: DataCenterSyncJob[]): DataCenterSyncJob[] {
+    return this.jobStatus ? jobs.filter((job) => job.status === this.jobStatus) : jobs;
+  }
+
+  filteredDictionary(): MetricDefinition[] {
+    const search = this.dictionarySearch.trim().toLowerCase();
+    return DOMESTIC_METRIC_DICTIONARY.filter((item) => {
+      const domainMatch = !this.dictionaryDomain || item.domain === this.dictionaryDomain;
+      const searchMatch = !search || [item.key, item.name, item.definition, item.formula]
+        .some((value) => value.toLowerCase().includes(search));
+      return domainMatch && searchMatch;
+    });
+  }
+
+  dictionaryDomains(): string[] {
+    return [...new Set(DOMESTIC_METRIC_DICTIONARY.map((item) => item.domain))];
+  }
+
+  number(value: number): string {
+    return new Intl.NumberFormat('zh-CN').format(Number(value) || 0);
+  }
+
+  percent(value: number): string {
+    return `${Math.round((Number(value) || 0) * 100)}%`;
+  }
+
+  dateTime(value: string | null): string {
+    if (!value) return '尚未记录';
+    const date = new Date(value);
+    return Number.isNaN(date.getTime()) ? '时间异常' : new Intl.DateTimeFormat('zh-CN', {
+      month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
+    }).format(date);
+  }
+
+  sourceKindLabel(value: string): string {
+    return ({ fmode_gateway: '企业数据网关', normalized_snapshot: '标准化快照' } as Record<string, string>)[value] ?? value;
+  }
+
+  sourceStatusLabel(value: string): string {
+    return ({ configured: '已配置', active: '运行中', disabled: '已停用', error: '异常' } as Record<string, string>)[value] ?? value;
+  }
+
+  credentialStorageLabel(value: DataCenterSource['credentialStorage']): string {
+    return value === 'external_secret' ? '外部密钥服务' : '运行环境';
+  }
+
+  scopeLabel(value: string): string {
+    return ({ product: '商品主数据', reviews: '商品评价' } as Record<string, string>)[value] ?? value;
+  }
+
+  eventLevelLabel(value: string): string {
+    return ({ info: '信息', warning: '关注', error: '错误' } as Record<string, string>)[value] ?? value;
+  }
+
+  eventTypeLabel(value: string): string {
+    return ({
+      sync_started: '任务开始',
+      product_synced: '商品已同步',
+      reviews_synced: '评价已同步',
+      product_sync_failed: '商品同步失败',
+      reviews_sync_failed: '评价同步失败',
+      sync_requeued: '任务重新排队',
+      sync_finished: '任务结束',
+      sync_stale_recovered: '中断任务恢复',
+      sync_manually_retried: '人工重试',
+      sync_manually_cancelled: '人工取消',
+    } as Record<string, string>)[value] ?? value;
+  }
+
+  eventDetailEntries(event: DataCenterJobEvent): EventDetailEntry[] {
+    const labels: Record<string, string> = {
+      status: '状态',
+      scope: '范围',
+      productId: '商品 ID',
+      missingProductCount: '缺失商品',
+      reviewCount: '评价数量',
+      retryable: '可重试',
+      manual: '人工操作',
+    };
+    return Object.entries(event.details ?? {}).map(([key, value]) => ({
+      key,
+      label: labels[key] ?? key,
+      value: this.eventDetailValue(value),
+    }));
+  }
+
+  sourceNextAction(source: DataCenterSource): { title: string; description: string } {
+    if (source.status === 'error') {
+      return { title: '检查接入配置', description: '连接当前处于异常状态,先核对网关、凭证和网络配置。' };
+    }
+    if (source.status === 'disabled') {
+      return { title: '恢复连接', description: '连接已停用,需要在接入配置中恢复后再发起同步。' };
+    }
+    if (!source.lastCheckedAt) {
+      return { title: '完成首次连接检查', description: '独立连接检查接口待建设,当前请先核对接入配置。' };
+    }
+    return { title: '发起商品与评价同步', description: '连接已配置,可选择商品 ID 和同步范围创建后台任务。' };
+  }
+
+  jobStatusLabel(value: string): string {
+    return ({
+      pending: '待执行', processing: '执行中', completed: '已完成', partial: '部分完成',
+      failed: '失败', cancelled: '已取消',
+    } as Record<string, string>)[value] ?? value;
+  }
+
+  importStatusLabel(value: string): string {
+    return ({ completed: '已完成', partial: '部分完成', failed: '失败', processing: '处理中' } as Record<string, string>)[value] ?? value;
+  }
+
+  coverageStateLabel(value: CoverageState): string {
+    return ({ ready: '已接入', partial: '部分接入', missing: '待接入' } as const)[value];
+  }
+
+  ruleStateLabel(value: RuleState): string {
+    return ({ passed: '通过', warning: '关注', failed: '阻断' } as const)[value];
+  }
+
+  availabilityLabel(value: MetricDefinition['availability']): string {
+    return ({ available: '可用', partial: '部分', missing: '缺失' } as const)[value];
+  }
+
+  private runJobAction(job: DataCenterSyncJob, request: () => ReturnType<DataCenterService['retryJob']>): void {
+    this.busyJobs.add(job.id);
+    this.actionError = '';
+    request().subscribe({
+      next: (response) => {
+        this.busyJobs.delete(job.id);
+        if (this.selectedJob?.id === job.id) {
+          this.selectedJob = response.job;
+          this.loadJobEvents(job.id);
+        }
+        this.refreshTrigger.next();
+        this.cdr.markForCheck();
+      },
+      error: (error) => {
+        this.busyJobs.delete(job.id);
+        this.actionError = this.apiError(error, '任务操作失败。');
+        this.cdr.markForCheck();
+      },
+    });
+  }
+
+  private loadJobEvents(jobId: string): void {
+    this.jobEventsLoading = true;
+    this.jobEventsError = '';
+    this.jobEvents = [];
+    this.dataCenter.jobEvents(this.workspaceId, jobId).subscribe({
+      next: (response) => {
+        if (this.selectedJob?.id !== jobId) return;
+        this.jobEvents = response.items;
+        this.jobEventsLoading = false;
+        this.cdr.markForCheck();
+      },
+      error: (error) => {
+        if (this.selectedJob?.id !== jobId) return;
+        this.jobEventsLoading = false;
+        this.jobEventsError = this.apiError(error, '任务事件读取失败,请稍后重试。');
+        this.cdr.markForCheck();
+      },
+    });
+  }
+
+  private eventDetailValue(value: unknown): string {
+    if (typeof value === 'boolean') return value ? '是' : '否';
+    if (value === null || value === undefined || value === '') return '--';
+    if (typeof value === 'object') return JSON.stringify(value);
+    return String(value);
+  }
+
+  private buildViewModel(dataset: DomesticDataset, api: DataCenterApiState): DataCenterViewModel {
+    const own = dataset.products.filter((product) => product.role === 'own');
+    const competitors = dataset.products.filter((product) => product.role === 'competitor');
+    const reviewScope = buildDomesticReviewScope(dataset);
+    const dateDays = dataset.dailyTotals.length;
+    const sourceAgeHours = Math.max(0, (Date.now() - new Date(dataset.generatedAt).getTime()) / 3_600_000);
+    const latestImport = [...api.imports].sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null;
+    const importSuccessRate = latestImport && latestImport.totalRows > 0
+      ? latestImport.successRows / latestImport.totalRows
+      : 0;
+    const shopCoverage = this.fieldCoverage(own, (product) => product.detail?.shopId);
+    const sellerCoverage = this.fieldCoverage(own, (product) => product.detail?.sellerId);
+    const modelCoverage = this.fieldCoverage(own, (product) => product.model);
+    const metricCoverage = own.length ? Math.min(1, dataset.summary.metricProducts / own.length) : 0;
+    const reviewedCompetitorProducts = new Set(
+      reviewScope.competitor.map((review) => review.productId),
+    ).size;
+    const competitorReviewCoverage = competitors.length ? reviewedCompetitorProducts / competitors.length : 0;
+    const relationOwnProducts = new Set(dataset.relations.map((relation) => relation.ownProductId)).size;
+    const relationCoverage = own.length ? relationOwnProducts / own.length : 0;
+    const hasTraffic = dataset.dailyTotals.some((row) => row.impressions > 0 || row.visitors > 0);
+    const hasTransactions = dataset.dailyTotals.some((row) => row.transactionOrders > 0 || row.gmv > 0);
+    const hasRefunds = dataset.dailyTotals.some((row) => row.refundAmount > 0 || row.refundOrders > 0);
+
+    const coverage: CoverageDomain[] = [
+      this.coverage('shop', '店铺与账号', '店铺 / 商家账号', '平台 × 店铺', Math.min(shopCoverage, sellerCoverage),
+        `${new Set(own.map((item) => item.detail?.shopId).filter(Boolean)).size} 个店铺标识`, '缺少稳定店铺授权、账号归属和店铺维表'),
+      this.coverage('product', '商品主数据', 'SPU / SKU / 平台商品', '平台 × 商品', Math.min(metricCoverage, modelCoverage),
+        `${this.number(own.length)} 个本品,${this.number(competitors.length)} 个竞品`, 'SKU 状态、类目版本和店铺商品关系仍需规范化'),
+      this.coverage('traffic', '流量与转化', '曝光 / 点击 / 访客', '商品 × 日', hasTraffic ? 1 : 0,
+        `${this.number(dataset.summary.metricRows)} 行商品日明细`, '当前为经营聚合,缺少渠道、关键词和广告计划粒度'),
+      this.coverage('trade', '下单与成交', '下单 / 成交订单', '商品 × 日', hasTransactions ? 0.72 : 0,
+        `${this.number(dateDays)} 天经营周期`, '缺少订单、子单、支付、发货和签收状态机'),
+      this.coverage('refund', '退款与售后', '退款 / 售后单', '商品 × 日', hasRefunds ? 0.45 : 0,
+        `退款金额和单量已汇总`, '缺少售后单类型、原因、责任、退货物流和完成时间'),
+      this.coverage('review', '评价与 VOC', '评价 / 追评 / 回复', '商品 × 评价', reviewScope.allCount ? Math.min(0.99, competitorReviewCoverage) : 0,
+        `本品 ${this.number(reviewScope.ownCount)},竞品 ${this.number(reviewScope.competitorCount)}`, '本品评价未接入,评价商品覆盖不完整'),
+      this.coverage('relation', '竞品关系', '本品 / 竞品映射', '本品 × 竞品', relationCoverage,
+        `${this.number(dataset.relations.length)} 条映射关系`, '仍有孤立映射、空竞品位和关系覆盖缺口'),
+      this.coverage('inventory', '库存与履约', '库存 / 仓 / 履约单', '店铺 × SKU × 仓', 0,
+        '当前快照无库存与物流实体', '需要接入库存、锁定量、仓库、发货和签收数据'),
+      this.coverage('settlement', '对账与结算', '账单 / 费用项 / 结算批次', '结算批次 × 子单', 0,
+        '当前快照无账单与结算实体', '需要接入应收、退款、佣金、服务费、优惠分摊和净额'),
+    ];
+
+    const issueCount = dataset.quality.orphanMappings.length
+      + dataset.quality.mappingsWithoutCompetitor.length
+      + dataset.quality.brandWithoutProductId.length;
+    const freshnessState: RuleState = sourceAgeHours <= 24 ? 'passed' : sourceAgeHours <= 72 ? 'warning' : 'failed';
+    const rules: QualityRule[] = [
+      { id: 'freshness', domain: '时效', name: '快照新鲜度', state: freshnessState, result: this.ageLabel(sourceAgeHours), threshold: '不超过 24 小时', affected: freshnessState === 'passed' ? 0 : 1, action: 'settings' },
+      { id: 'metric-product', domain: '经营', name: '经营商品覆盖', state: metricCoverage >= 0.98 ? 'passed' : metricCoverage >= 0.8 ? 'warning' : 'failed', result: this.percent(metricCoverage), threshold: '不低于 98%', affected: Math.max(0, own.length - dataset.summary.metricProducts), action: 'products' },
+      { id: 'model', domain: '商品', name: 'SKU / 型号完整度', state: modelCoverage >= 0.98 ? 'passed' : modelCoverage >= 0.8 ? 'warning' : 'failed', result: this.percent(modelCoverage), threshold: '不低于 98%', affected: Math.round(own.length * (1 - modelCoverage)), action: 'products' },
+      { id: 'shop', domain: '店铺', name: '店铺身份覆盖', state: shopCoverage >= 0.95 && sellerCoverage >= 0.95 ? 'passed' : shopCoverage > 0 ? 'warning' : 'failed', result: `${this.percent(shopCoverage)} / ${this.percent(sellerCoverage)}`, threshold: '店铺 / 商家均不低于 95%', affected: Math.round(own.length * (1 - Math.min(shopCoverage, sellerCoverage))), action: 'settings' },
+      { id: 'own-review', domain: '评价', name: '本品评价覆盖', state: reviewScope.ownCount > 0 ? 'passed' : 'failed', result: `${this.number(reviewScope.ownCount)} 条`, threshold: '至少存在 1 条本品评价', affected: reviewScope.ownCount ? 0 : own.length, action: 'reviews' },
+      { id: 'competitor-review', domain: '评价', name: '竞品评价商品覆盖', state: competitorReviewCoverage >= 0.8 ? 'passed' : competitorReviewCoverage >= 0.4 ? 'warning' : 'failed', result: this.percent(competitorReviewCoverage), threshold: '不低于 80%', affected: Math.max(0, competitors.length - reviewedCompetitorProducts), action: 'competitors' },
+      { id: 'relation', domain: '关系', name: '竞品映射完整性', state: issueCount === 0 ? 'passed' : issueCount <= 3 ? 'warning' : 'failed', result: `${issueCount} 个问题`, threshold: '0 个结构问题', affected: issueCount, action: 'competitors' },
+      { id: 'aftersale', domain: '售后', name: '售后明细可追溯', state: 'warning', result: '仅有商品日汇总', threshold: '售后单状态和原因可追溯', affected: dataset.dailyTotals.reduce((sum, row) => sum + row.refundOrders, 0), action: 'settings' },
+    ];
+    const qualityFailed = rules.filter((rule) => rule.state === 'failed').length;
+    const qualityWarnings = rules.filter((rule) => rule.state === 'warning').length;
+    const statusTone = qualityFailed ? 'red' : qualityWarnings ? 'amber' : 'green';
+    const statusTitle = qualityFailed ? `${qualityFailed} 项数据门禁未通过` : qualityWarnings ? `${qualityWarnings} 项数据质量需关注` : '核心数据链路正常';
+    const primaryGap = rules.find((rule) => rule.state === 'failed');
+    const statusMessage = primaryGap
+      ? `优先处理“${primaryGap.name}”:当前 ${primaryGap.result},门槛 ${primaryGap.threshold}。`
+      : '当前数据可以支撑既有经营与 VOC 分析,仍需按任务计划补齐明细业务对象。';
+
+    return {
+      dataset,
+      sources: api.sources,
+      imports: api.imports,
+      jobs: api.jobs,
+      warnings: api.warnings,
+      coverage,
+      rules,
+      ownProducts: own.length,
+      competitorProducts: competitors.length,
+      ownReviews: reviewScope.ownCount,
+      competitorReviews: reviewScope.competitorCount,
+      dateDays,
+      sourceAgeHours,
+      sourceAgeLabel: this.ageLabel(sourceAgeHours),
+      sourceFreshnessState: freshnessState,
+      latestImport,
+      importSuccessRate,
+      readyDomains: coverage.filter((item) => item.state === 'ready').length,
+      qualityPassed: rules.filter((rule) => rule.state === 'passed').length,
+      qualityWarnings,
+      qualityFailed,
+      activeJobs: api.jobs.filter((job) => job.status === 'pending' || job.status === 'processing').length,
+      statusTone,
+      statusTitle,
+      statusMessage,
+    };
+  }
+
+  private coverage(
+    id: string,
+    label: string,
+    objectLabel: string,
+    grain: string,
+    coverage: number,
+    evidence: string,
+    gap: string,
+  ): CoverageDomain {
+    const normalized = Math.max(0, Math.min(1, coverage));
+    return {
+      id,
+      label,
+      objectLabel,
+      grain,
+      state: normalized >= 0.95 ? 'ready' : normalized > 0 ? 'partial' : 'missing',
+      coverage: normalized,
+      evidence,
+      gap,
+    };
+  }
+
+  private fieldCoverage(products: DomesticProduct[], pick: (product: DomesticProduct) => unknown): number {
+    if (!products.length) return 0;
+    return products.filter((product) => String(pick(product) ?? '').trim()).length / products.length;
+  }
+
+  private ageLabel(hours: number): string {
+    if (!Number.isFinite(hours)) return '时间不可用';
+    if (hours < 1) return `${Math.max(1, Math.round(hours * 60))} 分钟前`;
+    if (hours < 48) return `${Math.round(hours)} 小时前`;
+    return `${Math.round(hours / 24)} 天前`;
+  }
+
+  private rememberDrawerTrigger(): void {
+    if (this.drawerOpen()) return;
+    this.drawerReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+  }
+
+  private focusActiveDrawer(): void {
+    window.setTimeout(() => {
+      const drawer = this.host.nativeElement.querySelector<HTMLElement>('.detail-drawer.open, .sync-drawer.open');
+      drawer?.querySelector<HTMLElement>('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])')?.focus();
+    });
+  }
+
+  private restoreDrawerFocus(): void {
+    if (this.drawerOpen()) return;
+    const target = this.drawerReturnFocus;
+    this.drawerReturnFocus = null;
+    window.setTimeout(() => target?.focus());
+  }
+
+  private keepFocusInDrawer(event: KeyboardEvent): void {
+    const drawer = this.host.nativeElement.querySelector<HTMLElement>('.detail-drawer.open, .sync-drawer.open');
+    if (!drawer) return;
+    const focusable = Array.from(drawer.querySelectorAll<HTMLElement>(
+      'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
+    ));
+    if (!focusable.length) return;
+    const first = focusable[0];
+    const last = focusable[focusable.length - 1];
+    if (event.shiftKey && document.activeElement === first) {
+      event.preventDefault();
+      last.focus();
+    } else if (!event.shiftKey && document.activeElement === last) {
+      event.preventDefault();
+      first.focus();
+    }
+  }
+
+  private apiError(error: { error?: { error?: string } } | null, fallback: string): string {
+    const code = error?.error?.error;
+    return ({
+      sync_job_not_retryable: '当前任务状态不支持重试。',
+      sync_job_not_cancellable: '只有待执行任务可以取消。',
+      workspace_permission_denied: '当前账号没有数据同步权限。',
+    } as Record<string, string>)[code ?? ''] ?? fallback;
+  }
+}

+ 118 - 0
src/modules/data-center/data-center.service.spec.ts

@@ -0,0 +1,118 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { DataCenterService } from './data-center.service';
+
+describe('DataCenterService', () => {
+  let service: DataCenterService;
+  let http: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
+    service = TestBed.inject(DataCenterService);
+    http = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => http.verify());
+
+  it('gets data sources from the encoded workspace URL', () => {
+    service.dataSources('workspace/alpha').subscribe();
+
+    const request = http.expectOne('/api/saas/workspaces/workspace%2Falpha/data-sources');
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.keys()).toEqual([]);
+    request.flush({ items: [] });
+  });
+
+  it('gets imports from the encoded workspace URL with the requested limit', () => {
+    service.imports('workspace/alpha', 25).subscribe();
+
+    const request = http.expectOne((candidate) =>
+      candidate.url === '/api/saas/workspaces/workspace%2Falpha/imports',
+    );
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('limit')).toBe('25');
+    request.flush({ items: [], nextCursor: null });
+  });
+
+  it('gets jobs with workspace, platform, status, and limit parameters', () => {
+    service.jobs('workspace/alpha', 'failed', 20).subscribe();
+
+    const request = http.expectOne((candidate) => candidate.url === '/api/domestic-voc/jobs');
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('workspaceId')).toBe('workspace/alpha');
+    expect(request.request.params.get('platform')).toBe('jd');
+    expect(request.request.params.get('status')).toBe('failed');
+    expect(request.request.params.get('limit')).toBe('20');
+    request.flush({ items: [], nextCursor: null });
+  });
+
+  it('omits an empty status when getting jobs with default parameters', () => {
+    service.jobs('demashi').subscribe();
+
+    const request = http.expectOne((candidate) => candidate.url === '/api/domestic-voc/jobs');
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('workspaceId')).toBe('demashi');
+    expect(request.request.params.get('platform')).toBe('jd');
+    expect(request.request.params.has('status')).toBeFalse();
+    expect(request.request.params.get('limit')).toBe('50');
+    request.flush({ items: [], nextCursor: null });
+  });
+
+  it('gets ordered job events with an encoded job ID and workspace context', () => {
+    service.jobEvents('workspace/alpha', 'job/001 review').subscribe((response) => {
+      expect(response.items[0]).toEqual(jasmine.objectContaining({
+        id: 'event-1',
+        level: 'warning',
+        type: 'sync_requeued',
+      }));
+    });
+
+    const request = http.expectOne((candidate) =>
+      candidate.url === '/api/domestic-voc/jobs/job%2F001%20review/events',
+    );
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('workspaceId')).toBe('workspace/alpha');
+    expect(request.request.params.keys()).toEqual(['workspaceId']);
+    request.flush({
+      items: [{
+        id: 'event-1',
+        level: 'warning',
+        type: 'sync_requeued',
+        message: 'Sync will retry',
+        details: { retryable: true },
+        createdAt: '2026-08-07T08:00:00.000Z',
+      }],
+    });
+  });
+
+  it('enqueues a sync with the configured platform and requested products and scopes', () => {
+    service.enqueueSync('demashi', ['product-001', 'product-002'], ['product', 'reviews']).subscribe();
+
+    const request = http.expectOne('/api/domestic-voc/sync');
+    expect(request.request.method).toBe('POST');
+    expect(request.request.body).toEqual({
+      workspaceId: 'demashi',
+      platform: 'jd',
+      productIds: ['product-001', 'product-002'],
+      scopes: ['product', 'reviews'],
+    });
+    request.flush({ job: {} });
+  });
+
+  it('encodes job IDs in retry and cancel URLs', () => {
+    const jobId = 'job/001 review';
+
+    service.retryJob(jobId).subscribe();
+    const retry = http.expectOne('/api/domestic-voc/jobs/job%2F001%20review/retry');
+    expect(retry.request.method).toBe('POST');
+    expect(retry.request.body).toEqual({});
+    retry.flush({ job: {} });
+
+    service.cancelJob(jobId).subscribe();
+    const cancel = http.expectOne('/api/domestic-voc/jobs/job%2F001%20review/cancel');
+    expect(cancel.request.method).toBe('POST');
+    expect(cancel.request.body).toEqual({});
+    cancel.flush({ job: {} });
+  });
+});

+ 124 - 0
src/modules/data-center/data-center.service.ts

@@ -0,0 +1,124 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+
+export type DataCenterJobStatus = 'pending' | 'processing' | 'completed' | 'partial' | 'failed' | 'cancelled';
+export type DataCenterSyncScope = 'product' | 'reviews';
+export type DataCenterJobEventLevel = 'info' | 'warning' | 'error';
+
+export interface DataCenterSource {
+  id: string;
+  workspaceId: string;
+  platform: string;
+  kind: string;
+  status: string;
+  lastCheckedAt: string | null;
+  credentialStorage: 'environment' | 'external_secret';
+}
+
+export interface DataCenterImportBatch {
+  id: string;
+  workspaceId: string;
+  platform: string;
+  sourceKind: string;
+  sourceFile: string | null;
+  status: string;
+  totalRows: number;
+  successRows: number;
+  failedRows: number;
+  createdAt: string;
+  completedAt: string | null;
+}
+
+export interface DataCenterSyncJob {
+  id: string;
+  workspaceId: string;
+  platform: string;
+  status: DataCenterJobStatus;
+  scopes: DataCenterSyncScope[];
+  productIds: string[];
+  progress: number;
+  attempts: number;
+  maxAttempts: number;
+  errorSummary: string | null;
+  requestedAt: string;
+  startedAt: string | null;
+  completedAt: string | null;
+}
+
+export interface DataCenterJobEvent {
+  id: string;
+  level: DataCenterJobEventLevel;
+  type: string;
+  message: string;
+  details: Record<string, unknown>;
+  createdAt: string;
+}
+
+export interface DataCenterPage<T> {
+  items: T[];
+  nextCursor: string | null;
+}
+
+@Injectable({ providedIn: 'root' })
+export class DataCenterService {
+  private readonly http = inject(HttpClient);
+  private readonly apiBase = RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '');
+  private readonly saasBase = `${this.apiBase}/${RUNTIME_CONFIG.saasPath.replace(/^\/+|\/+$/g, '')}`;
+  private readonly domesticBase = `${this.apiBase}/${RUNTIME_CONFIG.domesticVocPath.replace(/^\/+|\/+$/g, '')}`;
+
+  dataSources(workspaceId: string): Observable<{ items: DataCenterSource[] }> {
+    return this.http.get<{ items: DataCenterSource[] }>(`${this.workspaceUrl(workspaceId)}/data-sources`);
+  }
+
+  imports(workspaceId: string, limit = 50, cursor = ''): Observable<DataCenterPage<DataCenterImportBatch>> {
+    let params = new HttpParams().set('limit', limit);
+    if (cursor) params = params.set('cursor', cursor);
+    return this.http.get<DataCenterPage<DataCenterImportBatch>>(`${this.workspaceUrl(workspaceId)}/imports`, { params });
+  }
+
+  jobs(workspaceId: string, status: DataCenterJobStatus | '' = '', limit = 50, cursor = ''): Observable<DataCenterPage<DataCenterSyncJob>> {
+    let params = new HttpParams()
+      .set('workspaceId', workspaceId)
+      .set('platform', RUNTIME_CONFIG.domesticPlatform)
+      .set('limit', limit);
+    if (status) params = params.set('status', status);
+    if (cursor) params = params.set('cursor', cursor);
+    return this.http.get<DataCenterPage<DataCenterSyncJob>>(`${this.domesticBase}/jobs`, { params });
+  }
+
+  jobEvents(workspaceId: string, jobId: string): Observable<{ items: DataCenterJobEvent[] }> {
+    return this.http.get<{ items: DataCenterJobEvent[] }>(
+      `${this.domesticBase}/jobs/${encodeURIComponent(jobId)}/events`,
+      { params: new HttpParams().set('workspaceId', workspaceId) },
+    );
+  }
+
+  enqueueSync(workspaceId: string, productIds: string[], scopes: DataCenterSyncScope[]): Observable<{ job: DataCenterSyncJob }> {
+    return this.http.post<{ job: DataCenterSyncJob }>(`${this.domesticBase}/sync`, {
+      workspaceId,
+      platform: RUNTIME_CONFIG.domesticPlatform,
+      productIds,
+      scopes,
+    });
+  }
+
+  retryJob(jobId: string): Observable<{ job: DataCenterSyncJob }> {
+    return this.http.post<{ job: DataCenterSyncJob }>(
+      `${this.domesticBase}/jobs/${encodeURIComponent(jobId)}/retry`,
+      {},
+    );
+  }
+
+  cancelJob(jobId: string): Observable<{ job: DataCenterSyncJob }> {
+    return this.http.post<{ job: DataCenterSyncJob }>(
+      `${this.domesticBase}/jobs/${encodeURIComponent(jobId)}/cancel`,
+      {},
+    );
+  }
+
+  private workspaceUrl(workspaceId: string): string {
+    return `${this.saasBase}/workspaces/${encodeURIComponent(workspaceId)}`;
+  }
+}

+ 46 - 0
src/modules/domestic-voc/category-analysis.component.scss

@@ -0,0 +1,46 @@
+.category-analysis-table {
+  min-width: 880px;
+}
+
+.strong-number {
+  color: #1f2937 !important;
+  font-weight: 700;
+}
+
+.risk-value,
+.coverage-value {
+  display: inline-flex;
+  min-height: 24px;
+  padding: 0 8px;
+  align-items: center;
+  justify-content: center;
+  border-radius: 6px;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.risk-value.high {
+  color: #b91c1c;
+  background: #fee2e2;
+}
+
+.risk-value.medium {
+  color: #92400e;
+  background: #fef3c7;
+}
+
+.risk-value.low {
+  color: #166534;
+  background: #dcfce7;
+}
+
+.coverage-value {
+  min-width: 30px;
+  color: #64748b;
+  background: #f1f5f9;
+}
+
+.coverage-value.covered {
+  color: #1d4ed8;
+  background: #dbeafe;
+}

+ 185 - 16
src/modules/domestic-voc/category-analysis.component.ts

@@ -4,9 +4,14 @@ import { NgxEchartsModule } from 'ngx-echarts';
 import type { EChartsOption } from 'echarts';
 import { Subject, takeUntil } from 'rxjs';
 import { DomesticAnalyticsService, DomesticCategorySummary } from '../../app/core/services/domestic-analytics.service';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
 import { BoardTabItem, BoardTabsComponent } from '../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
-import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
+import {
+  CrudTableColumn,
+  CrudTableFilter,
+  CrudTableWorkbenchComponent,
+} from '../shared/components/crud-table-workbench/crud-table-workbench.component';
 import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
 import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
@@ -14,7 +19,7 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
 @Component({
   selector: 'app-domestic-category-analysis',
   standalone: true,
-  imports: [CommonModule, NgxEchartsModule, BoardTabsComponent, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, DataTableShellComponent, LoadingSpinnerComponent],
+  imports: [CommonModule, NgxEchartsModule, BoardTabsComponent, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, CrudTableWorkbenchComponent, LoadingSpinnerComponent],
   template: `
     <section class="domestic-page">
       <app-page-header title="品类分析" eyebrow="Category Performance" description="按国内类目层级比较商品规模、成交贡献、转化效率和退款表现。" icon="◇"></app-page-header>
@@ -42,32 +47,69 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
           }
         </app-content-card>
 
-        <app-data-table-shell title="类目经营明细" [subtitle]="dimension === 'category2' ? '二级类目汇总' : '三级类目汇总'" [count]="rows.length" variant="analytics" [scrollable]="true">
-          <table class="analytics-table">
-            <thead><tr><th>类目</th><th class="numeric">商品数</th><th class="numeric">成交金额</th><th class="numeric">成交件数</th><th class="numeric">订单数</th><th class="numeric">转化率</th><th class="numeric">退款占比</th><th class="numeric">竞品关系</th></tr></thead>
+        <app-crud-table-workbench
+          title="类目经营明细"
+          [subtitle]="dimension === 'category2' ? '二级类目经营指标与竞品覆盖' : '三级类目经营指标与竞品覆盖'"
+          queryPlaceholder="搜索类目名称"
+          [query]="searchQuery"
+          [filters]="crudFilters"
+          [columns]="crudColumns"
+          [total]="filteredRows.length"
+          [page]="page"
+          [pageSize]="pageSize"
+          [density]="tableDensity"
+          [loading]="refreshing"
+          (queryChange)="onQueryChange($event)"
+          (filtersChange)="onFiltersChange($event)"
+          (columnsChange)="crudColumns = $event"
+          (densityChange)="tableDensity = $event"
+          (pageChange)="onPageChange($event)"
+          (pageSizeChange)="onPageSizeChange($event)"
+          (apply)="applyFilters(false)"
+          (reset)="resetFilters()"
+          (refresh)="refreshData()"
+          (exportRequested)="exportRows()">
+          <table class="analytics-table category-analysis-table">
+            <thead>
+              <tr>
+                @if (isColumnVisible('category')) { <th>类目</th> }
+                @if (isColumnVisible('products')) { <th class="numeric">商品数</th> }
+                @if (isColumnVisible('gmv')) { <th class="numeric">成交金额</th> }
+                @if (isColumnVisible('units')) { <th class="numeric">成交件数</th> }
+                @if (isColumnVisible('orders')) { <th class="numeric">订单数</th> }
+                @if (isColumnVisible('conversion')) { <th class="numeric">转化率</th> }
+                @if (isColumnVisible('refund')) { <th class="numeric">退款占比</th> }
+                @if (isColumnVisible('relations')) { <th class="numeric">竞品关系</th> }
+              </tr>
+            </thead>
             <tbody>
-              @for (row of rows; track row.name) {
+              @for (row of pagedRows; track row.name) {
                 <tr>
-                  <td><span class="row-title">{{ row.name }}</span></td>
-                  <td class="numeric">{{ number(row.productCount) }}</td>
-                  <td class="numeric">{{ currency(row.gmv) }}</td>
-                  <td class="numeric">{{ number(row.soldUnits) }}</td>
-                  <td class="numeric">{{ number(row.transactionOrders) }}</td>
-                  <td class="numeric">{{ percent(row.conversionRate) }}</td>
-                  <td class="numeric">{{ percent(row.refundToGmvRate) }}</td>
-                  <td class="numeric">{{ number(row.relationCount) }}</td>
+                  @if (isColumnVisible('category')) { <td><span class="row-title">{{ row.name }}</span></td> }
+                  @if (isColumnVisible('products')) { <td class="numeric">{{ number(row.productCount) }}</td> }
+                  @if (isColumnVisible('gmv')) { <td class="numeric strong-number">{{ currency(row.gmv) }}</td> }
+                  @if (isColumnVisible('units')) { <td class="numeric">{{ number(row.soldUnits) }}</td> }
+                  @if (isColumnVisible('orders')) { <td class="numeric">{{ number(row.transactionOrders) }}</td> }
+                  @if (isColumnVisible('conversion')) { <td class="numeric">{{ percent(row.conversionRate) }}</td> }
+                  @if (isColumnVisible('refund')) {
+                    <td class="numeric"><span class="risk-value" [ngClass]="refundRiskClass(row.refundToGmvRate)">{{ percent(row.refundToGmvRate) }}</span></td>
+                  }
+                  @if (isColumnVisible('relations')) {
+                    <td class="numeric"><span class="coverage-value" [class.covered]="row.relationCount > 0">{{ number(row.relationCount) }}</span></td>
+                  }
                 </tr>
               }
             </tbody>
           </table>
-        </app-data-table-shell>
+        </app-crud-table-workbench>
       }
     </section>
   `,
-  styleUrls: ['./domestic-page.shared.scss'],
+  styleUrls: ['./domestic-page.shared.scss', './category-analysis.component.scss'],
 })
 export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
   private readonly analytics = inject(DomesticAnalyticsService);
+  private readonly dataset = inject(DomesticDatasetService);
   private readonly cdr = inject(ChangeDetectorRef);
   private readonly destroy$ = new Subject<void>();
   private category2Rows: DomesticCategorySummary[] = [];
@@ -80,6 +122,43 @@ export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
   dimension: 'category2' | 'category3' = 'category2';
   loading = true;
   rows: DomesticCategorySummary[] = [];
+  filteredRows: DomesticCategorySummary[] = [];
+  searchQuery = '';
+  page = 1;
+  pageSize = 20;
+  tableDensity: 'default' | 'compact' = 'default';
+  refreshing = false;
+  crudFilters: CrudTableFilter[] = [
+    {
+      key: 'refundRisk',
+      label: '退款风险',
+      value: '',
+      options: [
+        { value: 'high', label: '高风险(≥ 10%)' },
+        { value: 'medium', label: '需关注(3% - 10%)' },
+        { value: 'low', label: '健康(< 3%)' },
+      ],
+    },
+    {
+      key: 'coverage',
+      label: '竞品覆盖',
+      value: '',
+      options: [
+        { value: 'covered', label: '已有竞品关系' },
+        { value: 'uncovered', label: '尚未配置竞品' },
+      ],
+    },
+  ];
+  crudColumns: CrudTableColumn[] = [
+    { key: 'category', label: '类目', visible: true, locked: true },
+    { key: 'products', label: '商品数', visible: true },
+    { key: 'gmv', label: '成交金额', visible: true, locked: true },
+    { key: 'units', label: '成交件数', visible: true },
+    { key: 'orders', label: '订单数', visible: true },
+    { key: 'conversion', label: '转化率', visible: true },
+    { key: 'refund', label: '退款占比', visible: true },
+    { key: 'relations', label: '竞品关系', visible: true },
+  ];
   totalProducts = 0;
   totalGmv = 0;
   totalRelations = 0;
@@ -95,6 +174,7 @@ export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
         this.allRelations = analysis.relationCount;
         this.refreshRows();
         this.loading = false;
+        this.refreshing = false;
       });
   }
 
@@ -122,12 +202,101 @@ export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
     return `${((value || 0) * 100).toFixed(1)}%`;
   }
 
+  get pagedRows(): DomesticCategorySummary[] {
+    const start = (this.page - 1) * this.pageSize;
+    return this.filteredRows.slice(start, start + this.pageSize);
+  }
+
+  onQueryChange(value: string): void {
+    this.searchQuery = value || '';
+    this.applyFilters();
+  }
+
+  onFiltersChange(filters: CrudTableFilter[]): void {
+    this.crudFilters = filters;
+    this.applyFilters();
+  }
+
+  onPageChange(page: number): void {
+    this.page = page;
+  }
+
+  onPageSizeChange(pageSize: number): void {
+    this.pageSize = pageSize;
+    this.page = 1;
+  }
+
+  resetFilters(): void {
+    this.searchQuery = '';
+    this.crudFilters = this.crudFilters.map((filter) => ({ ...filter, value: '' }));
+    this.applyFilters();
+  }
+
+  refreshData(): void {
+    this.refreshing = true;
+    this.dataset.refresh();
+  }
+
+  isColumnVisible(key: string): boolean {
+    return this.crudColumns.find((column) => column.key === key)?.visible !== false;
+  }
+
+  refundRiskClass(rate: number): string {
+    if (rate >= 0.1) return 'high';
+    if (rate >= 0.03) return 'medium';
+    return 'low';
+  }
+
+  exportRows(): void {
+    const headers = ['类目', '商品数', '成交金额', '成交件数', '订单数', '转化率', '退款占比', '竞品关系'];
+    const values = this.filteredRows.map((row) => [
+      row.name,
+      row.productCount,
+      row.gmv,
+      row.soldUnits,
+      row.transactionOrders,
+      this.percent(row.conversionRate),
+      this.percent(row.refundToGmvRate),
+      row.relationCount,
+    ]);
+    const csv = [headers, ...values]
+      .map((line) => line.map((value) => `"${String(value).replace(/"/g, '""')}"`).join(','))
+      .join('\r\n');
+    const link = document.createElement('a');
+    link.href = URL.createObjectURL(new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' }));
+    link.download = `类目经营明细-${new Date().toISOString().slice(0, 10)}.csv`;
+    link.click();
+    URL.revokeObjectURL(link.href);
+  }
+
+  applyFilters(resetPage = true): void {
+    const query = this.searchQuery.trim().toLowerCase();
+    const values = new Map(this.crudFilters.map((filter) => [filter.key, filter.value]));
+    this.filteredRows = this.rows.filter((row) => {
+      const risk = values.get('refundRisk');
+      const coverage = values.get('coverage');
+      const matchesRisk = !risk
+        || (risk === 'high' && row.refundToGmvRate >= 0.1)
+        || (risk === 'medium' && row.refundToGmvRate >= 0.03 && row.refundToGmvRate < 0.1)
+        || (risk === 'low' && row.refundToGmvRate < 0.03);
+      const matchesCoverage = !coverage
+        || (coverage === 'covered' && row.relationCount > 0)
+        || (coverage === 'uncovered' && row.relationCount === 0);
+      return (!query || row.name.toLowerCase().includes(query)) && matchesRisk && matchesCoverage;
+    });
+    if (resetPage) this.page = 1;
+    const pageCount = Math.max(1, Math.ceil(this.filteredRows.length / this.pageSize));
+    this.page = Math.min(this.page, pageCount);
+    this.cdr.markForCheck();
+  }
+
   private refreshRows(): void {
     this.rows = this.dimension === 'category2' ? this.category2Rows : this.category3Rows;
     this.totalProducts = this.rows.reduce((sum, row) => sum + row.productCount, 0);
     this.totalGmv = this.rows.reduce((sum, row) => sum + row.gmv, 0);
     this.totalRelations = this.rows.reduce((sum, row) => sum + row.relationCount, 0);
     this.chartOptions = this.buildChartOptions(this.rows.slice(0, 12));
+    this.applyFilters();
     this.cdr.markForCheck();
   }
 

+ 535 - 61
src/modules/domestic-voc/competitor-detail.component.scss

@@ -3,50 +3,52 @@
   min-width: 0;
 }
 
-.competitor-hero {
+.competitor-detail-shell {
   display: grid;
-  grid-template-columns: 220px minmax(0, 1fr);
-  gap: 28px;
-  align-items: center;
-  min-height: 250px;
-  padding: 24px 0;
-  border-top: 1px solid #e6eaf0;
-  border-bottom: 1px solid #e6eaf0;
+  grid-template-columns: 264px minmax(0, 1fr);
+  gap: 18px;
+  align-items: start;
+}
+
+.competitor-product-rail {
+  position: sticky;
+  top: 76px;
+  min-width: 0;
+  padding: 14px;
+  border: 1px solid #e5eaf1;
+  border-radius: 8px;
   background: #fff;
+  box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
 }
 
-.competitor-media {
+.rail-product-image {
   display: grid;
   place-items: center;
-  width: 220px;
-  height: 220px;
+  height: 226px;
   overflow: hidden;
-  border: 1px solid #e6eaf0;
-  border-radius: 6px;
+  border: 1px solid #e5eaf1;
+  border-radius: 8px;
   background: #f8fafc;
 }
 
-.competitor-media img {
+.rail-product-image img {
   display: block;
   width: 100%;
   height: 100%;
   object-fit: contain;
 }
 
-.competitor-hero-copy {
-  min-width: 0;
-}
-
-.competitor-kicker {
+.rail-kicker {
   display: flex;
-  gap: 8px;
+  gap: 7px;
   align-items: center;
+  margin-top: 14px;
   color: #64748b;
   font-size: 12px;
   font-weight: 700;
 }
 
-.competitor-kicker > span:first-child {
+.rail-kicker > span:first-child {
   color: #1d4ed8;
 }
 
@@ -57,28 +59,126 @@
   background: #16a34a;
 }
 
-.competitor-hero h2 {
-  max-width: 900px;
-  margin: 12px 0 8px;
+.competitor-product-rail h2 {
+  display: -webkit-box;
+  margin: 9px 0 0;
+  overflow: hidden;
+  color: #111827;
+  font-size: 15px;
+  line-height: 1.55;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 3;
+}
+
+.rail-tags,
+.feature-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-top: 12px;
+}
+
+.rail-tags span,
+.feature-tags span {
+  padding: 4px 8px;
+  border-radius: 999px;
+  background: #f1f5f9;
+  color: #475569;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.feature-tags span.active {
+  background: #dcfce7;
+  color: #15803d;
+}
+
+.rail-meta-list {
+  display: grid;
+  gap: 0;
+  margin: 14px 0 0;
+}
+
+.rail-meta-list > div {
+  display: grid;
+  grid-template-columns: 74px minmax(0, 1fr);
+  gap: 8px;
+  padding: 9px 0;
+  border-top: 1px solid #edf1f5;
+}
+
+.rail-meta-list dt {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.rail-meta-list dd {
+  min-width: 0;
+  margin: 0;
+  overflow: hidden;
+  color: #1f2937;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  font-weight: 700;
+  text-align: right;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rail-actions {
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
+  margin-top: 14px;
+}
+
+.competitor-detail-workspace {
+  display: grid;
+  gap: 24px;
+  min-width: 0;
+}
+
+.competitor-overview-card {
+  overflow: hidden;
+  border: 1px solid #e5eaf1;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
+}
+
+.overview-copy {
+  min-width: 0;
+  padding: 20px 20px 16px;
+}
+
+.overview-eyebrow {
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 800;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.overview-copy h2 {
+  margin: 8px 0 5px;
   color: #111827;
-  font-size: 24px;
-  line-height: 1.35;
-  letter-spacing: 0;
+  font-size: 20px;
+  line-height: 1.4;
 }
 
-.competitor-hero p {
-  max-width: 860px;
+.overview-copy p,
+.feature-copy {
   margin: 0;
   color: #64748b;
-  font-size: 13px;
+  font-size: 12px;
   line-height: 1.7;
 }
 
 .competitor-meta {
   display: flex;
   flex-wrap: wrap;
-  gap: 10px 18px;
-  margin-top: 20px;
+  gap: 8px 18px;
+  margin-top: 12px;
 }
 
 .competitor-meta span {
@@ -91,6 +191,96 @@
   color: #1f2937;
 }
 
+.detail-navigation {
+  display: block;
+  padding: 0 12px 12px;
+}
+
+.detail-tab-panel {
+  display: grid;
+  gap: 16px;
+  min-width: 0;
+}
+
+.detail-tab-panel[hidden] {
+  display: none;
+}
+
+.linked-products-table {
+  table-layout: fixed;
+}
+
+.linked-products-table th:first-child {
+  width: 42%;
+}
+
+.review-summary-strip {
+  display: grid;
+  grid-template-columns: minmax(260px, 1fr) minmax(440px, 1.35fr);
+  gap: 18px;
+  align-items: center;
+  padding: 18px 20px;
+  border: 1px solid #dbe5f2;
+  border-radius: 8px;
+  background: linear-gradient(135deg, #f8fbff 0%, #fff 70%);
+  box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
+}
+
+.review-summary-copy > span {
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 800;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.review-summary-copy h3 {
+  margin: 4px 0 3px;
+  color: #172033;
+  font-size: 18px;
+}
+
+.review-summary-copy p {
+  margin: 0;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.review-summary-metrics {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  overflow: hidden;
+  border: 1px solid #e6ebf2;
+  border-radius: 8px;
+  background: #fff;
+}
+
+.review-summary-metrics > div {
+  min-width: 0;
+  padding: 12px 10px;
+  border-right: 1px solid #e6ebf2;
+  text-align: center;
+}
+
+.review-summary-metrics > div:last-child {
+  border-right: 0;
+}
+
+.review-summary-metrics span {
+  display: block;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.review-summary-metrics strong {
+  display: block;
+  margin-top: 4px;
+  color: #1f2937;
+  font-size: 30px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+
 .rating-list {
   display: grid;
   gap: 14px;
@@ -124,23 +314,45 @@
   text-align: right;
 }
 
-.review-table {
-  min-width: 700px;
+.review-filter-panel,
+.review-filter-panel app-board-tabs {
+  display: block;
 }
 
-.review-table .review-content {
-  min-width: 360px;
-  max-width: 720px;
-  padding-top: 12px;
-  padding-bottom: 12px;
-  line-height: 1.6;
-  white-space: normal;
+.review-filter-panel .filter-section {
+  min-width: min(100%, 360px);
 }
 
-.review-rating {
-  color: #a16207;
+.review-filter-panel .filter-label {
+  display: block;
+  margin-bottom: 2px;
+  color: #64748b;
+  font-size: 12px;
   font-weight: 700;
-  white-space: nowrap;
+}
+
+.review-card-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 16px;
+}
+
+/* 评论证据趋势图容器高度(shared .chart 默认 310px,此处收敛为 280px) */
+.review-analytics-grid .chart {
+  height: 280px;
+}
+
+.more-reviews-btn {
+  display: block;
+  margin: 14px auto 0;
+  padding: 8px 14px;
+  border: 1px solid #d8e1ec;
+  border-radius: 8px;
+  background: #fff;
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 700;
+  cursor: pointer;
 }
 
 .boundary-list {
@@ -157,38 +369,300 @@
   color: #1f2937;
 }
 
+.review-ai-panel {
+  display: block;
+  padding: 20px;
+  border: 1px solid #e6eaf0;
+  border-radius: 8px;
+  background: #fff;
+}
+
+// ── 本品 vs 竞品 对比面板 ──
+.own-vs-card {
+  overflow: hidden;
+}
+
+.vs-grid {
+  display: grid;
+  grid-template-columns: 1fr 64px 1fr;
+  gap: 12px;
+  align-items: stretch;
+}
+
+.vs-col {
+  min-width: 0;
+  padding: 14px;
+  border: 1px solid #e6ebf2;
+  border-radius: 8px;
+  background: #fafbfd;
+}
+
+.vs-competitor {
+  background: linear-gradient(135deg, #f8fbff 0%, #fff 70%);
+}
+
+.vs-col-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
+.vs-col-title strong {
+  min-width: 0;
+  overflow: hidden;
+  color: #1f2937;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.vs-tag {
+  display: inline-flex;
+  align-items: center;
+  flex: none;
+  padding: 3px 8px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.vs-tag.own {
+  background: #dcfce7;
+  color: #15803d;
+}
+
+.vs-tag.comp {
+  background: #dbeafe;
+  color: #1d4ed8;
+}
+
+.vs-list {
+  display: grid;
+  gap: 0;
+  margin: 0;
+}
+
+.vs-list > div {
+  display: grid;
+  grid-template-columns: 88px minmax(0, 1fr);
+  gap: 10px;
+  align-items: center;
+  padding: 8px 0;
+  border-top: 1px solid #edf1f5;
+}
+
+.vs-list > div:first-child {
+  border-top: 0;
+}
+
+.vs-list dt {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.vs-list dd {
+  min-width: 0;
+  margin: 0;
+  overflow: hidden;
+  color: #1f2937;
+  font-size: 12px;
+  font-weight: 700;
+  text-align: right;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.vs-axis {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.vs-axis span {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 44px;
+  height: 44px;
+  border-radius: 50%;
+  background: #eef2f7;
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 800;
+  letter-spacing: 0.05em;
+}
+
+.vs-diff-row {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-top: 12px;
+  padding: 10px 12px;
+  border: 1px dashed #d8e1ec;
+  border-radius: 8px;
+  background: #fff;
+}
+
+.vs-diff-label {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.vs-note {
+  margin: 12px 0 0;
+  color: #94a3b8;
+  font-size: 12px;
+  line-height: 1.7;
+}
+
+// ── 差异徽章(红/绿/中性)──
+.diff-badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 3px 9px;
+  border-radius: 999px;
+  border: 1px solid transparent;
+  font-size: 12px;
+  font-weight: 700;
+  white-space: nowrap;
+
+  &.higher {
+    background: #fee2e2;
+    color: #b91c1c;
+    border-color: rgba(185, 28, 28, 0.16);
+  }
+
+  &.lower {
+    background: #dcfce7;
+    color: #15803d;
+    border-color: rgba(21, 128, 61, 0.16);
+  }
+
+  &.neutral {
+    background: #f3f4f6;
+    color: #475569;
+    border-color: rgba(71, 85, 105, 0.14);
+  }
+}
+
+.linked-products-table .diff-badge {
+  padding: 2px 7px;
+  font-size: 12px;
+}
+
+.table-foot-note {
+  display: block;
+  color: #94a3b8;
+  font-size: 12px;
+  line-height: 1.7;
+}
+
+@media (max-width: 1180px) {
+  .competitor-detail-shell {
+    grid-template-columns: 230px minmax(0, 1fr);
+  }
+
+  .rail-product-image {
+    height: 190px;
+  }
+
+  .review-summary-strip {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 900px) {
+  .competitor-detail-shell {
+    grid-template-columns: 1fr;
+  }
+
+  .competitor-product-rail {
+    position: static;
+    display: grid;
+    grid-template-columns: 112px minmax(0, 1fr);
+    gap: 0 14px;
+  }
+
+  .rail-product-image {
+    grid-row: 1 / 6;
+    width: 112px;
+    height: 112px;
+  }
+
+  .rail-kicker,
+  .competitor-product-rail h2,
+  .rail-tags,
+  .rail-meta-list,
+  .rail-actions {
+    grid-column: 2;
+  }
+
+  .rail-meta-list {
+    display: none;
+  }
+
+  .review-card-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
 @media (max-width: 720px) {
-  .competitor-hero {
-    grid-template-columns: 96px minmax(0, 1fr);
-    gap: 14px;
-    min-height: 0;
-    padding: 18px 0;
+  .competitor-product-rail {
+    grid-template-columns: 88px minmax(0, 1fr);
+  }
+
+  .rail-product-image {
+    width: 88px;
+    height: 88px;
   }
 
-  .competitor-media {
-    width: 96px;
-    height: 96px;
+  .overview-copy {
+    padding: 16px;
   }
 
-  .competitor-hero h2 {
+  .overview-copy h2 {
     display: -webkit-box;
-    margin-top: 8px;
     overflow: hidden;
     font-size: 16px;
     -webkit-box-orient: vertical;
     -webkit-line-clamp: 3;
   }
 
-  .competitor-hero p {
-    display: none;
+  .review-summary-metrics {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .review-summary-metrics > div:nth-child(2) {
+    border-right: 0;
+  }
+
+  .review-summary-metrics > div:nth-child(-n + 2) {
+    border-bottom: 1px solid #e6ebf2;
+  }
+
+  .review-ai-panel {
+    padding: 14px;
+  }
+}
+
+@media (max-width: 900px) {
+  .vs-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .vs-axis {
+    min-height: 40px;
   }
 
-  .competitor-meta {
-    grid-column: 1 / -1;
-    margin-top: 12px;
+  .vs-axis span {
+    width: 36px;
+    height: 36px;
   }
 
-  .competitor-meta span {
-    flex: 1 1 140px;
+  .vs-diff-row {
+    align-items: flex-start;
   }
 }

+ 464 - 123
src/modules/domestic-voc/competitor-detail.component.ts

@@ -14,6 +14,14 @@ import { NeuButtonComponent } from '../shared/components/neu-button/neu-button.c
 import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
 import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
+import { AiAnalysisPanelComponent } from '../shared/components/ai-analysis-panel/ai-analysis-panel.component';
+import { AiChatModalComponent } from '../shared/components/ai-chat-modal/ai-chat-modal.component';
+import { BoardTabItem, BoardTabsComponent } from '../shared/components/board-tabs/board-tabs.component';
+import { FilterPanelComponent } from '../shared/components/filter-panel/filter-panel.component';
+import { AllReviewsModalComponent } from '../shared/components/all-reviews-modal/all-reviews-modal.component';
+import { ReviewCardComponent } from '../shared/components/review-card/review-card.component';
+import { ReviewDetailModalComponent } from '../shared/components/review-detail-modal/review-detail-modal.component';
+import { ReviewDataUtils } from '../shared/services/review-data-utils';
 
 interface RatingRow {
   rating: number;
@@ -21,6 +29,11 @@ interface RatingRow {
   width: number;
 }
 
+interface OwnComparisonRow {
+  own: DomesticProduct;
+  diff: { label: string; cls: string; title: string };
+}
+
 @Component({
   selector: 'app-domestic-competitor-detail',
   standalone: true,
@@ -35,6 +48,13 @@ interface RatingRow {
     LoadingSpinnerComponent,
     NeuButtonComponent,
     ProductIdentityComponent,
+    AiAnalysisPanelComponent,
+    AiChatModalComponent,
+    BoardTabsComponent,
+    FilterPanelComponent,
+    AllReviewsModalComponent,
+    ReviewCardComponent,
+    ReviewDetailModalComponent,
   ],
   template: `
     <section class="domestic-page competitor-detail-page">
@@ -44,130 +64,213 @@ interface RatingRow {
         description="复用原竞品详情框架,展示国内平台商品档案、关联本品与真实评论证据。"
         icon="◇">
         <app-neu-button pageHeaderActions size="sm" (btnClick)="goBack()">返回竞品关系</app-neu-button>
-        <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="goVoc()">查看竞品 VOC</app-neu-button>
+        <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="setActiveDetailTab('reviews')">评价工作台</app-neu-button>
       </app-page-header>
 
       @if (product && dataset) {
-        <section class="competitor-hero" aria-label="竞品商品信息">
-          <div class="competitor-media">
-            <img [src]="product.detail?.imageUrl || fallbackImage" [alt]="product.title" (error)="useFallback($event)" />
-          </div>
-          <div class="competitor-hero-copy">
-            <div class="competitor-kicker"><span>京东竞品</span><span class="status-dot"></span>{{ statusLabel }}</div>
-            <h2>{{ product.title }}</h2>
-            <p>{{ product.detail?.sellPoint || '商品详情、规格与评论均来自公司电商数据中转。' }}</p>
-            <div class="competitor-meta">
-              <span>品牌 <strong>{{ product.brand || '-' }}</strong></span>
-              <span>型号 <strong>{{ product.model || '-' }}</strong></span>
-              <span>商品ID <strong>{{ product.productId }}</strong></span>
-              <span>类目 <strong>{{ product.category3 || product.category2 || '-' }}</strong></span>
+        <div class="competitor-detail-shell">
+          <aside class="competitor-product-rail" aria-label="竞品商品上下文">
+            <div class="rail-product-image">
+              <img [src]="product.detail?.imageUrl || fallbackImage" [alt]="product.title" (error)="useFallback($event)" />
             </div>
-          </div>
-        </section>
+            <div class="rail-kicker"><span>京东竞品</span><span class="status-dot"></span>{{ statusLabel }}</div>
+            <h2 [title]="product.title">{{ product.title }}</h2>
+            <div class="rail-tags"><span>{{ product.brand || '未知品牌' }}</span><span>jd</span><span>{{ product.category3 || product.category2 || '未分类' }}</span></div>
+            <dl class="rail-meta-list">
+              <div><dt>商品 ID</dt><dd>{{ product.productId }}</dd></div>
+              <div><dt>型号</dt><dd>{{ product.model || '-' }}</dd></div>
+              <div><dt>当前价</dt><dd>{{ currentPriceLabel }}</dd></div>
+              <div><dt>销量信号</dt><dd [title]="salesSignal">{{ salesSignal }}</dd></div>
+              <div><dt>有效评论</dt><dd>{{ number(reviews.length) }} 条</dd></div>
+              <div><dt>关联本品</dt><dd>{{ number(ownProducts.length) }} 个</dd></div>
+            </dl>
+            <div class="rail-actions">
+              <app-neu-button size="sm" color="primary" (btnClick)="setActiveDetailTab('reviews')">查看评价</app-neu-button>
+              <app-neu-button size="sm" (btnClick)="goBack()">竞品关系</app-neu-button>
+            </div>
+          </aside>
 
-        <div class="metric-grid">
-          <app-summary-metric-card label="已采评论" [value]="number(reviews.length)" icon="□" tone="blue" description="当前已入库真实评论样本"></app-summary-metric-card>
-          <app-summary-metric-card label="平均评分" [value]="averageRating ? averageRating.toFixed(1) : '-'" unit="/ 5" icon="★" tone="green" [sampleSize]="ratedReviews.length"></app-summary-metric-card>
-          <app-summary-metric-card label="正向评论" [value]="percent(positiveRate)" icon="↑" tone="purple" description="按 4-5 星样本计算"></app-summary-metric-card>
-          <app-summary-metric-card label="关联本品" [value]="number(ownProducts.length)" icon="⇄" tone="amber" [description]="number(relations.length) + ' 条映射关系'"></app-summary-metric-card>
-        </div>
+          <div class="competitor-detail-workspace">
+            <section class="competitor-overview-card">
+              <div class="overview-copy">
+                <span class="overview-eyebrow">Domestic Competitor Overview</span>
+                <h2>{{ product.title }}</h2>
+                <p>{{ product.detail?.sellPoint || '商品详情、规格与评论均来自公司电商数据中转。' }}</p>
+                <div class="competitor-meta">
+                  <span>品牌 <strong>{{ product.brand || '-' }}</strong></span>
+                  <span>型号 <strong>{{ product.model || '-' }}</strong></span>
+                  <span>类目 <strong>{{ product.category3 || product.category2 || '-' }}</strong></span>
+                  <span>店铺 <strong>{{ product.market?.shopName || '-' }}</strong></span>
+                </div>
+              </div>
+              <app-board-tabs class="detail-navigation" [tabs]="detailTabs" [activeKey]="activeDetailTab" density="compact" (tabChange)="setActiveDetailTab($event)"></app-board-tabs>
+            </section>
 
-        <div class="detail-grid">
-          <app-content-card title="商品档案" subtitle="商品身份、类目与规格字段" tone="blue">
-            <dl class="info-list">
-              <div><dt>品牌 / 型号</dt><dd>{{ product.brand || '-' }} / {{ product.model || '-' }}</dd></div>
-              <div><dt>颜色规格</dt><dd>{{ product.detail?.color || product.detail?.specification || '-' }}</dd></div>
-              <div><dt>商品产地</dt><dd>{{ product.detail?.origin || '-' }}</dd></div>
-              <div><dt>重量</dt><dd>{{ weightLabel }}</dd></div>
-              <div><dt>包装尺寸</dt><dd>{{ dimensionsLabel }}</dd></div>
-              <div><dt>上架时间</dt><dd>{{ date(product.detail?.saleDate) }}</dd></div>
-            </dl>
-          </app-content-card>
-          <app-content-card title="店铺与履约" subtitle="平台商家和配送能力快照" tone="green">
-            <dl class="info-list">
-              <div><dt>店铺 ID</dt><dd>{{ product.detail?.shopId || '-' }}</dd></div>
-              <div><dt>商家 ID</dt><dd>{{ product.detail?.sellerId || '-' }}</dd></div>
-              <div><dt>商品状态</dt><dd>{{ statusLabel }}</dd></div>
-              <div><dt>京东配送</dt><dd>{{ product.detail?.jdExpress ? '支持' : '未标记' }}</dd></div>
-              <div><dt>即时零售</dt><dd>{{ product.detail?.localDelivery ? '支持' : '未标记' }}</dd></div>
-              <div><dt>详情采集</dt><dd>{{ dateTime(product.detail?.collectedAt) }}</dd></div>
-            </dl>
-          </app-content-card>
-        </div>
+            <section class="detail-tab-panel" [hidden]="activeDetailTab !== 'overview'">
+              <div class="metric-grid">
+                <app-summary-metric-card label="当前价" [value]="currentPriceLabel" icon="元" tone="amber" description="品牌品类检索快照"></app-summary-metric-card>
+                <app-summary-metric-card label="销量信号" [value]="salesSignal" icon="↗" tone="purple" description="平台公开销量文案"></app-summary-metric-card>
+                <app-summary-metric-card label="有效评论" [value]="number(reviews.length)" icon="□" tone="blue" [description]="reviewQualityDescription"></app-summary-metric-card>
+                <app-summary-metric-card label="平均评分" [value]="averageRating ? averageRating.toFixed(1) : '-'" unit="/ 5" icon="★" tone="green" [sampleSize]="ratedReviews.length"></app-summary-metric-card>
+                <app-summary-metric-card label="4-5 星占比" [value]="percent(positiveRate)" icon="↑" tone="purple" description="评分分组,不等同正文情绪"></app-summary-metric-card>
+                <app-summary-metric-card label="关联本品" [value]="number(ownProducts.length)" icon="⇄" tone="amber" [description]="number(relations.length) + ' 条映射关系'"></app-summary-metric-card>
+              </div>
 
-        <div class="detail-grid">
-          <app-content-card title="评论证据趋势" subtitle="按评论日期聚合当前样本的平均评分和评论数" tone="purple">
-            @if (hasReviewTrend) {
-              <div echarts class="chart" [options]="reviewTrendOptions"></div>
-            } @else {
-              <app-empty-state size="sm" variant="plain" icon="⌁" title="暂无可绘制的评论趋势" description="评论日期不足时不生成推断曲线。"></app-empty-state>
-            }
-          </app-content-card>
-          <app-content-card title="评分分布" subtitle="仅统计已采评论样本" tone="amber">
-            @if (ratedReviews.length) {
-              <div class="rating-list">
-                @for (row of ratingRows; track row.rating) {
-                  <div class="rating-row">
-                    <span>{{ row.rating }} 星</span>
-                    <span class="rating-track"><span class="rating-fill" [style.width.%]="row.width"></span></span>
-                    <strong>{{ row.count }}</strong>
+              <app-content-card class="own-vs-card" title="本品关联均值 vs 竞品" subtitle="关联本品的经营汇总与竞品市场快照并排对比;仅价格同量纲可计算差异" tone="blue">
+                <div class="vs-grid">
+                  <div class="vs-col vs-own">
+                    <div class="vs-col-title"><span class="vs-tag own">本品关联</span><strong>{{ number(ownProducts.length) }} 个德玛仕商品</strong></div>
+                    <dl class="vs-list">
+                      <div><dt>成交均价</dt><dd>{{ ownAvgUnitPriceLabel }}</dd></div>
+                      <div><dt>成交金额</dt><dd>{{ currency(ownLinkedGmv) }}</dd></div>
+                      <div><dt>成交件数</dt><dd>{{ number(ownLinkedSoldUnits) }} 件</dd></div>
+                      <div><dt>数据口径</dt><dd>内部经营数据</dd></div>
+                    </dl>
+                  </div>
+                  <div class="vs-axis" aria-hidden="true"><span>VS</span></div>
+                  <div class="vs-col vs-competitor">
+                    <div class="vs-col-title"><span class="vs-tag comp">竞品</span><strong>{{ competitorBrandLabel }}</strong></div>
+                    <dl class="vs-list">
+                      <div><dt>当前价</dt><dd>{{ competitorPriceLabel }}</dd></div>
+                      <div><dt>销量信号</dt><dd [title]="salesSignal">{{ salesSignal }}</dd></div>
+                      <div><dt>成交经营数据</dt><dd>无同量纲数据</dd></div>
+                      <div><dt>数据口径</dt><dd>市场搜索快照</dd></div>
+                    </dl>
                   </div>
+                </div>
+                <div class="vs-diff-row">
+                  <span class="vs-diff-label">参考价差异(本品成交均价·内部经营数据 vs 竞品挂牌价·市场快照)</span>
+                  <span class="diff-badge" [ngClass]="ownAvgVsCompetitor.cls">{{ ownAvgVsCompetitor.label }}</span>
+                </div>
+                <p class="vs-note">仅「参考价」为同量纲可比(均为 元/件,本品为成交均价、竞品为挂牌价快照);竞品无成交金额/件数经营数据,销量为平台文案区间,故其余指标仅并列展示、不计算百分比差异。</p>
+              </app-content-card>
+
+              <div class="detail-grid">
+                <app-content-card title="商品档案" subtitle="商品身份、类目与规格字段" tone="blue">
+                  <dl class="info-list">
+                    <div><dt>品牌 / 型号</dt><dd>{{ product.brand || '-' }} / {{ product.model || '-' }}</dd></div>
+                    <div><dt>颜色规格</dt><dd>{{ product.detail?.color || product.detail?.specification || '-' }}</dd></div>
+                    <div><dt>商品产地</dt><dd>{{ product.detail?.origin || '-' }}</dd></div>
+                    <div><dt>重量</dt><dd>{{ weightLabel }}</dd></div>
+                    <div><dt>包装尺寸</dt><dd>{{ dimensionsLabel }}</dd></div>
+                    <div><dt>上架时间</dt><dd>{{ date(product.detail?.saleDate) }}</dd></div>
+                  </dl>
+                </app-content-card>
+                <app-content-card title="店铺与履约" subtitle="平台商家和配送能力快照" tone="green">
+                  <dl class="info-list">
+                    <div><dt>店铺名称</dt><dd>{{ product.market?.shopName || '-' }}</dd></div>
+                    <div><dt>店铺 ID</dt><dd>{{ product.detail?.shopId || '-' }}</dd></div>
+                    <div><dt>商家 ID</dt><dd>{{ product.detail?.sellerId || '-' }}</dd></div>
+                    <div><dt>商品状态</dt><dd>{{ statusLabel }}</dd></div>
+                    <div><dt>京东配送</dt><dd>{{ product.detail?.jdExpress ? '支持' : '未标记' }}</dd></div>
+                    <div><dt>即时零售</dt><dd>{{ product.detail?.localDelivery ? '支持' : '未标记' }}</dd></div>
+                    <div><dt>详情采集</dt><dd>{{ dateTime(product.detail?.collectedAt) }}</dd></div>
+                  </dl>
+                </app-content-card>
+              </div>
+
+              <app-data-table-shell title="关联本品" subtitle="表格关系与品牌品类发现中对应的德玛仕商品" [count]="ownProducts.length" variant="admin" [empty]="ownProducts.length === 0" emptyTitle="暂无关联本品" [hasFooter]="true">
+                <table class="analytics-table linked-products-table">
+                  <thead><tr><th>德玛仕商品</th><th>映射类目</th><th class="numeric">成交金额</th><th class="numeric">成交件数</th><th title="本品成交均价(金额/件数) vs 竞品当前价,均为 元/件">对比竞品</th></tr></thead>
+                  <tbody>
+                    @for (row of ownComparisonRows; track row.own.productId) {
+                      <tr>
+                        <td><app-product-identity [showImage]="false" [title]="row.own.title" [productSku]="row.own.model" [asin]="row.own.productId" size="xs" [detailRoute]="['/voc-insight/detail']" [detailQueryParams]="{ productId: row.own.productId }"></app-product-identity></td>
+                        <td>{{ relationCategory(row.own.productId) }}</td>
+                        <td class="numeric">{{ currency(row.own.summary.gmv) }}</td>
+                        <td class="numeric">{{ number(row.own.summary.soldUnits) }}</td>
+                        <td><span class="diff-badge" [ngClass]="row.diff.cls" [title]="row.diff.title">{{ row.diff.label }}</span></td>
+                      </tr>
+                    }
+                  </tbody>
+                </table>
+                <ng-container dataTableFooter>
+                  <span class="table-foot-note">对比口径:本品成交均价 = 成交金额 ÷ 成交件数(内部经营数据);竞品当前价来自市场搜索快照。绿色 = 本品均价低于竞品当前价,红色 = 高于,仅反映相对定价、不构成优劣结论。竞品侧无成交金额/件数经营数据,故仅在本列做价格对比。</span>
+                </ng-container>
+              </app-data-table-shell>
+            </section>
+
+            <section class="detail-tab-panel review-workbench" [hidden]="activeDetailTab !== 'reviews'">
+              <div class="review-summary-strip">
+                <div class="review-summary-copy"><span>Review Intelligence</span><h3>评价健康度</h3><p>先查看评分与原始评论证据,再使用 AI 形成分析结论。</p></div>
+                <div class="review-summary-metrics">
+                  <div><span>有效评论</span><strong>{{ number(reviews.length) }}</strong></div>
+                  <div><span>平均评分</span><strong>{{ averageRating ? averageRating.toFixed(1) : '-' }}</strong></div>
+                  <div><span>4-5 星占比</span><strong>{{ percent(positiveRate) }}</strong></div>
+                  <div><span>过滤占位</span><strong>{{ number(filteredPlaceholderCount) }}</strong></div>
+                </div>
+              </div>
+
+              <div class="detail-grid review-analytics-grid">
+                <app-content-card title="评论证据趋势" subtitle="按评论日期聚合当前样本的平均评分和评论数" tone="purple">
+                  @if (hasReviewTrend && activeDetailTab === 'reviews') { <div echarts class="chart" [options]="reviewTrendOptions"></div> }
+                  @else { <app-empty-state size="sm" variant="plain" icon="⌁" title="暂无可绘制的评论趋势" description="评论日期不足时不生成推断曲线。"></app-empty-state> }
+                </app-content-card>
+                <app-content-card title="评分分布" subtitle="仅统计已过滤平台占位文案的有效评论" tone="amber">
+                  @if (ratedReviews.length) {
+                    <div class="rating-list">
+                      @for (row of ratingRows; track row.rating) {
+                        <div class="rating-row"><span>{{ row.rating }} 星</span><span class="rating-track"><span class="rating-fill" [style.width.%]="row.width"></span></span><strong>{{ row.count }}</strong></div>
+                      }
+                    </div>
+                  } @else { <app-empty-state size="sm" variant="plain" title="暂无评分样本"></app-empty-state> }
+                </app-content-card>
+              </div>
+
+              <app-filter-panel class="review-filter-panel" title="评论证据筛选" [description]="'当前展示 ' + number(filteredReviews.length) + ' / ' + number(reviews.length) + ' 条有效评论;筛选不改变总体统计。'" [showHeader]="true" density="compact" variant="toolbar">
+                <div class="filter-section"><span class="filter-label">星级</span><app-board-tabs [tabs]="ratingFilterTabs" [activeKey]="ratingFilter" density="compact" (tabChange)="setRatingFilter($event)"></app-board-tabs></div>
+                <div class="filter-section"><span class="filter-label">评分分组</span><app-board-tabs [tabs]="sentimentFilterTabs" [activeKey]="sentimentFilter" density="compact" (tabChange)="setSentimentFilter($event)"></app-board-tabs></div>
+              </app-filter-panel>
+
+              <app-content-card title="评论证据" [subtitle]="'仅展示有效文本;当前筛选 ' + filteredReviews.length + ' 条'" tone="blue" [hasHeaderActions]="true">
+                <app-neu-button contentCardActions size="sm" (btnClick)="openAllReviews()">查看全部评论</app-neu-button>
+                @if (filteredReviews.length) {
+                  <div class="review-card-grid">
+                    @for (review of filteredReviews.slice(0, 6); track review.reviewId) {
+                      <app-review-card [review]="review" [showDetailAction]="true" (cardClick)="openReview($event)" (detailClick)="openReview($event)"></app-review-card>
+                    }
+                  </div>
+                  @if (filteredReviews.length > 6) { <button type="button" class="more-reviews-btn" (click)="openAllReviews()">查看其余 {{ filteredReviews.length - 6 }} 条评论</button> }
+                } @else {
+                  <app-empty-state variant="low-sample" title="当前筛选下暂无评论证据" description="可切换星级或评分分组查看其他评论。"></app-empty-state>
                 }
+              </app-content-card>
+
+              @if (aiReviewPrompt && activeDetailTab === 'reviews') {
+                <section class="review-ai-panel" aria-label="AI 竞品评论分析">
+                  <app-ai-analysis-panel [prompt]="aiReviewPrompt" promptKey="domestic.competitorReview.system" [systemPrompt]="aiReviewSystemPrompt" title="AI 竞品评论可视化分析" [autoGenerate]="true" [usePageContext]="false" [showDataSourceAlert]="false" renderMode="visual-report" (openChat)="onOpenAiChat($event)"></app-ai-analysis-panel>
+                </section>
+              }
+            </section>
+
+            <section class="detail-tab-panel" [hidden]="activeDetailTab !== 'features'">
+              <div class="detail-grid">
+                <app-content-card title="商品卖点" subtitle="来自国内商品详情字段" tone="blue">
+                  @if (product.detail?.sellPoint) { <p class="feature-copy">{{ product.detail?.sellPoint }}</p> }
+                  @else { <app-empty-state size="sm" title="暂无结构化卖点" description="当前数据源没有返回可展示的卖点字段。"></app-empty-state> }
+                </app-content-card>
+                <app-content-card title="履约特征" subtitle="不推断接口未提供的能力" tone="green">
+                  <div class="feature-tags"><span [class.active]="product.detail?.jdExpress">京东配送</span><span [class.active]="product.detail?.localDelivery">即时零售</span><span [class.active]="statusLabel === '在售'">在售</span></div>
+                </app-content-card>
+              </div>
+            </section>
+
+            <section class="detail-tab-panel" [hidden]="activeDetailTab !== 'specs'">
+              <div class="detail-grid">
+                <app-content-card title="结构化规格" subtitle="国内商品详情字段" tone="blue">
+                  <dl class="info-list"><div><dt>颜色</dt><dd>{{ product.detail?.color || '-' }}</dd></div><div><dt>规格</dt><dd>{{ product.detail?.specification || '-' }}</dd></div><div><dt>重量</dt><dd>{{ weightLabel }}</dd></div><div><dt>包装尺寸</dt><dd>{{ dimensionsLabel }}</dd></div><div><dt>产地</dt><dd>{{ product.detail?.origin || '-' }}</dd></div></dl>
+                </app-content-card>
+                <app-content-card title="数据边界" subtitle="只展示已落库字段" tone="amber" variant="dashed">
+                  <ul class="boundary-list"><li><strong>已有:</strong>商品档案、图片、规格、履约、关联本品和评论。</li><li><strong>可计算:</strong>平均评分、评分分布和评论证据趋势。</li><li><strong>暂缺:</strong>价格、销量历史、利润、BSR、FBA 与变体历史。</li></ul>
+                </app-content-card>
               </div>
-            } @else {
-              <app-empty-state size="sm" variant="plain" title="暂无评分样本"></app-empty-state>
-            }
-          </app-content-card>
+            </section>
+          </div>
         </div>
 
-        <app-data-table-shell title="关联本品" subtitle="Excel 映射中与当前竞品对应的德玛仕商品" [count]="ownProducts.length" variant="admin" [empty]="ownProducts.length === 0" emptyTitle="暂无关联本品">
-          <table class="analytics-table">
-            <thead><tr><th>德玛仕商品</th><th>映射类目</th><th class="numeric">成交金额</th><th class="numeric">成交件数</th></tr></thead>
-            <tbody>
-              @for (own of ownProducts; track own.productId) {
-                <tr>
-                  <td>
-                    <app-product-identity
-                      [showImage]="false"
-                      [title]="own.title"
-                      [productSku]="own.model"
-                      [asin]="own.productId"
-                      size="xs"
-                      [detailRoute]="['/domestic/product']"
-                      [detailQueryParams]="{ productId: own.productId }">
-                    </app-product-identity>
-                  </td>
-                  <td>{{ relationCategory(own.productId) }}</td>
-                  <td class="numeric">{{ currency(own.summary.gmv) }}</td>
-                  <td class="numeric">{{ number(own.summary.soldUnits) }}</td>
-                </tr>
-              }
-            </tbody>
-          </table>
-        </app-data-table-shell>
-
-        <app-data-table-shell title="评论证据" subtitle="首批评论样本;不展示用户昵称、头像或其他身份字段" [count]="reviews.length" variant="admin" [empty]="reviews.length === 0" emptyTitle="暂无评论证据" emptyDescription="数据源返回评论后会在此展示。">
-          <table class="analytics-table review-table">
-            <thead><tr><th>评分</th><th>评论内容</th><th>评论时间</th></tr></thead>
-            <tbody>
-              @for (review of reviews; track review.reviewId) {
-                <tr>
-                  <td><span class="review-rating">{{ review.rating > 0 ? review.rating + ' 星' : '未评分' }}</span></td>
-                  <td class="review-content">{{ review.content }}</td>
-                  <td>{{ date(review.reviewDate) }}</td>
-                </tr>
-              }
-            </tbody>
-          </table>
-        </app-data-table-shell>
-
-        <app-content-card title="趋势与数据边界" subtitle="只展示接口已提供并已落库的数据" tone="amber" variant="dashed">
-          <ul class="boundary-list">
-            <li><strong>已有:</strong>商品档案、图片、规格、店铺履约、关联本品和首批评论。</li>
-            <li><strong>可计算:</strong>评论样本平均分、评分分布和按评论日期聚合的证据趋势。</li>
-            <li><strong>暂缺:</strong>价格接口当前不可用,销量与价格历史序列尚未入库,因此不展示价格或销量趋势。</li>
-          </ul>
-        </app-content-card>
+        <app-all-reviews-modal [visible]="showAllReviews" title="竞品评论证据" [asin]="product.productId" [reviews]="reviews" [showAiButton]="false" [showCacheInfo]="true" [showTranslateButton]="false" [showVpFilter]="false" (selectReview)="selectReviewFromModal($event)" (close)="showAllReviews = false"></app-all-reviews-modal>
+        <app-review-detail-modal [visible]="!!selectedReview" [review]="selectedReview" [showTranslateButton]="false" (close)="selectedReview = undefined"></app-review-detail-modal>
+        <app-ai-chat-modal [visible]="showAiChat" title="竞品评论 AI 深度分析" [contextPrompt]="aiChatContext" [reportText]="aiChatReportText" [systemPrompt]="aiReviewSystemPrompt" promptKey="domestic.competitorReview.system" [quickQuestions]="aiQuickQuestions" (close)="showAiChat = false"></app-ai-chat-modal>
       } @else if (loaded) {
         <app-empty-state variant="card" icon="◇" title="未找到竞品" description="该商品可能尚未完成竞品详情采集。" actionText="返回竞品关系" (action)="goBack()"></app-empty-state>
       } @else {
@@ -185,18 +288,59 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   private readonly destroy$ = new Subject<void>();
 
   readonly fallbackImage = 'assets/images/placeholder.svg';
+  readonly aiReviewSystemPrompt = [
+    '你是一名专业的国内电商 VOC 与竞品研究分析师。严格依据提供的商品档案、评论样本和关联数据作答,不得补造价格、销量、市场份额或历史趋势。',
+    '评分星级和评论正文必须分开判断。“4-5星占比”只能描述评分分组,不得改写成“正向评论率”或“正文情绪正向率”;如果评分与正文矛盾,必须明确标记为数据质量风险。',
+    '本次必须只返回一个合法 JSON 对象,不要输出 Markdown、代码块或额外说明。顶层字段必须是 visualReport。',
+    'visualReport 至少包含 title、blocks 和 summary。blocks 依次使用 metric-grid、chart、evidence-list、action-list;chart 使用 pie 或 bar,并且每个数值只能来自上下文。',
+    'metric-grid 的 metrics 只允许有效评论数、过滤占位数、有评分评论数、平均评分、4-5星占比这些可直接计算的指标,不得生成评价一致性、健康分、竞争力等推断指标。metrics 字段包含 label、value、description、tone;chart 包含 title、type、data;evidence-list 使用 evidenceItems;action-list 使用 actionItems。',
+    '可用 tone 仅限 navy、blue、green、orange、red、purple、slate。数据不足时明确写“数据不足”,仍返回合法 JSON。',
+  ].join('\n');
+  readonly aiQuickQuestions = [
+    '差评最集中的问题是什么?请按影响优先级排序。',
+    '哪些竞品弱点可以转化为德玛仕的产品改进机会?',
+    '基于真实评论,给出详情页卖点与客服话术建议。',
+    '现有样本还缺少哪些数据,下一批应优先采集什么?',
+  ];
+  readonly detailTabs: BoardTabItem[] = [
+    { key: 'overview', label: '概览', icon: '▦' },
+    { key: 'reviews', label: '评价', icon: '◉' },
+    { key: 'features', label: '特征', icon: '◇' },
+    { key: 'specs', label: '规格', icon: '≡' },
+  ];
+  activeDetailTab = 'overview';
   dataset?: DomesticDataset;
   product?: DomesticProduct;
+  selectedReview?: DomesticReview;
+  showAllReviews = false;
+  rawReviews: DomesticReview[] = [];
   reviews: DomesticReview[] = [];
   ratedReviews: DomesticReview[] = [];
   relations: DomesticDataset['relations'] = [];
   ownProducts: DomesticProduct[] = [];
+  ownComparisonRows: OwnComparisonRow[] = [];
   ratingRows: RatingRow[] = [];
   reviewTrendOptions: EChartsOption = {};
   averageRating = 0;
   positiveRate = 0;
   hasReviewTrend = false;
+  aiReviewPrompt = '';
+  showAiChat = false;
+  aiChatContext = '';
+  aiChatReportText = '';
   loaded = false;
+  ratingFilter = 'all';
+  sentimentFilter = 'all';
+  readonly ratingFilterTabs: BoardTabItem[] = [
+    { key: 'all', label: '全部' },
+    ...[5, 4, 3, 2, 1].map(rating => ({ key: String(rating), label: `${rating} 星` })),
+  ];
+  readonly sentimentFilterTabs: BoardTabItem[] = [
+    { key: 'all', label: '全部' },
+    { key: 'positive', label: '高分(4-5 星)' },
+    { key: 'neutral', label: '中评(3 星)' },
+    { key: 'negative', label: '低分(1-2 星)' },
+  ];
 
   ngOnInit(): void {
     this.datasetService.dataset$.pipe(takeUntil(this.destroy$)).subscribe((dataset) => {
@@ -211,9 +355,14 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
       this.relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
       const ownIds = new Set(this.relations.map((relation) => relation.ownProductId));
       this.ownProducts = dataset.products.filter((product) => product.role === 'own' && ownIds.has(product.productId));
-      this.reviews = (dataset.reviews ?? [])
+      this.ownComparisonRows = this.ownProducts.map((own) => ({
+        own,
+        diff: this.priceDiffState(this.ownUnitPrice(own)),
+      }));
+      this.rawReviews = (dataset.reviews ?? [])
         .filter((review) => review.productId === productId)
         .sort((left, right) => String(right.reviewDate ?? '').localeCompare(String(left.reviewDate ?? '')));
+      this.reviews = ReviewDataUtils.filterMeaningfulReviews(this.rawReviews);
       this.ratedReviews = this.reviews.filter((review) => review.rating > 0);
       this.averageRating = this.ratedReviews.length
         ? this.ratedReviews.reduce((total, review) => total + review.rating, 0) / this.ratedReviews.length
@@ -223,6 +372,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
         : 0;
       this.ratingRows = this.buildRatingRows();
       this.reviewTrendOptions = this.buildReviewTrend();
+      this.aiReviewPrompt = this.buildAiReviewPrompt();
       this.cdr.markForCheck();
     });
   }
@@ -233,7 +383,18 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   }
 
   get statusLabel(): string {
-    return this.product?.detail?.skuStatus === '1' ? '在售' : this.product?.detail?.skuStatus ? `状态 ${this.product.detail.skuStatus}` : '状态待确认';
+    if (this.product?.detail?.skuStatus === '1') return '在售';
+    if (this.product?.detail?.skuStatus) return `状态 ${this.product.detail.skuStatus}`;
+    return this.product?.market ? '市场快照已采集' : '状态待确认';
+  }
+
+  get currentPriceLabel(): string {
+    const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
+    return price > 0 ? this.currency(price) : '-';
+  }
+
+  get salesSignal(): string {
+    return this.product?.market?.monthSalesText || this.product?.market?.salesText || '-';
   }
 
   get weightLabel(): string {
@@ -246,14 +407,56 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
     return `${dimensions.length} × ${dimensions.width} × ${dimensions.height} mm`;
   }
 
+  get filteredPlaceholderCount(): number {
+    return Math.max(0, this.rawReviews.length - this.reviews.length);
+  }
+
+  get reviewQualityDescription(): string {
+    return `原始 ${this.number(this.rawReviews.length)} 条,过滤平台占位 ${this.number(this.filteredPlaceholderCount)} 条`;
+  }
+
+  get filteredReviews(): DomesticReview[] {
+    return this.reviews.filter(review => {
+      const matchesRating = this.ratingFilter === 'all' || review.rating === Number(this.ratingFilter);
+      const matchesSentiment = this.sentimentFilter === 'all'
+        || ReviewDataUtils.classifySentiment(review.rating) === this.sentimentFilter;
+      return matchesRating && matchesSentiment;
+    });
+  }
+
   goBack(): void {
     this.router.navigate(['/domestic/competitors']);
   }
 
-  goVoc(): void {
-    this.router.navigate(['/voc-insight/detail'], {
-      queryParams: { productId: this.product?.productId || null },
-    });
+  onOpenAiChat(event: { prompt: string; report: string }): void {
+    this.aiChatContext = event.prompt;
+    this.aiChatReportText = event.report;
+    this.showAiChat = true;
+  }
+
+  setActiveDetailTab(key: string): void {
+    if (this.detailTabs.some((tab) => tab.key === key)) this.activeDetailTab = key;
+  }
+
+  openReview(review: DomesticReview): void {
+    this.selectedReview = review;
+  }
+
+  openAllReviews(): void {
+    this.showAllReviews = true;
+  }
+
+  selectReviewFromModal(review: DomesticReview): void {
+    this.showAllReviews = false;
+    this.selectedReview = review;
+  }
+
+  setRatingFilter(key: string): void {
+    if (this.ratingFilterTabs.some(tab => tab.key === key)) this.ratingFilter = key;
+  }
+
+  setSentimentFilter(key: string): void {
+    if (this.sentimentFilterTabs.some(tab => tab.key === key)) this.sentimentFilter = key;
   }
 
   useFallback(event: Event): void {
@@ -265,12 +468,99 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
     return this.relations.find((relation) => relation.ownProductId === productId)?.category || '-';
   }
 
+  // ── 本品 vs 竞品 对比(组件内基于现有字段派生,不改动数据获取逻辑)──
+
+  /** 竞品参考价:优先市场快照当前价,回退均价字段;无有效值返回 null。 */
+  get competitorPrice(): number | null {
+    const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
+    return price > 0 ? price : null;
+  }
+
+  get competitorPriceLabel(): string {
+    return this.competitorPrice != null ? this.currency(this.competitorPrice) : '-';
+  }
+
+  get competitorBrandLabel(): string {
+    return this.product?.brand || '竞品';
+  }
+
+  get ownLinkedGmv(): number {
+    return this.ownProducts.reduce((sum, own) => sum + own.summary.gmv, 0);
+  }
+
+  get ownLinkedSoldUnits(): number {
+    return this.ownProducts.reduce((sum, own) => sum + own.summary.soldUnits, 0);
+  }
+
+  /** 本品关联商品加权成交均价(成交金额 / 成交件数),无成交件数时返回 null。 */
+  get ownLinkedAverageUnitPrice(): number | null {
+    const units = this.ownLinkedSoldUnits;
+    if (units <= 0) return null;
+    return this.ownLinkedGmv / units;
+  }
+
+  get ownAvgUnitPriceLabel(): string {
+    const avg = this.ownLinkedAverageUnitPrice;
+    return avg != null ? this.currency(avg) : '-';
+  }
+
+  /** 顶部对比面板的参考价差异徽章状态(仅价格同量纲可计算)。 */
+  get ownAvgVsCompetitor(): { label: string; cls: string } {
+    const state = this.priceDiffState(this.ownLinkedAverageUnitPrice);
+    return { label: state.label, cls: state.cls };
+  }
+
+  /** 单件本品的成交均价;无成交件数时返回 null。 */
+  ownUnitPrice(own: DomesticProduct): number | null {
+    return own.summary.soldUnits > 0 ? own.summary.gmv / own.summary.soldUnits : null;
+  }
+
+  /**
+   * 计算「本品成交均价 vs 竞品当前价」的差异状态(单位口径均为 元/件)。
+   * 绿色 lower = 本品均价低于竞品,红色 higher = 本品均价高于竞品;
+   * 仅反映相对定价,不作为优劣结论;不可比时返回中性文案,不编造数字。
+   */
+  priceDiffState(ownPrice: number | null): { label: string; cls: string; title: string } {
+    const comp = this.competitorPrice;
+    if (ownPrice == null || comp == null) {
+      return {
+        label: '无法对比',
+        cls: 'neutral',
+        title: '本品缺少成交件数或竞品缺少当前价,无法计算价格差异。',
+      };
+    }
+    if (ownPrice <= 0) {
+      return { label: '无法对比', cls: 'neutral', title: '本品成交数据缺失。' };
+    }
+    const diff = (ownPrice - comp) / comp;
+    if (Math.abs(diff) < 0.005) {
+      return {
+        label: '持平',
+        cls: 'neutral',
+        title: `本品成交均价 ${this.currency(ownPrice)} 与竞品当前价 ${this.currency(comp)} 基本相当。`,
+      };
+    }
+    const pct = `${Math.abs(diff * 100).toFixed(1)}%`;
+    if (ownPrice > comp) {
+      return {
+        label: `高 ${pct}`,
+        cls: 'higher',
+        title: `本品成交均价 ${this.currency(ownPrice)} 较竞品当前价 ${this.currency(comp)} 高 ${pct}(元/件)。`,
+      };
+    }
+    return {
+      label: `低 ${pct}`,
+      cls: 'lower',
+      title: `本品成交均价 ${this.currency(ownPrice)} 较竞品当前价 ${this.currency(comp)} 低 ${pct}(元/件)。`,
+    };
+  }
+
   number(value: number): string {
     return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
   }
 
   currency(value: number): string {
-    return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
+    return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0)} 元`;
   }
 
   percent(value: number): string {
@@ -300,6 +590,57 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
     return [5, 4, 3, 2, 1].map((rating) => ({ rating, count: counts.get(rating) ?? 0, width: (counts.get(rating) ?? 0) / max * 100 }));
   }
 
+  private buildAiReviewPrompt(): string {
+    if (!this.product || !this.reviews.length) return '';
+
+    const starDistribution = this.ratingRows
+      .map((row) => `${row.rating}星=${row.count}`)
+      .join(',');
+    const linkedProducts = this.ownProducts.slice(0, 20).map((own) => [
+      `- 本品ID:${own.productId}`,
+      `型号:${own.model || '-'}`,
+      `映射类目:${this.relationCategory(own.productId)}`,
+      `成交件数:${this.number(own.summary.soldUnits)}`,
+      `成交金额:${this.currency(own.summary.gmv)}`,
+    ].join(';')).join('\n');
+    const reviewEvidence = this.reviews.slice(0, 80).map((review, index) => {
+      const content = String(review.content || '').replace(/\s+/g, ' ').trim().slice(0, 600);
+      return `- 样本${index + 1}|${review.rating > 0 ? `${review.rating}星` : '未评分'}|${this.date(review.reviewDate)}|${content || '无评论正文'}`;
+    }).join('\n');
+
+    return [
+      '【分析对象】国内京东竞品,以下均为当前数据库中的真实字段和评论样本。',
+      `商品ID: ${this.product.productId}`,
+      `商品标题: ${this.product.title}`,
+      `品牌: ${this.product.brand || '-'}`,
+      `型号: ${this.product.model || '-'}`,
+      `类目: ${this.product.category3 || this.product.category2 || '-'}`,
+      `当前价快照: ${this.currentPriceLabel}`,
+      `平台销量信号: ${this.salesSignal}`,
+      `店铺: ${this.product.market?.shopName || '-'}`,
+      `市场快照时间: ${this.dateTime(this.product.market?.collectedAt)}`,
+      `在售状态: ${this.statusLabel}`,
+      `原始入库评论: ${this.rawReviews.length}`,
+      `有效评论: ${this.reviews.length}`,
+      `已过滤平台占位评论: ${this.filteredPlaceholderCount}`,
+      `有评分评论: ${this.ratedReviews.length}`,
+      `平均评分: ${this.averageRating ? this.averageRating.toFixed(2) : '数据不足'}`,
+      `4-5星占比: ${this.ratedReviews.length ? `${(this.positiveRate * 100).toFixed(1)}%` : '数据不足'}(仅为评分分组,不代表正文情绪)`,
+      `评分分布: ${starDistribution}`,
+      `样本判断: ${ReviewDataUtils.hasSufficientSample(this.reviews.length) ? '达到基础分析阈值' : `有效样本少于 ${ReviewDataUtils.MIN_ANALYZABLE_SAMPLE} 条,只能输出观察性结论`}`,
+      '',
+      '【关联德玛仕本品】',
+      linkedProducts || '无关联本品',
+      '',
+      '【真实评论证据】',
+      reviewEvidence,
+      '',
+      '【数据边界】当前价和销量文案来自本次市场搜索快照,可用于横向观察;价格历史与销量历史序列尚未入库,不得生成时间趋势,也不得根据德玛仕本品成交数据反推竞品销量。',
+      '',
+      '请生成可视化报告:核心指标只使用上述真实统计;评分分布使用图表;证据卡引用评论原句;单独检查评分与正文情绪是否冲突;行动建议区分产品、详情页和数据采集动作。',
+    ].join('\n');
+  }
+
   private buildReviewTrend(): EChartsOption {
     const daily = new Map<string, { total: number; count: number }>();
     for (const review of this.ratedReviews) {
@@ -315,12 +656,12 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
     return {
       color: ['#4f6bed', '#38a169'],
       tooltip: { trigger: 'axis' },
-      legend: { data: ['平均评分', '评论数'], top: 0 },
+      legend: { data: ['平均评分', '评论数'], top: 0, textStyle: { fontSize: 12 } },
       grid: { left: 46, right: 44, top: 48, bottom: 34 },
-      xAxis: { type: 'category', data: rows.map(([date]) => date.slice(5)), axisTick: { show: false } },
+      xAxis: { type: 'category', data: rows.map(([date]) => date.slice(5)), axisTick: { show: false }, axisLabel: { fontSize: 12 } },
       yAxis: [
-        { type: 'value', min: 0, max: 5, name: '评分', splitLine: { lineStyle: { color: '#edf0f4' } } },
-        { type: 'value', minInterval: 1, name: '评论', splitLine: { show: false } },
+        { type: 'value', min: 0, max: 5, name: '评分', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 12 }, splitLine: { lineStyle: { color: '#edf0f4' } } },
+        { type: 'value', minInterval: 1, name: '评论', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 12 }, splitLine: { show: false } },
       ],
       series: [
         { name: '平均评分', type: 'line', smooth: true, data: rows.map(([, value]) => Number((value.total / value.count).toFixed(2))) },

+ 354 - 0
src/modules/domestic-voc/competitor-relations.component.scss

@@ -0,0 +1,354 @@
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.competitor-map-table {
+  min-width: 1120px;
+  table-layout: fixed;
+}
+
+.competitor-map-table .selection-col {
+  width: 44px;
+}
+
+.competitor-map-table .own-product-col {
+  width: 18%;
+}
+
+.competitor-map-table .category-col {
+  width: 10%;
+}
+
+.competitor-map-table .competitor-product-col {
+  width: 20%;
+}
+
+.competitor-map-table .product-id-col {
+  width: 9%;
+}
+
+.competitor-map-table .source-col {
+  width: 9%;
+}
+
+.competitor-map-table .market-col {
+  width: 12%;
+}
+
+.competitor-map-table .status-col {
+  width: 11%;
+}
+
+.competitor-map-table th,
+.competitor-map-table td {
+  height: 44px;
+  vertical-align: middle;
+}
+
+.competitor-map-table.density-compact th,
+.competitor-map-table.density-compact td {
+  height: 44px;
+  padding-top: 6px;
+  padding-bottom: 6px;
+}
+
+.competitor-map-table.density-compact ::ng-deep .pi-body {
+  gap: 1px;
+}
+
+.competitor-map-table.density-compact ::ng-deep .pi-secondary,
+.competitor-map-table.density-compact ::ng-deep .pi-subtitle {
+  display: none;
+}
+
+.competitor-map-table app-product-identity {
+  display: block;
+  width: 100%;
+  min-width: 0;
+}
+
+.competitor-map-table tr.selected td {
+  background: #eff6ff;
+}
+
+.selection-cell {
+  width: 44px;
+  text-align: center;
+}
+
+.selection-cell input {
+  width: 15px;
+  height: 15px;
+  margin: 0;
+  accent-color: #2563eb;
+  cursor: pointer;
+}
+
+.category-cell {
+  overflow: hidden;
+  color: #334155;
+  font-weight: 600;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+}
+
+.product-id-cell {
+  overflow: hidden;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-cell {
+  white-space: nowrap;
+}
+
+.status-cell .status-pill {
+  font-size: 12px;
+}
+
+.source-pill {
+  display: inline-flex;
+  min-height: 24px;
+  padding: 3px 8px;
+  align-items: center;
+  border-radius: 4px;
+  color: #475569;
+  background: #f1f5f9;
+  font-size: 12px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.source-pill.discovery {
+  color: #7c3aed;
+  background: #f3e8ff;
+}
+
+.market-cell strong,
+.market-cell small {
+  display: block;
+  max-width: 180px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.market-cell strong {
+  color: #111827;
+  font-size: 12px;
+}
+
+.market-cell small,
+.market-cell .uncollected {
+  margin-top: 2px;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.domestic-page {
+  gap: 24px;
+}
+
+/* ═══════════ 竞品呼声 · 投票卡区 ═══════════ */
+
+.voice-coverage-strip {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+  margin: 0 0 16px;
+}
+
+.voice-coverage-strip span {
+  padding: 3px 10px;
+  border-radius: 999px;
+  background: #f3f4f6;
+  color: #374151;
+  font-size: 12px;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
+.voice-coverage-strip strong {
+  font-variant-numeric: tabular-nums;
+}
+
+.voice-low-sample-note {
+  margin: 0 0 16px;
+  padding: 8px 12px;
+  border: 1px dashed #fde68a;
+  border-radius: 8px;
+  background: linear-gradient(135deg, rgba(217, 119, 6, 0.06), #fff);
+  color: #92400e;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.voice-card-list {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+  gap: 16px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.voice-card {
+  display: flex;
+  gap: 16px;
+  align-items: stretch;
+  padding: 14px;
+  border: 1px solid #edf1f7;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
+  transition: border-color 0.16s ease, box-shadow 0.16s ease;
+}
+
+.voice-card:hover {
+  border-color: #dbe3ef;
+  box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
+}
+
+/* 左侧票数(fider 风格大数字按钮区) */
+.voice-vote {
+  display: flex;
+  flex: 0 0 58px;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 2px;
+  align-self: center;
+  padding: 8px 4px;
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  background: #f8fafc;
+  color: #4b5563;
+}
+
+.voice-vote.strong {
+  border-color: rgba(5, 150, 105, 0.28);
+  background: #ecfdf5;
+  color: #047857;
+}
+
+.voice-vote.weak {
+  color: #9ca3af;
+}
+
+.voice-vote-arrow {
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.voice-vote-count {
+  font-size: 22px;
+  font-weight: 800;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.1;
+}
+
+.voice-vote-caption {
+  font-size: 12px;
+  color: inherit;
+  opacity: 0.75;
+}
+
+.voice-card-main {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 6px;
+  min-width: 0;
+}
+
+.voice-card-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.voice-card-title {
+  margin: 0;
+  color: #111827;
+  font-size: 14px;
+  font-weight: 700;
+  line-height: 1.4;
+}
+
+.voice-status-badge {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  min-height: 20px;
+  padding: 2px 8px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.2;
+  white-space: nowrap;
+}
+
+.voice-status-badge.success {
+  border: 1px solid rgba(21, 128, 61, 0.16);
+  background: #dcfce7;
+  color: #15803d;
+}
+
+.voice-status-badge.warning {
+  border: 1px solid rgba(180, 83, 9, 0.16);
+  background: #fef3c7;
+  color: #b45309;
+}
+
+.voice-status-badge.muted {
+  border: 1px solid rgba(71, 85, 105, 0.12);
+  background: #e5e7eb;
+  color: #475569;
+}
+
+.voice-card-desc {
+  margin: 0;
+  color: #64748b;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.voice-card-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px 14px;
+  color: #94a3b8;
+  font-size: 12px;
+}
+
+.voice-card-meta strong {
+  color: #334155;
+  font-variant-numeric: tabular-nums;
+}
+
+.voice-card-sample {
+  margin: 2px 0 0;
+  padding: 8px 10px;
+  border-left: 3px solid #ede9fe;
+  border-radius: 4px;
+  background: #faf9ff;
+  color: #6d28d9;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+@media (max-width: 720px) {
+  .voice-card-list {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .voice-vote {
+    flex-basis: 52px;
+  }
+}

+ 566 - 36
src/modules/domestic-voc/competitor-relations.component.ts

@@ -5,19 +5,73 @@ import { Router } from '@angular/router';
 import { Subject, combineLatest, takeUntil } from 'rxjs';
 import { DomesticDataset } from '../../app/core/models/domestic.models';
 import { DomesticAnalyticsService, DomesticCompetitorRelationRow } from '../../app/core/services/domestic-analytics.service';
-import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
-import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
+import { DomesticDatasetService, buildDomesticReviewScope } from '../../app/core/services/domestic-dataset.service';
+import { DomesticReviewInsightService } from '../../app/core/services/domestic-review-insight.service';
+import {
+  CrudTableBulkAction,
+  CrudTableColumn,
+  CrudTableFilter,
+  CrudTableWorkbenchComponent,
+} from '../shared/components/crud-table-workbench/crud-table-workbench.component';
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
 import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
-import { MsqSearchBoxComponent } from '../shared/components/msq-search-box/msq-search-box.component';
 import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
 import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
+import { ReviewDataUtils } from '../shared/services/review-data-utils';
 import { VocInsightStateService } from '../shared/services/voc-insight-state.service';
 
+type CompetitorVoiceStatus = 'sufficient' | 'observational' | 'insufficient';
+
+/** 一条呼声 = 一张投票卡:标题 + 票数(提及次数)+ 涉及竞品数 + 状态徽章。 */
+interface CompetitorVoiceSignal {
+  id: string;
+  label: string;
+  description: string;
+  mentions: number;
+  share: number;
+  competitorCount: number;
+  sampleContent: string;
+  status: CompetitorVoiceStatus;
+  statusLabel: string;
+  statusHint: string;
+}
+
+/** 竞品呼声区的整体状态(含空态分支)。 */
+interface CompetitorVoiceState {
+  signals: CompetitorVoiceSignal[];
+  rawCount: number;
+  meaningfulCount: number;
+  placeholderCount: number;
+  negativeCount: number;
+  coveredCompetitorCount: number;
+  lowSample: boolean;
+  subtitle: string;
+  emptyVariant: 'plain' | 'low-sample';
+  emptyIcon: string;
+  emptyTitle: string;
+  emptyDescription: string;
+}
+
+/**
+ * 呼声主题正则镜像 —— 与 DomesticReviewInsightService.rules 保持一致。
+ * 服务只暴露每个主题的命中数量与样例,不暴露命中评论集合;这里用相同正则从同一
+ * 负面样本集合重新匹配,用于统计「涉及竞品数量」(按 productId 去重),保证票数与涉及数口径一致。
+ */
+const COMPETITOR_VOICE_PATTERNS: Record<string, RegExp> = {
+  performance: /功率|瓦|加热|火力|断电|跳闸|煮得久|豆子还是生/i,
+  quality: /质量|垃圾|烂|差劲|损坏|坏了|松动|不行|黑心/i,
+  service: /客服|售后|服务态度|补偿|道歉|保修|维修|运费|处理问题/i,
+  safety: /漏油|漏电|油缸|穿窿|着火|线头裸|安全/i,
+  usability: /清洗|笨重|不好用|麻烦|不实用|份量少|稀/i,
+  noise: /声音|噪音|轰轰|顾客影响/i,
+};
+
 @Component({
   selector: 'app-domestic-competitor-relations',
   standalone: true,
-  imports: [CommonModule, FormsModule, PageHeaderComponent, SummaryMetricCardComponent, DataTableShellComponent, LoadingSpinnerComponent, MsqSearchBoxComponent, ProductIdentityComponent],
+  imports: [CommonModule, FormsModule, PageHeaderComponent, SummaryMetricCardComponent, CrudTableWorkbenchComponent, LoadingSpinnerComponent, ProductIdentityComponent, ContentCardComponent, EmptyStateComponent],
   template: `
     <section class="domestic-page">
       <app-page-header title="竞品关系" eyebrow="Competitor Mapping" description="管理德玛仕商品与国内平台竞品的映射,并明确区分已配置关系和待采集指标。" icon="⇄"></app-page-header>
@@ -25,21 +79,130 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
       @if (dataset) {
         <div class="metric-grid">
           <app-summary-metric-card label="竞品关系" [value]="number(dataset.summary.relations)" icon="⇄" tone="blue"></app-summary-metric-card>
-          <app-summary-metric-card label="映射商品" [value]="number(dataset.summary.mappingRows)" icon="□" tone="green"></app-summary-metric-card>
+          <app-summary-metric-card label="表格关系" [value]="number(workbookRelationCount)" icon="□" tone="green" description="原始人工整理关系"></app-summary-metric-card>
+          <app-summary-metric-card label="维度发现" [value]="number(discoveredRelationCount)" icon="⌕" tone="amber" description="按品牌与品类检索"></app-summary-metric-card>
           <app-summary-metric-card label="唯一竞品" [value]="number(dataset.summary.uniqueCompetitorProducts)" icon="◇" tone="purple"></app-summary-metric-card>
-          <app-summary-metric-card label="映射覆盖率" [value]="percent(mappingCoverage)" icon="◎" tone="amber" description="按配置商品计算"></app-summary-metric-card>
+          <app-summary-metric-card label="市场快照" [value]="number(marketProductCount)" icon="▤" tone="blue" description="含价格、店铺和销量信号"></app-summary-metric-card>
+          <app-summary-metric-card label="评论覆盖" [value]="number(reviewedProductCount)" icon="◉" tone="green" [description]="number(dataset.summary.reviewCount) + ' 条评论' "></app-summary-metric-card>
         </div>
 
-        <app-data-table-shell title="竞品映射明细" subtitle="当前展示已配置的本品与竞品映射关系" [count]="filteredRows.length" variant="admin" [scrollable]="true" [hasToolbar]="true" [empty]="filteredRows.length === 0" emptyTitle="没有匹配的竞品关系">
-          <div dataTableToolbar class="table-toolbar">
-            <msq-search-box [(ngModel)]="searchQuery" placeholder="搜索商品ID、型号、竞品品牌或类目" (valueChange)="applyFilter($event)"></msq-search-box>
-          </div>
-          <table class="analytics-table">
-            <thead><tr><th>本品</th><th>类目</th><th>竞品品牌</th><th>竞品商品ID</th><th>数据状态</th></tr></thead>
+        @if (voiceState; as voice) {
+          <section class="competitor-voice" aria-label="竞品呼声 / 高频需求">
+            <app-content-card title="竞品呼声" [subtitle]="voice.subtitle" tone="purple" visualLevel="section">
+              @if (voice.signals.length) {
+                <div class="voice-coverage-strip">
+                  <span>原始评论 <strong>{{ number(voice.rawCount) }}</strong></span>
+                  <span>有效评论 <strong>{{ number(voice.meaningfulCount) }}</strong></span>
+                  <span>占位过滤 <strong>{{ number(voice.placeholderCount) }}</strong></span>
+                  <span>覆盖竞品 <strong>{{ number(voice.coveredCompetitorCount) }}</strong></span>
+                  <span>负面样本 <strong>{{ number(voice.negativeCount) }}</strong></span>
+                </div>
+
+                @if (voice.lowSample) {
+                  <p class="voice-low-sample-note">总体负面样本 {{ number(voice.negativeCount) }} 条 < 10,以下呼声仅作观察参考,不作比例结论。</p>
+                }
+
+                <ol class="voice-card-list">
+                  @for (signal of voice.signals; track signal.id) {
+                    <li class="voice-card">
+                      <div class="voice-vote" [class.strong]="signal.status === 'sufficient'" [class.weak]="signal.status === 'insufficient'">
+                        <span class="voice-vote-arrow">↑</span>
+                        <strong class="voice-vote-count">{{ number(signal.mentions) }}</strong>
+                        <span class="voice-vote-caption">票 · 提及</span>
+                      </div>
+                      <div class="voice-card-main">
+                        <div class="voice-card-head">
+                          <h4 class="voice-card-title">{{ signal.label }}</h4>
+                          <span class="voice-status-badge" [title]="signal.statusHint" [class.success]="signal.status === 'sufficient'" [class.warning]="signal.status === 'observational'" [class.muted]="signal.status === 'insufficient'">{{ signal.statusLabel }}</span>
+                        </div>
+                        <p class="voice-card-desc">{{ signal.description }}</p>
+                        <div class="voice-card-meta">
+                          <span>样本 <strong>{{ number(signal.mentions) }}</strong> 条</span>
+                          <span>涉及 <strong>{{ number(signal.competitorCount) }}</strong> 个竞品</span>
+                          <span>占负面样本 {{ percent(signal.share) }}</span>
+                        </div>
+                        @if (signal.sampleContent) {
+                          <blockquote class="voice-card-sample">“{{ signal.sampleContent }}”</blockquote>
+                        }
+                      </div>
+                    </li>
+                  }
+                </ol>
+              } @else {
+                <app-empty-state size="sm" [variant]="voice.emptyVariant" [icon]="voice.emptyIcon" [title]="voice.emptyTitle" [description]="voice.emptyDescription" actionText="刷新数据" (action)="refreshRows()"></app-empty-state>
+              }
+            </app-content-card>
+          </section>
+        }
+
+        <app-crud-table-workbench
+          title="竞品映射明细"
+          subtitle="按本品、类目、竞品品牌和数据状态管理映射关系"
+          queryPlaceholder="搜索商品ID、型号、品牌、标题或类目"
+          [query]="searchQuery"
+          [filters]="crudFilters"
+          [columns]="crudColumns"
+          [bulkActions]="bulkActions"
+          [selectedCount]="selectedRelationKeys.length"
+          [total]="filteredRows.length"
+          [page]="page"
+          [pageSize]="pageSize"
+          [density]="tableDensity"
+          [loading]="refreshing"
+          (queryChange)="onQueryChange($event)"
+          (filtersChange)="onFiltersChange($event)"
+          (columnsChange)="onColumnsChange($event)"
+          (densityChange)="tableDensity = $event"
+          (pageChange)="onPageChange($event)"
+          (pageSizeChange)="onPageSizeChange($event)"
+          (apply)="applyFilters(false)"
+          (reset)="resetFilters()"
+          (refresh)="refreshRows()"
+          (exportRequested)="exportRows($event)"
+          (bulkAction)="onBulkAction($event)"
+          (clearSelection)="clearSelection()">
+          <table class="analytics-table competitor-map-table" [class.density-compact]="tableDensity === 'compact'">
+            <colgroup>
+              <col class="selection-col" />
+              @if (isColumnVisible('own')) { <col class="own-product-col" /> }
+              @if (isColumnVisible('category')) { <col class="category-col" /> }
+              @if (isColumnVisible('competitor')) { <col class="competitor-product-col" /> }
+              @if (isColumnVisible('productId')) { <col class="product-id-col" /> }
+              @if (isColumnVisible('source')) { <col class="source-col" /> }
+              @if (isColumnVisible('market')) { <col class="market-col" /> }
+              @if (isColumnVisible('status')) { <col class="status-col" /> }
+            </colgroup>
+            <thead>
+              <tr>
+                <th class="selection-cell">
+                  <input
+                    type="checkbox"
+                    aria-label="选择当前页"
+                    [checked]="allPageSelected"
+                    [indeterminate]="somePageSelected"
+                    (change)="togglePageSelection()" />
+                </th>
+                @if (isColumnVisible('own')) { <th>本品</th> }
+                @if (isColumnVisible('category')) { <th>类目</th> }
+                @if (isColumnVisible('competitor')) { <th>竞品</th> }
+                @if (isColumnVisible('productId')) { <th>竞品商品ID</th> }
+                @if (isColumnVisible('source')) { <th>关系来源</th> }
+                @if (isColumnVisible('market')) { <th>市场快照</th> }
+                @if (isColumnVisible('status')) { <th>数据状态</th> }
+              </tr>
+            </thead>
             <tbody>
-              @for (row of filteredRows; track row.relationKey) {
-                <tr>
-                  <td>
+              @for (row of pagedRows; track row.relationKey) {
+                <tr [class.selected]="isRowSelected(row.relationKey)">
+                  <td class="selection-cell">
+                    <input
+                      type="checkbox"
+                      [attr.aria-label]="'选择关系 ' + row.relationKey"
+                      [checked]="isRowSelected(row.relationKey)"
+                      (change)="toggleRow(row.relationKey)" />
+                  </td>
+                  @if (isColumnVisible('own')) {
+                    <td>
                     <app-product-identity
                       [showImage]="false"
                       [title]="row.ownTitle"
@@ -51,9 +214,13 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
                       size="xs"
                       (identityClick)="openOwnProduct(row)">
                     </app-product-identity>
-                  </td>
-                  <td>{{ row.category }}</td>
-                  <td>
+                    </td>
+                  }
+                  @if (isColumnVisible('category')) {
+                    <td class="category-cell" [title]="row.category">{{ row.category }}</td>
+                  }
+                  @if (isColumnVisible('competitor')) {
+                    <td>
                     <app-product-identity
                       [imageUrl]="row.competitorProduct?.detail?.imageUrl || ''"
                       [title]="row.competitorTitle"
@@ -65,28 +232,45 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
                       size="xs"
                       (identityClick)="openCompetitor(row)">
                     </app-product-identity>
-                  </td>
-                  <td>{{ row.competitorProductId }}</td>
-                  <td>
-                    <span class="status-pill" [class.success]="row.competitorProductAvailable">
-                      {{ row.competitorProductAvailable ? (row.competitorReviewCount > 0 ? '详情 · ' + row.competitorReviewCount + ' 条评论' : '商品详情已入库') : '待采集商品详情' }}
-                    </span>
-                  </td>
+                    </td>
+                  }
+                  @if (isColumnVisible('productId')) {
+                    <td class="product-id-cell">{{ row.competitorProductId }}</td>
+                  }
+                  @if (isColumnVisible('source')) {
+                    <td><span class="source-pill" [class.discovery]="row.discoverySource === 'brand_category_search'">{{ sourceLabel(row) }}</span></td>
+                  }
+                  @if (isColumnVisible('market')) {
+                    <td class="market-cell">
+                      @if (row.competitorProduct?.market; as market) {
+                        <strong>{{ market.currentPrice > 0 ? currency(market.currentPrice) : '价格待补' }}</strong>
+                        <small>{{ market.monthSalesText || market.salesText || market.shopName || '市场信号待补' }}</small>
+                      } @else { <span class="uncollected">待采集</span> }
+                    </td>
+                  }
+                  @if (isColumnVisible('status')) {
+                    <td class="status-cell">
+                      <span class="status-pill" [class.success]="row.competitorProductAvailable">
+                        {{ row.competitorProductAvailable ? (row.competitorReviewCount > 0 ? '详情 · ' + row.competitorReviewCount + ' 条评论' : '商品详情已入库') : '待采集商品详情' }}
+                      </span>
+                    </td>
+                  }
                 </tr>
               }
             </tbody>
           </table>
-        </app-data-table-shell>
+        </app-crud-table-workbench>
       } @else {
         <app-loading-spinner variant="card" tone="blue" text="正在加载竞品关系"></app-loading-spinner>
       }
     </section>
   `,
-  styleUrls: ['./domestic-page.shared.scss'],
+  styleUrls: ['./domestic-page.shared.scss', './competitor-relations.component.scss'],
 })
 export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
   private readonly analytics = inject(DomesticAnalyticsService);
   private readonly datasetService = inject(DomesticDatasetService);
+  private readonly reviewInsight = inject(DomesticReviewInsightService);
   private readonly state = inject(VocInsightStateService);
   private readonly router = inject(Router);
   private readonly cdr = inject(ChangeDetectorRef);
@@ -97,16 +281,39 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
   filteredRows: DomesticCompetitorRelationRow[] = [];
   searchQuery = '';
   mappingCoverage = 0;
+  page = 1;
+  pageSize = 20;
+  tableDensity: 'default' | 'compact' = 'default';
+  refreshing = false;
+  selectedRelationKeys: string[] = [];
+  readonly minAnalyzableSample = ReviewDataUtils.MIN_ANALYZABLE_SAMPLE;
+  voiceState?: CompetitorVoiceState;
+  crudFilters: CrudTableFilter[] = [];
+  crudColumns: CrudTableColumn[] = [
+    { key: 'own', label: '本品', visible: true, locked: true },
+    { key: 'category', label: '类目', visible: true },
+    { key: 'competitor', label: '竞品', visible: true, locked: true },
+    { key: 'productId', label: '竞品商品ID', visible: true },
+    { key: 'source', label: '关系来源', visible: true },
+    { key: 'market', label: '市场快照', visible: true },
+    { key: 'status', label: '数据状态', visible: true },
+  ];
+  readonly bulkActions: CrudTableBulkAction[] = [
+    { key: 'export-selected', label: '导出所选' },
+  ];
 
   ngOnInit(): void {
     combineLatest([this.datasetService.dataset$, this.analytics.competitorRelations$])
       .pipe(takeUntil(this.destroy$))
       .subscribe(([dataset, rows]) => {
         this.dataset = dataset;
+        this.voiceState = this.buildCompetitorVoice(dataset);
         this.allRows = rows;
         const mapped = new Set(rows.map((row) => row.ownProductId)).size;
         this.mappingCoverage = dataset.summary.mappingRows ? mapped / dataset.summary.mappingRows : 0;
-        this.applyFilter(this.searchQuery);
+        this.buildCrudFilters();
+        this.applyFilters();
+        this.refreshing = false;
       });
   }
 
@@ -115,20 +322,335 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
     this.destroy$.complete();
   }
 
-  applyFilter(value: string): void {
+  get pagedRows(): DomesticCompetitorRelationRow[] {
+    const start = (this.page - 1) * this.pageSize;
+    return this.filteredRows.slice(start, start + this.pageSize);
+  }
+
+  get allPageSelected(): boolean {
+    return this.pagedRows.length > 0 && this.pagedRows.every((row) => this.isRowSelected(row.relationKey));
+  }
+
+  get somePageSelected(): boolean {
+    const selectedOnPage = this.pagedRows.filter((row) => this.isRowSelected(row.relationKey)).length;
+    return selectedOnPage > 0 && selectedOnPage < this.pagedRows.length;
+  }
+
+  get workbookRelationCount(): number {
+    return this.dataset?.relations.filter((relation) => relation.discoverySource !== 'brand_category_search').length ?? 0;
+  }
+
+  get discoveredRelationCount(): number {
+    return this.dataset?.relations.filter((relation) => relation.discoverySource === 'brand_category_search').length ?? 0;
+  }
+
+  get marketProductCount(): number {
+    return this.dataset?.products.filter((product) => product.role === 'competitor' && !!product.market).length ?? 0;
+  }
+
+  get reviewedProductCount(): number {
+    return new Set((this.dataset?.reviews ?? []).map((review) => review.productId)).size;
+  }
+
+  /**
+   * 从真实竞品评论组件内派生「竞品呼声 / 高频需求」:
+   * 先按情绪过滤平台占位文案,再对负面评论按呼声主题规则聚合出票数(提及次数),
+   * 并用相同正则统计每条呼声涉及的竞品数量(productId 去重)。
+   * 每条呼声按自身样本量分档可信度(<10 观察参考 / 10–29 方向性参考 / ≥30 结论可信),
+   * 不达到阈值仅作观察参考,不为演示编造呼声。
+   */
+  private buildCompetitorVoice(dataset: DomesticDataset): CompetitorVoiceState {
+    const competitorReviews = buildDomesticReviewScope(dataset).competitor;
+    const reference = this.reviewInsight.build(competitorReviews);
+    const coveredCompetitorCount = new Set(competitorReviews.map((review) => review.productId)).size;
+    const lowSample = reference.negativeCount < ReviewDataUtils.MIN_ANALYZABLE_SAMPLE;
+    const base = {
+      rawCount: competitorReviews.length,
+      meaningfulCount: reference.meaningfulCount,
+      placeholderCount: reference.placeholderCount,
+      negativeCount: reference.negativeCount,
+      coveredCompetitorCount,
+      lowSample,
+    };
+
+    const signals: CompetitorVoiceSignal[] = reference.topics.map((topic) => {
+      const pattern = COMPETITOR_VOICE_PATTERNS[topic.id];
+      const matched = pattern ? reference.negative.filter((review) => pattern.test(review.content || '')) : [];
+      const competitorCount = new Set(matched.map((review) => review.productId)).size;
+      const tier = this.voiceTier(topic.count);
+      return {
+        id: topic.id,
+        label: topic.label,
+        description: topic.description,
+        mentions: topic.count,
+        share: topic.share,
+        competitorCount,
+        sampleContent: this.clip(topic.sample?.content, 96),
+        ...tier,
+      };
+    });
+
+    if (!base.rawCount) {
+      return {
+        ...base,
+        signals: [],
+        subtitle: '按真实竞品评论聚合高频呼声与需求;票数 = 提及次数。',
+        emptyVariant: 'plain',
+        emptyIcon: '🗳',
+        emptyTitle: '暂无可聚合的竞品评论',
+        emptyDescription: '当前数据源未返回竞品评论,无法派生呼声主题。完成竞品评论采集后此处将自动展示。',
+      };
+    }
+    if (!base.meaningfulCount) {
+      return {
+        ...base,
+        signals: [],
+        subtitle: `已过滤 ${this.number(base.placeholderCount)} 条平台占位文案,暂无有效评论样本。`,
+        emptyVariant: 'low-sample',
+        emptyIcon: '⚠️',
+        emptyTitle: '暂无有效竞品评论',
+        emptyDescription: '现有竞品评论均为平台占位文案,过滤后无可聚合的呼声样本。',
+      };
+    }
+    if (!signals.length) {
+      return {
+        ...base,
+        signals: [],
+        subtitle: `有效负面评论 ${this.number(base.negativeCount)} 条未命中任何呼声主题规则。`,
+        emptyVariant: 'low-sample',
+        emptyIcon: '📭',
+        emptyTitle: '暂未命中呼声主题',
+        emptyDescription: `现有竞品负面评论未聚合出高频呼声;样本 <10 条时仅作观察参考,不作比例结论。`,
+      };
+    }
+    return {
+      ...base,
+      signals,
+      subtitle: `基于 ${this.number(base.meaningfulCount)} 条有效竞品评论、覆盖 ${this.number(coveredCompetitorCount)} 个竞品商品聚合;票数 = 提及次数,按样本量分档(<10 仅观察参考、10–29 方向性参考、≥30 结论可信)。`,
+      emptyVariant: 'plain',
+      emptyIcon: '',
+      emptyTitle: '',
+      emptyDescription: '',
+    };
+  }
+
+  /**
+   * 呼声可信度三档:按该呼声的提及样本量 N 分档。
+   * N<10:样本 {N} 条 < 10,仅作观察参考,不作比例结论;
+   * 10≤N<30:方向性参考;N≥30:结论可信。
+   * statusLabel 为徽章短标签,statusHint 为完整分档措辞(徽章悬停提示)。
+   */
+  private voiceTier(count: number): { status: CompetitorVoiceStatus; statusLabel: string; statusHint: string } {
+    if (count < this.minAnalyzableSample) {
+      return {
+        status: 'insufficient',
+        statusLabel: '观察参考',
+        statusHint: `样本 ${count} 条 < 10,仅作观察参考,不作比例结论`,
+      };
+    }
+    if (count < 30) {
+      return {
+        status: 'observational',
+        statusLabel: '方向性参考',
+        statusHint: `样本 ${count} 条(10–29),方向性参考,可作初步判断`,
+      };
+    }
+    return {
+      status: 'sufficient',
+      statusLabel: '结论可信',
+      statusHint: `样本 ${count} 条 ≥ 30,结论可信`,
+    };
+  }
+
+  private clip(text: string | undefined, max: number): string {
+    const cleaned = String(text ?? '').replace(/\s+/g, ' ').trim();
+    return cleaned.length > max ? `${cleaned.slice(0, max)}…` : cleaned;
+  }
+
+  onQueryChange(value: string): void {
     this.searchQuery = value || '';
+    this.applyFilters();
+  }
+
+  onFiltersChange(filters: CrudTableFilter[]): void {
+    this.crudFilters = filters;
+    this.applyFilters();
+  }
+
+  applyFilters(resetPage = true): void {
     const query = this.searchQuery.trim().toLowerCase();
-    this.filteredRows = !query ? [...this.allRows] : this.allRows.filter((row) => [
+    const filterValues = new Map(this.crudFilters.map((filter) => [filter.key, filter.value]));
+    this.filteredRows = this.allRows.filter((row) => {
+      const matchesQuery = !query || [
+        row.ownProductId,
+        row.ownModel,
+        row.ownTitle,
+        row.competitorProductId,
+        row.competitorBrand,
+        row.competitorTitle,
+        row.competitorModel,
+        row.category,
+      ].join(' ').toLowerCase().includes(query);
+      const category = filterValues.get('category');
+      const brand = filterValues.get('brand');
+      const status = filterValues.get('status');
+      const reviews = filterValues.get('reviews');
+      const source = filterValues.get('source');
+      return matchesQuery
+        && (!category || row.category === category)
+        && (!brand || row.competitorBrand === brand)
+        && (!status || (status === 'ready' ? row.competitorProductAvailable : !row.competitorProductAvailable))
+        && (!reviews || (reviews === 'with-reviews' ? row.competitorReviewCount > 0 : row.competitorReviewCount === 0))
+        && (!source || (source === 'discovery' ? row.discoverySource === 'brand_category_search' : row.discoverySource !== 'brand_category_search'));
+    });
+    if (resetPage) this.page = 1;
+    const pageCount = Math.max(1, Math.ceil(this.filteredRows.length / this.pageSize));
+    this.page = Math.min(this.page, pageCount);
+    this.cdr.markForCheck();
+  }
+
+  resetFilters(): void {
+    this.searchQuery = '';
+    this.crudFilters = this.crudFilters.map((filter) => ({ ...filter, value: '' }));
+    this.applyFilters();
+  }
+
+  onColumnsChange(columns: CrudTableColumn[]): void {
+    this.crudColumns = columns;
+  }
+
+  onPageChange(page: number): void {
+    this.page = page;
+  }
+
+  onPageSizeChange(pageSize: number): void {
+    this.pageSize = pageSize;
+    this.page = 1;
+  }
+
+  isColumnVisible(key: string): boolean {
+    return this.crudColumns.find((column) => column.key === key)?.visible !== false;
+  }
+
+  isRowSelected(relationKey: string): boolean {
+    return this.selectedRelationKeys.includes(relationKey);
+  }
+
+  toggleRow(relationKey: string): void {
+    this.selectedRelationKeys = this.isRowSelected(relationKey)
+      ? this.selectedRelationKeys.filter((key) => key !== relationKey)
+      : [...this.selectedRelationKeys, relationKey];
+  }
+
+  togglePageSelection(): void {
+    const pageKeys = this.pagedRows.map((row) => row.relationKey);
+    if (this.allPageSelected) {
+      this.selectedRelationKeys = this.selectedRelationKeys.filter((key) => !pageKeys.includes(key));
+      return;
+    }
+    this.selectedRelationKeys = [...new Set([...this.selectedRelationKeys, ...pageKeys])];
+  }
+
+  clearSelection(): void {
+    this.selectedRelationKeys = [];
+  }
+
+  refreshRows(): void {
+    this.refreshing = true;
+    this.datasetService.refresh();
+  }
+
+  onBulkAction(action: string): void {
+    if (action === 'export-selected') this.exportRows('selected');
+  }
+
+  exportRows(scope: 'all' | 'selected'): void {
+    const rows = scope === 'selected'
+      ? this.allRows.filter((row) => this.selectedRelationKeys.includes(row.relationKey))
+      : this.filteredRows;
+    if (!rows.length) return;
+    const header = ['本品商品ID', '本品型号', '本品标题', '类目', '竞品品牌', '竞品商品ID', '竞品标题', '关系来源', '当前价', '销量信号', '店铺', '评论数', '数据状态'];
+    const lines = rows.map((row) => [
       row.ownProductId,
       row.ownModel,
       row.ownTitle,
-      row.competitorProductId,
+      row.category,
       row.competitorBrand,
+      row.competitorProductId,
       row.competitorTitle,
-      row.competitorModel,
-      row.category,
-    ].join(' ').toLowerCase().includes(query));
-    this.cdr.markForCheck();
+      this.sourceLabel(row),
+      row.competitorProduct?.market?.currentPrice || '',
+      row.competitorProduct?.market?.monthSalesText || row.competitorProduct?.market?.salesText || '',
+      row.competitorProduct?.market?.shopName || '',
+      row.competitorReviewCount,
+      row.competitorProductAvailable ? '商品详情已入库' : '待采集商品详情',
+    ].map((value) => this.csvCell(value)).join(','));
+    const blob = new Blob([`\uFEFF${[header.join(','), ...lines].join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
+    const url = URL.createObjectURL(blob);
+    const anchor = document.createElement('a');
+    anchor.href = url;
+    anchor.download = `竞品映射-${scope === 'selected' ? '所选' : '筛选结果'}-${new Date().toISOString().slice(0, 10)}.csv`;
+    anchor.click();
+    URL.revokeObjectURL(url);
+  }
+
+  private buildCrudFilters(): void {
+    const values = new Map(this.crudFilters.map((filter) => [filter.key, filter.value]));
+    const categoryCounts = this.countValues(this.allRows.map((row) => row.category));
+    const brandCounts = this.countValues(this.allRows.map((row) => row.competitorBrand || '未知品牌'));
+    const readyCount = this.allRows.filter((row) => row.competitorProductAvailable).length;
+    const reviewCount = this.allRows.filter((row) => row.competitorReviewCount > 0).length;
+    this.crudFilters = [
+      {
+        key: 'category',
+        label: '类目',
+        value: values.get('category') || '',
+        options: [...categoryCounts.entries()].sort(([left], [right]) => left.localeCompare(right, 'zh-CN')).map(([value, count]) => ({ value, label: value, badge: String(count) })),
+      },
+      {
+        key: 'brand',
+        label: '竞品品牌',
+        value: values.get('brand') || '',
+        options: [...brandCounts.entries()].sort(([left], [right]) => left.localeCompare(right, 'zh-CN')).map(([value, count]) => ({ value, label: value, badge: String(count) })),
+      },
+      {
+        key: 'source',
+        label: '关系来源',
+        value: values.get('source') || '',
+        options: [
+          { value: 'workbook', label: '表格关系', badge: String(this.workbookRelationCount) },
+          { value: 'discovery', label: '品牌品类发现', badge: String(this.discoveredRelationCount) },
+        ],
+      },
+      {
+        key: 'status',
+        label: '详情状态',
+        value: values.get('status') || '',
+        options: [
+          { value: 'ready', label: '商品详情已入库', badge: String(readyCount) },
+          { value: 'pending', label: '待采集商品详情', badge: String(this.allRows.length - readyCount) },
+        ],
+      },
+      {
+        key: 'reviews',
+        label: '评论状态',
+        value: values.get('reviews') || '',
+        options: [
+          { value: 'with-reviews', label: '已有评论', badge: String(reviewCount) },
+          { value: 'without-reviews', label: '暂无评论', badge: String(this.allRows.length - reviewCount) },
+        ],
+      },
+    ];
+  }
+
+  private countValues(values: string[]): Map<string, number> {
+    const counts = new Map<string, number>();
+    values.filter(Boolean).forEach((value) => counts.set(value, (counts.get(value) || 0) + 1));
+    return counts;
+  }
+
+  private csvCell(value: unknown): string {
+    return `"${String(value ?? '').replace(/"/g, '""')}"`;
   }
 
   openOwnProduct(row: DomesticCompetitorRelationRow): void {
@@ -143,7 +665,7 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
       storeName: '京东',
       reviewCount: 0,
     });
-    this.router.navigate(['/domestic/product'], { queryParams: { productId: product.productId } });
+    this.router.navigate(['/voc-insight/detail'], { queryParams: { productId: product.productId } });
   }
 
   openCompetitor(row: DomesticCompetitorRelationRow): void {
@@ -151,6 +673,14 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
     this.router.navigate(['/domestic/competitors', row.competitorProductId]);
   }
 
+  sourceLabel(row: DomesticCompetitorRelationRow): string {
+    return row.discoverySource === 'brand_category_search' ? '品牌品类发现' : '表格关系';
+  }
+
+  currency(value: number): string {
+    return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0)} 元`;
+  }
+
   number(value: number): string {
     return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
   }

+ 25 - 3
src/modules/domestic-voc/data-status.component.ts

@@ -35,7 +35,9 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
           <app-summary-metric-card label="日指标记录" [value]="data.summary.metricRows" icon="▤" tone="blue"></app-summary-metric-card>
           <app-summary-metric-card label="经营商品" [value]="data.summary.metricProducts" icon="□" tone="green"></app-summary-metric-card>
           <app-summary-metric-card label="竞品关系" [value]="data.summary.relations" icon="⇄" tone="amber"></app-summary-metric-card>
-          <app-summary-metric-card label="评论样本" [value]="data.summary.reviewCount" icon="◇" tone="red" description="当前暂无样本"></app-summary-metric-card>
+          <app-summary-metric-card label="竞品档案" [value]="data.summary.uniqueCompetitorProducts" icon="◇" tone="purple"></app-summary-metric-card>
+          <app-summary-metric-card label="市场快照" [value]="marketProductCount(data)" icon="⌕" tone="blue" description="含价格、店铺与销量信号"></app-summary-metric-card>
+          <app-summary-metric-card label="本品评论样本" [value]="ownReviewCount(data)" icon="◇" [tone]="ownReviewCount(data) ? 'green' : 'red'" [description]="'竞品评论 ' + competitorReviewCount(data) + ' 条'"></app-summary-metric-card>
         </div>
 
         <div class="detail-grid">
@@ -45,14 +47,18 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
               <div><dt>平台</dt><dd>{{ data.platform }}</dd></div>
               <div><dt>源文件</dt><dd>{{ data.source.sourceFile }}</dd></div>
               <div><dt>日期范围</dt><dd>{{ data.source.dateRange.start }} 至 {{ data.source.dateRange.end }}</dd></div>
+              <div><dt>竞品检索</dt><dd>{{ data.enrichment?.successfulQueries || 0 }} / {{ data.enrichment?.queryCount || 0 }} 组品牌品类</dd></div>
+              <div><dt>评论证据</dt><dd>{{ competitorReviewCount(data) }} 条 · {{ reviewedProductCount(data) }} 款竞品</dd></div>
+              <div><dt>采集时间</dt><dd>{{ data.enrichment?.collectedAt ? (data.enrichment?.collectedAt | date:'yyyy-MM-dd HH:mm') : '-' }}</dd></div>
             </dl>
           </app-content-card>
-          <app-content-card title="待接通依赖" subtitle="不会访问原跨境线上数据库" tone="amber">
+          <app-content-card title="接入进度" subtitle="前端统一通过本地业务接口读取" tone="amber">
             <ol class="dependency-list">
               <li><span class="status-pill success">已完成</span> 国内 VOC 前端框架迁入(29 个业务入口)</li>
               <li><span class="status-pill success">已完成</span> 商品 ID 兼容层与本地案例数据</li>
+              <li><span class="status-pill success">已完成</span> 竞品品牌品类检索与代表商品评论</li>
               <li><span class="status-pill">待处理</span> 独立 PostgreSQL / Parse 应用</li>
-              <li><span class="status-pill">待处理</span> 评论采集服务</li>
+              <li><span class="status-pill">待处理</span> 德玛仕本品评论采集</li>
             </ol>
           </app-content-card>
         </div>
@@ -96,4 +102,20 @@ export class DomesticDataStatusComponent {
   issueCount(quality: { orphanMappings: unknown[]; mappingsWithoutCompetitor: unknown[]; brandWithoutProductId: unknown[] }): number {
     return quality.orphanMappings.length + quality.mappingsWithoutCompetitor.length + quality.brandWithoutProductId.length;
   }
+
+  ownReviewCount(data: Parameters<DomesticDatasetService['reviewScope']>[0]): number {
+    return this.dataset.reviewScope(data).ownCount;
+  }
+
+  competitorReviewCount(data: Parameters<DomesticDatasetService['reviewScope']>[0]): number {
+    return this.dataset.reviewScope(data).competitorCount;
+  }
+
+  marketProductCount(data: Parameters<DomesticDatasetService['reviewScope']>[0]): number {
+    return data.products.filter((product) => product.role === 'competitor' && !!product.market).length;
+  }
+
+  reviewedProductCount(data: Parameters<DomesticDatasetService['reviewScope']>[0]): number {
+    return new Set((data.reviews ?? []).map((review) => review.productId)).size;
+  }
 }

+ 14 - 5
src/modules/domestic-voc/domestic-page.shared.scss

@@ -127,6 +127,7 @@
 }
 
 .category-fill {
+  display: block;
   height: 100%;
   border-radius: inherit;
   background: #4f6bed;
@@ -265,17 +266,25 @@
 
   .chart,
   .chart.compact {
-    height: 260px;
+    height: 244px;
+  }
+
+  .metric-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px;
+  }
+
+  .metric-grid ::ng-deep .metric-value-row strong {
+    font-size: clamp(21px, 6.3vw, 27px);
   }
 
   .category-row {
-    grid-template-columns: minmax(90px, 1fr) minmax(80px, 1fr);
+    grid-template-columns: minmax(92px, 1.25fr) minmax(64px, 1fr) auto;
+    gap: 8px;
   }
 
   .category-value {
-    grid-column: 1 / -1;
-    margin-top: -6px;
-    text-align: right;
+    font-size: 10px;
   }
 
   .product-picker-wrap ::ng-deep .vpc-bar {

+ 406 - 82
src/modules/domestic-voc/framework/domestic-action-workbench.component.html

@@ -1,94 +1,418 @@
-<section class="framework-page">
-  @if (loading) {
-    <div class="framework-state"><app-loading-spinner size="lg" text="正在加载行动建议框架..."></app-loading-spinner></div>
-  } @else if (error) {
-    <div class="framework-state"><app-error-state [description]="error"></app-error-state></div>
-  } @else if (dataset) {
-    <app-page-header
-      [title]="title"
-      eyebrow="Action Suggestion"
-      [description]="description"
-      icon="✓"
-      [badges]="[{ label: '经营建议已接通', tone: 'green' }, { label: dataset.summary.reviewCount ? 'VOC 建议已接通' : 'VOC 建议待接入', tone: dataset.summary.reviewCount ? 'green' : 'amber' }]"
-      [meta]="[{ label: '案例', value: dataset.caseName }, { label: '建议项', value: actions.length }, { label: '证据评论', value: dataset.summary.reviewCount }]">
-    </app-page-header>
-
-    <app-board-tabs class="framework-tabs" [tabs]="tabs" [activeKey]="section" density="compact"></app-board-tabs>
-
-    @if (section === 'kanban') {
-      <div class="metric-grid">
-        <app-summary-metric-card label="建议总数" [value]="actions.length" icon="▦" tone="blue" description="基于真实数据规则生成"></app-summary-metric-card>
-        <app-summary-metric-card label="P0 事项" [value]="p0Count" icon="!" tone="red" description="优先处理"></app-summary-metric-card>
-        <app-summary-metric-card label="P1 事项" [value]="p1Count" icon="△" tone="amber" description="重点跟进"></app-summary-metric-card>
-        <app-summary-metric-card label="评论证据" [value]="dataset.summary.reviewCount" icon="▤" tone="default" description="VOC 建议样本"></app-summary-metric-card>
-      </div>
+<section class="framework-page action-workbench">
+  <app-page-header
+    title="行动中心"
+    eyebrow="Action Center"
+    description="统一管理 VOC 洞察、反馈原声和经营规则形成的行动项。"
+    icon="✓"
+    density="compact"
+    [badges]="[{ label: 'ActionItem API', tone: 'green' }, { label: currentTypeLabel, tone: 'blue' }]"
+    [meta]="[
+      { label: '行动项', value: actions.length },
+      { label: '待推进', value: activeCount },
+      { label: '进行中', value: inProgressCount },
+      { label: '逾期', value: overdueCount }
+    ]">
+    <div pageHeaderActions class="action-header-actions">
+      <button
+        type="button"
+        class="action-icon-button"
+        aria-label="刷新行动项"
+        title="刷新"
+        [disabled]="refreshing"
+        (click)="loadActions(true)">
+        <lucide-icon [class.spinning]="refreshing" [img]="icons.refresh" [size]="17" [strokeWidth]="1.9"></lucide-icon>
+      </button>
+      <button type="button" class="action-primary-button" (click)="openCreatePanel()">
+        <lucide-icon [img]="icons.plus" [size]="17" [strokeWidth]="2"></lucide-icon>
+        <span>新建行动</span>
+      </button>
+    </div>
+  </app-page-header>
 
-      <app-alert-card title="建议来源已分层" description="退款、转化和竞品事项来自经营或映射数据;评论为 0 时不生成体验痛点、用户需求或情绪建议。" [primaryMetric]="actions.length + ' 项'" icon="i" tone="blue"></app-alert-card>
-
-      <app-priority-accordion
-        [sections]="accordionSections"
-        [(expandedIds)]="expandedIds"
-        variant="outlined">
-        <ng-template #sectionBody let-section>
-          @if (actionById(section.id); as action) {
-            <div class="info-list">
-              <div class="info-row"><div class="info-copy"><strong>建议说明</strong><span>{{ action.description }}</span></div><span class="info-value">{{ action.priority }}</span></div>
-              <div class="info-row"><div class="info-copy"><strong>证据来源</strong><span>{{ action.evidence }}</span></div><span class="info-value">{{ action.metric }}</span></div>
-              @if (action.productId) {
-                <div class="info-row"><div class="info-copy"><strong>关联商品</strong><span>{{ action.productTitle }}</span></div><span class="info-value">{{ action.productId }}</span></div>
-              }
-            </div>
-          }
-        </ng-template>
-      </app-priority-accordion>
-    }
+  <app-board-tabs
+    class="framework-tabs action-scope-tabs"
+    [tabs]="tabs"
+    [activeKey]="section"
+    density="compact">
+  </app-board-tabs>
 
-    @if (section === 'experience') {
-      <div class="metric-grid">
-        <app-summary-metric-card label="体验提案" value="0" icon="◇" tone="default" description="等待用户原声"></app-summary-metric-card>
-        <app-summary-metric-card label="痛点证据" value="0" icon="!" tone="amber" description="当前评论样本"></app-summary-metric-card>
-        <app-summary-metric-card label="使用场景" value="0" icon="⌖" tone="blue" description="等待场景识别"></app-summary-metric-card>
-        <app-summary-metric-card label="满意度主题" value="0" icon="◉" tone="purple" description="等待主题分析"></app-summary-metric-card>
-      </div>
-      <app-content-card title="体验优化提案" subtitle="从评论痛点、售后原声和场景证据形成" variant="dashed" tone="amber">
-        <app-empty-state variant="low-sample" title="暂无可验证的体验提案" description="当前不根据销量、转化或退款汇总猜测用户体验问题。"></app-empty-state>
-      </app-content-card>
-    }
+  <div class="action-health-band" aria-label="行动推进概况">
+    <div class="health-band-item">
+      <span>当前视图</span>
+      <strong>{{ filteredActions.length }}</strong>
+      <small>共 {{ actions.length }} 项</small>
+    </div>
+    <div class="health-band-item">
+      <span>进行中</span>
+      <strong>{{ inProgressCount }}</strong>
+      <small>正在推进</small>
+    </div>
+    <div class="health-band-item" [class.health-risk]="blockedCount > 0">
+      <span>阻塞</span>
+      <strong>{{ blockedCount }}</strong>
+      <small>等待处理</small>
+    </div>
+    <div class="health-band-item" [class.health-risk]="overdueCount > 0">
+      <span>已逾期</span>
+      <strong>{{ overdueCount }}</strong>
+      <small>未完成且超期</small>
+    </div>
+  </div>
 
-    @if (section === 'iteration') {
-      <app-alert-card title="产品迭代方向仅包含经营复核项" description="以下事项可用于排查,但在补充评论和退款原因前,不能直接认定为产品设计缺陷。" [primaryMetric]="operatingActions.length + ' 项'" icon="△" tone="amber"></app-alert-card>
-      <div class="stack">
-        @for (action of operatingActions; track action.id) {
-          <app-alert-card [title]="action.title" [description]="action.description" [primaryMetric]="action.metric" [icon]="action.type === '退款' ? '↩' : '◎'" [tone]="toneForAction(action)"></app-alert-card>
-        } @empty {
-          <app-content-card title="产品迭代方向" variant="dashed"><app-empty-state title="暂无经营风险迭代项" description="当前阈值下没有需要优先复核的商品。"></app-empty-state></app-content-card>
+  @if (loadWarning) {
+    <div class="action-load-warning" role="status">
+      <lucide-icon [img]="icons.alert" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+      <span>{{ loadWarning }};行动项仍可继续查看和更新。</span>
+    </div>
+  }
+
+  <section class="action-surface" aria-label="行动项工作台">
+    <div class="action-toolbar">
+      <label class="action-search">
+        <lucide-icon [img]="icons.search" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+        <input
+          type="search"
+          aria-label="搜索行动项"
+          placeholder="搜索标题、商品、负责人或验证指标"
+          [(ngModel)]="searchTerm" />
+      </label>
+
+      <div class="action-filter-group" aria-label="行动项筛选">
+        <label class="action-select">
+          <span class="sr-only">状态</span>
+          <select [(ngModel)]="statusFilter" aria-label="按状态筛选">
+            @for (option of statusOptions; track option.value) {
+              <option [ngValue]="option.value">{{ option.label }}</option>
+            }
+          </select>
+        </label>
+        <label class="action-select">
+          <span class="sr-only">负责人</span>
+          <select [(ngModel)]="assigneeFilter" aria-label="按负责人筛选">
+            <option value="all">全部负责人</option>
+            <option value="unassigned">待分配</option>
+            @for (owner of ownerOptions; track owner.value) {
+              <option [value]="owner.value">{{ owner.label }}</option>
+            }
+          </select>
+        </label>
+        <label class="action-select">
+          <span class="sr-only">来源</span>
+          <select [(ngModel)]="sourceFilter" aria-label="按来源筛选">
+            @for (option of sourceOptions; track option.value) {
+              <option [ngValue]="option.value">{{ option.label }}</option>
+            }
+          </select>
+        </label>
+        @if (hasActiveFilters) {
+          <button type="button" class="action-text-button" (click)="resetFilters()">
+            <lucide-icon [img]="icons.clear" [size]="15" [strokeWidth]="1.8"></lucide-icon>
+            <span>清除</span>
+          </button>
         }
       </div>
-    }
 
-    @if (section === 'strategy') {
-      <div class="metric-grid">
-        <app-summary-metric-card label="二级类目" [value]="dataset.summary.category2Count" icon="▦" tone="blue" description="经营覆盖"></app-summary-metric-card>
-        <app-summary-metric-card label="三级类目" [value]="dataset.summary.category3Count" icon="≡" tone="purple" description="细分覆盖"></app-summary-metric-card>
-        <app-summary-metric-card label="竞品关系" [value]="dataset.summary.relations" icon="↔" tone="green" description="横向对标基础"></app-summary-metric-card>
-        <app-summary-metric-card label="评论样本" [value]="dataset.summary.reviewCount" icon="▤" tone="default" description="长期 VOC 证据"></app-summary-metric-card>
+      <div class="action-view-switch" role="group" aria-label="视图模式">
+        <button
+          type="button"
+          aria-label="表格视图"
+          title="表格视图"
+          [class.active]="viewMode === 'table'"
+          [attr.aria-pressed]="viewMode === 'table'"
+          (click)="setViewMode('table')">
+          <lucide-icon [img]="icons.list" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+        </button>
+        <button
+          type="button"
+          aria-label="看板视图"
+          title="看板视图"
+          [class.active]="viewMode === 'board'"
+          [attr.aria-pressed]="viewMode === 'board'"
+          (click)="setViewMode('board')">
+          <lucide-icon [img]="icons.board" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+        </button>
+      </div>
+    </div>
+
+    @if (loading) {
+      <div class="action-state">
+        <app-loading-spinner size="lg" text="正在加载行动项..."></app-loading-spinner>
+      </div>
+    } @else if (error) {
+      <div class="action-state action-error-state" role="alert">
+        <lucide-icon [img]="icons.alert" [size]="24" [strokeWidth]="1.8"></lucide-icon>
+        <strong>行动中心暂未加载</strong>
+        <span>{{ error }}</span>
+        <button type="button" class="action-secondary-button" (click)="loadActions()">重新加载</button>
+      </div>
+    } @else if (!filteredActions.length) {
+      <div class="action-state action-empty-state">
+        <span class="state-icon"><lucide-icon [img]="icons.clipboard" [size]="25" [strokeWidth]="1.7"></lucide-icon></span>
+        <strong>{{ hasActiveFilters ? '当前筛选没有行动项' : '当前视图暂无行动项' }}</strong>
+        <span>{{ hasActiveFilters ? '清除筛选条件后查看其他行动。' : '从已确认洞察创建行动,或直接建立执行任务。' }}</span>
+        <div class="state-actions">
+          @if (hasActiveFilters) {
+            <button type="button" class="action-secondary-button" (click)="resetFilters()">清除筛选</button>
+          }
+          <button type="button" class="action-primary-button" (click)="openCreatePanel()">
+            <lucide-icon [img]="icons.plus" [size]="16" [strokeWidth]="2"></lucide-icon>
+            <span>新建行动</span>
+          </button>
+        </div>
       </div>
-      <div class="two-column equal-column">
-        <app-content-card title="类目经营结构" subtitle="当前周期成交规模与退款表现" tone="blue">
-          <div class="rank-list">
-            @for (item of categories; track item.category) {
-              <div class="rank-row"><div class="rank-copy"><strong>{{ item.category }}</strong><span>{{ item.productCount }} 个商品 · 退款金额占比 {{ percent(item.refundRate) }}</span></div><span class="rank-value">{{ currency(item.gmv) }}</span></div>
+    } @else if (viewMode === 'table') {
+      <div class="action-table-wrap">
+        <table class="action-table">
+          <thead>
+            <tr>
+              <th scope="col">行动项</th>
+              <th scope="col">类型 / 来源</th>
+              <th scope="col">优先级</th>
+              <th scope="col">状态</th>
+              <th scope="col">负责人</th>
+              <th scope="col">期限</th>
+              <th scope="col">验证指标</th>
+              <th scope="col"><span class="sr-only">查看</span></th>
+            </tr>
+          </thead>
+          <tbody>
+            @for (action of filteredActions; track action.id) {
+              <tr [class.is-overdue]="isOverdue(action)">
+                <td class="action-main-cell">
+                  <button type="button" class="action-title-button" (click)="openDetail(action)">
+                    <strong>{{ action.title }}</strong>
+                    <span>{{ action.description || '暂无补充说明' }}</span>
+                  </button>
+                </td>
+                <td>
+                  <span class="action-type-label">{{ actionTypeLabel(action.actionType) }}</span>
+                  <span class="action-source-label">{{ sourceLabel(action) }}</span>
+                </td>
+                <td><span class="priority-badge" [class]="'priority-badge priority-' + action.priority">{{ priorityLabel(action.priority) }}</span></td>
+                <td><span class="status-badge" [class]="'status-badge ' + statusClass(action.status)"><i></i>{{ statusLabel(action.status) }}</span></td>
+                <td><span class="owner-cell"><lucide-icon [img]="icons.user" [size]="14" [strokeWidth]="1.8"></lucide-icon>{{ ownerLabel(action.assigneeUserId) }}</span></td>
+                <td>
+                  <span class="due-cell" [class.due-overdue]="isOverdue(action)">{{ date(action.dueAt) }}</span>
+                </td>
+                <td><span class="validation-cell">{{ action.validationMetric || '待定义' }}</span></td>
+                <td>
+                  <button type="button" class="row-open-button" aria-label="查看行动详情" title="查看详情" (click)="openDetail(action)">
+                    <lucide-icon [img]="icons.chevron" [size]="17" [strokeWidth]="1.8"></lucide-icon>
+                  </button>
+                </td>
+              </tr>
             }
-          </div>
-        </app-content-card>
-        <app-content-card title="策略证据边界" subtitle="长期策略需要更长周期和用户证据" tone="amber">
-          <div class="info-list">
-            <div class="info-row"><div class="info-copy"><strong>当前经营周期</strong><span>{{ dataset.source.dateRange.start }} 至 {{ dataset.source.dateRange.end }}</span></div><span class="info-value">短周期</span></div>
-            <div class="info-row"><div class="info-copy"><strong>竞品关系</strong><span>国内商品映射</span></div><span class="info-value">{{ dataset.summary.relations }} 条</span></div>
-            <div class="info-row"><div class="info-copy"><strong>用户声音</strong><span>评论正文与长期主题</span></div><span class="info-value">待接入</span></div>
-          </div>
-        </app-content-card>
+          </tbody>
+        </table>
+      </div>
+    } @else {
+      <div class="action-board">
+        @for (lane of lanes; track lane.id) {
+          <section class="action-lane" [attr.aria-label]="lane.label">
+            <header class="lane-header">
+              <div>
+                <strong>{{ lane.label }}</strong>
+                <span>{{ lane.description }}</span>
+              </div>
+              <b>{{ actionsForLane(lane).length }}</b>
+            </header>
+            <div class="lane-list">
+              @for (action of actionsForLane(lane); track action.id) {
+                <button type="button" class="action-board-card" [class.is-overdue]="isOverdue(action)" (click)="openDetail(action)">
+                  <span class="board-card-topline">
+                    <span class="priority-badge" [class]="'priority-badge priority-' + action.priority">{{ priorityLabel(action.priority) }}</span>
+                    <span>{{ sourceLabel(action) }}</span>
+                  </span>
+                  <strong>{{ action.title }}</strong>
+                  <span class="board-card-description">{{ action.description || '暂无补充说明' }}</span>
+                  <span class="board-card-meta">
+                    <span><lucide-icon [img]="icons.user" [size]="13" [strokeWidth]="1.8"></lucide-icon>{{ ownerLabel(action.assigneeUserId) }}</span>
+                    <span [class.due-overdue]="isOverdue(action)"><lucide-icon [img]="icons.calendar" [size]="13" [strokeWidth]="1.8"></lucide-icon>{{ date(action.dueAt) }}</span>
+                  </span>
+                </button>
+              } @empty {
+                <div class="lane-empty">暂无行动项</div>
+              }
+            </div>
+          </section>
+        }
       </div>
     }
+  </section>
+
+  @if (selectedAction; as action) {
+    <div class="action-detail-backdrop" (click)="closeDetail()">
+      <aside
+        #actionDetailDrawer
+        class="action-detail-drawer"
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="action-detail-title"
+        (click)="$event.stopPropagation()">
+        <header class="detail-header">
+          <div class="detail-heading">
+            <span class="detail-kicker">{{ actionTypeLabel(action.actionType) }} · {{ sourceLabel(action) }}</span>
+            <h2 id="action-detail-title">{{ action.title }}</h2>
+            <div class="detail-badges">
+              <span class="priority-badge" [class]="'priority-badge priority-' + action.priority">{{ priorityLabel(action.priority) }}</span>
+              <span class="status-badge" [class]="'status-badge ' + statusClass(action.status)"><i></i>{{ statusLabel(action.status) }}</span>
+              @if (isOverdue(action)) { <span class="overdue-badge">已逾期</span> }
+            </div>
+          </div>
+          <button type="button" class="action-icon-button" aria-label="关闭行动详情" title="关闭" (click)="closeDetail()">
+            <lucide-icon [img]="icons.x" [size]="19" [strokeWidth]="1.8"></lucide-icon>
+          </button>
+        </header>
+
+        <div class="detail-body">
+          <section class="detail-section detail-summary">
+            <h3>行动说明</h3>
+            <p>{{ action.description || '暂无补充说明。' }}</p>
+            @if (action.productKey) {
+              <div class="detail-product">
+                <lucide-icon [img]="icons.package" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+                <span>关联商品</span>
+                <strong>{{ action.productKey }}</strong>
+              </div>
+            }
+          </section>
+
+          <section class="detail-section detail-execution">
+            <div class="section-title-row">
+              <h3>执行信息</h3>
+              <span>更新后写入 ActionItem</span>
+            </div>
+            <div class="detail-form-grid">
+              <label>
+                <span>状态</span>
+                <select [(ngModel)]="editStatus">
+                  @for (option of statusOptions.slice(1); track option.value) {
+                    <option [ngValue]="option.value">{{ option.label }}</option>
+                  }
+                </select>
+              </label>
+              <label>
+                <span>负责人</span>
+                <input
+                  type="text"
+                  list="action-owner-suggestions"
+                  [(ngModel)]="editAssignee"
+                  placeholder="输入工作空间用户 ID" />
+                <datalist id="action-owner-suggestions">
+                  @for (owner of ownerOptions; track owner.value) {
+                    <option [value]="owner.value">{{ owner.label }}</option>
+                  }
+                </datalist>
+              </label>
+              <label>
+                <span>期限</span>
+                <input type="date" [(ngModel)]="editDueDate" />
+              </label>
+            </div>
+            @if (updateError) {
+              <p class="detail-error" role="alert">{{ updateError }}</p>
+            }
+          </section>
+
+          <section class="detail-section">
+            <div class="section-title-row">
+              <h3>来源洞察</h3>
+              <span>{{ sourceLabel(action) }}</span>
+            </div>
+            @if (selectedInsight; as insight) {
+              <div class="insight-reference">
+                <span class="insight-icon"><lucide-icon [img]="icons.lightbulb" [size]="17" [strokeWidth]="1.8"></lucide-icon></span>
+                <div>
+                  <strong>{{ insight.title || action.sourceInsightId }}</strong>
+                  @if (insight.finding) { <p>{{ insight.finding }}</p> }
+                  @if (insight.recommendation) { <small>建议:{{ insight.recommendation }}</small> }
+                </div>
+              </div>
+            } @else {
+              <div class="reference-grid">
+                <div><span>来源类型</span><strong>{{ sourceLabel(action) }}</strong></div>
+                <div><span>来源洞察</span><strong>{{ action.sourceInsightId || '无关联洞察' }}</strong></div>
+              </div>
+            }
+            @if (action.sourceAnalysisId || action.sourceDecisionId) {
+              <dl class="id-list">
+                @if (action.sourceAnalysisId) { <div><dt>分析运行</dt><dd>{{ action.sourceAnalysisId }}</dd></div> }
+                @if (action.sourceDecisionId) { <div><dt>人工决策</dt><dd>{{ action.sourceDecisionId }}</dd></div> }
+              </dl>
+            }
+          </section>
+
+          <section class="detail-section">
+            <div class="section-title-row">
+              <h3>证据</h3>
+              <span>{{ (action.evidenceIds || []).length }} 条</span>
+            </div>
+            @if (action.evidenceIds?.length) {
+              <div class="evidence-list">
+                @for (evidenceId of action.evidenceIds; track evidenceId; let index = $index) {
+                  <div class="evidence-row">
+                    <span>{{ index + 1 }}</span>
+                    <code>{{ evidenceId }}</code>
+                    <lucide-icon [img]="icons.link" [size]="14" [strokeWidth]="1.7"></lucide-icon>
+                  </div>
+                }
+              </div>
+            } @else {
+              <div class="detail-empty-row">该行动项未关联证据 ID</div>
+            }
+          </section>
+
+          <section class="detail-section validation-section">
+            <div class="validation-icon"><lucide-icon [img]="icons.target" [size]="18" [strokeWidth]="1.8"></lucide-icon></div>
+            <div>
+              <h3>验证指标</h3>
+              <p>{{ action.validationMetric || '待补充可量化的验证指标。' }}</p>
+            </div>
+          </section>
+
+          <section class="detail-section">
+            <div class="section-title-row">
+              <h3>当前记录摘要</h3>
+              <span>非审计日志</span>
+            </div>
+            <ol class="status-timeline">
+              <li class="complete">
+                <i><lucide-icon [img]="icons.check" [size]="13" [strokeWidth]="2"></lucide-icon></i>
+                <div><strong>行动项已创建</strong><span>{{ dateTime(action.createdAt) }}</span></div>
+              </li>
+              @if (action.updatedAt !== action.createdAt) {
+                <li class="complete">
+                  <i><lucide-icon [img]="icons.check" [size]="13" [strokeWidth]="2"></lucide-icon></i>
+                  <div><strong>行动项已更新</strong><span>{{ dateTime(action.updatedAt) }}</span></div>
+                </li>
+              }
+              <li class="current">
+                <i><lucide-icon [img]="icons.circle" [size]="13" [strokeWidth]="2"></lucide-icon></i>
+                <div><strong>当前状态:{{ statusLabel(action.status) }}</strong><span>最后同步 {{ dateTime(action.updatedAt) }}</span></div>
+              </li>
+            </ol>
+          </section>
+        </div>
+
+        <footer class="detail-footer">
+          <span>更新状态、负责人和期限</span>
+          <button type="button" class="action-primary-button" [disabled]="updating" (click)="saveAction()">
+            <lucide-icon [class.spinning]="updating" [img]="updating ? icons.loader : icons.save" [size]="16" [strokeWidth]="2"></lucide-icon>
+            <span>{{ updating ? '保存中' : '保存更新' }}</span>
+          </button>
+        </footer>
+      </aside>
+    </div>
+  }
+
+  @if (createPanelOpen) {
+    <app-action-create-panel
+      [workspaceId]="workspaceId"
+      [actionType]="createActionType"
+      [sourceKind]="'raw_feedback'"
+      sourceLabel="行动中心手动创建"
+      (created)="onActionCreated($event)"
+      (cancelled)="closeCreatePanel()">
+    </app-action-create-panel>
   }
 </section>

+ 449 - 93
src/modules/domestic-voc/framework/domestic-action-workbench.component.ts

@@ -1,98 +1,183 @@
 import { CommonModule } from '@angular/common';
-import { Component, OnDestroy, OnInit } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { FormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
-import { Subject, combineLatest, takeUntil } from 'rxjs';
-import { DomesticDataset } from '../../../app/core/models/domestic.models';
 import {
-  DomesticAnalyticsAdapterService,
-  DomesticCategoryAggregate,
-  DomesticOperatingAction,
-} from '../../../app/core/services/domestic-analytics-adapter.service';
-import { DomesticDatasetService } from '../../../app/core/services/domestic-dataset.service';
-import { AlertCardComponent } from '../../shared/components/alert-card/alert-card.component';
+  AlertTriangle,
+  CalendarDays,
+  Check,
+  ChevronRight,
+  CircleDot,
+  ClipboardList,
+  Columns3,
+  Database,
+  FilterX,
+  LayoutList,
+  Lightbulb,
+  Link2,
+  LoaderCircle,
+  LucideAngularModule,
+  Package,
+  Plus,
+  RefreshCw,
+  Save,
+  Search,
+  Target,
+  UserRound,
+  X,
+} from 'lucide-angular';
+import { EMPTY, Observable, Subject, catchError, expand, forkJoin, map, of, reduce, takeUntil } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import {
+  SaasActionItem,
+  SaasAnalysisRun,
+  SaasContext,
+  CursorPage,
+  SaasPlatformService,
+} from '../../../app/core/services/saas-platform.service';
+import { ActionCreatePanelComponent } from '../../shared/components/action-create-panel/action-create-panel.component';
 import { BoardTabItem, BoardTabsComponent } from '../../shared/components/board-tabs/board-tabs.component';
-import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
-import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
-import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
-import {
-  PriorityAccordionComponent,
-  PriorityAccordionSection,
-} from '../../shared/components/priority-accordion/priority-accordion.component';
-import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 
 type ActionSection = 'kanban' | 'experience' | 'iteration' | 'strategy';
+type ActionViewMode = 'table' | 'board';
+type ActionStatus = SaasActionItem['status'];
+type ActionSource = NonNullable<SaasActionItem['sourceKind']>;
+type ActionTypeFilter = SaasActionItem['actionType'] | 'all';
+
+interface ActionStatusOption {
+  value: ActionStatus | 'all';
+  label: string;
+}
+
+interface ActionSourceOption {
+  value: ActionSource | 'all';
+  label: string;
+}
+
+interface ActionLane {
+  id: 'todo' | 'doing' | 'blocked' | 'done';
+  label: string;
+  description: string;
+  statuses: ActionStatus[];
+}
+
+interface InsightSummary {
+  title: string;
+  finding: string;
+  recommendation: string;
+  confidence: string;
+}
 
 @Component({
   selector: 'app-domestic-action-workbench',
   standalone: true,
   imports: [
     CommonModule,
-    AlertCardComponent,
+    FormsModule,
+    LucideAngularModule,
+    ActionCreatePanelComponent,
     BoardTabsComponent,
-    ContentCardComponent,
-    EmptyStateComponent,
-    ErrorStateComponent,
     LoadingSpinnerComponent,
     PageHeaderComponent,
-    PriorityAccordionComponent,
-    SummaryMetricCardComponent,
   ],
   templateUrl: './domestic-action-workbench.component.html',
   styleUrls: ['./domestic-framework.shared.scss'],
 })
 export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
   private readonly destroy$ = new Subject<void>();
+  private detailReturnFocus: HTMLElement | null = null;
 
+  @ViewChild('actionDetailDrawer') private actionDetailDrawer?: ElementRef<HTMLElement>;
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
   readonly tabs: BoardTabItem[] = [
-    { key: 'kanban', label: '建议看板', icon: '▦', route: ['/action-suggestion/kanban'] },
-    { key: 'experience', label: '体验优化', icon: '◉', route: ['/action-suggestion/experience'] },
-    { key: 'iteration', label: '产品迭代', icon: '△', route: ['/action-suggestion/iteration'] },
-    { key: 'strategy', label: '长期策略', icon: '↗', route: ['/action-suggestion/strategy'] },
+    { key: 'kanban', label: '全部行动', iconKey: 'actionSuggestion', route: ['/action-suggestion/kanban'] },
+    { key: 'experience', label: '体验优化', iconKey: 'experienceOptimization', route: ['/action-suggestion/experience'] },
+    { key: 'iteration', label: '产品迭代', iconKey: 'productIteration', route: ['/action-suggestion/iteration'] },
+    { key: 'strategy', label: '长期策略', iconKey: 'strategyAdjustment', route: ['/action-suggestion/strategy'] },
+  ];
+  readonly statusOptions: ActionStatusOption[] = [
+    { value: 'all', label: '全部状态' },
+    { value: 'open', label: '待评估' },
+    { value: 'planned', label: '待执行' },
+    { value: 'in_progress', label: '进行中' },
+    { value: 'blocked', label: '阻塞' },
+    { value: 'completed', label: '已完成' },
+    { value: 'cancelled', label: '已关闭' },
+  ];
+  readonly sourceOptions: ActionSourceOption[] = [
+    { value: 'all', label: '全部来源' },
+    { value: 'insight', label: 'AI 洞察' },
+    { value: 'raw_feedback', label: '反馈原声' },
+    { value: 'rule_action', label: '规则识别' },
   ];
+  readonly lanes: ActionLane[] = [
+    { id: 'todo', label: '待处理', description: '待评估与待执行', statuses: ['open', 'planned'] },
+    { id: 'doing', label: '进行中', description: '正在推进', statuses: ['in_progress'] },
+    { id: 'blocked', label: '阻塞', description: '等待解除依赖', statuses: ['blocked'] },
+    { id: 'done', label: '已结束', description: '已完成与已关闭', statuses: ['completed', 'cancelled'] },
+  ];
+  readonly icons = {
+    alert: AlertTriangle,
+    calendar: CalendarDays,
+    check: Check,
+    chevron: ChevronRight,
+    circle: CircleDot,
+    clipboard: ClipboardList,
+    board: Columns3,
+    database: Database,
+    clear: FilterX,
+    list: LayoutList,
+    lightbulb: Lightbulb,
+    link: Link2,
+    loader: LoaderCircle,
+    package: Package,
+    plus: Plus,
+    refresh: RefreshCw,
+    save: Save,
+    search: Search,
+    target: Target,
+    user: UserRound,
+    x: X,
+  };
 
   section: ActionSection = 'kanban';
+  viewMode: ActionViewMode = 'table';
+  typeFilter: ActionTypeFilter = 'all';
+  statusFilter: ActionStatus | 'all' = 'all';
+  assigneeFilter = 'all';
+  sourceFilter: ActionSource | 'all' = 'all';
+  searchTerm = '';
+
   loading = true;
+  refreshing = false;
   error = '';
-  dataset: DomesticDataset | null = null;
-  actions: DomesticOperatingAction[] = [];
-  categories: DomesticCategoryAggregate[] = [];
-  accordionSections: PriorityAccordionSection[] = [];
-  expandedIds: string[] = [];
+  loadWarning = '';
+  updateError = '';
+  updating = false;
+  createPanelOpen = false;
+  actions: SaasActionItem[] = [];
+  analyses: SaasAnalysisRun[] = [];
+  context: SaasContext | null = null;
+  selectedAction: SaasActionItem | null = null;
+  editStatus: ActionStatus = 'open';
+  editAssignee = '';
+  editDueDate = '';
 
   constructor(
     private readonly route: ActivatedRoute,
-    private readonly datasetService: DomesticDatasetService,
-    private readonly analytics: DomesticAnalyticsAdapterService,
+    private readonly platform: SaasPlatformService,
   ) {}
 
   ngOnInit(): void {
-    combineLatest([this.route.data, this.datasetService.dataset$])
-      .pipe(takeUntil(this.destroy$))
-      .subscribe({
-        next: ([routeData, dataset]) => {
-          this.section = (routeData['section'] || 'kanban') as ActionSection;
-          this.dataset = dataset;
-          this.actions = this.analytics.actions(dataset, 12);
-          this.categories = this.analytics.categories(dataset, 8);
-          this.accordionSections = this.actions.map((action) => ({
-            id: action.id,
-            title: action.title,
-            subtitle: `${action.type} · ${action.evidence}`,
-            badge: `${action.priority} · ${action.metric}`,
-            icon: action.type === '退款' ? '↩' : action.type === '转化' ? '◎' : action.type === '竞品' ? '↔' : '▤',
-            tone: this.toneForPriority(action.priority),
-          }));
-          this.expandedIds = this.accordionSections.slice(0, 1).map((item) => item.id);
-          this.loading = false;
-          this.error = '';
-        },
-        error: () => {
-          this.error = '行动建议框架数据加载失败,请检查国内数据文件。';
-          this.loading = false;
-        },
-      });
+    this.route.data.pipe(takeUntil(this.destroy$)).subscribe((routeData) => {
+      this.section = this.normalizeSection(routeData['section']);
+      this.typeFilter = this.typeForSection(this.section);
+    });
+    this.loadActions();
   }
 
   ngOnDestroy(): void {
@@ -100,59 +185,330 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     this.destroy$.complete();
   }
 
-  get title(): string {
-    const labels: Record<ActionSection, string> = {
-      kanban: '行动建议看板',
-      experience: '体验优化提案',
-      iteration: '产品迭代方向',
-      strategy: '长期策略调整',
+  @HostListener('document:keydown', ['$event'])
+  handleDocumentKeydown(event: KeyboardEvent): void {
+    if (this.createPanelOpen || !this.selectedAction) return;
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      this.closeDetail();
+      return;
+    }
+    if (event.key === 'Tab') this.keepFocusInDetail(event);
+  }
+
+  get filteredActions(): SaasActionItem[] {
+    const term = this.searchTerm.trim().toLocaleLowerCase('zh-CN');
+    return this.actions
+      .filter((action) => this.typeFilter === 'all' || action.actionType === this.typeFilter)
+      .filter((action) => this.statusFilter === 'all' || action.status === this.statusFilter)
+      .filter((action) => this.sourceFilter === 'all' || this.sourceValue(action) === this.sourceFilter)
+      .filter((action) => {
+        if (this.assigneeFilter === 'all') return true;
+        if (this.assigneeFilter === 'unassigned') return !action.assigneeUserId;
+        return action.assigneeUserId === this.assigneeFilter;
+      })
+      .filter((action) => !term || [
+        action.title,
+        action.description,
+        action.productKey,
+        action.assigneeUserId,
+        action.validationMetric,
+        action.sourceInsightId,
+      ].some((value) => String(value ?? '').toLocaleLowerCase('zh-CN').includes(term)))
+      .sort((left, right) => this.compareActions(left, right));
+  }
+
+  get ownerOptions(): ReadonlyArray<{ value: string; label: string }> {
+    const owners = new Set(
+      this.actions.map((action) => action.assigneeUserId).filter((value): value is string => Boolean(value)),
+    );
+    if (this.context?.principal.userId) owners.add(this.context.principal.userId);
+    return [...owners]
+      .sort((left, right) => this.ownerLabel(left).localeCompare(this.ownerLabel(right), 'zh-CN'))
+      .map((value) => ({ value, label: this.ownerLabel(value) }));
+  }
+
+  get activeCount(): number {
+    return this.actions.filter((action) => ['open', 'planned', 'in_progress', 'blocked'].includes(action.status)).length;
+  }
+
+  get inProgressCount(): number {
+    return this.actions.filter((action) => action.status === 'in_progress').length;
+  }
+
+  get blockedCount(): number {
+    return this.actions.filter((action) => action.status === 'blocked').length;
+  }
+
+  get overdueCount(): number {
+    return this.actions.filter((action) => this.isOverdue(action)).length;
+  }
+
+  get selectedAnalysis(): SaasAnalysisRun | null {
+    const analysisId = this.selectedAction?.sourceAnalysisId;
+    return analysisId ? this.analyses.find((run) => run.id === analysisId) ?? null : null;
+  }
+
+  get selectedInsight(): InsightSummary | null {
+    const action = this.selectedAction;
+    const analysis = this.selectedAnalysis;
+    if (!action?.sourceInsightId || !analysis?.result) return null;
+    const insights = analysis.result['insights'];
+    if (!Array.isArray(insights)) return null;
+    const source = insights.find((item) => this.isRecord(item) && item['id'] === action.sourceInsightId);
+    if (!this.isRecord(source)) return null;
+    return {
+      title: this.recordString(source, 'title'),
+      finding: this.recordString(source, 'finding'),
+      recommendation: this.recordString(source, 'recommendation'),
+      confidence: this.recordString(source, 'confidence'),
     };
-    return labels[this.section];
   }
 
-  get description(): string {
-    const labels: Record<ActionSection, string> = {
-      kanban: '汇总有真实指标依据的退款、转化、竞品和数据准备事项。',
-      experience: '保留原体验提案框架,等待评论和售后原声后形成可验证的用户体验建议。',
-      iteration: '从当前经营风险中提取产品复核方向,并与尚缺的 VOC 证据严格区分。',
-      strategy: '结合类目经营结构和竞品覆盖,形成当前周期的策略观察。',
+  get currentTypeLabel(): string {
+    return this.typeFilter === 'all' ? '全部类型' : this.actionTypeLabel(this.typeFilter);
+  }
+
+  get createActionType(): SaasActionItem['actionType'] {
+    return this.typeFilter === 'all' ? 'general' : this.typeFilter;
+  }
+
+  get hasActiveFilters(): boolean {
+    return Boolean(
+      this.searchTerm.trim()
+      || this.statusFilter !== 'all'
+      || this.assigneeFilter !== 'all'
+      || this.sourceFilter !== 'all',
+    );
+  }
+
+  loadActions(refresh = false): void {
+    if (refresh) this.refreshing = true;
+    else this.loading = true;
+    this.error = '';
+    this.loadWarning = '';
+    forkJoin({
+      actions: this.collectPages((cursor) => this.platform.actions(this.workspaceId, '', cursor, 100)),
+      analyses: this.collectPages((cursor) => this.platform.analyses(this.workspaceId, '', cursor, 100)).pipe(
+        map((data) => ({ data, warning: '' })),
+        catchError(() => of({ data: [] as SaasAnalysisRun[], warning: '来源分析暂未加载' })),
+      ),
+      context: this.platform.context().pipe(
+        map((data) => ({ data, warning: '' })),
+        catchError(() => of({ data: null, warning: '工作空间成员信息暂未加载' })),
+      ),
+    }).pipe(takeUntil(this.destroy$)).subscribe({
+      next: ({ actions, analyses, context }) => {
+        this.actions = actions;
+        this.analyses = analyses.data;
+        this.context = context.data;
+        this.loadWarning = [analyses.warning, context.warning].filter(Boolean).join(';');
+        if (this.selectedAction) {
+          this.selectedAction = this.actions.find((item) => item.id === this.selectedAction?.id) ?? null;
+        }
+        this.loading = false;
+        this.refreshing = false;
+      },
+      error: () => {
+        this.error = '行动项加载失败,请检查工作区服务后重试。';
+        this.loading = false;
+        this.refreshing = false;
+      },
+    });
+  }
+
+  setViewMode(mode: ActionViewMode): void {
+    this.viewMode = mode;
+  }
+
+  resetFilters(): void {
+    this.searchTerm = '';
+    this.statusFilter = 'all';
+    this.assigneeFilter = 'all';
+    this.sourceFilter = 'all';
+  }
+
+  actionsForLane(lane: ActionLane): SaasActionItem[] {
+    return this.filteredActions.filter((action) => lane.statuses.includes(action.status));
+  }
+
+  openDetail(action: SaasActionItem): void {
+    this.detailReturnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+    this.selectedAction = action;
+    this.editStatus = action.status;
+    this.editAssignee = action.assigneeUserId ?? '';
+    this.editDueDate = action.dueAt?.slice(0, 10) ?? '';
+    this.updateError = '';
+    window.setTimeout(() => {
+      this.actionDetailDrawer?.nativeElement.querySelector<HTMLElement>('[aria-label="关闭行动详情"]')?.focus();
+    });
+  }
+
+  closeDetail(): void {
+    if (this.updating) return;
+    this.selectedAction = null;
+    this.updateError = '';
+    window.setTimeout(() => this.detailReturnFocus?.focus());
+  }
+
+  openCreatePanel(): void {
+    this.createPanelOpen = true;
+  }
+
+  closeCreatePanel(): void {
+    this.createPanelOpen = false;
+  }
+
+  onActionCreated(action: SaasActionItem): void {
+    this.actions = [action, ...this.actions.filter((item) => item.id !== action.id)];
+    this.openDetail(action);
+    window.setTimeout(() => this.closeCreatePanel(), 450);
+  }
+
+  saveAction(): void {
+    const action = this.selectedAction;
+    if (!action || this.updating) return;
+    this.updating = true;
+    this.updateError = '';
+    this.platform.updateAction(this.workspaceId, action.id, {
+      status: this.editStatus,
+      assigneeUserId: this.editAssignee || null,
+      dueAt: this.editDueDate ? new Date(`${this.editDueDate}T23:59:59.999`).toISOString() : null,
+    }).pipe(takeUntil(this.destroy$)).subscribe({
+      next: ({ action: updated }) => {
+        this.actions = this.actions.map((item) => item.id === updated.id ? updated : item);
+        this.selectedAction = updated;
+        this.editStatus = updated.status;
+        this.editAssignee = updated.assigneeUserId ?? '';
+        this.editDueDate = updated.dueAt?.slice(0, 10) ?? '';
+        this.updating = false;
+      },
+      error: (error: unknown) => {
+        this.updateError = this.updateErrorMessage(error);
+        this.updating = false;
+      },
+    });
+  }
+
+  sourceValue(action: SaasActionItem): ActionSource {
+    return action.sourceKind ?? (action.sourceAnalysisId ? 'insight' : 'rule_action');
+  }
+
+  sourceLabel(action: SaasActionItem): string {
+    const labels: Record<ActionSource, string> = {
+      insight: 'AI 洞察',
+      raw_feedback: '反馈原声',
+      rule_action: '规则识别',
     };
-    return labels[this.section];
+    return labels[this.sourceValue(action)];
+  }
+
+  statusLabel(status: ActionStatus): string {
+    return this.statusOptions.find((option) => option.value === status)?.label ?? status;
+  }
+
+  statusClass(status: ActionStatus): string {
+    return `status-${status.replace('_', '-')}`;
+  }
+
+  priorityLabel(priority: SaasActionItem['priority']): string {
+    return ({ critical: '紧急', high: '高', medium: '中', low: '低' } as const)[priority];
+  }
+
+  actionTypeLabel(type: SaasActionItem['actionType']): string {
+    return ({
+      general: '通用行动',
+      experience: '体验优化',
+      product: '产品迭代',
+      strategy: '长期策略',
+      data_quality: '补充证据',
+    } as const)[type];
+  }
+
+  ownerLabel(userId: string | null): string {
+    if (!userId) return '待分配';
+    if (userId === this.context?.principal.userId) return `${this.context.principal.displayName}(我)`;
+    return userId;
+  }
+
+  isOverdue(action: SaasActionItem): boolean {
+    if (!action.dueAt || ['completed', 'cancelled'].includes(action.status)) return false;
+    return new Date(action.dueAt).getTime() < Date.now();
+  }
+
+  date(value: string | null): string {
+    if (!value) return '未设置';
+    return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(value));
+  }
+
+  dateTime(value: string | null): string {
+    if (!value) return '-';
+    return new Intl.DateTimeFormat('zh-CN', {
+      year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
+    }).format(new Date(value));
   }
 
-  get p0Count(): number {
-    return this.actions.filter((action) => action.priority === 'P0').length;
+  private compareActions(left: SaasActionItem, right: SaasActionItem): number {
+    const priority = { critical: 0, high: 1, medium: 2, low: 3 } as const;
+    const byPriority = priority[left.priority] - priority[right.priority];
+    if (byPriority) return byPriority;
+    if (left.dueAt && right.dueAt) return new Date(left.dueAt).getTime() - new Date(right.dueAt).getTime();
+    if (left.dueAt) return -1;
+    if (right.dueAt) return 1;
+    return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
   }
 
-  get p1Count(): number {
-    return this.actions.filter((action) => action.priority === 'P1').length;
+  private normalizeSection(value: unknown): ActionSection {
+    return value === 'experience' || value === 'iteration' || value === 'strategy' ? value : 'kanban';
   }
 
-  get operatingActions(): DomesticOperatingAction[] {
-    return this.actions.filter((action) => action.type === '退款' || action.type === '转化');
+  private typeForSection(section: ActionSection): ActionTypeFilter {
+    return ({ kanban: 'all', experience: 'experience', iteration: 'product', strategy: 'strategy' } as const)[section];
   }
 
-  actionById(id: string): DomesticOperatingAction | undefined {
-    return this.actions.find((action) => action.id === id);
+  private isRecord(value: unknown): value is Record<string, unknown> {
+    return typeof value === 'object' && value !== null && !Array.isArray(value);
   }
 
-  toneForAction(action: DomesticOperatingAction): 'blue' | 'green' | 'amber' | 'red' | 'neutral' {
-    if (action.priority === 'P0') return 'red';
-    if (action.priority === 'P1') return 'amber';
-    return 'blue';
+  private recordString(record: Record<string, unknown>, key: string): string {
+    const value = record[key];
+    return typeof value === 'string' ? value : '';
   }
 
-  currency(value: number): string {
-    return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
+  private collectPages<T>(request: (cursor: string) => Observable<CursorPage<T>>): Observable<T[]> {
+    return request('').pipe(
+      expand((page) => page.nextCursor ? request(page.nextCursor) : EMPTY),
+      map((page) => page.items),
+      reduce((items, pageItems) => [...items, ...pageItems], [] as T[]),
+    );
   }
 
-  percent(value: number): string {
-    return new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value || 0);
+  private keepFocusInDetail(event: KeyboardEvent): void {
+    const root = this.actionDetailDrawer?.nativeElement;
+    if (!root) return;
+    const focusable = Array.from(root.querySelectorAll<HTMLElement>(
+      'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
+    ));
+    if (!focusable.length) return;
+    const first = focusable[0];
+    const last = focusable[focusable.length - 1];
+    if (event.shiftKey && document.activeElement === first) {
+      event.preventDefault();
+      last.focus();
+    } else if (!event.shiftKey && document.activeElement === last) {
+      event.preventDefault();
+      first.focus();
+    }
   }
 
-  private toneForPriority(priority: DomesticOperatingAction['priority']): 'red' | 'amber' | 'blue' {
-    if (priority === 'P0') return 'red';
-    if (priority === 'P1') return 'amber';
-    return 'blue';
+  private updateErrorMessage(error: unknown): string {
+    if (error instanceof HttpErrorResponse) {
+      const message = (error.error as { message?: unknown } | null)?.message;
+      if (typeof message === 'string' && message.trim()) return `行动项更新失败:${message}`;
+      if (error.status === 0) return '行动项更新失败:服务不可达,请检查网络后重试。';
+      if (error.status === 403) return '行动项更新失败:当前账号没有更新权限。';
+      if (error.status === 404) return '行动项更新失败:该记录已不存在,请刷新列表。';
+    }
+    return '行动项更新失败,请稍后重试。';
   }
 }

+ 1371 - 5
src/modules/domestic-voc/framework/domestic-framework.shared.scss

@@ -26,10 +26,19 @@
   display: grid;
   grid-template-columns: minmax(0, 1.6fr) minmax(280px, 1fr);
   gap: 16px;
-  align-items: start;
+  align-items: stretch;
   margin-bottom: 18px;
 }
 
+.two-column > app-content-card {
+  min-width: 0;
+}
+
+.two-column > app-content-card ::ng-deep .content-card {
+  height: 100%;
+  box-sizing: border-box;
+}
+
 .equal-column {
   grid-template-columns: repeat(2, minmax(0, 1fr));
 }
@@ -48,6 +57,23 @@
   height: 300px;
 }
 
+.trend-reference-grid {
+  grid-template-columns: minmax(0, 1.35fr) minmax(360px, 1fr);
+}
+
+.emotion-reference-grid {
+  align-items: stretch;
+}
+
+.emotion-reference-grid app-empty-state {
+  min-height: 230px;
+  display: grid;
+}
+
+.emotion-chart {
+  height: 250px;
+}
+
 .data-table {
   width: 100%;
   border-collapse: collapse;
@@ -64,6 +90,7 @@
   text-align: left;
   vertical-align: top;
   overflow-wrap: anywhere;
+  font-variant-numeric: tabular-nums;
 }
 
 .data-table th {
@@ -80,6 +107,58 @@
   width: 34%;
 }
 
+.reference-table th:nth-child(1),
+.reference-table td:nth-child(1) {
+  width: 15%;
+}
+
+.reference-table th:nth-child(2),
+.reference-table td:nth-child(2),
+.reference-table th:nth-child(3),
+.reference-table td:nth-child(3),
+.reference-table th:nth-child(5),
+.reference-table td:nth-child(5) {
+  width: 10%;
+}
+
+.review-copy-cell {
+  width: auto;
+}
+
+.review-copy {
+  display: -webkit-box;
+  overflow: hidden;
+  color: #334155;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.rating-badge {
+  display: inline-flex;
+  align-items: center;
+  min-height: 22px;
+  padding: 0 8px;
+  border-radius: 999px;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.rating-badge.negative {
+  color: #b91c1c;
+  background: #fee2e2;
+}
+
+.table-action-link {
+  color: #2563eb;
+  font-weight: 800;
+  text-decoration: none;
+  white-space: nowrap;
+}
+
+.table-action-link:hover {
+  text-decoration: underline;
+}
+
 .rank-list,
 .info-list {
   display: grid;
@@ -125,6 +204,26 @@
   font-size: 11px;
 }
 
+.topic-rank-row {
+  align-items: start;
+}
+
+.topic-rank-list .rank-copy strong,
+.topic-rank-list .rank-copy span {
+  white-space: normal;
+}
+
+.topic-sample {
+  display: -webkit-box;
+  margin: 6px 0 0;
+  overflow: hidden;
+  color: #475569;
+  font-size: 11px;
+  line-height: 1.55;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
 .rank-value,
 .info-value {
   color: #2563eb;
@@ -178,27 +277,1294 @@
 
 @media (max-width: 900px) {
   .two-column,
-  .equal-column {
+  .equal-column,
+  .trend-reference-grid {
     grid-template-columns: 1fr;
   }
 }
 
 @media (max-width: 720px) {
   .framework-page {
-    padding: 16px;
+    padding: 16px 14px 72px;
+  }
+
+  .metric-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px;
+  }
+
+  .metric-grid ::ng-deep .metric-value-row strong {
+    font-size: clamp(21px, 6.3vw, 27px);
   }
 
-  .metric-grid,
   .status-grid {
     grid-template-columns: 1fr;
   }
 
   .chart,
   .compact-chart {
-    height: 290px;
+    height: 260px;
   }
 
   .product-cell {
     width: 48%;
   }
 }
+
+/* Action center workbench. Keep this block scoped because this stylesheet is shared. */
+.action-workbench {
+  color: #18212f;
+  letter-spacing: 0;
+
+  .action-header-actions,
+  .state-actions,
+  .detail-badges {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .action-primary-button,
+  .action-secondary-button,
+  .action-text-button,
+  .action-icon-button,
+  .row-open-button {
+    display: inline-flex;
+    height: 34px;
+    padding: 0 12px;
+    align-items: center;
+    justify-content: center;
+    gap: 7px;
+    border: 1px solid transparent;
+    border-radius: 5px;
+    font: inherit;
+    font-size: 12px;
+    font-weight: 700;
+    letter-spacing: 0;
+    cursor: pointer;
+    transition: 140ms ease;
+  }
+
+  .action-primary-button {
+    color: #fff;
+    background: #2563eb;
+
+    &:hover:not(:disabled) {
+      background: #1d4ed8;
+    }
+  }
+
+  .action-secondary-button {
+    border-color: #d5dce6;
+    color: #334155;
+    background: #fff;
+
+    &:hover:not(:disabled) {
+      border-color: #aeb8c6;
+      background: #f8fafc;
+    }
+  }
+
+  .action-text-button {
+    padding: 0 8px;
+    color: #475569;
+    background: transparent;
+
+    &:hover {
+      color: #1d4ed8;
+      background: #eff6ff;
+    }
+  }
+
+  .action-icon-button,
+  .row-open-button {
+    width: 34px;
+    padding: 0;
+    border-color: #dbe1e9;
+    color: #526072;
+    background: #fff;
+
+    &:hover:not(:disabled) {
+      border-color: #b9c3d1;
+      color: #1d4ed8;
+      background: #f8fafc;
+    }
+  }
+
+  button:disabled {
+    opacity: 0.52;
+    cursor: not-allowed;
+  }
+
+  button:focus-visible,
+  input:focus-visible,
+  select:focus-visible {
+    outline: 3px solid rgba(37, 99, 235, 0.2);
+    outline-offset: 1px;
+  }
+
+  .action-scope-tabs {
+    margin-bottom: 12px;
+  }
+
+  .action-health-band {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+    margin-bottom: 14px;
+    overflow: hidden;
+    border: 1px solid #dfe5ec;
+    border-radius: 6px;
+    background: #fff;
+  }
+
+  .health-band-item {
+    position: relative;
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) auto;
+    gap: 2px 10px;
+    padding: 12px 16px;
+    border-right: 1px solid #edf0f4;
+
+    &:last-child {
+      border-right: 0;
+    }
+
+    span {
+      color: #64748b;
+      font-size: 11px;
+      font-weight: 700;
+    }
+
+    strong {
+      grid-row: 1 / span 2;
+      grid-column: 2;
+      align-self: center;
+      color: #18212f;
+      font-size: 21px;
+      line-height: 1;
+      font-variant-numeric: tabular-nums;
+    }
+
+    small {
+      color: #94a3b8;
+      font-size: 10px;
+    }
+
+    &.health-risk strong {
+      color: #c2410c;
+    }
+  }
+
+  .action-surface {
+    min-width: 0;
+    overflow: hidden;
+    border: 1px solid #dfe5ec;
+    border-radius: 6px;
+    background: #fff;
+  }
+
+  .action-toolbar {
+    display: grid;
+    grid-template-columns: minmax(240px, 1fr) auto auto;
+    gap: 12px;
+    align-items: center;
+    min-height: 58px;
+    padding: 10px 12px;
+    border-bottom: 1px solid #e8edf3;
+    background: #fbfcfd;
+  }
+
+  .action-search {
+    position: relative;
+    display: block;
+    min-width: 0;
+
+    lucide-icon {
+      position: absolute;
+      z-index: 1;
+      top: 50%;
+      left: 10px;
+      color: #7a8797;
+      transform: translateY(-50%);
+      pointer-events: none;
+    }
+
+    input {
+      width: 100%;
+      height: 34px;
+      box-sizing: border-box;
+      padding: 0 10px 0 34px;
+      border: 1px solid #d5dce6;
+      border-radius: 5px;
+      color: #1f2937;
+      background: #fff;
+      font: inherit;
+      font-size: 12px;
+
+      &::placeholder {
+        color: #9aa5b4;
+      }
+    }
+  }
+
+  .action-filter-group {
+    display: flex;
+    min-width: 0;
+    align-items: center;
+    gap: 7px;
+  }
+
+  .action-select select,
+  .detail-form-grid select,
+  .detail-form-grid input {
+    height: 34px;
+    box-sizing: border-box;
+    padding: 0 28px 0 9px;
+    border: 1px solid #d5dce6;
+    border-radius: 5px;
+    color: #334155;
+    background: #fff;
+    font: inherit;
+    font-size: 12px;
+  }
+
+  .action-select select {
+    min-width: 112px;
+  }
+
+  .action-view-switch {
+    display: grid;
+    grid-template-columns: repeat(2, 32px);
+    gap: 2px;
+    padding: 2px;
+    border: 1px solid #d5dce6;
+    border-radius: 5px;
+    background: #f1f5f9;
+
+    button {
+      display: grid;
+      width: 32px;
+      height: 28px;
+      padding: 0;
+      place-items: center;
+      border: 0;
+      border-radius: 3px;
+      color: #64748b;
+      background: transparent;
+      cursor: pointer;
+
+      &.active {
+        color: #1d4ed8;
+        background: #fff;
+        box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
+      }
+    }
+  }
+
+  .action-state {
+    display: flex;
+    min-height: 390px;
+    padding: 36px;
+    box-sizing: border-box;
+    align-items: center;
+    justify-content: center;
+    flex-direction: column;
+    gap: 9px;
+    color: #64748b;
+    text-align: center;
+
+    > strong {
+      color: #253044;
+      font-size: 15px;
+    }
+
+    > span:not(.state-icon) {
+      max-width: 440px;
+      font-size: 12px;
+      line-height: 1.65;
+    }
+
+    .state-icon {
+      display: grid;
+      width: 44px;
+      height: 44px;
+      margin-bottom: 4px;
+      place-items: center;
+      border: 1px solid #dbeafe;
+      border-radius: 6px;
+      color: #2563eb;
+      background: #eff6ff;
+    }
+
+    .state-actions {
+      margin-top: 8px;
+    }
+  }
+
+  .action-error-state > lucide-icon {
+    color: #dc2626;
+  }
+
+  .action-load-warning {
+    display: flex;
+    min-height: 38px;
+    margin-bottom: 10px;
+    padding: 8px 12px;
+    align-items: center;
+    gap: 8px;
+    border: 1px solid #fde68a;
+    border-radius: 6px;
+    color: #92400e;
+    background: #fffbeb;
+    font-size: 12px;
+    line-height: 1.5;
+  }
+
+  .action-table-wrap {
+    width: 100%;
+    min-width: 0;
+    overflow-x: auto;
+  }
+
+  .action-table {
+    width: 100%;
+    min-width: 1060px;
+    border-collapse: collapse;
+    table-layout: fixed;
+
+    th,
+    td {
+      padding: 10px 12px;
+      border-bottom: 1px solid #edf0f4;
+      color: #475569;
+      font-size: 11px;
+      line-height: 1.45;
+      text-align: left;
+      vertical-align: middle;
+    }
+
+    th {
+      position: sticky;
+      z-index: 1;
+      top: 0;
+      color: #64748b;
+      background: #f8fafc;
+      font-weight: 700;
+    }
+
+    th:nth-child(1) { width: 30%; }
+    th:nth-child(2) { width: 12%; }
+    th:nth-child(3) { width: 7%; }
+    th:nth-child(4) { width: 9%; }
+    th:nth-child(5) { width: 12%; }
+    th:nth-child(6) { width: 9%; }
+    th:nth-child(7) { width: 17%; }
+    th:nth-child(8) { width: 40px; }
+
+    tbody tr {
+      transition: background 120ms ease;
+
+      &:hover {
+        background: #f8fbff;
+      }
+
+      &.is-overdue {
+        box-shadow: inset 3px 0 #ef4444;
+      }
+    }
+
+    tbody tr:last-child td {
+      border-bottom: 0;
+    }
+  }
+
+  .action-title-button {
+    display: block;
+    width: 100%;
+    padding: 0;
+    overflow: hidden;
+    border: 0;
+    color: inherit;
+    background: transparent;
+    text-align: left;
+    cursor: pointer;
+
+    strong,
+    span {
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    strong {
+      color: #1f2937;
+      font-size: 12px;
+      font-weight: 700;
+    }
+
+    span {
+      margin-top: 3px;
+      color: #7a8797;
+      font-size: 10px;
+    }
+
+    &:hover strong {
+      color: #1d4ed8;
+    }
+  }
+
+  .action-type-label,
+  .action-source-label,
+  .validation-cell {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .action-type-label {
+    color: #334155;
+    font-weight: 700;
+  }
+
+  .action-source-label {
+    margin-top: 2px;
+    color: #94a3b8;
+    font-size: 10px;
+  }
+
+  .priority-badge,
+  .status-badge,
+  .overdue-badge {
+    display: inline-flex;
+    min-height: 22px;
+    padding: 0 7px;
+    align-items: center;
+    gap: 5px;
+    border: 1px solid transparent;
+    border-radius: 4px;
+    font-size: 10px;
+    font-weight: 700;
+    white-space: nowrap;
+  }
+
+  .priority-critical {
+    border-color: #fecaca;
+    color: #b91c1c;
+    background: #fef2f2;
+  }
+
+  .priority-high {
+    border-color: #fed7aa;
+    color: #c2410c;
+    background: #fff7ed;
+  }
+
+  .priority-medium {
+    border-color: #bfdbfe;
+    color: #1d4ed8;
+    background: #eff6ff;
+  }
+
+  .priority-low {
+    border-color: #dbe1e9;
+    color: #64748b;
+    background: #f8fafc;
+  }
+
+  .status-badge i {
+    width: 6px;
+    height: 6px;
+    border-radius: 50%;
+    background: currentColor;
+  }
+
+  .status-open,
+  .status-planned {
+    color: #526072;
+    background: #f1f5f9;
+  }
+
+  .status-in-progress {
+    color: #1d4ed8;
+    background: #eff6ff;
+  }
+
+  .status-blocked {
+    color: #b45309;
+    background: #fffbeb;
+  }
+
+  .status-completed {
+    color: #047857;
+    background: #ecfdf5;
+  }
+
+  .status-cancelled {
+    color: #64748b;
+    background: #f1f5f9;
+    text-decoration: line-through;
+  }
+
+  .overdue-badge {
+    color: #b91c1c;
+    background: #fef2f2;
+  }
+
+  .owner-cell,
+  .board-card-meta span {
+    display: inline-flex;
+    min-width: 0;
+    align-items: center;
+    gap: 5px;
+  }
+
+  .owner-cell {
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .due-cell {
+    white-space: nowrap;
+    font-variant-numeric: tabular-nums;
+  }
+
+  .due-overdue {
+    color: #dc2626 !important;
+    font-weight: 700;
+  }
+
+  .row-open-button {
+    border-color: transparent;
+    background: transparent;
+  }
+
+  .action-board {
+    display: grid;
+    grid-template-columns: repeat(4, minmax(220px, 1fr));
+    min-height: 480px;
+    overflow-x: auto;
+    background: #f6f8fa;
+  }
+
+  .action-lane {
+    min-width: 0;
+    padding: 12px;
+    border-right: 1px solid #e3e8ef;
+
+    &:last-child {
+      border-right: 0;
+    }
+  }
+
+  .lane-header {
+    display: flex;
+    min-height: 40px;
+    margin-bottom: 10px;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 10px;
+
+    strong,
+    span {
+      display: block;
+    }
+
+    strong {
+      color: #253044;
+      font-size: 12px;
+    }
+
+    span {
+      margin-top: 2px;
+      color: #8a96a6;
+      font-size: 10px;
+    }
+
+    b {
+      display: grid;
+      min-width: 24px;
+      height: 22px;
+      place-items: center;
+      border: 1px solid #d5dce6;
+      border-radius: 4px;
+      color: #526072;
+      background: #fff;
+      font-size: 10px;
+    }
+  }
+
+  .lane-list {
+    display: grid;
+    align-content: start;
+    gap: 8px;
+  }
+
+  .action-board-card {
+    display: block;
+    width: 100%;
+    min-width: 0;
+    padding: 11px;
+    overflow: hidden;
+    border: 1px solid #dfe5ec;
+    border-radius: 5px;
+    color: #475569;
+    background: #fff;
+    font: inherit;
+    text-align: left;
+    cursor: pointer;
+    box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
+    transition: 140ms ease;
+
+    &:hover {
+      border-color: #b9c7d8;
+      box-shadow: 0 4px 12px rgba(15, 23, 42, 0.07);
+      transform: translateY(-1px);
+    }
+
+    &.is-overdue {
+      border-left: 3px solid #ef4444;
+    }
+
+    > strong,
+    .board-card-description {
+      display: -webkit-box;
+      overflow: hidden;
+      -webkit-box-orient: vertical;
+    }
+
+    > strong {
+      margin-top: 9px;
+      color: #1f2937;
+      font-size: 12px;
+      line-height: 1.45;
+      -webkit-line-clamp: 2;
+    }
+  }
+
+  .board-card-topline,
+  .board-card-meta {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .board-card-topline > span:last-child {
+    overflow: hidden;
+    color: #8a96a6;
+    font-size: 10px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .board-card-description {
+    margin-top: 5px;
+    color: #7a8797;
+    font-size: 10px;
+    line-height: 1.5;
+    -webkit-line-clamp: 2;
+  }
+
+  .board-card-meta {
+    margin-top: 12px;
+    padding-top: 9px;
+    border-top: 1px solid #edf0f4;
+    color: #64748b;
+    font-size: 9px;
+  }
+
+  .lane-empty {
+    display: grid;
+    min-height: 72px;
+    place-items: center;
+    border: 1px dashed #d5dce6;
+    border-radius: 5px;
+    color: #9aa5b4;
+    background: rgba(255, 255, 255, 0.55);
+    font-size: 10px;
+  }
+
+  .action-detail-backdrop {
+    position: fixed;
+    z-index: 100100;
+    inset: 0;
+    display: flex;
+    justify-content: flex-end;
+    background: rgba(15, 23, 42, 0.36);
+    backdrop-filter: blur(1px);
+    animation: action-backdrop-in 150ms ease-out;
+  }
+
+  .action-detail-drawer {
+    display: flex;
+    width: min(560px, 100vw);
+    min-width: 0;
+    height: 100%;
+    flex-direction: column;
+    background: #fff;
+    border-left: 1px solid #d5dce6;
+    box-shadow: -18px 0 48px rgba(15, 23, 42, 0.16);
+    animation: action-drawer-in 170ms ease-out;
+  }
+
+  .detail-header {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) 34px;
+    gap: 14px;
+    padding: 17px 18px 15px;
+    border-bottom: 1px solid #e8edf3;
+  }
+
+  .detail-heading {
+    min-width: 0;
+
+    h2 {
+      margin: 4px 0 9px;
+      color: #172033;
+      font-size: 17px;
+      line-height: 1.45;
+      letter-spacing: 0;
+    }
+  }
+
+  .detail-kicker {
+    color: #64748b;
+    font-size: 10px;
+    font-weight: 700;
+  }
+
+  .detail-body {
+    min-height: 0;
+    flex: 1;
+    overflow-y: auto;
+  }
+
+  .detail-section {
+    padding: 17px 18px;
+    border-bottom: 1px solid #edf0f4;
+
+    h3 {
+      margin: 0;
+      color: #253044;
+      font-size: 12px;
+      line-height: 1.4;
+      letter-spacing: 0;
+    }
+
+    > p,
+    .validation-section p {
+      margin: 8px 0 0;
+      color: #526072;
+      font-size: 12px;
+      line-height: 1.7;
+      white-space: pre-wrap;
+    }
+  }
+
+  .detail-summary {
+    background: #fbfcfd;
+  }
+
+  .detail-product {
+    display: grid;
+    grid-template-columns: 18px auto minmax(0, 1fr);
+    gap: 7px;
+    align-items: center;
+    margin-top: 12px;
+    padding: 8px 10px;
+    border: 1px solid #e2e8f0;
+    border-radius: 5px;
+    color: #64748b;
+    background: #fff;
+    font-size: 10px;
+
+    strong {
+      overflow: hidden;
+      color: #334155;
+      text-align: right;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .section-title-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+
+    > span {
+      color: #8a96a6;
+      font-size: 10px;
+    }
+  }
+
+  .detail-form-grid {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 9px;
+    margin-top: 11px;
+
+    label {
+      display: grid;
+      min-width: 0;
+      gap: 5px;
+
+      > span {
+        color: #64748b;
+        font-size: 10px;
+        font-weight: 700;
+      }
+    }
+
+    select,
+    input {
+      width: 100%;
+      min-width: 0;
+    }
+  }
+
+  .detail-error {
+    margin: 9px 0 0;
+    color: #b91c1c;
+    font-size: 10px;
+  }
+
+  .insight-reference {
+    display: grid;
+    grid-template-columns: 32px minmax(0, 1fr);
+    gap: 10px;
+    margin-top: 11px;
+
+    .insight-icon {
+      display: grid;
+      width: 32px;
+      height: 32px;
+      place-items: center;
+      border-radius: 5px;
+      color: #7c3aed;
+      background: #f5f3ff;
+    }
+
+    strong,
+    p,
+    small {
+      display: block;
+    }
+
+    strong {
+      color: #253044;
+      font-size: 12px;
+    }
+
+    p {
+      margin: 5px 0 0;
+      color: #526072;
+      font-size: 11px;
+      line-height: 1.55;
+    }
+
+    small {
+      margin-top: 6px;
+      color: #7a8797;
+      font-size: 10px;
+      line-height: 1.55;
+    }
+  }
+
+  .reference-grid {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 9px;
+    margin-top: 11px;
+
+    div {
+      min-width: 0;
+      padding: 9px 10px;
+      border: 1px solid #e3e8ef;
+      border-radius: 5px;
+      background: #fbfcfd;
+    }
+
+    span,
+    strong {
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    span {
+      color: #8a96a6;
+      font-size: 9px;
+    }
+
+    strong {
+      margin-top: 3px;
+      color: #334155;
+      font-size: 10px;
+    }
+  }
+
+  .id-list {
+    display: grid;
+    gap: 6px;
+    margin: 10px 0 0;
+
+    div {
+      display: grid;
+      grid-template-columns: 64px minmax(0, 1fr);
+      gap: 10px;
+      align-items: start;
+    }
+
+    dt {
+      color: #8a96a6;
+      font-size: 9px;
+    }
+
+    dd {
+      margin: 0;
+      overflow: hidden;
+      color: #526072;
+      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+      font-size: 9px;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .evidence-list {
+    display: grid;
+    gap: 6px;
+    margin-top: 10px;
+  }
+
+  .evidence-row {
+    display: grid;
+    grid-template-columns: 22px minmax(0, 1fr) 16px;
+    gap: 8px;
+    align-items: center;
+    min-height: 32px;
+    padding: 0 9px;
+    border: 1px solid #e3e8ef;
+    border-radius: 5px;
+    color: #7a8797;
+    background: #fbfcfd;
+
+    > span {
+      font-size: 9px;
+      font-variant-numeric: tabular-nums;
+    }
+
+    code {
+      overflow: hidden;
+      color: #334155;
+      font-size: 9px;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .detail-empty-row {
+    margin-top: 10px;
+    padding: 12px;
+    border: 1px dashed #d5dce6;
+    border-radius: 5px;
+    color: #8a96a6;
+    background: #fbfcfd;
+    font-size: 10px;
+    text-align: center;
+  }
+
+  .validation-section {
+    display: grid;
+    grid-template-columns: 36px minmax(0, 1fr);
+    gap: 10px;
+
+    .validation-icon {
+      display: grid;
+      width: 36px;
+      height: 36px;
+      place-items: center;
+      border-radius: 5px;
+      color: #047857;
+      background: #ecfdf5;
+    }
+  }
+
+  .status-timeline {
+    display: grid;
+    gap: 0;
+    margin: 12px 0 0;
+    padding: 0;
+    list-style: none;
+
+    li {
+      position: relative;
+      display: grid;
+      grid-template-columns: 24px minmax(0, 1fr);
+      gap: 9px;
+      min-height: 48px;
+
+      &:not(:last-child)::after {
+        position: absolute;
+        top: 22px;
+        bottom: 0;
+        left: 11px;
+        width: 1px;
+        background: #dbe1e9;
+        content: '';
+      }
+
+      > i {
+        position: relative;
+        z-index: 1;
+        display: grid;
+        width: 24px;
+        height: 24px;
+        place-items: center;
+        border: 1px solid #cbd5e1;
+        border-radius: 50%;
+        color: #64748b;
+        background: #fff;
+        font-style: normal;
+      }
+
+      &.complete > i {
+        border-color: #a7f3d0;
+        color: #047857;
+        background: #ecfdf5;
+      }
+
+      &.current > i {
+        border-color: #bfdbfe;
+        color: #2563eb;
+        background: #eff6ff;
+      }
+
+      strong,
+      span {
+        display: block;
+      }
+
+      strong {
+        color: #334155;
+        font-size: 11px;
+      }
+
+      span {
+        margin-top: 3px;
+        color: #8a96a6;
+        font-size: 9px;
+      }
+    }
+  }
+
+  .detail-footer {
+    display: flex;
+    min-height: 64px;
+    padding: 12px 18px;
+    box-sizing: border-box;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    border-top: 1px solid #e3e8ef;
+    background: #fbfcfd;
+
+    > span {
+      color: #7a8797;
+      font-size: 10px;
+    }
+  }
+
+  .spinning {
+    animation: action-spin 800ms linear infinite;
+  }
+
+  .sr-only {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    overflow: hidden;
+    clip: rect(0, 0, 0, 0);
+    border: 0;
+    white-space: nowrap;
+  }
+}
+
+@keyframes action-drawer-in {
+  from { transform: translateX(28px); }
+  to { transform: translateX(0); }
+}
+
+@keyframes action-backdrop-in {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+@keyframes action-spin {
+  to { transform: rotate(360deg); }
+}
+
+@media (max-width: 1120px) {
+  .action-workbench {
+    .action-toolbar {
+      grid-template-columns: minmax(220px, 1fr) auto;
+    }
+
+    .action-view-switch {
+      grid-row: 1;
+      grid-column: 2;
+    }
+
+    .action-filter-group {
+      grid-row: 2;
+      grid-column: 1 / -1;
+    }
+
+    .action-board {
+      grid-template-columns: repeat(4, minmax(250px, 1fr));
+    }
+  }
+}
+
+@media (max-width: 720px) {
+  .action-workbench {
+    .action-header-actions {
+      width: 100%;
+
+      .action-primary-button {
+        flex: 1;
+      }
+    }
+
+    .action-health-band {
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+
+    .health-band-item {
+      border-bottom: 1px solid #edf0f4;
+
+      &:nth-child(2) {
+        border-right: 0;
+      }
+
+      &:nth-child(n + 3) {
+        border-bottom: 0;
+      }
+    }
+
+    .action-toolbar {
+      grid-template-columns: minmax(0, 1fr) auto;
+      gap: 8px;
+      padding: 9px;
+    }
+
+    .action-filter-group {
+      display: grid;
+      grid-template-columns: repeat(3, minmax(0, 1fr));
+      gap: 6px;
+
+      .action-text-button {
+        grid-column: 1 / -1;
+      }
+    }
+
+    .action-select,
+    .action-select select {
+      width: 100%;
+      min-width: 0;
+    }
+
+    .action-state {
+      min-height: 340px;
+      padding: 28px 16px;
+    }
+
+    .action-table {
+      min-width: 0;
+      table-layout: auto;
+
+      th:nth-child(2),
+      td:nth-child(2),
+      th:nth-child(3),
+      td:nth-child(3),
+      th:nth-child(5),
+      td:nth-child(5),
+      th:nth-child(7),
+      td:nth-child(7) {
+        display: none;
+      }
+
+      th,
+      td {
+        padding: 10px 8px;
+      }
+
+      th:nth-child(1) { width: auto; }
+      th:nth-child(4) { width: 82px; }
+      th:nth-child(6) { width: 82px; }
+      th:nth-child(8) { width: 32px; }
+    }
+
+    .action-board {
+      grid-template-columns: minmax(0, 1fr);
+      min-height: 0;
+      overflow-x: hidden;
+    }
+
+    .action-lane {
+      border-right: 0;
+      border-bottom: 1px solid #e3e8ef;
+
+      &:last-child {
+        border-bottom: 0;
+      }
+    }
+
+    .action-detail-drawer {
+      width: 100vw;
+      border-left: 0;
+    }
+
+    .detail-header,
+    .detail-section {
+      padding-right: 14px;
+      padding-left: 14px;
+    }
+
+    .detail-form-grid {
+      grid-template-columns: minmax(0, 1fr);
+    }
+
+    .reference-grid {
+      grid-template-columns: minmax(0, 1fr);
+    }
+
+    .detail-footer {
+      padding: 10px 14px max(10px, env(safe-area-inset-bottom));
+
+      > span {
+        display: none;
+      }
+
+      .action-primary-button {
+        width: 100%;
+      }
+    }
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .action-workbench .action-detail-backdrop,
+  .action-workbench .action-detail-drawer,
+  .action-workbench .spinning {
+    animation-duration: 1ms;
+  }
+}

+ 77 - 21
src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.html

@@ -3,57 +3,112 @@
     <div class="framework-state"><app-loading-spinner size="lg" text="正在加载监测框架..."></app-loading-spinner></div>
   } @else if (error) {
     <div class="framework-state"><app-error-state [description]="error"></app-error-state></div>
-  } @else if (dataset && summary) {
+  } @else if (dataset && summary && reviewScope) {
     <app-page-header
       [title]="title"
       eyebrow="Domestic Monitoring"
       [description]="description"
       icon="◉"
-      [badges]="[{ label: dataset.platform, tone: 'blue' }, { label: dataset.summary.reviewCount ? '评论已接入' : '评论待接入', tone: dataset.summary.reviewCount ? 'green' : 'amber' }]"
+      [badges]="[{ label: dataset.platform, tone: 'blue' }, { label: reviewScope.ownCount ? '本品评论已接入' : '本品评论待接入', tone: reviewScope.ownCount ? 'green' : 'amber' }, { label: '竞品评论 ' + reviewScope.competitorCount, tone: 'purple' }]"
       [meta]="[{ label: '案例', value: dataset.caseName }, { label: '数据周期', value: dataset.source.dateRange.start + ' 至 ' + dataset.source.dateRange.end }]"
-      [sampleSize]="dataset.summary.reviewCount"
-      [confidence]="dataset.summary.reviewCount ? 'medium' : 'low'">
+      [sampleSize]="reviewScope.ownCount"
+      [confidence]="reviewScope.ownCount ? 'medium' : 'low'">
     </app-page-header>
 
     <app-board-tabs class="framework-tabs" [tabs]="tabs" [activeKey]="section" density="compact"></app-board-tabs>
 
     @if (section === 'trend') {
       <div class="metric-grid">
-        <app-summary-metric-card label="评论样本" [value]="number(dataset.summary.reviewCount)" icon="▤" [tone]="dataset.summary.reviewCount ? 'green' : 'default'" description="VOC 趋势统计口径"></app-summary-metric-card>
+        <app-summary-metric-card label="本品评论样本" [value]="number(reviewScope.ownCount)" icon="▤" [tone]="reviewScope.ownCount ? 'green' : 'default'" description="德玛仕 VOC 趋势口径"></app-summary-metric-card>
         <app-summary-metric-card label="数据天数" [value]="number(dataset.dailyTotals.length)" icon="▦" tone="blue" description="当前经营指标周期"></app-summary-metric-card>
         <app-summary-metric-card label="经营商品" [value]="number(dataset.summary.metricProducts)" icon="□" tone="purple" description="可关联评论的商品池"></app-summary-metric-card>
-        <app-summary-metric-card label="评论覆盖率" [value]="dataset.summary.reviewCount ? '已接入' : '0.0%'" icon="◎" tone="amber" description="当前文件无评论正文"></app-summary-metric-card>
+        <app-summary-metric-card label="竞品评论样本" [value]="number(reviewScope.competitorCount)" icon="◎" tone="purple" description="仅用于竞品分析"></app-summary-metric-card>
       </div>
 
       <app-alert-card
         title="VOC 趋势等待评论数据"
-        description="下方经营趋势用于核对业务背景,不参与情绪或话题结论;评论明细接入后,原趋势框架可在同一页面增加声量序列。"
-        primaryMetric="0 条评论"
+        description="经营趋势仅用于业务背景;右侧竞品评论趋势是独立参照池,不参与德玛仕本品 VOC 结论。"
+        [primaryMetric]="reviewScope.ownCount + ' 条本品评论'"
         icon="!"
         tone="amber">
       </app-alert-card>
 
-      <app-content-card title="经营背景趋势" subtitle="成交金额与退款金额,为后续 VOC 异常提供业务对照" tone="blue">
-        <div echarts class="chart" [options]="trendOptions"></div>
-      </app-content-card>
+      <div class="two-column trend-reference-grid">
+        <app-content-card title="经营背景趋势" subtitle="成交金额与退款金额,为后续 VOC 异常提供业务对照" tone="blue">
+          <div echarts class="chart compact-chart" [options]="trendOptions"></div>
+        </app-content-card>
+        <app-content-card title="竞品评论参照趋势" [subtitle]="'独立统计 ' + meaningfulCompetitorReviews.length + ' 条有效竞品评论;不代表本品'" tone="purple">
+          @if (meaningfulCompetitorReviews.length) {
+            <div echarts class="chart compact-chart" [options]="competitorReviewTrendOptions"></div>
+          } @else {
+            <app-empty-state size="sm" title="暂无竞品评论趋势" description="竞品评论接入后展示月度声量与平均评分。"></app-empty-state>
+          }
+        </app-content-card>
+      </div>
+
+      <app-data-table-shell title="竞品负向证据参照" [subtitle]="'仅展示有效文本;已过滤平台占位评价 ' + filteredPlaceholderCount + ' 条'" [count]="competitorNegativeEvidence.length" [scrollable]="true">
+        <table class="data-table reference-table">
+          <thead><tr><th>竞品商品</th><th>评分</th><th>评价日期</th><th class="review-copy-cell">原始评价证据</th><th>下钻</th></tr></thead>
+          <tbody>
+            @for (review of competitorNegativeEvidence; track review.reviewId) {
+              <tr>
+                <td><strong>{{ review.productId }}</strong></td>
+                <td><span class="rating-badge negative">{{ review.rating }} 星</span></td>
+                <td>{{ date(review.reviewDate) }}</td>
+                <td class="review-copy-cell"><span class="review-copy">{{ review.content }}</span></td>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', review.productId]">竞品详情</a></td>
+              </tr>
+            } @empty {
+              <tr><td colspan="5"><app-empty-state size="sm" variant="table" title="暂无有效负向证据"></app-empty-state></td></tr>
+            }
+          </tbody>
+        </table>
+      </app-data-table-shell>
     }
 
     @if (section === 'emotion') {
       <div class="metric-grid">
-        <app-summary-metric-card label="正向评论" value="-" icon="↑" tone="green" description="等待真实评分"></app-summary-metric-card>
-        <app-summary-metric-card label="中性评论" value="-" icon="—" tone="amber" description="等待真实评分"></app-summary-metric-card>
-        <app-summary-metric-card label="负向评论" value="-" icon="↓" tone="red" description="等待真实评分"></app-summary-metric-card>
-        <app-summary-metric-card label="评论样本" [value]="dataset.summary.reviewCount" icon="▤" tone="default" description="当前可分析样本"></app-summary-metric-card>
+        <app-summary-metric-card label="本品评论样本" [value]="reviewScope.ownCount" icon="▤" tone="default" description="德玛仕情绪结论口径"></app-summary-metric-card>
+        <app-summary-metric-card label="本品情绪分布" value="-" icon="◉" tone="amber" description="等待本品真实评分"></app-summary-metric-card>
+        <app-summary-metric-card label="竞品有效参照" [value]="meaningfulCompetitorReviews.length" icon="◇" tone="purple" [description]="filteredPlaceholderCount + ' 条占位评价已过滤'"></app-summary-metric-card>
+        <app-summary-metric-card label="竞品负向参照" [value]="percentValue(competitorReviewStats.negativeRate)" icon="↓" tone="red" [description]="competitorReviewStats.negative + ' 条 1-2 星有效文本'"></app-summary-metric-card>
       </div>
 
-      <div class="two-column equal-column">
-        <app-content-card title="情绪分布" subtitle="按真实评论评分和文本分析生成" variant="dashed" tone="amber">
-          <app-empty-state variant="low-sample" title="暂无情绪样本" description="当前德玛仕源文件不包含评论正文,因此不展示正向、负向或中性占比。"></app-empty-state>
+      <app-alert-card
+        title="本品结论与竞品参照严格分池"
+        [description]="'德玛仕本品评论仍为 0;下方竞品分布仅用于了解类目参照,已过滤 ' + filteredPlaceholderCount + ' 条平台占位评价。'"
+        [primaryMetric]="meaningfulCompetitorReviews.length + ' 条竞品有效文本'"
+        icon="i"
+        tone="blue">
+      </app-alert-card>
+
+      <div class="two-column equal-column emotion-reference-grid">
+        <app-content-card title="本品情绪分布" subtitle="德玛仕商品的真实评论评分与文本分析" variant="dashed" tone="amber">
+          <app-empty-state size="sm" variant="low-sample" title="暂无本品情绪样本" description="当前德玛仕源文件不包含评论正文;接入后在此恢复正向、中性、负向分布和主题下钻。"></app-empty-state>
         </app-content-card>
-        <app-content-card title="关键词与情绪主题" subtitle="保留原系统的话题和关键词分析入口" variant="dashed" tone="purple">
-          <app-empty-state title="暂无关键词证据" description="评论接入后才能提取情绪主题、典型短语和代表性原声。"></app-empty-state>
+        <app-content-card title="竞品评分分布(参照)" [subtitle]="'有效文本 ' + meaningfulCompetitorReviews.length + ' 条 · 平均评分 ' + competitorReviewStats.averageRating.toFixed(1)" tone="purple">
+          <div echarts class="chart emotion-chart" [options]="competitorRatingOptions"></div>
         </app-content-card>
       </div>
+
+      <app-data-table-shell title="竞品负向原声" subtitle="保留原版证据优先的展示方式;点击可进入竞品详情继续筛选和 AI 分析" [count]="competitorNegativeEvidence.length" [scrollable]="true">
+        <table class="data-table reference-table">
+          <thead><tr><th>竞品商品</th><th>评分</th><th>评价日期</th><th class="review-copy-cell">原始评价证据</th><th>下钻</th></tr></thead>
+          <tbody>
+            @for (review of competitorNegativeEvidence; track review.reviewId) {
+              <tr>
+                <td><strong>{{ review.productId }}</strong></td>
+                <td><span class="rating-badge negative">{{ review.rating }} 星</span></td>
+                <td>{{ date(review.reviewDate) }}</td>
+                <td class="review-copy-cell"><span class="review-copy">{{ review.content }}</span></td>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', review.productId]">竞品详情</a></td>
+              </tr>
+            } @empty {
+              <tr><td colspan="5"><app-empty-state size="sm" variant="table" title="暂无有效负向证据"></app-empty-state></td></tr>
+            }
+          </tbody>
+        </table>
+      </app-data-table-shell>
     }
 
     @if (section === 'store') {
@@ -72,7 +127,8 @@
           <div class="info-list">
             <div class="info-row"><div class="info-copy"><strong>商品经营指标</strong><span>成交、流量、转化、退款</span></div><span class="info-value">已接通</span></div>
             <div class="info-row"><div class="info-copy"><strong>竞品关系</strong><span>自营商品与竞品映射</span></div><span class="info-value">{{ dataset.summary.relations }} 条</span></div>
-            <div class="info-row"><div class="info-copy"><strong>评论明细</strong><span>情绪、主题与痛点来源</span></div><span class="info-value">{{ dataset.summary.reviewCount }} 条</span></div>
+            <div class="info-row"><div class="info-copy"><strong>本品评论明细</strong><span>德玛仕情绪、主题与痛点来源</span></div><span class="info-value">{{ reviewScope.ownCount }} 条</span></div>
+            <div class="info-row"><div class="info-copy"><strong>竞品评论明细</strong><span>竞品评价工作台证据</span></div><span class="info-value">{{ reviewScope.competitorCount }} 条</span></div>
           </div>
         </app-content-card>
       </div>

+ 90 - 3
src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.ts

@@ -1,16 +1,16 @@
 import { CommonModule } from '@angular/common';
 import { Component, OnDestroy, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, RouterLink } from '@angular/router';
 import type { EChartsOption } from 'echarts';
 import { NgxEchartsModule } from 'ngx-echarts';
 import { Subject, combineLatest, takeUntil } from 'rxjs';
-import { DomesticDataset } from '../../../app/core/models/domestic.models';
+import { DomesticDataset, DomesticReview } from '../../../app/core/models/domestic.models';
 import {
   DomesticAggregateSummary,
   DomesticAnalyticsAdapterService,
   DomesticCategoryAggregate,
 } from '../../../app/core/services/domestic-analytics-adapter.service';
-import { DomesticDatasetService } from '../../../app/core/services/domestic-dataset.service';
+import { DomesticDatasetService, DomesticReviewScope } from '../../../app/core/services/domestic-dataset.service';
 import { AlertCardComponent } from '../../shared/components/alert-card/alert-card.component';
 import { BoardTabItem, BoardTabsComponent } from '../../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
@@ -20,6 +20,7 @@ import { ErrorStateComponent } from '../../shared/components/error-state/error-s
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
+import { ReviewDataUtils, SentimentDistribution } from '../../shared/services/review-data-utils';
 
 type MonitoringSection = 'trend' | 'emotion' | 'store';
 
@@ -28,6 +29,7 @@ type MonitoringSection = 'trend' | 'emotion' | 'store';
   standalone: true,
   imports: [
     CommonModule,
+    RouterLink,
     NgxEchartsModule,
     AlertCardComponent,
     BoardTabsComponent,
@@ -55,10 +57,17 @@ export class DomesticMonitoringWorkbenchComponent implements OnInit, OnDestroy {
   loading = true;
   error = '';
   dataset: DomesticDataset | null = null;
+  reviewScope: DomesticReviewScope | null = null;
   summary: DomesticAggregateSummary | null = null;
   categories: DomesticCategoryAggregate[] = [];
   trendOptions: EChartsOption = {};
   storeOptions: EChartsOption = {};
+  competitorReviewTrendOptions: EChartsOption = {};
+  competitorRatingOptions: EChartsOption = {};
+  meaningfulCompetitorReviews: DomesticReview[] = [];
+  competitorNegativeEvidence: DomesticReview[] = [];
+  competitorReviewStats: SentimentDistribution = ReviewDataUtils.calcSentimentDistribution([]);
+  filteredPlaceholderCount = 0;
 
   constructor(
     private readonly route: ActivatedRoute,
@@ -73,10 +82,12 @@ export class DomesticMonitoringWorkbenchComponent implements OnInit, OnDestroy {
         next: ([routeData, dataset]) => {
           this.section = (routeData['section'] || 'trend') as MonitoringSection;
           this.dataset = dataset;
+          this.reviewScope = this.datasetService.reviewScope(dataset);
           this.summary = this.analytics.aggregate(dataset);
           this.categories = this.analytics.categories(dataset, 10);
           this.trendOptions = this.buildTrendOptions(dataset);
           this.storeOptions = this.buildStoreOptions(this.categories);
+          this.prepareCompetitorReference();
           this.loading = false;
           this.error = '';
         },
@@ -116,6 +127,27 @@ export class DomesticMonitoringWorkbenchComponent implements OnInit, OnDestroy {
     return new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value || 0);
   }
 
+  percentValue(value: number): string {
+    return `${new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value || 0)}%`;
+  }
+
+  date(value = ''): string {
+    return value ? value.slice(0, 10) : '-';
+  }
+
+  private prepareCompetitorReference(): void {
+    const rawReviews = this.reviewScope?.competitor ?? [];
+    this.meaningfulCompetitorReviews = ReviewDataUtils.filterMeaningfulReviews(rawReviews);
+    this.filteredPlaceholderCount = Math.max(0, rawReviews.length - this.meaningfulCompetitorReviews.length);
+    this.competitorReviewStats = ReviewDataUtils.calcSentimentDistribution(this.meaningfulCompetitorReviews);
+    this.competitorNegativeEvidence = [...this.meaningfulCompetitorReviews]
+      .filter((review) => ReviewDataUtils.classifySentiment(review.rating) === 'negative')
+      .sort((left, right) => ReviewDataUtils.parseReviewDate(right) - ReviewDataUtils.parseReviewDate(left))
+      .slice(0, 6);
+    this.competitorReviewTrendOptions = this.buildCompetitorReviewTrendOptions(this.meaningfulCompetitorReviews);
+    this.competitorRatingOptions = this.buildCompetitorRatingOptions(this.competitorReviewStats);
+  }
+
   private buildTrendOptions(dataset: DomesticDataset): EChartsOption {
     const daily = this.analytics.daily(dataset);
     return {
@@ -143,4 +175,59 @@ export class DomesticMonitoringWorkbenchComponent implements OnInit, OnDestroy {
       series: [{ type: 'bar', data: rows.map((item) => item.gmv), barMaxWidth: 18, itemStyle: { borderRadius: [0, 4, 4, 0] } }],
     };
   }
+
+  private buildCompetitorReviewTrendOptions(reviews: DomesticReview[]): EChartsOption {
+    const monthly = new Map<string, { count: number; ratingTotal: number }>();
+    reviews.forEach((review) => {
+      const timestamp = ReviewDataUtils.parseReviewDate(review);
+      if (!timestamp) return;
+      const date = new Date(timestamp);
+      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+      const item = monthly.get(key) ?? { count: 0, ratingTotal: 0 };
+      item.count += 1;
+      item.ratingTotal += review.rating || 0;
+      monthly.set(key, item);
+    });
+    const rows = [...monthly.entries()].sort(([left], [right]) => left.localeCompare(right)).slice(-12);
+    return {
+      color: ['#7c3aed', '#f59e0b'],
+      tooltip: { trigger: 'axis' },
+      legend: { data: ['评论数', '平均评分'], top: 0, right: 0 },
+      grid: { left: 12, right: 18, top: 48, bottom: 16, containLabel: true },
+      xAxis: { type: 'category', data: rows.map(([month]) => month.slice(2)), axisLabel: { color: '#64748b' } },
+      yAxis: [
+        { type: 'value', name: '评论数', minInterval: 1, axisLabel: { color: '#64748b' }, splitLine: { lineStyle: { color: '#eef2f7' } } },
+        { type: 'value', name: '评分', min: 0, max: 5, axisLabel: { color: '#64748b' }, splitLine: { show: false } },
+      ],
+      series: [
+        { name: '评论数', type: 'bar', data: rows.map(([, item]) => item.count), barMaxWidth: 20, itemStyle: { borderRadius: [4, 4, 0, 0] } },
+        { name: '平均评分', type: 'line', yAxisIndex: 1, smooth: true, data: rows.map(([, item]) => Number((item.ratingTotal / item.count).toFixed(2))) },
+      ],
+    };
+  }
+
+  private buildCompetitorRatingOptions(stats: SentimentDistribution): EChartsOption {
+    return {
+      color: ['#16a34a', '#f59e0b', '#dc2626'],
+      tooltip: { trigger: 'item', formatter: '{b}<br/>{c} 条({d}%)' },
+      legend: { orient: 'vertical', right: 8, top: 'middle', textStyle: { color: '#475569' } },
+      series: [{
+        type: 'pie',
+        radius: ['52%', '76%'],
+        center: ['35%', '50%'],
+        label: { show: false },
+        data: [
+          { name: '正向(4-5 星)', value: stats.positive },
+          { name: '中性(3 星)', value: stats.neutral },
+          { name: '负向(1-2 星)', value: stats.negative },
+        ],
+      }],
+      graphic: [{
+        type: 'text',
+        left: '25%',
+        top: '43%',
+        style: { text: `${stats.averageRating.toFixed(1)}\n平均评分`, align: 'center', fill: '#1f2937', font: '700 16px sans-serif', lineHeight: 22 },
+      }],
+    };
+  }
 }

+ 81 - 11
src/modules/domestic-voc/framework/domestic-product-development.component.html

@@ -3,14 +3,14 @@
     <div class="framework-state"><app-loading-spinner size="lg" text="正在加载新品开发框架..."></app-loading-spinner></div>
   } @else if (error) {
     <div class="framework-state"><app-error-state [description]="error"></app-error-state></div>
-  } @else if (dataset && summary) {
+  } @else if (dataset && summary && reviewScope) {
     <app-page-header
       [title]="title"
       eyebrow="Product Development"
       [description]="description"
       icon="△"
-      [badges]="[{ label: '国内通用框架', tone: 'blue' }, { label: dataset.summary.reviewCount ? 'VOC 已接入' : 'VOC 待接入', tone: dataset.summary.reviewCount ? 'green' : 'amber' }]"
-      [meta]="[{ label: '案例', value: dataset.caseName }, { label: '商品', value: dataset.summary.metricProducts }, { label: '竞品关系', value: dataset.summary.relations }]">
+      [badges]="[{ label: '国内通用框架', tone: 'blue' }, { label: reviewScope.ownCount ? '本品 VOC 已接入' : '本品 VOC 待接入', tone: reviewScope.ownCount ? 'green' : 'amber' }]"
+      [meta]="[{ label: '案例', value: dataset.caseName }, { label: '商品', value: dataset.summary.metricProducts }, { label: '竞品关系', value: dataset.summary.relations }, { label: '竞品评论', value: reviewScope.competitorCount }]">
     </app-page-header>
 
     <app-board-tabs class="framework-tabs" [tabs]="tabs" [activeKey]="section" density="compact"></app-board-tabs>
@@ -51,16 +51,52 @@
 
     @if (section === 'requirement') {
       <div class="metric-grid">
-        <app-summary-metric-card label="评论样本" [value]="dataset.summary.reviewCount" icon="▤" tone="default" description="需求原声来源"></app-summary-metric-card>
-        <app-summary-metric-card label="需求主题" value="-" icon="◉" tone="purple" description="等待评论主题"></app-summary-metric-card>
-        <app-summary-metric-card label="未满足需求" value="-" icon="!" tone="amber" description="等待痛点证据"></app-summary-metric-card>
-        <app-summary-metric-card label="使用场景" value="-" icon="⌖" tone="blue" description="等待场景原声"></app-summary-metric-card>
+        <app-summary-metric-card label="本品评论样本" [value]="reviewScope.ownCount" icon="▤" [tone]="reviewScope.ownCount ? 'green' : 'default'" description="德玛仕需求原声来源"></app-summary-metric-card>
+        <app-summary-metric-card label="本品需求主题" value="-" icon="◉" tone="amber" description="等待德玛仕评论主题"></app-summary-metric-card>
+        <app-summary-metric-card label="竞品负向参照" [value]="competitorReference.negativeCount" icon="!" tone="red" [description]="competitorReference.placeholderCount + ' 条占位评价已过滤'"></app-summary-metric-card>
+        <app-summary-metric-card label="竞品观察维度" [value]="competitorReference.topics.length" icon="⌖" tone="purple" description="基础规则命中的参照主题"></app-summary-metric-card>
       </div>
-      <app-alert-card title="需求挖掘框架已迁入,当前没有用户原声" description="经营指标可以反映销售和转化,但不能替代用户需求、场景或痛点证据。" primaryMetric="0 条评论" icon="!" tone="amber"></app-alert-card>
+
+      <app-alert-card title="本品需求结论仍待接入,竞品信号只作机会参照" [description]="'基础规则从 ' + competitorReference.meaningfulCount + ' 条有效竞品文本中定位负向观察维度;不得替代德玛仕本品需求结论。'" [primaryMetric]="reviewScope.ownCount + ' 条本品评论'" icon="!" tone="amber"></app-alert-card>
+
       <div class="two-column equal-column">
-        <app-content-card title="显性需求" subtitle="用户明确提出的功能、规格和体验要求" variant="dashed" tone="blue"><app-empty-state variant="low-sample" title="暂无显性需求样本" description="接入评论正文后展示需求主题、频次和代表性原声。"></app-empty-state></app-content-card>
-        <app-content-card title="隐性需求与机会" subtitle="从痛点、场景和期望差中提炼" variant="dashed" tone="purple"><app-empty-state title="暂无隐性需求证据" description="当前不根据经营数据猜测用户需求。"></app-empty-state></app-content-card>
+        <app-content-card title="德玛仕本品需求" subtitle="用户明确提出的功能、规格和体验要求" variant="dashed" tone="blue">
+          <app-empty-state size="sm" variant="low-sample" title="暂无本品需求样本" description="接入德玛仕评论正文后展示需求主题、频次、场景和代表性原声。"></app-empty-state>
+        </app-content-card>
+        <app-content-card title="竞品痛点参照(非本品)" subtitle="透明基础规则命中,可回到原始评价核验" tone="purple">
+          <div class="rank-list topic-rank-list">
+            @for (topic of competitorReference.topics; track topic.id) {
+              <div class="rank-row topic-rank-row">
+                <div class="rank-copy">
+                  <strong>{{ topic.label }}</strong>
+                  <span>{{ topic.description }}</span>
+                  @if (topic.sample) { <p class="topic-sample">“{{ topic.sample.content }}”</p> }
+                </div>
+                <span class="rank-value">{{ topic.count }} 条</span>
+              </div>
+            } @empty {
+              <app-empty-state size="sm" title="暂无竞品负向主题"></app-empty-state>
+            }
+          </div>
+        </app-content-card>
       </div>
+
+      <app-data-table-shell title="竞品需求证据池" [subtitle]="'仅展示 1-2 星有效文本;已过滤平台占位评价 ' + competitorReference.placeholderCount + ' 条'" [count]="competitorReference.negative.slice(0, 8).length" [scrollable]="true">
+        <table class="data-table reference-table">
+          <thead><tr><th>竞品商品</th><th>评分</th><th>评价日期</th><th class="review-copy-cell">原始评价证据</th><th>下钻</th></tr></thead>
+          <tbody>
+            @for (review of competitorReference.negative.slice(0, 8); track review.reviewId) {
+              <tr>
+                <td><strong>{{ review.productId }}</strong></td>
+                <td><span class="rating-badge negative">{{ review.rating }} 星</span></td>
+                <td>{{ date(review.reviewDate) }}</td>
+                <td class="review-copy-cell"><span class="review-copy">{{ review.content }}</span></td>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', review.productId]">竞品详情</a></td>
+              </tr>
+            }
+          </tbody>
+        </table>
+      </app-data-table-shell>
     }
 
     @if (section === 'benchmark') {
@@ -100,6 +136,30 @@
           <app-empty-state variant="low-sample" title="暂无用户需求输入" description="卖点优先级、功能满意度、场景和痛点需在评论接入后补齐。"></app-empty-state>
         </app-content-card>
       </div>
+
+      <app-data-table-shell
+        title="产品定义对标输入"
+        subtitle="所选商品已关联的国内竞品;价格、销量信号和评论覆盖均来自当前市场快照"
+        [count]="selectedCompetitors.length"
+        [scrollable]="true">
+        <table class="data-table">
+          <thead><tr><th class="product-cell">竞品商品</th><th>关系来源</th><th>当前价</th><th>销量信号</th><th>评论覆盖</th><th>下钻</th></tr></thead>
+          <tbody>
+            @for (competitor of selectedCompetitors; track competitor.productKey) {
+              <tr>
+                <td class="product-cell"><app-product-identity [title]="competitor.title" [asin]="competitor.productId" [productSku]="competitor.model" [subtitle]="competitor.brand" [imageUrl]="competitor.detail?.imageUrl || ''" size="xs"></app-product-identity></td>
+                <td>{{ competitorRelationSource(competitor) }}</td>
+                <td>{{ competitor.market?.currentPrice ? currency(competitor.market?.currentPrice || 0) : '待采集' }}</td>
+                <td>{{ competitor.market?.monthSalesText || competitor.market?.salesText || '待采集' }}</td>
+                <td>{{ reviewCount(competitor.productId) }} 条</td>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', competitor.productId]">竞品详情</a></td>
+              </tr>
+            } @empty {
+              <tr><td colspan="6"><app-empty-state size="sm" title="当前商品暂无竞品关系" description="可在竞品关系页按品牌和品类继续补充对标商品。"></app-empty-state></td></tr>
+            }
+          </tbody>
+        </table>
+      </app-data-table-shell>
     }
 
     @if (section === 'lifecycle' && selectedProduct) {
@@ -107,10 +167,20 @@
         <app-summary-metric-card label="周期成交金额" [value]="currency(selectedProduct.summary.gmv)" icon="¥" tone="blue" [description]="selectedProduct.trend.length + ' 个日度点'"></app-summary-metric-card>
         <app-summary-metric-card label="周期成交件数" [value]="number(selectedProduct.summary.soldUnits)" icon="▣" tone="green" description="当前数据周期"></app-summary-metric-card>
         <app-summary-metric-card label="退款金额" [value]="currency(selectedProduct.summary.refundAmount)" icon="↩" tone="amber" [description]="percent(selectedProduct.summary.refundToGmvRate) + ' 成交金额占比'"></app-summary-metric-card>
-        <app-summary-metric-card label="评论样本" [value]="dataset.summary.reviewCount" icon="▤" tone="default" description="生命周期 VOC 证据"></app-summary-metric-card>
+        <app-summary-metric-card label="本品评论样本" [value]="reviewScope.ownCount" icon="▤" tone="default" description="生命周期 VOC 证据"></app-summary-metric-card>
       </div>
       <app-alert-card title="当前仅支持短周期经营复盘" description="数据覆盖 2026-07-14 至 2026-07-20,不能据此判断完整上市、增长、成熟或衰退阶段。" [primaryMetric]="selectedProduct.model || selectedProduct.productId" icon="i" tone="blue"></app-alert-card>
       <app-content-card title="单商品周期趋势" subtitle="成交金额与成交件数" tone="blue"><div echarts class="chart" [options]="lifecycleOptions"></div></app-content-card>
+      <app-data-table-shell title="逐日经营明细" subtitle="用于核对趋势图中的成交、销量和订单变化" [count]="selectedProduct.trend.length">
+        <table class="data-table">
+          <thead><tr><th>日期</th><th>成交金额</th><th>成交件数</th><th>成交订单</th><th>件单价</th></tr></thead>
+          <tbody>
+            @for (row of selectedProduct.trend; track row.date) {
+              <tr><td>{{ date(row.date) }}</td><td>{{ currency(row.gmv) }}</td><td>{{ number(row.soldUnits) }}</td><td>{{ number(row.transactionOrders) }}</td><td>{{ currency(averageSoldPrice(row.gmv, row.soldUnits)) }}</td></tr>
+            }
+          </tbody>
+        </table>
+      </app-data-table-shell>
     }
   }
 </section>

+ 54 - 2
src/modules/domestic-voc/framework/domestic-product-development.component.ts

@@ -1,6 +1,6 @@
 import { CommonModule } from '@angular/common';
 import { Component, OnDestroy, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, RouterLink } from '@angular/router';
 import type { EChartsOption } from 'echarts';
 import { NgxEchartsModule } from 'ngx-echarts';
 import { Subject, combineLatest, takeUntil } from 'rxjs';
@@ -14,7 +14,11 @@ import {
   DomesticAnalyticsAdapterService,
   DomesticCategoryAggregate,
 } from '../../../app/core/services/domestic-analytics-adapter.service';
-import { DomesticDatasetService } from '../../../app/core/services/domestic-dataset.service';
+import { DomesticDatasetService, DomesticReviewScope } from '../../../app/core/services/domestic-dataset.service';
+import {
+  DomesticCompetitorReviewReference,
+  DomesticReviewInsightService,
+} from '../../../app/core/services/domestic-review-insight.service';
 import { AlertCardComponent } from '../../shared/components/alert-card/alert-card.component';
 import { BoardTabItem, BoardTabsComponent } from '../../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
@@ -35,6 +39,7 @@ type ProductDevelopmentSection = 'trend' | 'requirement' | 'benchmark' | 'defini
   standalone: true,
   imports: [
     CommonModule,
+    RouterLink,
     NgxEchartsModule,
     AlertCardComponent,
     BoardTabsComponent,
@@ -66,6 +71,7 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
   loading = true;
   error = '';
   dataset: DomesticDataset | null = null;
+  reviewScope: DomesticReviewScope | null = null;
   summary: DomesticAggregateSummary | null = null;
   categories: DomesticCategoryAggregate[] = [];
   topProducts: DomesticProduct[] = [];
@@ -73,12 +79,22 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
   selectedProduct: DomesticProduct | null = null;
   categoryOptions: EChartsOption = {};
   lifecycleOptions: EChartsOption = {};
+  competitorReference: DomesticCompetitorReviewReference = {
+    rawCount: 0,
+    meaningfulCount: 0,
+    placeholderCount: 0,
+    negativeCount: 0,
+    meaningful: [],
+    negative: [],
+    topics: [],
+  };
 
   constructor(
     private readonly route: ActivatedRoute,
     private readonly datasetService: DomesticDatasetService,
     private readonly analytics: DomesticAnalyticsAdapterService,
     private readonly stateService: VocInsightStateService,
+    private readonly reviewInsights: DomesticReviewInsightService,
   ) {}
 
   ngOnInit(): void {
@@ -88,6 +104,8 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
         next: ([routeData, dataset, context]) => {
           this.section = (routeData['section'] || 'trend') as ProductDevelopmentSection;
           this.dataset = dataset;
+          this.reviewScope = this.datasetService.reviewScope(dataset);
+          this.competitorReference = this.reviewInsights.build(this.reviewScope.competitor);
           this.summary = this.analytics.aggregate(dataset);
           this.categories = this.analytics.categories(dataset, 10);
           this.topProducts = this.analytics.topProducts(dataset, 10);
@@ -136,6 +154,36 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
     return [...new Set(group.competitors.map((item) => item.competitorBrand).filter(Boolean))].join('、') || '-';
   }
 
+  get selectedCompetitors(): DomesticProduct[] {
+    if (!this.dataset || !this.selectedProduct) return [];
+
+    const competitorKeys = new Set(
+      this.dataset.relations
+        .filter((relation) => relation.ownProductKey === this.selectedProduct?.productKey)
+        .map((relation) => relation.competitorProductKey),
+    );
+
+    return this.dataset.products
+      .filter((product) => product.role === 'competitor' && competitorKeys.has(product.productKey))
+      .sort((left, right) => Number(Boolean(right.market)) - Number(Boolean(left.market)));
+  }
+
+  competitorRelationSource(product: DomesticProduct): string {
+    const relation = this.dataset?.relations.find((item) =>
+      item.ownProductKey === this.selectedProduct?.productKey
+      && item.competitorProductKey === product.productKey,
+    );
+    return relation?.discoverySource === 'brand_category_search' ? '品牌品类发现' : '表格关系';
+  }
+
+  reviewCount(productId: string): number {
+    return (this.dataset?.reviews ?? []).filter((review) => review.productId === productId).length;
+  }
+
+  averageSoldPrice(gmv: number, soldUnits: number): number {
+    return soldUnits > 0 ? gmv / soldUnits : 0;
+  }
+
   currency(value: number): string {
     return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
   }
@@ -148,6 +196,10 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
     return new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value || 0);
   }
 
+  date(value = ''): string {
+    return value ? value.slice(0, 10) : '-';
+  }
+
   private buildCategoryOptions(categories: DomesticCategoryAggregate[]): EChartsOption {
     const rows = categories.slice(0, 8).reverse();
     return {

+ 2 - 2
src/modules/domestic-voc/framework/domestic-return-analysis.component.html

@@ -19,7 +19,7 @@
       <app-summary-metric-card label="退款金额" [value]="currency(summary.refundAmount)" icon="¥" tone="red" [description]="percent(summary.refundRate) + ' 成交金额占比'"></app-summary-metric-card>
       <app-summary-metric-card label="退款件数" [value]="number(summary.refundUnits)" icon="□" tone="amber" [description]="number(summary.soldUnits) + ' 件成交商品'"></app-summary-metric-card>
       <app-summary-metric-card label="退款订单" [value]="number(summary.refundOrders)" icon="▤" tone="purple" [description]="number(summary.transactionOrders) + ' 笔成交订单'"></app-summary-metric-card>
-      <app-summary-metric-card label="高退款商品池" [value]="number(riskProducts.length)" icon="!" tone="red" description="按退款金额占比排序"></app-summary-metric-card>
+      <app-summary-metric-card label="高退款商品池" [value]="number(riskProducts.length)" icon="!" tone="red" description="成交规模 ≥ ¥5,000,按退款金额优先"></app-summary-metric-card>
     </div>
 
     @if (section === 'overview') {
@@ -56,7 +56,7 @@
       </app-content-card>
     }
 
-    <app-data-table-shell title="高退款商品监测" subtitle="仅基于真实商品退款汇总,不代表原因归因" [count]="riskProducts.length" [scrollable]="true">
+    <app-data-table-shell title="高退款商品监测" subtitle="成交金额不少于 ¥5,000,按退款金额影响优先;同期比不代表订单退款率" [count]="riskProducts.length" [scrollable]="true">
       <table class="data-table">
         <thead><tr><th class="product-cell">商品</th><th>类目</th><th>成交金额</th><th>退款金额</th><th>退款金额占比</th></tr></thead>
         <tbody>

+ 1052 - 0
src/modules/domestic-voc/operating-overview.component.scss

@@ -0,0 +1,1052 @@
+@use '../shared/styles/tokens' as tokens;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.domestic-page {
+  gap: 14px;
+  padding-bottom: 48px;
+  letter-spacing: 0;
+}
+
+.overview-head {
+  display: flex;
+  gap: 20px;
+  align-items: flex-end;
+  justify-content: space-between;
+  min-width: 0;
+  padding: 2px 0 12px;
+  border-bottom: 1px solid #dfe4ea;
+}
+
+.overview-head-copy {
+  min-width: 220px;
+}
+
+.overview-head h1,
+.panel-head h2 {
+  margin: 0;
+  color: #172033;
+  letter-spacing: 0;
+}
+
+.overview-head h1 {
+  font-size: 24px;
+  line-height: 1.2;
+}
+
+.overview-head p,
+.panel-head p {
+  margin: 4px 0 0;
+  color: #667085;
+  font-size: 12px;
+  line-height: 1.45;
+}
+
+.overview-command-bar {
+  display: flex;
+  flex: none;
+  align-items: stretch;
+  min-height: 44px;
+  overflow: hidden;
+  border: 1px solid #d9dee7;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.command-field {
+  display: grid;
+  align-content: center;
+  min-width: 162px;
+  padding: 6px 12px;
+  border-right: 1px solid #e6e9ef;
+}
+
+.command-field span,
+.status-facts dt,
+.coverage-metrics span {
+  color: #8a94a6;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 1.2;
+}
+
+.command-field strong {
+  margin-top: 3px;
+  color: #344054;
+  font-size: 12px;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.command-actions {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 5px;
+}
+
+.command-actions button,
+.panel-head button,
+.table-toolbar-actions button {
+  min-height: 32px;
+  padding: 0 10px;
+  border: 1px solid transparent;
+  border-radius: 4px;
+  background: transparent;
+  color: #475467;
+  font: inherit;
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.command-actions button:hover,
+.command-actions button:focus-visible,
+.panel-head button:hover,
+.panel-head button:focus-visible,
+.table-toolbar-actions button:hover,
+.table-toolbar-actions button:focus-visible {
+  border-color: #cbd5e1;
+  background: #f8fafc;
+  color: #1d4ed8;
+  outline: none;
+}
+
+.command-actions .is-primary {
+  background: #1d4ed8;
+  color: #fff;
+}
+
+.command-actions .is-primary:hover,
+.command-actions .is-primary:focus-visible {
+  border-color: #1e40af;
+  background: #1e40af;
+  color: #fff;
+}
+
+.status-band {
+  display: grid;
+  grid-template-columns: minmax(280px, 1.1fr) minmax(520px, 1.9fr);
+  min-width: 0;
+  border: 1px solid #dfe4ea;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.status-lead {
+  display: grid;
+  grid-template-columns: auto auto minmax(0, 1fr);
+  gap: 8px;
+  align-items: center;
+  min-width: 0;
+  padding: 10px 14px;
+  border-right: 1px solid #e6e9ef;
+}
+
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #64748b;
+}
+
+.status-lead.tone-green .status-dot { background: #15803d; }
+.status-lead.tone-amber .status-dot { background: #b45309; }
+.status-lead.tone-red .status-dot { background: #b91c1c; }
+.status-lead.tone-blue .status-dot { background: #2563eb; }
+
+.status-lead strong {
+  color: #172033;
+  font-size: 13px;
+  white-space: nowrap;
+}
+
+.status-lead p {
+  min-width: 0;
+  margin: 0;
+  overflow: hidden;
+  color: #596579;
+  font-size: 11px;
+  line-height: 1.4;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-facts {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  min-width: 0;
+  margin: 0;
+}
+
+.status-facts > div {
+  display: grid;
+  align-content: center;
+  min-width: 0;
+  padding: 8px 12px;
+  border-right: 1px solid #eef1f5;
+}
+
+.status-facts > div:last-child {
+  border-right: 0;
+}
+
+.status-facts dd {
+  margin: 3px 0 0;
+  overflow: hidden;
+  color: #344054;
+  font-size: 11px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-facts dd.is-partial {
+  color: #b45309;
+}
+
+.kpi-strip {
+  display: grid;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+  overflow: hidden;
+  border: 1px solid #dfe4ea;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.kpi-cell {
+  position: relative;
+  display: grid;
+  min-width: 0;
+  min-height: 132px;
+  padding: 12px 14px 9px;
+  overflow: hidden;
+  border: 0;
+  border-right: 1px solid #e6e9ef;
+  background: transparent;
+  color: #172033;
+  font: inherit;
+  letter-spacing: 0;
+  text-align: left;
+  cursor: pointer;
+}
+
+.kpi-cell:last-child {
+  border-right: 0;
+}
+
+.kpi-cell:hover,
+.kpi-cell:focus-visible {
+  z-index: 1;
+  background: #f8fafc;
+  outline: 2px solid #93c5fd;
+  outline-offset: -2px;
+}
+
+.kpi-cell.tone-critical { box-shadow: inset 0 3px #b91c1c; }
+.kpi-cell.tone-caution { box-shadow: inset 0 3px #b45309; }
+.kpi-cell.tone-primary { box-shadow: inset 0 3px #2563eb; }
+
+.kpi-label {
+  color: #667085;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.kpi-cell > strong {
+  align-self: end;
+  margin-top: 7px;
+  overflow: hidden;
+  color: #172033;
+  font-size: 22px;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.05;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.kpi-description {
+  margin-top: 5px;
+  overflow: hidden;
+  color: #7b8494;
+  font-size: 10px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.kpi-trend {
+  position: relative;
+  z-index: 1;
+  align-self: end;
+  margin-top: 10px;
+  color: #8a94a6;
+  font-size: 10px;
+  white-space: nowrap;
+}
+
+.kpi-trend b {
+  color: #475467;
+  font-variant-numeric: tabular-nums;
+}
+
+.trend-good .kpi-trend b { color: #15803d; }
+.trend-bad .kpi-trend b { color: #b91c1c; }
+
+.kpi-spark {
+  position: absolute;
+  right: 8px;
+  bottom: 6px;
+  width: 72px;
+  height: 26px;
+  opacity: 0.42;
+  color: #64748b;
+  pointer-events: none;
+}
+
+.tone-primary .kpi-spark { color: #2563eb; }
+.tone-caution .kpi-spark { color: #b45309; }
+.tone-critical .kpi-spark { color: #b91c1c; }
+
+.kpi-spark polygon {
+  fill: currentColor;
+  opacity: 0.12;
+}
+
+.kpi-spark polyline {
+  fill: none;
+  stroke: currentColor;
+  stroke-width: 1.5;
+  vector-effect: non-scaling-stroke;
+}
+
+.workbench-grid,
+.middle-grid {
+  display: grid;
+  gap: 14px;
+  min-width: 0;
+}
+
+.workbench-grid {
+  grid-template-columns: minmax(0, 2fr) minmax(300px, 0.95fr);
+}
+
+.middle-grid {
+  grid-template-columns: minmax(330px, 0.8fr) minmax(0, 1.2fr);
+}
+
+.ops-panel {
+  min-width: 0;
+  overflow: hidden;
+  border: 1px solid #dfe4ea;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.panel-head {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 52px;
+  padding: 9px 14px;
+  border-bottom: 1px solid #e6e9ef;
+}
+
+.panel-head > div {
+  min-width: 0;
+}
+
+.panel-head h2 {
+  font-size: 14px;
+  line-height: 1.25;
+}
+
+.panel-head > span {
+  color: #7b8494;
+  font-size: 10px;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.trend-chart {
+  height: 386px;
+}
+
+.signal-list {
+  display: grid;
+}
+
+.signal-row {
+  display: grid;
+  grid-template-columns: 28px minmax(0, 1fr) auto;
+  gap: 10px;
+  align-items: center;
+  min-height: 83px;
+  padding: 10px 12px;
+  border: 0;
+  border-bottom: 1px solid #edf0f4;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  letter-spacing: 0;
+  text-align: left;
+  cursor: pointer;
+}
+
+.signal-row:last-child {
+  border-bottom: 0;
+}
+
+.signal-row:hover,
+.signal-row:focus-visible {
+  background: #f8fafc;
+  outline: none;
+}
+
+.signal-rank {
+  display: grid;
+  width: 26px;
+  height: 26px;
+  place-items: center;
+  border: 1px solid #d5dbe5;
+  border-radius: 4px;
+  color: #667085;
+  font-size: 10px;
+  font-weight: 800;
+  font-variant-numeric: tabular-nums;
+}
+
+.severity-critical .signal-rank { border-color: #fecaca; background: #fef2f2; color: #b91c1c; }
+.severity-caution .signal-rank { border-color: #fde68a; background: #fffbeb; color: #b45309; }
+.severity-info .signal-rank { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
+
+.signal-copy {
+  display: grid;
+  min-width: 0;
+}
+
+.signal-copy b {
+  color: #8a94a6;
+  font-size: 9px;
+  font-weight: 800;
+}
+
+.signal-copy strong,
+.signal-copy small {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.signal-copy strong {
+  margin-top: 2px;
+  color: #344054;
+  font-size: 12px;
+}
+
+.signal-copy small {
+  margin-top: 3px;
+  color: #7b8494;
+  font-size: 10px;
+}
+
+.signal-metric {
+  color: #172033;
+  font-size: 14px;
+  font-weight: 800;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.flow-list {
+  display: grid;
+}
+
+.flow-row {
+  display: grid;
+  grid-template-columns: 104px minmax(0, 1fr);
+  gap: 7px 12px;
+  min-height: 78px;
+  padding: 10px 14px;
+  border-bottom: 1px solid #edf0f4;
+}
+
+.flow-row:last-child {
+  border-bottom: 0;
+}
+
+.flow-title {
+  display: grid;
+  align-content: center;
+}
+
+.flow-title strong {
+  color: #344054;
+  font-size: 11px;
+}
+
+.flow-title span {
+  margin-top: 3px;
+  color: #1d4ed8;
+  font-size: 10px;
+  font-weight: 700;
+  font-variant-numeric: tabular-nums;
+}
+
+.flow-values {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  justify-content: space-between;
+  min-width: 0;
+  color: #7b8494;
+  font-size: 10px;
+}
+
+.flow-values span {
+  display: grid;
+  min-width: 0;
+}
+
+.flow-values span:last-child {
+  text-align: right;
+}
+
+.flow-values b {
+  margin-top: 2px;
+  color: #172033;
+  font-size: 13px;
+  font-variant-numeric: tabular-nums;
+}
+
+.flow-values i {
+  color: #94a3b8;
+  font-style: normal;
+}
+
+.flow-track {
+  grid-column: 2;
+  height: 4px;
+  overflow: hidden;
+  background: #edf0f4;
+}
+
+.flow-track span {
+  display: block;
+  height: 100%;
+  background: #2563eb;
+}
+
+.category-matrix {
+  display: grid;
+}
+
+.category-matrix-head,
+.category-matrix-row {
+  display: grid;
+  grid-template-columns: minmax(150px, 1.1fr) minmax(210px, 1.5fr) 72px 72px;
+  gap: 12px;
+  align-items: center;
+  min-width: 0;
+}
+
+.category-matrix-head {
+  min-height: 28px;
+  padding: 0 14px;
+  border-bottom: 1px solid #edf0f4;
+  background: #f8fafc;
+  color: #8a94a6;
+  font-size: 9px;
+  font-weight: 800;
+}
+
+.category-matrix-head span:nth-child(n + 3) {
+  text-align: right;
+}
+
+.category-matrix-row {
+  min-height: 42px;
+  padding: 5px 14px;
+  border: 0;
+  border-bottom: 1px solid #edf0f4;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  letter-spacing: 0;
+  text-align: left;
+  cursor: pointer;
+}
+
+.category-matrix-row:last-child {
+  border-bottom: 0;
+}
+
+.category-matrix-row:hover,
+.category-matrix-row:focus-visible {
+  background: #f8fafc;
+  outline: none;
+}
+
+.category-copy,
+.category-contribution {
+  display: grid;
+  min-width: 0;
+}
+
+.category-copy b {
+  overflow: hidden;
+  color: #344054;
+  font-size: 10px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.category-copy small,
+.category-contribution small {
+  margin-top: 2px;
+  color: #8a94a6;
+  font-size: 9px;
+}
+
+.category-contribution {
+  position: relative;
+  grid-template-columns: minmax(70px, 1fr) auto auto;
+  gap: 6px;
+  align-items: center;
+}
+
+.category-contribution i,
+.coverage-row i,
+.rating-distribution i {
+  height: 5px;
+  overflow: hidden;
+  background: #edf0f4;
+  font-style: normal;
+}
+
+.category-contribution em,
+.coverage-row em,
+.rating-distribution em {
+  display: block;
+  height: 100%;
+  background: #2563eb;
+  font-style: normal;
+}
+
+.category-contribution b,
+.category-matrix-row .numeric,
+.risk-value {
+  font-size: 10px;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.category-matrix-row .numeric,
+.risk-value {
+  color: #475467;
+  text-align: right;
+}
+
+.risk-value.is-normal { color: #15803d; }
+.risk-value.is-caution { color: #b45309; }
+.risk-value.is-critical { color: #b91c1c; font-weight: 800; }
+
+.voc-grid {
+  display: grid;
+  grid-template-columns: 0.85fr 1.05fr 1.1fr;
+  min-width: 0;
+}
+
+.coverage-metrics,
+.coverage-bars,
+.rating-distribution {
+  display: grid;
+  align-content: center;
+  gap: 9px;
+  min-width: 0;
+  padding: 12px 14px;
+}
+
+.coverage-metrics,
+.coverage-bars {
+  border-right: 1px solid #e6e9ef;
+}
+
+.coverage-metrics {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.coverage-metrics > div {
+  display: grid;
+  min-width: 0;
+  padding-right: 10px;
+  border-right: 1px solid #edf0f4;
+}
+
+.coverage-metrics > div:last-child {
+  border-right: 0;
+}
+
+.coverage-metrics strong {
+  margin-top: 4px;
+  color: #172033;
+  font-size: 20px;
+  font-variant-numeric: tabular-nums;
+}
+
+.coverage-metrics small,
+.coverage-note {
+  margin-top: 4px;
+  color: #8a94a6;
+  font-size: 9px;
+  line-height: 1.35;
+}
+
+.coverage-row,
+.rating-distribution > div {
+  display: grid;
+  grid-template-columns: 118px minmax(70px, 1fr) 42px;
+  gap: 8px;
+  align-items: center;
+  color: #667085;
+  font-size: 10px;
+}
+
+.coverage-row b,
+.rating-distribution b,
+.rating-distribution small {
+  color: #344054;
+  font-size: 10px;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.rating-distribution > div {
+  grid-template-columns: 38px minmax(80px, 1fr) 34px 38px;
+}
+
+.rating-distribution em {
+  background: #64748b;
+}
+
+.table-toolbar-actions {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.table-toolbar-actions span {
+  color: #8a94a6;
+  font-size: 10px;
+}
+
+.analytics-table {
+  width: 100%;
+  min-width: 1260px;
+  table-layout: fixed;
+}
+
+.analytics-table th,
+.analytics-table td {
+  height: 48px;
+  padding: 0 9px;
+}
+
+.analytics-table th {
+  background: #f8fafc;
+  font-size: 10px;
+}
+
+.analytics-table td {
+  font-size: 11px;
+}
+
+.analytics-table .rank-col {
+  position: sticky;
+  left: 0;
+  z-index: 3;
+  width: 42px;
+  background: #fff;
+  color: #8a94a6;
+  text-align: center;
+}
+
+.analytics-table th.rank-col {
+  z-index: 5;
+  background: #f8fafc;
+}
+
+.analytics-table .product-col {
+  position: sticky;
+  left: 42px;
+  z-index: 2;
+  width: 300px;
+  background: #fff;
+  box-shadow: 1px 0 #e6e9ef;
+}
+
+.analytics-table th.product-col {
+  z-index: 4;
+  background: #f8fafc;
+}
+
+.analytics-table th:nth-child(3) { width: 160px; }
+.analytics-table th:nth-child(4) { width: 110px; }
+.analytics-table th:nth-child(5) { width: 70px; }
+.analytics-table th:nth-child(6) { width: 80px; }
+.analytics-table th:nth-child(7) { width: 100px; }
+.analytics-table th:nth-child(8) { width: 105px; }
+.analytics-table th:nth-child(9) { width: 80px; }
+.analytics-table th:nth-child(10) { width: 64px; }
+
+.analytics-table .numeric {
+  white-space: nowrap;
+}
+
+.analytics-table ::ng-deep .pi-title {
+  max-width: 280px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.analytics-table ::ng-deep .pi-secondary {
+  display: none;
+}
+
+.risk-tag {
+  display: inline-flex;
+  min-height: 22px;
+  align-items: center;
+  padding: 0 6px;
+  border-radius: 4px;
+  font-size: 9px;
+  font-weight: 800;
+  white-space: nowrap;
+}
+
+.risk-tag.is-normal { background: #ecfdf3; color: #15803d; }
+.risk-tag.is-caution { background: #fffbeb; color: #b45309; }
+.risk-tag.is-critical { background: #fef2f2; color: #b91c1c; }
+
+@media (max-width: 1180px) {
+  .overview-head {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .overview-command-bar {
+    width: 100%;
+  }
+
+  .command-actions {
+    margin-left: auto;
+  }
+
+  .status-band {
+    grid-template-columns: 1fr;
+  }
+
+  .status-lead {
+    border-right: 0;
+    border-bottom: 1px solid #e6e9ef;
+  }
+
+  .kpi-strip {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  .kpi-cell:nth-child(3) {
+    border-right: 0;
+  }
+
+  .kpi-cell:nth-child(-n + 3) {
+    border-bottom: 1px solid #e6e9ef;
+  }
+
+  .workbench-grid,
+  .middle-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .signal-list {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .signal-row:nth-child(odd) {
+    border-right: 1px solid #edf0f4;
+  }
+
+  .voc-grid {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .coverage-metrics {
+    grid-column: 1 / -1;
+    border-right: 0;
+    border-bottom: 1px solid #e6e9ef;
+  }
+}
+
+@media (max-width: 720px) {
+  .domestic-page {
+    gap: 10px;
+    padding-bottom: 28px;
+  }
+
+  .overview-head {
+    gap: 12px;
+    padding-bottom: 10px;
+  }
+
+  .overview-head h1 {
+    font-size: 20px;
+  }
+
+  .overview-head p {
+    max-width: 42ch;
+    font-size: 11px;
+  }
+
+  .overview-command-bar {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .command-field {
+    min-width: 0;
+    border-bottom: 1px solid #e6e9ef;
+  }
+
+  .command-field:nth-child(2) {
+    border-right: 0;
+  }
+
+  .command-actions {
+    grid-column: 1 / -1;
+    justify-content: flex-end;
+    margin-left: 0;
+  }
+
+  .status-lead {
+    grid-template-columns: auto minmax(0, 1fr);
+  }
+
+  .status-lead p {
+    grid-column: 1 / -1;
+    overflow: visible;
+    white-space: normal;
+  }
+
+  .status-facts {
+    grid-template-columns: 1fr 1fr;
+  }
+
+  .status-facts > div:nth-child(2) {
+    border-right: 0;
+  }
+
+  .status-facts > div:nth-child(-n + 2) {
+    border-bottom: 1px solid #eef1f5;
+  }
+
+  .kpi-strip {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .kpi-cell,
+  .kpi-cell:nth-child(3) {
+    min-height: 124px;
+    border-right: 1px solid #e6e9ef;
+    border-bottom: 1px solid #e6e9ef;
+  }
+
+  .kpi-cell:nth-child(even) {
+    border-right: 0;
+  }
+
+  .kpi-cell:nth-last-child(-n + 2) {
+    border-bottom: 0;
+  }
+
+  .kpi-cell > strong {
+    font-size: 19px;
+  }
+
+  .trend-chart {
+    height: 404px;
+  }
+
+  .signal-list {
+    grid-template-columns: 1fr;
+  }
+
+  .signal-row:nth-child(odd) {
+    border-right: 0;
+  }
+
+  .flow-row {
+    grid-template-columns: 88px minmax(0, 1fr);
+  }
+
+  .category-panel {
+    overflow-x: auto;
+  }
+
+  .category-matrix {
+    min-width: 650px;
+  }
+
+  .voc-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .coverage-metrics,
+  .coverage-bars {
+    grid-column: auto;
+    border-right: 0;
+    border-bottom: 1px solid #e6e9ef;
+  }
+
+  .coverage-metrics strong {
+    font-size: 18px;
+  }
+}
+
+@media (max-width: 420px) {
+  .command-actions {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+
+  .command-actions button {
+    padding: 0 6px;
+  }
+
+  .kpi-cell {
+    padding-inline: 10px;
+  }
+
+  .kpi-cell > strong {
+    font-size: 17px;
+  }
+
+  .kpi-trend {
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .panel-head {
+    align-items: flex-start;
+  }
+
+  .panel-head > span {
+    display: none;
+  }
+}

+ 711 - 63
src/modules/domestic-voc/operating-overview.component.ts

@@ -4,68 +4,254 @@ import { Router } from '@angular/router';
 import { NgxEchartsModule } from 'ngx-echarts';
 import type { EChartsOption } from 'echarts';
 import { map } from 'rxjs';
-import { DomesticProduct } from '../../app/core/models/domestic.models';
-import { DomesticAnalyticsService } from '../../app/core/services/domestic-analytics.service';
-import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
+import { DomesticDailyMetric, DomesticDataset, DomesticMetricSummary, DomesticProduct } from '../../app/core/models/domestic.models';
+import { DomesticAnalyticsService, DomesticCategorySummary } from '../../app/core/services/domestic-analytics.service';
+import { buildDomesticReviewScope, DomesticReviewScope } from '../../app/core/services/domestic-dataset.service';
 import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
 import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
-import { NeuButtonComponent } from '../shared/components/neu-button/neu-button.component';
-import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
 import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
-import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
 import { VocInsightStateService } from '../shared/services/voc-insight-state.service';
 
+interface OverviewKpi {
+  key: string;
+  label: string;
+  tone: 'primary' | 'neutral' | 'caution' | 'critical';
+  value: string;
+  description: string;
+  helpText: string;
+  trendValue: string;
+  trendLabel: string;
+  trendState: 'good' | 'bad' | 'neutral';
+  sparkAvailable: boolean;
+  sparkPoints: string;
+  sparkArea: string;
+}
+
+interface OverviewSignal {
+  id: string;
+  severity: 'critical' | 'caution' | 'info';
+  label: string;
+  title: string;
+  detail: string;
+  metric: string;
+  action: 'product' | 'refund' | 'category' | 'voc';
+  product?: DomesticProduct;
+}
+
+interface OverviewFlowRow {
+  id: string;
+  label: string;
+  fromLabel: string;
+  fromValue: string;
+  toLabel: string;
+  toValue: string;
+  rate: number;
+  rateLabel: string;
+}
+
+interface OverviewCategoryRow extends DomesticCategorySummary {
+  share: number;
+}
+
+interface OverviewRatingRow {
+  rating: number;
+  count: number;
+  share: number;
+}
+
+function kpiSeriesSum(rows: DomesticDailyMetric[], pick: (row: DomesticDailyMetric) => number): number {
+  return rows.reduce((acc, row) => acc + (Number(pick(row)) || 0), 0);
+}
+
+function kpiSeriesRatio(rows: DomesticDailyMetric[], numerator: (row: DomesticDailyMetric) => number, denominator: (row: DomesticDailyMetric) => number): number {
+  const total = kpiSeriesSum(rows, denominator);
+  return total > 0 ? kpiSeriesSum(rows, numerator) / total : 0;
+}
+
+/** 相邻周期差值变化率;分母为 0 时无法比较返回 null,由 UI 表达为“数据不足”。 */
+function kpiPeriodPercent(current: number, previous: number): number | null {
+  if (previous === 0) return current === 0 ? 0 : null;
+  return (current - previous) / previous;
+}
+
+function ratio(numerator: number, denominator: number): number {
+  return denominator > 0 ? numerator / denominator : 0;
+}
+
+function comparableWindows(rows: DomesticDailyMetric[]): { previous: DomesticDailyMetric[]; current: DomesticDailyMetric[] } {
+  const size = Math.floor(rows.length / 2);
+  if (!size) return { previous: [], current: [] };
+  return {
+    previous: rows.slice(0, size),
+    current: rows.slice(-size),
+  };
+}
+
+interface OverviewConclusion {
+  tone: 'blue' | 'green' | 'amber' | 'red' | 'neutral';
+  icon: string;
+  primaryMetric: string;
+  text: string;
+}
+
 @Component({
   selector: 'app-domestic-operating-overview',
   standalone: true,
-  imports: [CommonModule, NgxEchartsModule, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, DataTableShellComponent, LoadingSpinnerComponent, NeuButtonComponent, ProductIdentityComponent],
+  imports: [CommonModule, NgxEchartsModule, DataTableShellComponent, LoadingSpinnerComponent, ProductIdentityComponent],
   changeDetection: ChangeDetectionStrategy.OnPush,
   template: `
     <section class="domestic-page">
       @if (vm$ | async; as vm) {
-        <app-page-header
-          title="经营概览"
-          eyebrow="Domestic Commerce"
-          description="汇总德玛仕京东经营数据,查看成交、流量、退款和核心商品表现。"
-          icon="▦"
-          [meta]="[
-            { label: '平台', value: '京东' },
-            { label: '周期', value: vm.dataset.source.dateRange.start + ' 至 ' + vm.dataset.source.dateRange.end }
-          ]">
-          <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="goProducts()">商品列表</app-neu-button>
-        </app-page-header>
-
-        <div class="metric-grid">
-          <app-summary-metric-card label="成交金额" [value]="currency(vm.totals.gmv)" icon="¥" tone="blue" [trendValue]="signedPercent(vm.gmvTrend)" trendLabel="首尾日变化" [trendDirection]="vm.gmvTrend > 0 ? 'up' : vm.gmvTrend < 0 ? 'down' : 'flat'"></app-summary-metric-card>
-          <app-summary-metric-card label="成交件数" [value]="number(vm.totals.soldUnits)" icon="□" tone="green" [description]="number(vm.totals.transactionOrders) + ' 笔成交订单'"></app-summary-metric-card>
-          <app-summary-metric-card label="访客转化率" [value]="percent(vm.totals.conversionRate)" icon="◎" tone="purple" [description]="number(vm.totals.visitors) + ' 位访客'"></app-summary-metric-card>
-          <app-summary-metric-card label="退款金额占比" [value]="percent(vm.totals.refundToGmvRate)" icon="↩" [tone]="vm.totals.refundToGmvRate > 0.12 ? 'red' : 'amber'" [description]="currency(vm.totals.refundAmount) + ' 退款金额'"></app-summary-metric-card>
-        </div>
-
-        <div class="analysis-grid">
-          <app-content-card title="7 日经营趋势" subtitle="成交金额与成交订单按日汇总" tone="blue">
-            <div echarts class="chart" [options]="vm.trendOptions"></div>
-          </app-content-card>
-          <app-content-card title="类目成交贡献" subtitle="按二级类目汇总成交金额" tone="green">
-            <ol class="category-list">
-              @for (row of vm.topCategories; track row.name) {
-                <li class="category-row">
-                  <span class="category-name" [title]="row.name">{{ row.name }}</span>
-                  <span class="category-track"><span class="category-fill" [style.width.%]="categoryWidth(row.gmv, vm.maxCategoryGmv)"></span></span>
-                  <span class="category-value">{{ currency(row.gmv) }}</span>
-                </li>
+        <header class="overview-head">
+          <div class="overview-head-copy">
+            <h1>经营概览</h1>
+            <p>从规模、流量、转化、退款到 VOC 证据,定位需要优先处理的类目与商品。</p>
+          </div>
+          <div class="overview-command-bar" aria-label="经营概览命令栏">
+            <div class="command-field">
+              <span>周期</span>
+              <strong>{{ vm.dataset.source.dateRange.start }} 至 {{ vm.dataset.source.dateRange.end }}</strong>
+            </div>
+            <div class="command-field">
+              <span>比较</span>
+              <strong>{{ vm.comparisonLabel }}</strong>
+            </div>
+            <div class="command-actions">
+              <button type="button" (click)="goDataStatus()">数据口径</button>
+              <button type="button" (click)="goProducts()">商品列表</button>
+              <button type="button" class="is-primary" (click)="goAiInsights()">AI 洞察</button>
+            </div>
+          </div>
+        </header>
+
+        <section class="status-band" aria-label="数据状态">
+          <div class="status-lead" [ngClass]="'tone-' + vm.conclusion.tone">
+            <span class="status-dot" aria-hidden="true"></span>
+            <strong>{{ vm.conclusion.primaryMetric }}</strong>
+            <p>{{ vm.conclusion.text }}</p>
+          </div>
+          <dl class="status-facts">
+            <div><dt>经营明细</dt><dd>{{ number(vm.dataset.summary.metricRows) }} 行 · {{ vm.dataset.dailyTotals.length }} 天</dd></div>
+            <div><dt>经营商品</dt><dd>{{ number(vm.dataset.summary.metricProducts) }} 个</dd></div>
+            <div><dt>VOC 状态</dt><dd class="is-partial">部分数据 · 本品 {{ vm.reviewScope.ownCount }} / 竞品 {{ vm.reviewScope.competitorCount }}</dd></div>
+            <div><dt>快照时间</dt><dd>{{ vm.generatedAtLabel }}</dd></div>
+          </dl>
+        </section>
+
+        <section class="kpi-strip" aria-label="核心经营指标">
+          @for (card of vm.kpis; track card.key) {
+            <button type="button" class="kpi-cell" [ngClass]="['tone-' + card.tone, 'trend-' + card.trendState]" [title]="card.helpText" (click)="runKpiAction(card.key)">
+              <span class="kpi-label">{{ card.label }}</span>
+              <strong>{{ card.value }}</strong>
+              <span class="kpi-description">{{ card.description }}</span>
+              <span class="kpi-trend"><b>{{ card.trendValue }}</b>{{ card.trendLabel }}</span>
+              @if (card.sparkAvailable) {
+                <svg class="kpi-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true">
+                  <polygon [attr.points]="card.sparkArea"></polygon>
+                  <polyline [attr.points]="card.sparkPoints"></polyline>
+                </svg>
               }
-            </ol>
-          </app-content-card>
-        </div>
+            </button>
+          }
+        </section>
 
-        <app-data-table-shell title="成交商品" subtitle="按周期成交金额排序,点击商品进入经营详情" [count]="vm.dataset.summary.metricProducts" variant="analytics" [scrollable]="true">
+        <section class="workbench-grid">
+          <div class="ops-panel trend-panel">
+            <header class="panel-head">
+              <div><h2>7 日经营趋势</h2><p>成交规模、成交单转化率与退款率共享同一时间轴</p></div>
+              <span>{{ vm.dataset.source.dateRange.start }} 至 {{ vm.dataset.source.dateRange.end }}</span>
+            </header>
+            <div echarts class="trend-chart" [options]="vm.trendOptions" role="img" aria-label="7日成交金额、成交单转化率和退款率趋势"></div>
+          </div>
+
+          <aside class="ops-panel signal-panel" aria-label="异常与机会队列">
+            <header class="panel-head">
+              <div><h2>异常与机会</h2><p>按影响与证据完整度排序</p></div>
+              <span>{{ vm.signals.length }} 项</span>
+            </header>
+            <div class="signal-list">
+              @for (signal of vm.signals; track signal.id; let index = $index) {
+                <button type="button" class="signal-row" [ngClass]="'severity-' + signal.severity" (click)="runSignalAction(signal)">
+                  <span class="signal-rank">{{ (index + 1).toString().padStart(2, '0') }}</span>
+                  <span class="signal-copy"><b>{{ signal.label }}</b><strong>{{ signal.title }}</strong><small>{{ signal.detail }}</small></span>
+                  <span class="signal-metric">{{ signal.metric }}</span>
+                </button>
+              }
+            </div>
+          </aside>
+        </section>
+
+        <section class="middle-grid">
+          <div class="ops-panel flow-panel">
+            <header class="panel-head">
+              <div><h2>经营链路</h2><p>分开呈现搜索获取、客户成交与订单实现,避免混用不同分母</p></div>
+            </header>
+            <div class="flow-list">
+              @for (row of vm.flowRows; track row.id) {
+                <div class="flow-row">
+                  <div class="flow-title"><strong>{{ row.label }}</strong><span>{{ row.rateLabel }}</span></div>
+                  <div class="flow-values"><span>{{ row.fromLabel }} <b>{{ row.fromValue }}</b></span><i aria-hidden="true">→</i><span>{{ row.toLabel }} <b>{{ row.toValue }}</b></span></div>
+                  <div class="flow-track"><span [style.width.%]="flowWidth(row.rate)"></span></div>
+                </div>
+              }
+            </div>
+          </div>
+
+          <div class="ops-panel category-panel">
+            <header class="panel-head">
+              <div><h2>类目经营效率</h2><p>成交贡献、成交单转化与退款风险同屏比较</p></div>
+              <button type="button" (click)="goCategories()">全部类目</button>
+            </header>
+            <div class="category-matrix" role="table" aria-label="类目经营效率">
+              <div class="category-matrix-head" role="row"><span>二级类目</span><span>成交贡献</span><span>转化率</span><span>退款率</span></div>
+              @for (row of vm.categoryRows; track row.name) {
+                <button type="button" class="category-matrix-row" role="row" (click)="goCategories()">
+                  <span class="category-copy"><b [title]="row.name">{{ row.name }}</b><small>{{ number(row.productCount) }} 个商品</small></span>
+                  <span class="category-contribution"><i><em [style.width.%]="categoryWidth(row.gmv, vm.maxCategoryGmv)"></em></i><b>{{ currency(row.gmv) }}</b><small>{{ percent(row.share) }}</small></span>
+                  <span class="numeric">{{ percent(row.conversionRate) }}</span>
+                  <span class="risk-value" [ngClass]="refundRiskClass(row.refundToGmvRate)">{{ percent(row.refundToGmvRate) }}</span>
+                </button>
+              }
+            </div>
+          </div>
+        </section>
+
+        <section class="ops-panel voc-panel">
+          <header class="panel-head">
+            <div><h2>VOC 证据覆盖</h2><p>当前只能用于竞品风险观察,本品评论尚未接入</p></div>
+            <button type="button" (click)="goCompetitors()">查看竞品证据</button>
+          </header>
+          <div class="voc-grid">
+            <div class="coverage-metrics">
+              <div><span>本品评论</span><strong>{{ vm.reviewScope.ownCount }}</strong><small>数据缺口:暂不输出本品满意度</small></div>
+              <div><span>竞品评论</span><strong>{{ vm.reviewScope.competitorCount }}</strong><small>覆盖 {{ vm.competitorProductsWithReviews }} / {{ vm.dataset.summary.uniqueCompetitorProducts }} 个竞品</small></div>
+              <div><span>竞品关系</span><strong>{{ vm.mappedOwnProducts }} / {{ vm.dataset.mappingGroups.length }}</strong><small>已建立映射的本品商品</small></div>
+            </div>
+            <div class="coverage-bars">
+              <div class="coverage-row"><span>竞品评论商品覆盖</span><i><em [style.width.%]="vm.competitorCoverage * 100"></em></i><b>{{ percent(vm.competitorCoverage) }}</b></div>
+              <div class="coverage-row"><span>竞品映射覆盖</span><i><em [style.width.%]="vm.mappingCoverage * 100"></em></i><b>{{ percent(vm.mappingCoverage) }}</b></div>
+              <div class="coverage-note">本品与竞品证据池严格分开;当前评分分布仅代表已采集竞品评论。</div>
+            </div>
+            <div class="rating-distribution" aria-label="竞品评论评分分布">
+              @for (row of vm.ratingRows; track row.rating) {
+                <div><span>{{ row.rating }} 星</span><i><em [style.width.%]="ratingWidth(row.count, vm.maxRatingCount)"></em></i><b>{{ row.count }}</b><small>{{ percent(row.share) }}</small></div>
+              }
+            </div>
+          </div>
+        </section>
+
+        <app-data-table-shell title="重点商品经营矩阵" [subtitle]="'按成交金额排序,同时展示流量、转化、退款和成交贡献;当前 ' + vm.topProducts.length + ' / ' + number(vm.dataset.summary.metricProducts) + ' 个商品'" [count]="vm.topProducts.length" variant="analytics" density="compact" [scrollable]="true" [hasToolbar]="true">
+          <div dataTableToolbar class="table-toolbar-actions">
+            <span>排序:成交金额</span>
+            <button type="button" (click)="goProducts()">查看全量</button>
+          </div>
           <table class="analytics-table">
-            <thead><tr><th>商品</th><th>类目</th><th class="numeric">成交金额</th><th class="numeric">成交件数</th><th class="numeric">转化率</th><th class="numeric">退款占比</th></tr></thead>
+            <thead><tr><th class="rank-col">#</th><th class="product-col">商品</th><th>类目</th><th class="numeric">成交金额</th><th class="numeric">贡献</th><th class="numeric">访客</th><th class="numeric">成交单转化</th><th class="numeric">退款金额</th><th class="numeric">退款率</th><th>风险</th></tr></thead>
             <tbody>
-              @for (product of vm.topProducts; track product.productKey) {
+              @for (product of vm.topProducts; track product.productKey; let index = $index) {
                 <tr>
-                  <td>
+                  <td class="rank-col">{{ index + 1 }}</td>
+                  <td class="product-col">
                     <app-product-identity
                       [showImage]="false"
                       [title]="product.title"
@@ -78,9 +264,12 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
                   </td>
                   <td>{{ product.category3 || product.category2 }}</td>
                   <td class="numeric">{{ currency(product.summary.gmv) }}</td>
-                  <td class="numeric">{{ number(product.summary.soldUnits) }}</td>
+                  <td class="numeric">{{ percent(ratioValue(product.summary.gmv, vm.totals.gmv)) }}</td>
+                  <td class="numeric">{{ number(product.summary.visitors) }}</td>
                   <td class="numeric">{{ percent(product.summary.conversionRate) }}</td>
+                  <td class="numeric">{{ currency(product.summary.refundAmount) }}</td>
                   <td class="numeric">{{ percent(product.summary.refundToGmvRate) }}</td>
+                  <td><span class="risk-tag" [ngClass]="refundRiskClass(product.summary.refundToGmvRate)">{{ refundRiskLabel(product.summary.refundToGmvRate) }}</span></td>
                 </tr>
               }
             </tbody>
@@ -91,23 +280,98 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
       }
     </section>
   `,
-  styleUrls: ['./domestic-page.shared.scss'],
+  styleUrls: ['./domestic-page.shared.scss', './operating-overview.component.scss'],
 })
 export class DomesticOperatingOverviewComponent {
   private readonly analytics = inject(DomesticAnalyticsService);
   private readonly router = inject(Router);
   private readonly state = inject(VocInsightStateService);
 
-  readonly vm$ = this.analytics.overview$.pipe(map((overview) => ({
-    ...overview,
-    maxCategoryGmv: Math.max(...overview.topCategories.map((row) => row.gmv), 1),
-    trendOptions: this.buildTrendOptions(overview.dataset.dailyTotals),
-  })));
+  private readonly REFUND_WARN = 0.12;
+  private readonly REFUND_CAUTION = 0.08;
+
+  readonly vm$ = this.analytics.overview$.pipe(map((overview) => {
+    const ownProducts = overview.dataset.products.filter((product) => product.role === 'own');
+    const topProducts = [...ownProducts]
+      .sort((left, right) => right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits)
+      .slice(0, 12);
+    const refundCandidates = ownProducts
+      .filter((product) => product.summary.gmv >= 5000 && product.summary.refundAmount > 0)
+      .sort((left, right) => right.summary.refundAmount - left.summary.refundAmount || right.summary.refundToGmvRate - left.summary.refundToGmvRate);
+    const lowConversionProduct = [...ownProducts]
+      .filter((product) => product.summary.visitors >= 500 && product.summary.gmv > 0)
+      .sort((left, right) => left.summary.conversionRate - right.summary.conversionRate || right.summary.visitors - left.summary.visitors)[0] ?? null;
+    const reviewScope = buildDomesticReviewScope(overview.dataset);
+    const topCategory = overview.topCategories[0];
+    const competitorProductsWithReviews = new Set(reviewScope.competitor.map((review) => review.productId)).size;
+    const mappedOwnProducts = new Set(overview.dataset.relations.map((relation) => relation.ownProductId)).size;
+    const competitorCoverage = ratio(competitorProductsWithReviews, overview.dataset.summary.uniqueCompetitorProducts);
+    const mappingCoverage = ratio(mappedOwnProducts, overview.dataset.mappingGroups.length);
+    const categoryRows: OverviewCategoryRow[] = overview.topCategories.map((row) => ({
+      ...row,
+      share: ratio(row.gmv, overview.totals.gmv),
+    }));
+    const ratingRows = this.buildRatingRows(reviewScope.competitor);
+    const windows = comparableWindows(overview.dataset.dailyTotals);
+    return {
+      ...overview,
+      topProducts,
+      reviewScope,
+      topCategory,
+      topCategoryShare: topCategory && overview.totals.gmv ? topCategory.gmv / overview.totals.gmv : 0,
+      refundRiskProduct: refundCandidates[0] || null,
+      competitorProductsWithReviews,
+      competitorCoverage,
+      mappedOwnProducts,
+      mappingCoverage,
+      ratingRows,
+      maxRatingCount: Math.max(...ratingRows.map((row) => row.count), 1),
+      categoryRows,
+      maxCategoryGmv: Math.max(...overview.topCategories.map((row) => row.gmv), 1),
+      comparisonLabel: windows.previous.length && windows.current.length
+        ? `后 ${windows.current.length} 日日均 vs 前 ${windows.previous.length} 日日均`
+        : '当前周期',
+      generatedAtLabel: this.formatDateTime(overview.dataset.generatedAt),
+      kpis: this.buildKpis(overview.dataset, overview.totals),
+      trendOptions: this.buildTrendOptions(overview.dataset.dailyTotals),
+      flowRows: this.buildFlowRows(overview.totals),
+      signals: this.buildSignals(
+        overview.dataset,
+        overview.totals,
+        reviewScope,
+        refundCandidates[0] ?? null,
+        lowConversionProduct,
+        competitorProductsWithReviews,
+        topCategory,
+      ),
+      conclusion: this.buildConclusion(overview.dataset, overview.totals, reviewScope),
+    };
+  }));
 
   goProducts(): void {
     this.router.navigate(['/voc-insight/products']);
   }
 
+  goCategories(): void {
+    this.router.navigate(['/domestic/categories']);
+  }
+
+  goCompetitors(): void {
+    this.router.navigate(['/domestic/competitors']);
+  }
+
+  goRefundAnalysis(): void {
+    this.router.navigate(['/return-analysis/overview']);
+  }
+
+  goDataStatus(): void {
+    this.router.navigate(['/data-status']);
+  }
+
+  goAiInsights(): void {
+    this.router.navigate(['/voc-insight/ai']);
+  }
+
   openProduct(product: DomesticProduct): void {
     this.state.setProduct({
       asin: product.productId,
@@ -118,13 +382,25 @@ export class DomesticOperatingOverviewComponent {
       storeName: '京东',
       reviewCount: 0,
     });
-    this.router.navigate(['/domestic/product'], { queryParams: { productId: product.productId } });
+    this.router.navigate(['/voc-insight/detail'], { queryParams: { productId: product.productId } });
   }
 
   categoryWidth(value: number, max: number): number {
     return Math.max(4, (value / max) * 100);
   }
 
+  flowWidth(value: number): number {
+    return value > 0 ? Math.min(100, Math.max(3, value * 100)) : 0;
+  }
+
+  ratingWidth(value: number, max: number): number {
+    return value > 0 ? Math.max(4, (value / max) * 100) : 0;
+  }
+
+  ratioValue(numerator: number, denominator: number): number {
+    return ratio(numerator, denominator);
+  }
+
   currency(value: number): string {
     return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
   }
@@ -142,21 +418,393 @@ export class DomesticOperatingOverviewComponent {
     return `${prefix}${(value * 100).toFixed(1)}%`;
   }
 
-  private buildTrendOptions(rows: Array<{ date: string; gmv: number; transactionOrders: number }>): EChartsOption {
+  runKpiAction(key: string): void {
+    if (key === 'refund') {
+      this.goRefundAnalysis();
+    } else {
+      this.goProducts();
+    }
+  }
+
+  runSignalAction(signal: OverviewSignal): void {
+    if (signal.action === 'product' && signal.product) {
+      this.openProduct(signal.product);
+    } else if (signal.action === 'refund') {
+      this.goRefundAnalysis();
+    } else if (signal.action === 'category') {
+      this.goCategories();
+    } else {
+      this.goCompetitors();
+    }
+  }
+
+  refundRiskClass(value: number): string {
+    if (value > this.REFUND_WARN) return 'is-critical';
+    if (value >= this.REFUND_CAUTION) return 'is-caution';
+    return 'is-normal';
+  }
+
+  refundRiskLabel(value: number): string {
+    if (value > this.REFUND_WARN) return '严重';
+    if (value >= this.REFUND_CAUTION) return '关注';
+    return '正常';
+  }
+
+  private buildKpis(dataset: DomesticDataset, totals: DomesticMetricSummary): OverviewKpi[] {
+    const rows = dataset.dailyTotals;
+    const { previous, current } = comparableWindows(rows);
+    const hasPeriod = previous.length > 0 && current.length > 0;
+    const period = `${dataset.source.dateRange.start} 至 ${dataset.source.dateRange.end}`;
+    const sample = this.number(dataset.summary.metricRows);
+    const average = (window: DomesticDailyMetric[], pick: (row: DomesticDailyMetric) => number): number => (
+      window.length ? kpiSeriesSum(window, pick) / window.length : 0
+    );
+
+    const compose = (cfg: {
+      key: string;
+      label: string;
+      tone: OverviewKpi['tone'];
+      value: string;
+      description: string;
+      helpText: string;
+      series: number[];
+      current: number;
+      previous: number;
+      higherIsBetter: boolean;
+      deltaMode?: 'relative' | 'points';
+    }): OverviewKpi => {
+      const pct = hasPeriod ? kpiPeriodPercent(cfg.current, cfg.previous) : null;
+      const delta = cfg.current - cfg.previous;
+      const trendState: OverviewKpi['trendState'] = pct == null || Math.abs(delta) < 0.000001
+        ? 'neutral'
+        : (cfg.higherIsBetter ? delta > 0 : delta < 0) ? 'good' : 'bad';
+      const spark = this.sparklineGeometry(cfg.series);
+      const trendValue = pct == null
+        ? '--'
+        : cfg.deltaMode === 'points'
+          ? `${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}pp`
+          : `${pct > 0 ? '+' : ''}${(pct * 100).toFixed(1)}%`;
+      return {
+        key: cfg.key,
+        label: cfg.label,
+        tone: cfg.tone,
+        value: cfg.value,
+        description: cfg.description,
+        helpText: cfg.helpText,
+        trendValue,
+        trendLabel: hasPeriod ? ` · 后 ${current.length} 日较前 ${previous.length} 日` : ' · 无等长对比',
+        trendState,
+        sparkAvailable: spark !== null,
+        sparkPoints: spark ? spark.points : '',
+        sparkArea: spark ? spark.area : '',
+      };
+    };
+
+    return [
+      compose({
+        key: 'gmv',
+        label: '成交金额',
+        tone: 'primary',
+        value: this.currency(totals.gmv),
+        description: '7 日成交金额合计',
+        helpText: `口径:成交商品金额合计;变化:后 ${current.length} 日日均与前 ${previous.length} 日日均;周期:${period};样本 ${sample} 行`,
+        series: rows.map((row) => row.gmv),
+        current: average(current, (row) => row.gmv),
+        previous: average(previous, (row) => row.gmv),
+        higherIsBetter: true,
+      }),
+      compose({
+        key: 'orders',
+        label: '成交订单',
+        tone: 'neutral',
+        value: this.number(totals.transactionOrders),
+        description: `${this.number(totals.soldUnits)} 件成交商品`,
+        helpText: `口径:成交单量;变化:后 ${current.length} 日日均与前 ${previous.length} 日日均;周期:${period};样本 ${sample} 行`,
+        series: rows.map((row) => row.transactionOrders),
+        current: average(current, (row) => row.transactionOrders),
+        previous: average(previous, (row) => row.transactionOrders),
+        higherIsBetter: true,
+      }),
+      compose({
+        key: 'visitors',
+        label: '商品访客',
+        tone: 'neutral',
+        value: this.number(totals.visitors),
+        description: '商品日访客数汇总',
+        helpText: `口径:商品日访客数汇总,同一用户跨商品可能重复;变化:后 ${current.length} 日日均与前 ${previous.length} 日日均;周期:${period}`,
+        series: rows.map((row) => row.visitors),
+        current: average(current, (row) => row.visitors),
+        previous: average(previous, (row) => row.visitors),
+        higherIsBetter: true,
+      }),
+      compose({
+        key: 'conversion',
+        label: '成交单转化率',
+        tone: 'neutral',
+        value: this.percent(totals.conversionRate),
+        description: '成交订单 ÷ 商品访客',
+        helpText: `口径:成交订单数 ÷ 商品访客数汇总;变化使用百分点;周期:${period};另有客户转化率 ${this.percent(ratio(totals.transactionCustomers, totals.visitors))}`,
+        series: rows.map((row) => kpiSeriesRatio([row], (r) => r.transactionOrders, (r) => r.visitors)),
+        current: kpiSeriesRatio(current, (row) => row.transactionOrders, (row) => row.visitors),
+        previous: kpiSeriesRatio(previous, (row) => row.transactionOrders, (row) => row.visitors),
+        higherIsBetter: true,
+        deltaMode: 'points',
+      }),
+      compose({
+        key: 'refund',
+        label: '退款金额占比',
+        tone: totals.refundToGmvRate > this.REFUND_WARN ? 'critical' : totals.refundToGmvRate >= this.REFUND_CAUTION ? 'caution' : 'neutral',
+        value: this.percent(totals.refundToGmvRate),
+        description: `退款 ${this.currency(totals.refundAmount)} · 阈值 12%`,
+        helpText: `口径:退款金额 ÷ 成交金额;变化使用百分点,下降为改善;周期:${period};样本 ${sample} 行`,
+        series: rows.map((row) => kpiSeriesRatio([row], (r) => r.refundAmount, (r) => r.gmv)),
+        current: kpiSeriesRatio(current, (row) => row.refundAmount, (row) => row.gmv),
+        previous: kpiSeriesRatio(previous, (row) => row.refundAmount, (row) => row.gmv),
+        higherIsBetter: false,
+        deltaMode: 'points',
+      }),
+      compose({
+        key: 'aov',
+        label: '成交客单价',
+        tone: 'neutral',
+        value: this.currency(ratio(totals.gmv, totals.transactionOrders)),
+        description: '成交金额 ÷ 成交订单',
+        helpText: `口径:成交金额 ÷ 成交单量;变化:后 ${current.length} 日与前 ${previous.length} 日的聚合客单价;周期:${period}`,
+        series: rows.map((row) => ratio(row.gmv, row.transactionOrders)),
+        current: ratio(kpiSeriesSum(current, (row) => row.gmv), kpiSeriesSum(current, (row) => row.transactionOrders)),
+        previous: ratio(kpiSeriesSum(previous, (row) => row.gmv), kpiSeriesSum(previous, (row) => row.transactionOrders)),
+        higherIsBetter: true,
+      }),
+    ];
+  }
+
+  private sparklineGeometry(values: number[], width = 100, height = 30, padding = 3): { points: string; area: string } | null {
+    const nums = values.map((value) => Number(value) || 0);
+    if (nums.length < 2) return null;
+    const max = Math.max(...nums);
+    const min = Math.min(...nums);
+    const span = max - min;
+    const innerWidth = width - padding * 2;
+    const innerHeight = height - padding * 2;
+    const yFor = (value: number) => (span === 0 ? height / 2 : padding + innerHeight * (1 - (value - min) / span));
+    const step = innerWidth / (nums.length - 1);
+    const coords = nums.map((value, index) => `${(padding + index * step).toFixed(1)},${yFor(value).toFixed(1)}`);
+    const points = coords.join(' ');
+    const area = `${padding},${height - padding} ${points} ${width - padding},${height - padding}`;
+    return { points, area };
+  }
+
+  private buildTrendOptions(rows: DomesticDailyMetric[]): EChartsOption {
+    const dates = rows.map((row) => row.date.slice(5));
     return {
-      color: ['#4f6bed', '#38a169'],
-      tooltip: { trigger: 'axis' },
-      legend: { data: ['成交金额', '成交订单'], top: 0 },
-      grid: { left: 58, right: 52, top: 48, bottom: 34 },
-      xAxis: { type: 'category', data: rows.map((row) => row.date.slice(5)), axisTick: { show: false } },
+      animation: false,
+      color: ['#2563eb', '#15803d', '#b91c1c'],
+      tooltip: { trigger: 'axis', axisPointer: { type: 'line' }, confine: true },
+      axisPointer: { link: [{ xAxisIndex: 'all' }] },
+      title: [
+        { text: '成交金额', subtext: '元', left: 0, top: 0, textStyle: { fontSize: 12, fontWeight: 700, color: '#334155' }, subtextStyle: { fontSize: 10, color: '#94a3b8' } },
+        { text: '成交单转化率', subtext: '订单 / 商品访客', left: 0, top: 128, textStyle: { fontSize: 12, fontWeight: 700, color: '#334155' }, subtextStyle: { fontSize: 10, color: '#94a3b8' } },
+        { text: '退款金额占比', subtext: '8% 关注 · 12% 严重', left: 0, top: 256, textStyle: { fontSize: 12, fontWeight: 700, color: '#334155' }, subtextStyle: { fontSize: 10, color: '#94a3b8' } },
+      ],
+      grid: [
+        { left: 112, right: 54, top: 16, height: 88 },
+        { left: 112, right: 54, top: 144, height: 88 },
+        { left: 112, right: 54, top: 272, height: 88 },
+      ],
+      xAxis: [
+        { type: 'category', gridIndex: 0, data: dates, boundaryGap: false, axisTick: { show: false }, axisLabel: { show: false }, axisLine: { lineStyle: { color: '#e2e8f0' } } },
+        { type: 'category', gridIndex: 1, data: dates, boundaryGap: false, axisTick: { show: false }, axisLabel: { show: false }, axisLine: { lineStyle: { color: '#e2e8f0' } } },
+        { type: 'category', gridIndex: 2, data: dates, boundaryGap: false, axisTick: { show: false }, axisLabel: { fontSize: 11, color: '#64748b' }, axisLine: { lineStyle: { color: '#cbd5e1' } } },
+      ],
       yAxis: [
-        { type: 'value', name: '金额', axisLabel: { formatter: (value: number) => `${Math.round(value / 1000)}k` }, splitLine: { lineStyle: { color: '#edf0f4' } } },
-        { type: 'value', name: '订单', splitLine: { show: false } },
+        { type: 'value', gridIndex: 0, axisLabel: { formatter: (value: number) => this.formatAxisMoney(value), fontSize: 10, color: '#64748b' }, splitNumber: 2, splitLine: { lineStyle: { color: '#edf0f4' } } },
+        { type: 'value', gridIndex: 1, axisLabel: { formatter: '{value}%', fontSize: 10, color: '#64748b' }, splitNumber: 2, splitLine: { lineStyle: { color: '#edf0f4' } } },
+        { type: 'value', gridIndex: 2, axisLabel: { formatter: '{value}%', fontSize: 10, color: '#64748b' }, splitNumber: 2, splitLine: { lineStyle: { color: '#edf0f4' } } },
       ],
       series: [
-        { name: '成交金额', type: 'line', smooth: true, symbolSize: 7, data: rows.map((row) => Math.round(row.gmv)), areaStyle: { opacity: 0.08 } },
-        { name: '成交订单', type: 'bar', yAxisIndex: 1, barMaxWidth: 24, data: rows.map((row) => row.transactionOrders) },
+        { name: '成交金额', type: 'line', xAxisIndex: 0, yAxisIndex: 0, smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: rows.map((row) => Math.round(row.gmv)), endLabel: { show: true, formatter: '{c}', color: '#2563eb', fontWeight: 700, fontSize: 10 }, emphasis: { focus: 'series' } },
+        { name: '成交单转化率', type: 'line', xAxisIndex: 1, yAxisIndex: 1, smooth: false, showSymbol: false, lineStyle: { width: 2 }, data: rows.map((row) => Number((ratio(row.transactionOrders, row.visitors) * 100).toFixed(2))), endLabel: { show: true, formatter: '{c}%', color: '#15803d', fontWeight: 700, fontSize: 10 }, emphasis: { focus: 'series' } },
+        {
+          name: '退款金额占比', type: 'line', xAxisIndex: 2, yAxisIndex: 2, smooth: false, showSymbol: false, lineStyle: { width: 2 },
+          data: rows.map((row) => Number((ratio(row.refundAmount, row.gmv) * 100).toFixed(2))),
+          endLabel: { show: true, formatter: '{c}%', color: '#b91c1c', fontWeight: 700, fontSize: 10 },
+          markLine: {
+            silent: true,
+            symbol: 'none',
+            lineStyle: { type: 'dashed', width: 1 },
+            label: { fontSize: 9, formatter: '{b}' },
+            data: [
+              { name: '关注 8%', yAxis: 8, lineStyle: { color: '#b45309' }, label: { color: '#b45309' } },
+              { name: '严重 12%', yAxis: 12, lineStyle: { color: '#b91c1c' }, label: { color: '#b91c1c' } },
+            ],
+          },
+          emphasis: { focus: 'series' },
+        },
       ],
     };
   }
+
+  private formatAxisMoney(value: number): string {
+    if (Math.abs(value) >= 10000) {
+      const wan = value / 10000;
+      return `${wan >= 100 ? Math.round(wan) : wan.toFixed(1)}万`;
+    }
+    return String(Math.round(value));
+  }
+
+  private buildFlowRows(totals: DomesticMetricSummary): OverviewFlowRow[] {
+    const searchRate = ratio(totals.clicks, totals.impressions);
+    const customerRate = ratio(totals.transactionCustomers, totals.visitors);
+    const orderRetention = ratio(totals.transactionOrders, totals.orderCount);
+    const amountRealization = ratio(totals.gmv, totals.orderAmount);
+    return [
+      {
+        id: 'search', label: '搜索获取', fromLabel: '搜索曝光', fromValue: this.number(totals.impressions),
+        toLabel: '搜索点击', toValue: this.number(totals.clicks), rate: searchRate, rateLabel: `点击率 ${this.percent(searchRate)}`,
+      },
+      {
+        id: 'customer', label: '客户成交', fromLabel: '商品访客', fromValue: this.number(totals.visitors),
+        toLabel: '成交客户', toValue: this.number(totals.transactionCustomers), rate: customerRate, rateLabel: `客户转化 ${this.percent(customerRate)}`,
+      },
+      {
+        id: 'orders', label: '订单实现', fromLabel: '下单单量', fromValue: this.number(totals.orderCount),
+        toLabel: '成交单量', toValue: this.number(totals.transactionOrders), rate: orderRetention, rateLabel: `实现率 ${this.percent(orderRetention)}`,
+      },
+      {
+        id: 'amount', label: '金额实现', fromLabel: '下单金额', fromValue: this.currency(totals.orderAmount),
+        toLabel: '成交金额', toValue: this.currency(totals.gmv), rate: amountRealization, rateLabel: `实现率 ${this.percent(amountRealization)}`,
+      },
+    ];
+  }
+
+  private buildSignals(
+    dataset: DomesticDataset,
+    totals: DomesticMetricSummary,
+    reviewScope: DomesticReviewScope,
+    refundRiskProduct: DomesticProduct | null,
+    lowConversionProduct: DomesticProduct | null,
+    competitorProductsWithReviews: number,
+    topCategory?: DomesticCategorySummary,
+  ): OverviewSignal[] {
+    const signals: OverviewSignal[] = [];
+    if (refundRiskProduct) {
+      signals.push({
+        id: 'refund-product',
+        severity: refundRiskProduct.summary.refundToGmvRate > this.REFUND_WARN ? 'critical' : 'caution',
+        label: '退款风险商品',
+        title: refundRiskProduct.model || refundRiskProduct.productId,
+        detail: `${this.currency(refundRiskProduct.summary.refundAmount)} 退款 · ${this.currency(refundRiskProduct.summary.gmv)} 成交`,
+        metric: this.percent(refundRiskProduct.summary.refundToGmvRate),
+        action: 'product',
+        product: refundRiskProduct,
+      });
+    }
+    if (lowConversionProduct) {
+      signals.push({
+        id: 'conversion-product',
+        severity: 'caution',
+        label: '高流量低转化',
+        title: lowConversionProduct.model || lowConversionProduct.productId,
+        detail: `${this.number(lowConversionProduct.summary.visitors)} 访客 · ${this.currency(lowConversionProduct.summary.gmv)} 成交`,
+        metric: this.percent(lowConversionProduct.summary.conversionRate),
+        action: 'product',
+        product: lowConversionProduct,
+      });
+    }
+    if (topCategory) {
+      signals.push({
+        id: 'category-concentration',
+        severity: ratio(topCategory.gmv, totals.gmv) >= 0.4 ? 'caution' : 'info',
+        label: '类目集中度',
+        title: topCategory.name,
+        detail: `${this.number(topCategory.productCount)} 个商品 · ${this.currency(topCategory.gmv)} 成交`,
+        metric: this.percent(ratio(topCategory.gmv, totals.gmv)),
+        action: 'category',
+      });
+    }
+    signals.push({
+      id: 'voc-coverage',
+      severity: 'info',
+      label: reviewScope.ownCount ? 'VOC 证据覆盖' : 'VOC 数据缺口',
+      title: reviewScope.ownCount ? '本品与竞品证据已接入' : '本品评论尚未接入',
+      detail: `竞品评论覆盖 ${competitorProductsWithReviews} / ${dataset.summary.uniqueCompetitorProducts} 个商品`,
+      metric: `${reviewScope.competitorCount} 条`,
+      action: 'voc',
+    });
+    return signals.slice(0, 4);
+  }
+
+  private buildRatingRows(reviews: DomesticReviewScope['competitor']): OverviewRatingRow[] {
+    const total = reviews.length;
+    return [5, 4, 3, 2, 1].map((rating) => {
+      const count = reviews.filter((review) => Math.round(Number(review.rating)) === rating).length;
+      return { rating, count, share: ratio(count, total) };
+    });
+  }
+
+  private formatDateTime(value: string): string {
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return '时间未知';
+    return new Intl.DateTimeFormat('zh-CN', {
+      month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
+    }).format(date);
+  }
+
+  /** 一句话执行结论:全部由现有字段按规则派生,风险定级依据退款占比预警线。 */
+  private buildConclusion(dataset: DomesticDataset, totals: DomesticMetricSummary, reviewScope: DomesticReviewScope): OverviewConclusion {
+    const rows = dataset.dailyTotals;
+    if (!rows.length || !dataset.summary.metricRows) {
+      return { tone: 'neutral', icon: 'i', primaryMetric: '', text: '数据不足:当前周期没有可用的经营明细,暂无法形成执行结论。' };
+    }
+
+    const { previous, current } = comparableWindows(rows);
+    const hasPeriod = previous.length > 0 && current.length > 0;
+    const previousDailyGmv = previous.length ? kpiSeriesSum(previous, (row) => row.gmv) / previous.length : 0;
+    const currentDailyGmv = current.length ? kpiSeriesSum(current, (row) => row.gmv) / current.length : 0;
+    const gmvPct = hasPeriod ? kpiPeriodPercent(currentDailyGmv, previousDailyGmv) : null;
+
+    const refundRate = totals.refundToGmvRate;
+    const tone = refundRate > this.REFUND_WARN ? 'red' : refundRate > this.REFUND_CAUTION ? 'amber' : 'green';
+    const icon = tone === 'red' ? '⚠' : tone === 'amber' ? '△' : '✓';
+
+    let gmvClause = `本周期成交 ${this.currency(totals.gmv)}`;
+    gmvClause += gmvPct == null
+      ? ',当前快照缺少可比周期'
+      : `,后 ${current.length} 日日均较前 ${previous.length} 日日均${gmvPct >= 0 ? '+' : ''}${(gmvPct * 100).toFixed(1)}%`;
+
+    const refundCategory = this.refundTopCategory(dataset);
+    let refundClause: string;
+    if (refundRate > this.REFUND_WARN) {
+      refundClause = `;退款占比 ${this.percent(refundRate)} 超预警线(12%),建议优先复核 ${refundCategory || '退款主要类目'}`;
+    } else if (refundRate > this.REFUND_CAUTION) {
+      refundClause = `;退款占比 ${this.percent(refundRate)} 接近预警线(12%),建议关注 ${refundCategory ? refundCategory + ' 退款情况' : '退款趋势'}`;
+    } else {
+      refundClause = `;退款占比 ${this.percent(refundRate)} 处于安全区间`;
+    }
+
+    return {
+      tone,
+      icon,
+      primaryMetric: refundRate > this.REFUND_CAUTION ? `退款占比 ${this.percent(refundRate)}` : `成交 ${this.currency(totals.gmv)}`,
+      text: `${gmvClause}${refundClause}。样本:本品评论 ${reviewScope.ownCount} 条,经营结论来自 ${this.number(dataset.summary.metricRows)} 行商品日明细。`,
+    };
+  }
+
+  private refundTopCategory(dataset: DomesticDataset): string {
+    const buckets = new Map<string, number>();
+    dataset.products.forEach((product) => {
+      if (product.role !== 'own') return;
+      const name = product.category2 || product.category1 || '未分类';
+      buckets.set(name, (buckets.get(name) ?? 0) + Number(product.summary.refundAmount || 0));
+    });
+    let best = '';
+    let bestValue = 0;
+    buckets.forEach((value, name) => {
+      if (value > bestValue) {
+        bestValue = value;
+        best = name;
+      }
+    });
+    return bestValue > 0 ? best : '';
+  }
 }

+ 312 - 0
src/modules/feedback-inbox/feedback-inbox.component.html

@@ -0,0 +1,312 @@
+<section class="inbox-page">
+  <header class="page-heading">
+    <div class="page-title-block">
+      <div class="title-icon" aria-hidden="true"><lucide-icon name="Inbox" [size]="20"></lucide-icon></div>
+      <div>
+        <h1>反馈收件箱</h1>
+        <p>集中审阅真实评价证据,并将已确认的问题转入行动流程。</p>
+      </div>
+    </div>
+
+    <div class="page-actions">
+      @if (dataset) {
+        <span class="dataset-status">
+          <lucide-icon name="Database" [size]="14"></lucide-icon>
+          {{ items.length }} 条有效反馈
+        </span>
+      }
+      <button
+        type="button"
+        class="icon-button"
+        aria-label="刷新反馈数据"
+        title="刷新反馈数据"
+        [disabled]="loading"
+        (click)="refresh()">
+        <lucide-icon name="RefreshCw" [size]="17" [class.is-spinning]="loading"></lucide-icon>
+      </button>
+    </div>
+  </header>
+
+  @if (errorMessage && !dataset) {
+    <section class="full-state" role="alert">
+      <span class="state-icon state-icon-error"><lucide-icon name="CircleAlert" [size]="22"></lucide-icon></span>
+      <h2>反馈数据暂时不可用</h2>
+      <p>{{ errorMessage }}</p>
+      <button type="button" class="secondary-button" (click)="retry()">
+        <lucide-icon name="RefreshCw" [size]="16"></lucide-icon>
+        重新加载
+      </button>
+    </section>
+  } @else if (loading && !dataset) {
+    <section class="full-state" aria-live="polite" aria-busy="true">
+      <span class="state-icon"><lucide-icon name="Inbox" [size]="22"></lucide-icon></span>
+      <h2>正在整理反馈队列</h2>
+      <p>正在读取评价、商品与主题信号。</p>
+      <span class="loading-line" aria-hidden="true"></span>
+    </section>
+  } @else if (dataset && items.length === 0) {
+    <section class="full-state">
+      <span class="state-icon"><lucide-icon name="MessageSquare" [size]="22"></lucide-icon></span>
+      <h2>当前没有可审阅的反馈</h2>
+      <p>数据集已加载,但没有包含有效评论正文。收件箱不会使用商品指标推断客户反馈。</p>
+      <div class="empty-facts">
+        <span>入库评论 {{ dataset.summary.reviewCount || 0 }}</span>
+        <span>数据范围 {{ dataset.source.dateRange.start }} 至 {{ dataset.source.dateRange.end }}</span>
+      </div>
+      <button type="button" class="secondary-button" (click)="refresh()">
+        <lucide-icon name="RefreshCw" [size]="16"></lucide-icon>
+        刷新数据
+      </button>
+    </section>
+  } @else {
+    @if (errorMessage) {
+      <div class="inline-alert" role="alert">
+        <lucide-icon name="CircleAlert" [size]="16"></lucide-icon>
+        <span>{{ errorMessage }} 当前仍展示上一次成功加载的数据。</span>
+        <button type="button" (click)="retry()">重试</button>
+      </div>
+    }
+
+    <section
+      class="inbox-workbench"
+      [attr.data-mobile-view]="mobileView"
+      [attr.aria-busy]="loading">
+      <aside class="queue-panel" aria-label="反馈队列">
+        <header class="panel-heading queue-heading">
+          <div>
+            <span class="panel-label">反馈队列</span>
+            <strong>{{ filteredItems.length }} / {{ items.length }}</strong>
+          </div>
+          <span class="issue-count">{{ issueCount }} 条待关注</span>
+        </header>
+
+        <div class="filter-area">
+          <label class="search-field">
+            <lucide-icon name="Search" [size]="16"></lucide-icon>
+            <input
+              type="search"
+              [(ngModel)]="searchTerm"
+              (ngModelChange)="onFiltersChanged()"
+              placeholder="搜索正文、商品或主题"
+              aria-label="搜索反馈" />
+            @if (searchTerm) {
+              <button type="button" aria-label="清除搜索" title="清除搜索" (click)="searchTerm = ''; onFiltersChanged()">
+                <lucide-icon name="X" [size]="15"></lucide-icon>
+              </button>
+            }
+          </label>
+          <div class="filter-row">
+            <span aria-hidden="true"><lucide-icon name="ListFilter" [size]="15"></lucide-icon></span>
+            <label>
+              <span class="sr-only">来源筛选</span>
+              <select [(ngModel)]="sourceFilter" (ngModelChange)="onFiltersChanged()">
+                @for (option of sourceOptions; track option.value) {
+                  <option [ngValue]="option.value">{{ option.label }}</option>
+                }
+              </select>
+            </label>
+            <label>
+              <span class="sr-only">评分筛选</span>
+              <select [(ngModel)]="sentimentFilter" (ngModelChange)="onFiltersChanged()">
+                @for (option of sentimentOptions; track option.value) {
+                  <option [ngValue]="option.value">{{ option.label }}</option>
+                }
+              </select>
+            </label>
+          </div>
+        </div>
+
+        <div class="queue-list">
+          @for (item of displayedItems; track trackItem($index, item)) {
+            <button
+              type="button"
+              class="queue-item"
+              [class.is-selected]="item.id === selectedId"
+              [class.is-negative]="item.sentiment === 'negative'"
+              [attr.aria-pressed]="item.id === selectedId"
+              (click)="selectItem(item)">
+              <span class="queue-item-topline">
+                <span class="source-label">{{ item.sourceLabel }}</span>
+                <span class="date-label">{{ item.dateLabel }}</span>
+              </span>
+              <strong class="queue-topic">{{ item.topicLabel }}</strong>
+              <span class="queue-copy">{{ item.review.content }}</span>
+              <span class="queue-item-meta">
+                <span class="rating" [attr.data-sentiment]="item.sentiment">
+                  <lucide-icon name="Star" [size]="13"></lucide-icon>
+                  {{ item.review.rating > 0 ? item.review.rating + '.0' : '未评分' }}
+                </span>
+                <span>{{ item.sampleCount }} 条同类样本</span>
+                <span>{{ item.confidence.label }}</span>
+              </span>
+            </button>
+          } @empty {
+            <div class="queue-empty">
+              <lucide-icon name="Search" [size]="20"></lucide-icon>
+              <strong>没有匹配的反馈</strong>
+              <span>调整搜索词或筛选条件后重试。</span>
+              <button type="button" (click)="clearFilters()">清除筛选</button>
+            </div>
+          }
+          @if (hasMoreItems) {
+            <button type="button" class="load-more-button" (click)="loadMore()">
+              再加载 {{ filteredItems.length - visibleLimit > 60 ? 60 : filteredItems.length - visibleLimit }} 条
+            </button>
+          }
+        </div>
+
+        <footer class="queue-footer">
+          <span><i class="legend-dot legend-dot-issue"></i>低评分优先</span>
+          <span>主题 {{ topicCount }} 个</span>
+        </footer>
+      </aside>
+
+      <main class="evidence-panel" aria-label="证据详情">
+        @if (selectedItem; as item) {
+          <header class="evidence-heading">
+            <button type="button" class="mobile-back" (click)="showQueue()">
+              <lucide-icon name="ChevronLeft" [size]="17"></lucide-icon>
+              返回队列
+            </button>
+            <div class="evidence-heading-row">
+              <div>
+                <span class="panel-label">证据详情</span>
+                <h2>{{ item.topicLabel }}</h2>
+              </div>
+              <span class="priority-badge" [attr.data-sentiment]="item.sentiment">{{ item.priorityLabel }}</span>
+            </div>
+            <div class="evidence-meta-line">
+              <span><lucide-icon name="MessageSquare" [size]="14"></lucide-icon>{{ item.sourceLabel }}</span>
+              <span><lucide-icon name="Calendar" [size]="14"></lucide-icon>{{ item.dateLabel }}</span>
+              <span><lucide-icon name="Star" [size]="14"></lucide-icon>{{ item.review.rating > 0 ? item.review.rating + ' / 5' : '未评分' }}</span>
+            </div>
+          </header>
+
+          <div class="evidence-body">
+            <section class="verbatim-section">
+              <span class="section-title">客户原声</span>
+              <blockquote>{{ item.review.content }}</blockquote>
+              <div class="evidence-id">评价 ID:{{ item.review.reviewId }}</div>
+            </section>
+
+            <section class="signal-section">
+              <div class="section-heading-row">
+                <span class="section-title">主题判断</span>
+                <span class="confidence-badge" [attr.data-level]="item.confidence.level">{{ item.confidence.label }}</span>
+              </div>
+              <h3>{{ item.topicLabel }}</h3>
+              <p>{{ item.topicDescription }}</p>
+              <div class="confidence-note">
+                <lucide-icon name="CircleAlert" [size]="16"></lucide-icon>
+                <span>{{ item.confidence.description }}</span>
+              </div>
+            </section>
+
+            <section class="evidence-facts" aria-label="证据统计">
+              <div>
+                <span>同类样本</span>
+                <strong>{{ item.sampleCount }}</strong>
+                <small>当前商品范围</small>
+              </div>
+              <div>
+                <span>来源类型</span>
+                <strong>{{ item.sourceRole === 'own' ? '本品' : item.sourceRole === 'competitor' ? '竞品' : '待确认' }}</strong>
+                <small>{{ dataset?.platform?.toUpperCase() }}</small>
+              </div>
+              <div>
+                <span>证据口径</span>
+                <strong>评价正文</strong>
+                <small>不含经营指标推断</small>
+              </div>
+            </section>
+
+            <section class="handling-note">
+              <span class="section-title">处理建议</span>
+              <p>{{ actionRecommendation(item) }}</p>
+            </section>
+          </div>
+        } @else {
+          <div class="panel-empty">
+            <lucide-icon name="MessageSquare" [size]="22"></lucide-icon>
+            <strong>选择一条反馈查看证据</strong>
+            <span>详情会保留原始正文、主题依据和样本边界。</span>
+          </div>
+        }
+      </main>
+
+      <aside class="context-panel" aria-label="处理上下文">
+        @if (selectedItem; as item) {
+          <header class="panel-heading context-heading">
+            <div>
+              <span class="panel-label">处理上下文</span>
+              <strong>{{ isActionCreated(item) ? '行动草稿已创建' : '等待研判' }}</strong>
+            </div>
+            @if (isActionCreated(item)) {
+              <lucide-icon class="created-icon" name="CircleCheckBig" [size]="18"></lucide-icon>
+            }
+          </header>
+
+          <section class="context-section product-context">
+            <span class="section-title">关联商品</span>
+            <div class="product-summary">
+              <img
+                [src]="item.product?.detail?.imageUrl || fallbackImage"
+                [alt]="item.product?.title || '商品图片'"
+                (error)="useFallback($event)" />
+              <div>
+                <strong>{{ item.product?.title || '商品信息待补' }}</strong>
+                <span>{{ item.product?.brand || '-' }} · {{ item.product?.model || item.review.productId }}</span>
+              </div>
+            </div>
+            <dl class="context-list">
+              <div><dt>商品 ID</dt><dd>{{ item.review.productId }}</dd></div>
+              <div><dt>类目</dt><dd>{{ productCategory(item.product) }}</dd></div>
+              <div><dt>价格快照</dt><dd>{{ productPrice(item.product) }}</dd></div>
+              <div><dt>关系数量</dt><dd>{{ selectedProductRelations }}</dd></div>
+            </dl>
+          </section>
+
+          <section class="context-section topic-context">
+            <span class="section-title">主题上下文</span>
+            <dl class="context-list">
+              <div><dt>主题</dt><dd>{{ item.topicLabel }}</dd></div>
+              <div><dt>样本量</dt><dd>{{ item.sampleCount }} 条</dd></div>
+              <div><dt>置信表达</dt><dd>{{ item.confidence.label }}</dd></div>
+              <div><dt>当前状态</dt><dd>{{ isActionCreated(item) ? '已建草稿' : '待研判' }}</dd></div>
+            </dl>
+          </section>
+
+          <section class="context-section action-context">
+            <span class="section-title">下一步</span>
+            <p>将已确认的反馈证据转为行动项,继续补充负责人、截止时间和验证指标。</p>
+            <button
+              type="button"
+              class="primary-button"
+              [disabled]="isActionCreated(item)"
+              (click)="createActionDraft()">
+              <lucide-icon [name]="isActionCreated(item) ? 'CircleCheckBig' : 'ClipboardPlus'" [size]="16"></lucide-icon>
+              {{ isActionCreated(item) ? '已进入行动中心' : '创建行动项' }}
+            </button>
+          </section>
+
+          <footer class="context-footer">
+            <span><lucide-icon name="Clock" [size]="14"></lucide-icon>数据生成于</span>
+            <strong>{{ dataset?.generatedAt ? (dataset?.generatedAt | date:'yyyy-MM-dd HH:mm') : '-' }}</strong>
+          </footer>
+        }
+      </aside>
+    </section>
+  }
+</section>
+
+@if (actionDraft; as draft) {
+  <app-action-create-panel
+    [workspaceId]="workspaceId"
+    [productKey]="draft.productKey"
+    [suggestedTitle]="actionTitle(draft)"
+    [suggestedDescription]="actionDescription(draft)"
+    [sourceLabel]="draft.source"
+    (created)="onActionCreated($event)"
+    (cancelled)="cancelActionCreate()"
+  ></app-action-create-panel>
+}

+ 1069 - 0
src/modules/feedback-inbox/feedback-inbox.component.scss

@@ -0,0 +1,1069 @@
+:host {
+  display: block;
+  min-width: 0;
+  color: #1f2937;
+  letter-spacing: 0;
+}
+
+button,
+input,
+select {
+  letter-spacing: 0;
+}
+
+.inbox-page {
+  width: 100%;
+  padding: 24px;
+}
+
+.page-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  margin-bottom: 18px;
+}
+
+.page-title-block,
+.page-actions,
+.dataset-status,
+.evidence-meta-line,
+.filter-row,
+.queue-item-meta,
+.queue-footer,
+.inline-alert,
+.secondary-button,
+.primary-button,
+.mobile-back,
+.confidence-note,
+.context-footer,
+.section-heading-row {
+  display: flex;
+  align-items: center;
+}
+
+.page-title-block {
+  min-width: 0;
+  gap: 12px;
+}
+
+.title-icon,
+.state-icon {
+  display: grid;
+  place-items: center;
+  flex: 0 0 auto;
+  color: #35536b;
+  background: #edf3f6;
+}
+
+.title-icon {
+  width: 36px;
+  height: 36px;
+  border-radius: 8px;
+}
+
+.page-heading h1 {
+  color: #18212b;
+  font-size: 22px;
+  font-weight: 650;
+  line-height: 1.25;
+}
+
+.page-heading p {
+  margin-top: 3px;
+  color: #6b7280;
+  font-size: 13px;
+  line-height: 1.45;
+}
+
+.page-actions {
+  flex: 0 0 auto;
+  gap: 10px;
+}
+
+.dataset-status {
+  gap: 6px;
+  color: #667085;
+  font-size: 12px;
+}
+
+.icon-button {
+  display: grid;
+  place-items: center;
+  width: 34px;
+  height: 34px;
+  border: 1px solid #dfe4e8;
+  border-radius: 6px;
+  color: #4b5563;
+  background: #ffffff;
+  transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
+}
+
+.icon-button:hover:not(:disabled) {
+  border-color: #aeb9c2;
+  color: #1f2937;
+  background: #f8fafb;
+}
+
+.icon-button:disabled {
+  cursor: wait;
+  opacity: 0.65;
+}
+
+.is-spinning {
+  animation: spin 850ms linear infinite;
+}
+
+.inbox-workbench {
+  display: grid;
+  grid-template-columns: minmax(290px, 0.88fr) minmax(390px, 1.36fr) minmax(270px, 0.8fr);
+  width: 100%;
+  height: clamp(620px, calc(100dvh - 170px), 840px);
+  min-height: 620px;
+  overflow: hidden;
+  border: 1px solid #dfe4e8;
+  border-radius: 8px;
+  background: #ffffff;
+  box-shadow: 0 4px 18px rgba(24, 33, 43, 0.05);
+}
+
+.queue-panel,
+.evidence-panel,
+.context-panel {
+  min-width: 0;
+  min-height: 0;
+  background: #ffffff;
+}
+
+.queue-panel {
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr) auto;
+  border-right: 1px solid #e5e9ec;
+}
+
+.evidence-panel {
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  border-right: 1px solid #e5e9ec;
+}
+
+.context-panel {
+  overflow-y: auto;
+}
+
+.panel-heading {
+  min-height: 62px;
+  padding: 14px 16px;
+  border-bottom: 1px solid #e8ecef;
+}
+
+.panel-heading > div {
+  display: grid;
+  gap: 3px;
+}
+
+.panel-heading strong {
+  color: #27313b;
+  font-size: 14px;
+  font-weight: 650;
+}
+
+.panel-label,
+.section-title {
+  color: #7a8490;
+  font-size: 11px;
+  font-weight: 650;
+  line-height: 1.3;
+  text-transform: uppercase;
+}
+
+.queue-heading,
+.context-heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.issue-count,
+.priority-badge,
+.confidence-badge {
+  display: inline-flex;
+  align-items: center;
+  width: fit-content;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 650;
+  white-space: nowrap;
+}
+
+.issue-count {
+  padding: 3px 7px;
+  color: #9a3f3f;
+  background: #fff1f0;
+}
+
+.filter-area {
+  padding: 12px;
+  border-bottom: 1px solid #e8ecef;
+  background: #fafbfc;
+}
+
+.search-field {
+  display: grid;
+  grid-template-columns: 18px minmax(0, 1fr) 24px;
+  align-items: center;
+  height: 36px;
+  padding: 0 7px 0 10px;
+  border: 1px solid #d9e0e5;
+  border-radius: 6px;
+  color: #8a949e;
+  background: #ffffff;
+  transition: border-color 160ms ease, box-shadow 160ms ease;
+}
+
+.search-field:focus-within {
+  border-color: #6f8b9d;
+  box-shadow: 0 0 0 3px rgba(71, 107, 130, 0.1);
+}
+
+.search-field input {
+  width: 100%;
+  min-width: 0;
+  height: 34px;
+  border: 0;
+  color: #26313c;
+  background: transparent;
+  font-size: 12px;
+}
+
+.search-field input::placeholder {
+  color: #98a1aa;
+}
+
+.search-field button {
+  display: grid;
+  place-items: center;
+  width: 24px;
+  height: 24px;
+  border-radius: 4px;
+  color: #8a949e;
+}
+
+.search-field button:hover {
+  color: #374151;
+  background: #eef1f3;
+}
+
+.filter-row {
+  display: grid;
+  grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
+  gap: 7px;
+  margin-top: 8px;
+  color: #8a949e;
+}
+
+.filter-row label,
+.filter-row select {
+  min-width: 0;
+  width: 100%;
+}
+
+.filter-row select {
+  height: 32px;
+  padding: 0 24px 0 8px;
+  border: 1px solid #dfe4e8;
+  border-radius: 5px;
+  color: #4b5563;
+  background: #ffffff;
+  font-size: 11px;
+}
+
+.queue-list {
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.load-more-button {
+  display: block;
+  width: calc(100% - 20px);
+  min-height: 36px;
+  margin: 10px;
+  border: 1px solid #dbe3ec;
+  border-radius: 6px;
+  background: #ffffff;
+  color: #475569;
+  font-size: 12px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.load-more-button:hover {
+  border-color: #bfdbfe;
+  background: #eff6ff;
+  color: #1d4ed8;
+}
+
+.queue-item {
+  position: relative;
+  display: grid;
+  width: 100%;
+  min-height: 132px;
+  padding: 14px 15px 13px;
+  border-bottom: 1px solid #edf0f2;
+  color: #27313b;
+  text-align: left;
+  background: #ffffff;
+  transition: background 150ms ease;
+}
+
+.queue-item::before {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 3px;
+  background: transparent;
+}
+
+.queue-item:hover {
+  background: #f8fafb;
+}
+
+.queue-item.is-selected {
+  background: #f0f5f7;
+}
+
+.queue-item.is-selected::before {
+  background: #41697e;
+}
+
+.queue-item.is-negative:not(.is-selected)::before {
+  background: #d66b62;
+}
+
+.queue-item-topline {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 6px;
+}
+
+.source-label,
+.date-label,
+.queue-item-meta {
+  color: #7b858f;
+  font-size: 10px;
+}
+
+.source-label {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.date-label {
+  flex: 0 0 auto;
+}
+
+.queue-topic {
+  overflow: hidden;
+  color: #1f2933;
+  font-size: 13px;
+  font-weight: 650;
+  line-height: 1.35;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.queue-copy {
+  display: -webkit-box;
+  min-height: 36px;
+  margin-top: 5px;
+  overflow: hidden;
+  color: #59636e;
+  font-size: 12px;
+  line-height: 1.5;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow-wrap: anywhere;
+}
+
+.queue-item-meta {
+  gap: 10px;
+  margin-top: 9px;
+  overflow: hidden;
+  white-space: nowrap;
+}
+
+.queue-item-meta > span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.rating {
+  display: inline-flex;
+  align-items: center;
+  gap: 3px;
+  color: #52606d;
+}
+
+.rating[data-sentiment='negative'] {
+  color: #b34f48;
+}
+
+.rating[data-sentiment='positive'] {
+  color: #34775c;
+}
+
+.queue-footer {
+  justify-content: space-between;
+  gap: 12px;
+  min-height: 40px;
+  padding: 0 14px;
+  border-top: 1px solid #e8ecef;
+  color: #818a94;
+  background: #fafbfc;
+  font-size: 10px;
+}
+
+.queue-footer span:first-child {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.legend-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+}
+
+.legend-dot-issue {
+  background: #d66b62;
+}
+
+.queue-empty,
+.panel-empty {
+  display: grid;
+  place-items: center;
+  align-content: center;
+  min-height: 220px;
+  padding: 28px;
+  color: #8a949e;
+  text-align: center;
+}
+
+.queue-empty strong,
+.panel-empty strong {
+  margin-top: 10px;
+  color: #3b4651;
+  font-size: 13px;
+}
+
+.queue-empty span,
+.panel-empty span {
+  margin-top: 4px;
+  font-size: 11px;
+  line-height: 1.5;
+}
+
+.queue-empty button {
+  margin-top: 12px;
+  color: #41697e;
+  font-size: 11px;
+  font-weight: 650;
+}
+
+.evidence-heading {
+  padding: 18px 22px 16px;
+  border-bottom: 1px solid #e8ecef;
+}
+
+.evidence-heading-row {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.evidence-heading h2 {
+  margin-top: 5px;
+  color: #1b2530;
+  font-size: 19px;
+  font-weight: 650;
+  line-height: 1.35;
+}
+
+.priority-badge,
+.confidence-badge {
+  padding: 4px 8px;
+  color: #4f5d68;
+  background: #eef1f3;
+}
+
+.priority-badge[data-sentiment='negative'] {
+  color: #9a3f3f;
+  background: #fff1f0;
+}
+
+.priority-badge[data-sentiment='positive'] {
+  color: #2d6b50;
+  background: #edf8f2;
+}
+
+.evidence-meta-line {
+  flex-wrap: wrap;
+  gap: 8px 16px;
+  margin-top: 12px;
+  color: #737e88;
+  font-size: 11px;
+}
+
+.evidence-meta-line span {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.mobile-back {
+  display: none;
+  gap: 4px;
+  min-height: 32px;
+  margin: -4px 0 12px;
+  color: #41697e;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.evidence-body {
+  min-height: 0;
+  overflow-y: auto;
+}
+
+.verbatim-section,
+.signal-section,
+.handling-note {
+  padding: 24px 26px;
+  border-bottom: 1px solid #e8ecef;
+}
+
+.verbatim-section blockquote {
+  position: relative;
+  margin: 14px 0 0;
+  padding-left: 18px;
+  color: #202b36;
+  font-size: 16px;
+  font-weight: 500;
+  line-height: 1.75;
+  overflow-wrap: anywhere;
+}
+
+.verbatim-section blockquote::before {
+  content: '';
+  position: absolute;
+  inset: 4px auto 4px 0;
+  width: 3px;
+  border-radius: 2px;
+  background: #6e8796;
+}
+
+.evidence-id {
+  margin: 14px 0 0 18px;
+  color: #8a949e;
+  font-family: Consolas, 'SFMono-Regular', monospace;
+  font-size: 10px;
+  overflow-wrap: anywhere;
+}
+
+.section-heading-row {
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.signal-section h3 {
+  margin-top: 14px;
+  color: #25303a;
+  font-size: 15px;
+  font-weight: 650;
+}
+
+.signal-section > p,
+.handling-note p {
+  margin-top: 7px;
+  color: #5e6974;
+  font-size: 13px;
+  line-height: 1.65;
+}
+
+.confidence-badge[data-level='single'] {
+  color: #7a5a2d;
+  background: #fff7e8;
+}
+
+.confidence-badge[data-level='early'] {
+  color: #716125;
+  background: #f9f5df;
+}
+
+.confidence-badge[data-level='moderate'],
+.confidence-badge[data-level='strong'] {
+  color: #2c674e;
+  background: #edf8f2;
+}
+
+.confidence-note {
+  align-items: flex-start;
+  gap: 8px;
+  margin-top: 14px;
+  padding: 10px 12px;
+  border-left: 3px solid #8ca1ad;
+  border-radius: 0 5px 5px 0;
+  color: #63717c;
+  background: #f5f8f9;
+  font-size: 11px;
+  line-height: 1.55;
+}
+
+.confidence-note lucide-icon {
+  flex: 0 0 auto;
+  margin-top: 1px;
+}
+
+.evidence-facts {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  border-bottom: 1px solid #e8ecef;
+}
+
+.evidence-facts > div {
+  display: grid;
+  gap: 4px;
+  min-width: 0;
+  padding: 18px 20px;
+  border-right: 1px solid #e8ecef;
+}
+
+.evidence-facts > div:last-child {
+  border-right: 0;
+}
+
+.evidence-facts span,
+.evidence-facts small {
+  color: #7b858f;
+  font-size: 10px;
+}
+
+.evidence-facts strong {
+  overflow: hidden;
+  color: #293540;
+  font-size: 14px;
+  font-weight: 650;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.context-heading {
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  background: #ffffff;
+}
+
+.created-icon {
+  color: #34775c;
+}
+
+.context-section {
+  padding: 18px;
+  border-bottom: 1px solid #e8ecef;
+}
+
+.product-summary {
+  display: grid;
+  grid-template-columns: 46px minmax(0, 1fr);
+  gap: 10px;
+  align-items: center;
+  margin-top: 12px;
+}
+
+.product-summary img {
+  width: 46px;
+  height: 46px;
+  object-fit: contain;
+  border: 1px solid #e4e8eb;
+  border-radius: 6px;
+  background: #f8fafb;
+}
+
+.product-summary > div {
+  display: grid;
+  min-width: 0;
+  gap: 4px;
+}
+
+.product-summary strong {
+  display: -webkit-box;
+  overflow: hidden;
+  color: #2a3540;
+  font-size: 12px;
+  line-height: 1.4;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.product-summary span {
+  overflow: hidden;
+  color: #7b858f;
+  font-size: 10px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.context-list {
+  display: grid;
+  gap: 9px;
+  margin-top: 15px;
+}
+
+.context-list div {
+  display: grid;
+  grid-template-columns: minmax(70px, 0.72fr) minmax(0, 1.28fr);
+  gap: 10px;
+  align-items: start;
+}
+
+.context-list dt,
+.context-list dd {
+  margin: 0;
+  font-size: 11px;
+  line-height: 1.45;
+}
+
+.context-list dt {
+  color: #818a94;
+}
+
+.context-list dd {
+  color: #34404b;
+  font-weight: 600;
+  text-align: right;
+  overflow-wrap: anywhere;
+}
+
+.action-context p {
+  margin-top: 10px;
+  color: #6c7680;
+  font-size: 11px;
+  line-height: 1.6;
+}
+
+.primary-button,
+.secondary-button {
+  justify-content: center;
+  gap: 7px;
+  min-height: 36px;
+  padding: 0 14px;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 650;
+  transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
+}
+
+.primary-button {
+  width: 100%;
+  margin-top: 14px;
+  color: #ffffff;
+  background: #315c50;
+}
+
+.primary-button:hover:not(:disabled) {
+  background: #264b41;
+}
+
+.primary-button:disabled {
+  color: #4c6b5f;
+  background: #e7f1ec;
+  cursor: default;
+}
+
+.secondary-button {
+  margin-top: 18px;
+  border: 1px solid #d6dde2;
+  color: #3f5666;
+  background: #ffffff;
+}
+
+.secondary-button:hover {
+  border-color: #9eacb6;
+  background: #f8fafb;
+}
+
+.context-footer {
+  display: grid;
+  gap: 3px;
+  padding: 14px 18px 18px;
+  color: #8a949e;
+  font-size: 10px;
+}
+
+.context-footer span {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.context-footer strong {
+  color: #5f6973;
+  font-weight: 600;
+}
+
+.full-state {
+  display: grid;
+  place-items: center;
+  align-content: center;
+  min-height: min(620px, calc(100dvh - 170px));
+  padding: 48px 24px;
+  border: 1px solid #dfe4e8;
+  border-radius: 8px;
+  color: #77828c;
+  background: #ffffff;
+  text-align: center;
+}
+
+.state-icon {
+  width: 42px;
+  height: 42px;
+  border-radius: 8px;
+}
+
+.state-icon-error {
+  color: #9a3f3f;
+  background: #fff1f0;
+}
+
+.full-state h2 {
+  margin-top: 14px;
+  color: #2b3641;
+  font-size: 16px;
+  font-weight: 650;
+}
+
+.full-state p {
+  max-width: 520px;
+  margin-top: 7px;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.empty-facts {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 8px 16px;
+  margin-top: 13px;
+  color: #5f6973;
+  font-size: 11px;
+}
+
+.loading-line {
+  position: relative;
+  width: 180px;
+  height: 3px;
+  margin-top: 20px;
+  overflow: hidden;
+  border-radius: 2px;
+  background: #edf0f2;
+}
+
+.loading-line::after {
+  content: '';
+  position: absolute;
+  inset: 0 auto 0 0;
+  width: 42%;
+  border-radius: inherit;
+  background: #6f8b9d;
+  animation: loading 1.1s ease-in-out infinite;
+}
+
+.inline-alert {
+  gap: 8px;
+  min-height: 38px;
+  margin-bottom: 10px;
+  padding: 8px 12px;
+  border: 1px solid #efd7b8;
+  border-radius: 6px;
+  color: #775a31;
+  background: #fff9ef;
+  font-size: 11px;
+}
+
+.inline-alert span {
+  min-width: 0;
+  flex: 1;
+}
+
+.inline-alert button {
+  color: #6a4c24;
+  font-weight: 650;
+}
+
+.sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible {
+  outline: 2px solid #53788c;
+  outline-offset: 2px;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+@keyframes loading {
+  0% { transform: translateX(-110%); }
+  100% { transform: translateX(350%); }
+}
+
+@media (max-width: 1180px) {
+  .inbox-workbench {
+    grid-template-columns: minmax(270px, 0.84fr) minmax(350px, 1.25fr) minmax(250px, 0.76fr);
+  }
+
+  .inbox-page {
+    padding: 20px;
+  }
+}
+
+@media (max-width: 980px) {
+  .inbox-page {
+    padding: 16px;
+  }
+
+  .inbox-workbench {
+    display: block;
+    height: auto;
+    min-height: calc(100dvh - 150px);
+  }
+
+  .queue-panel,
+  .evidence-panel {
+    border-right: 0;
+  }
+
+  .queue-panel {
+    min-height: calc(100dvh - 150px);
+  }
+
+  .queue-list {
+    max-height: calc(100dvh - 350px);
+  }
+
+  .inbox-workbench[data-mobile-view='queue'] .evidence-panel,
+  .inbox-workbench[data-mobile-view='queue'] .context-panel {
+    display: none;
+  }
+
+  .inbox-workbench[data-mobile-view='detail'] .queue-panel {
+    display: none;
+  }
+
+  .inbox-workbench[data-mobile-view='detail'] .evidence-panel {
+    display: grid;
+    min-height: 520px;
+    border-bottom: 1px solid #e5e9ec;
+  }
+
+  .inbox-workbench[data-mobile-view='detail'] .context-panel {
+    display: block;
+  }
+
+  .mobile-back {
+    display: inline-flex;
+  }
+
+  .evidence-body {
+    overflow: visible;
+  }
+
+  .context-heading {
+    position: static;
+  }
+}
+
+@media (max-width: 600px) {
+  .page-heading {
+    align-items: flex-start;
+  }
+
+  .page-heading p,
+  .dataset-status {
+    display: none;
+  }
+
+  .page-heading h1 {
+    font-size: 19px;
+  }
+
+  .title-icon {
+    width: 34px;
+    height: 34px;
+  }
+
+  .filter-row {
+    grid-template-columns: 18px minmax(0, 1fr);
+  }
+
+  .filter-row label:last-child {
+    grid-column: 2;
+  }
+
+  .evidence-heading,
+  .verbatim-section,
+  .signal-section,
+  .handling-note {
+    padding-left: 18px;
+    padding-right: 18px;
+  }
+
+  .evidence-facts {
+    grid-template-columns: 1fr;
+  }
+
+  .evidence-facts > div {
+    grid-template-columns: minmax(92px, 0.8fr) minmax(0, 1.2fr);
+    align-items: center;
+    border-right: 0;
+    border-bottom: 1px solid #e8ecef;
+  }
+
+  .evidence-facts > div:last-child {
+    border-bottom: 0;
+  }
+
+  .evidence-facts small {
+    grid-column: 2;
+  }
+
+  .verbatim-section blockquote {
+    font-size: 15px;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .is-spinning,
+  .loading-line::after {
+    animation-duration: 1.8s;
+  }
+}

+ 477 - 0
src/modules/feedback-inbox/feedback-inbox.component.ts

@@ -0,0 +1,477 @@
+import { CommonModule } from '@angular/common';
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  EventEmitter,
+  OnDestroy,
+  OnInit,
+  Output,
+  inject,
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import {
+  Calendar,
+  ChevronLeft,
+  CircleAlert,
+  CircleCheckBig,
+  ClipboardPlus,
+  Clock,
+  Database,
+  Inbox,
+  ListFilter,
+  LUCIDE_ICONS,
+  LucideAngularModule,
+  LucideIconProvider,
+  MessageSquare,
+  Package,
+  RefreshCw,
+  Search,
+  Star,
+  Tag,
+  X,
+} from 'lucide-angular';
+import { Subject, Subscription, takeUntil } from 'rxjs';
+import { DomesticDataset, DomesticProduct, DomesticReview } from '../../app/core/models/domestic.models';
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import { SaasActionItem } from '../../app/core/services/saas-platform.service';
+import {
+  DomesticReviewInsightService,
+  DomesticReviewTopicSignal,
+} from '../../app/core/services/domestic-review-insight.service';
+import {
+  FeedbackActionDraft,
+  FeedbackConfidenceView,
+  FeedbackInboxItem,
+  FeedbackSentiment,
+  FeedbackSourceRole,
+} from './feedback-inbox.models';
+import { ActionCreatePanelComponent } from '../shared/components/action-create-panel/action-create-panel.component';
+
+type SourceFilter = 'all' | FeedbackSourceRole;
+type SentimentFilter = 'all' | FeedbackSentiment;
+type MobileView = 'queue' | 'detail';
+
+const TOPIC_COPY: Record<string, { label: string; description: string }> = {
+  performance: { label: '功率与加热表现', description: '关注功率标称、加热速度、断电或火力体验。' },
+  quality: { label: '质量与耐用性', description: '关注损坏、松动、短期失效及做工反馈。' },
+  service: { label: '售后与保修', description: '关注客服响应、维修、补偿及保修成本。' },
+  safety: { label: '安全与密封风险', description: '关注漏油、漏电、线材暴露及着火隐患。' },
+  usability: { label: '易用与清洁负担', description: '关注操作、清洁、体积及产出体验。' },
+  noise: { label: '噪声与经营干扰', description: '关注设备噪声及其对门店经营的影响。' },
+};
+
+@Component({
+  selector: 'app-feedback-inbox',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    LucideAngularModule,
+    ActionCreatePanelComponent,
+  ],
+  providers: [
+    {
+      provide: LUCIDE_ICONS,
+      multi: true,
+      useValue: new LucideIconProvider({
+      Calendar,
+      ChevronLeft,
+      CircleAlert,
+      CircleCheckBig,
+      ClipboardPlus,
+      Clock,
+      Database,
+      Inbox,
+      ListFilter,
+      MessageSquare,
+      Package,
+      RefreshCw,
+      Search,
+      Star,
+      Tag,
+      X,
+      }),
+    },
+  ],
+  templateUrl: './feedback-inbox.component.html',
+  styleUrl: './feedback-inbox.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FeedbackInboxComponent implements OnInit, OnDestroy {
+  private readonly datasetService = inject(DomesticDatasetService);
+  private readonly insightService = inject(DomesticReviewInsightService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly destroy$ = new Subject<void>();
+  private datasetSubscription?: Subscription;
+  private requestedReviewId = '';
+
+  @Output() readonly createAction = new EventEmitter<FeedbackActionDraft>();
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+
+  readonly fallbackImage = 'assets/images/placeholder.svg';
+  readonly sourceOptions: Array<{ value: SourceFilter; label: string }> = [
+    { value: 'all', label: '全部来源' },
+    { value: 'own', label: '本品评价' },
+    { value: 'competitor', label: '竞品评价' },
+    { value: 'unknown', label: '来源待确认' },
+  ];
+  readonly sentimentOptions: Array<{ value: SentimentFilter; label: string }> = [
+    { value: 'all', label: '全部评分' },
+    { value: 'negative', label: '低评分 1-2 星' },
+    { value: 'neutral', label: '中评 3 星' },
+    { value: 'positive', label: '高评分 4-5 星' },
+    { value: 'unknown', label: '未评分' },
+  ];
+
+  dataset?: DomesticDataset;
+  items: FeedbackInboxItem[] = [];
+  selectedId = '';
+  searchTerm = '';
+  sourceFilter: SourceFilter = 'all';
+  sentimentFilter: SentimentFilter = 'all';
+  mobileView: MobileView = 'queue';
+  loading = true;
+  errorMessage = '';
+  readonly createdActionIds = new Set<string>();
+  actionDraft: FeedbackActionDraft | null = null;
+  visibleLimit = 60;
+
+  ngOnInit(): void {
+    this.requestedReviewId = this.route.snapshot.queryParamMap.get('reviewId') || '';
+    this.searchTerm = this.route.snapshot.queryParamMap.get('q') || '';
+    this.subscribeToDataset();
+  }
+
+  ngOnDestroy(): void {
+    this.datasetSubscription?.unsubscribe();
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  get filteredItems(): FeedbackInboxItem[] {
+    const query = this.searchTerm.trim().toLocaleLowerCase('zh-CN');
+    return this.items.filter((item) => {
+      const matchesSource = this.sourceFilter === 'all' || item.sourceRole === this.sourceFilter;
+      const matchesSentiment = this.sentimentFilter === 'all' || item.sentiment === this.sentimentFilter;
+      if (!matchesSource || !matchesSentiment) return false;
+      if (!query) return true;
+
+      const searchText = [
+        item.review.content,
+        item.review.reviewId,
+        item.review.productId,
+        item.product?.title,
+        item.product?.brand,
+        item.product?.model,
+        item.topicLabel,
+        item.sourceLabel,
+      ].filter(Boolean).join(' ').toLocaleLowerCase('zh-CN');
+      return searchText.includes(query);
+    });
+  }
+
+  get selectedItem(): FeedbackInboxItem | undefined {
+    return this.items.find((item) => item.id === this.selectedId);
+  }
+
+  get displayedItems(): FeedbackInboxItem[] {
+    return this.filteredItems.slice(0, this.visibleLimit);
+  }
+
+  get hasMoreItems(): boolean {
+    return this.filteredItems.length > this.visibleLimit;
+  }
+
+  get issueCount(): number {
+    return this.items.filter((item) => item.sentiment === 'negative').length;
+  }
+
+  get topicCount(): number {
+    return new Set(this.items.map((item) => item.topicId)).size;
+  }
+
+  get selectedProductRelations(): number {
+    const item = this.selectedItem;
+    if (!item || !this.dataset) return 0;
+    if (item.sourceRole === 'competitor') {
+      return this.dataset.relations.filter((relation) => relation.competitorProductId === item.review.productId).length;
+    }
+    return item.product?.relationCount ?? 0;
+  }
+
+  refresh(): void {
+    if (this.loading) return;
+    this.loading = true;
+    this.errorMessage = '';
+    if (this.datasetSubscription?.closed) this.subscribeToDataset();
+    else this.datasetService.refresh();
+    this.cdr.markForCheck();
+  }
+
+  retry(): void {
+    this.datasetSubscription?.unsubscribe();
+    this.subscribeToDataset();
+  }
+
+  selectItem(item: FeedbackInboxItem): void {
+    this.selectedId = item.id;
+    this.mobileView = 'detail';
+    void this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: { reviewId: item.review.reviewId },
+      queryParamsHandling: 'merge',
+      replaceUrl: true,
+    });
+  }
+
+  showQueue(): void {
+    this.mobileView = 'queue';
+  }
+
+  onFiltersChanged(): void {
+    this.visibleLimit = 60;
+    const visible = this.filteredItems;
+    if (!visible.some((item) => item.id === this.selectedId)) this.selectedId = visible[0]?.id ?? '';
+  }
+
+  clearFilters(): void {
+    this.searchTerm = '';
+    this.sourceFilter = 'all';
+    this.sentimentFilter = 'all';
+    this.visibleLimit = 60;
+    this.selectedId = this.items[0]?.id ?? '';
+  }
+
+  loadMore(): void {
+    this.visibleLimit += 60;
+  }
+
+  createActionDraft(): void {
+    const item = this.selectedItem;
+    if (!item || this.createdActionIds.has(item.id)) return;
+
+    const draft: FeedbackActionDraft = {
+      feedbackId: item.id,
+      reviewId: item.review.reviewId,
+      productId: item.review.productId,
+      productKey: item.product?.productKey || `${this.dataset?.platform || 'unknown'}:${item.review.productId}`,
+      productTitle: item.product?.title || item.review.productId,
+      topicId: item.topicId,
+      topicLabel: item.topicLabel,
+      evidence: item.review.content,
+      source: item.sourceLabel,
+      sampleCount: item.sampleCount,
+      confidence: item.confidence.level,
+      createdAt: new Date().toISOString(),
+    };
+    this.actionDraft = draft;
+    this.createAction.emit(draft);
+  }
+
+  cancelActionCreate(): void {
+    this.actionDraft = null;
+  }
+
+  onActionCreated(_: SaasActionItem): void {
+    const feedbackId = this.actionDraft?.feedbackId;
+    if (feedbackId) this.createdActionIds.add(feedbackId);
+    window.setTimeout(() => {
+      this.actionDraft = null;
+      this.cdr.markForCheck();
+    }, 700);
+  }
+
+  actionTitle(draft: FeedbackActionDraft): string {
+    return `${draft.topicLabel}:${draft.productTitle}`.slice(0, 120);
+  }
+
+  actionDescription(draft: FeedbackActionDraft): string {
+    return [
+      `来源:${draft.source}`,
+      `证据样本:${draft.sampleCount} 条,置信等级 ${draft.confidence}`,
+      `原始反馈:${draft.evidence}`,
+    ].join('\n');
+  }
+
+  isActionCreated(item?: FeedbackInboxItem): boolean {
+    return !!item && this.createdActionIds.has(item.id);
+  }
+
+  productPrice(product: DomesticProduct | null): string {
+    const value = product?.market?.currentPrice || product?.summary.averageUnitPrice || 0;
+    return value > 0
+      ? new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value)
+      : '-';
+  }
+
+  productCategory(product: DomesticProduct | null): string {
+    return product?.category3 || product?.category2 || product?.category1 || '-';
+  }
+
+  actionRecommendation(item?: FeedbackInboxItem): string {
+    if (!item) return '';
+    if (item.sentiment === 'negative' && item.sampleCount >= 3) {
+      return '核对同主题样本的一致性,补充负责人、截止时间和验证指标后进入行动排期。';
+    }
+    if (item.sentiment === 'negative') {
+      return '保留为观察项,等待更多同类证据后再判断是否进入行动排期。';
+    }
+    return '记录可复用的体验要点,并与低评分主题交叉检查,避免只依据单条高分评价决策。';
+  }
+
+  useFallback(event: Event): void {
+    const image = event.target as HTMLImageElement;
+    if (!image.src.endsWith(this.fallbackImage)) image.src = this.fallbackImage;
+  }
+
+  trackItem(_: number, item: FeedbackInboxItem): string {
+    return item.id;
+  }
+
+  private subscribeToDataset(): void {
+    this.loading = true;
+    this.errorMessage = '';
+    this.datasetSubscription = this.datasetService.dataset$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (dataset) => {
+          this.dataset = dataset;
+          this.items = this.buildItems(dataset);
+          const requestedItem = this.items.find((item) =>
+            item.id === this.requestedReviewId || item.review.reviewId === this.requestedReviewId,
+          );
+          if (requestedItem) this.selectedId = requestedItem.id;
+          else if (!this.items.some((item) => item.id === this.selectedId)) this.selectedId = this.items[0]?.id ?? '';
+          this.loading = false;
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          this.loading = false;
+          this.errorMessage = '反馈数据加载失败,请检查数据服务后重试。';
+          this.cdr.markForCheck();
+        },
+      });
+  }
+
+  private buildItems(dataset: DomesticDataset): FeedbackInboxItem[] {
+    const products = new Map(dataset.products.map((product) => [product.productId, product]));
+    const groupedReviews = new Map<string, DomesticReview[]>();
+
+    for (const review of dataset.reviews ?? []) {
+      const group = groupedReviews.get(review.productId) ?? [];
+      group.push(review);
+      groupedReviews.set(review.productId, group);
+    }
+
+    const items: FeedbackInboxItem[] = [];
+    groupedReviews.forEach((reviews, productId) => {
+      const product = products.get(productId) ?? null;
+      const productInsight = this.insightService.build(reviews);
+
+      for (const review of productInsight.meaningful) {
+        const sentiment = this.sentiment(review.rating);
+        const reviewTopic = this.insightService.build([review]).topics[0] ?? null;
+        const productTopic = reviewTopic
+          ? productInsight.topics.find((topic) => topic.id === reviewTopic.id) ?? null
+          : null;
+        const sampleCount = productTopic?.count ?? this.sentimentSampleCount(productInsight.meaningful, sentiment);
+        const topic = this.topicView(reviewTopic, sentiment);
+        const sourceRole: FeedbackSourceRole = product?.role ?? 'unknown';
+
+        items.push({
+          id: `${review.productId}:${review.reviewId}`,
+          review,
+          product,
+          sourceRole,
+          sourceLabel: this.sourceLabel(sourceRole, dataset.platform),
+          sentiment,
+          topicId: topic.id,
+          topicLabel: topic.label,
+          topicDescription: topic.description,
+          sampleCount: Math.max(1, sampleCount),
+          confidence: this.confidence(Math.max(1, sampleCount)),
+          dateLabel: this.dateLabel(review.reviewDate),
+          priorityLabel: sentiment === 'negative' ? '待关注' : sentiment === 'neutral' ? '观察' : '正向证据',
+        });
+      }
+    });
+
+    return items.sort((left, right) => {
+      const priority = this.sentimentOrder(left.sentiment) - this.sentimentOrder(right.sentiment);
+      if (priority !== 0) return priority;
+      return String(right.review.reviewDate ?? '').localeCompare(String(left.review.reviewDate ?? ''));
+    });
+  }
+
+  private topicView(
+    signal: DomesticReviewTopicSignal | null,
+    sentiment: FeedbackSentiment,
+  ): { id: string; label: string; description: string } {
+    if (signal) {
+      const copy = TOPIC_COPY[signal.id];
+      return {
+        id: signal.id,
+        label: copy?.label || '其他体验议题',
+        description: copy?.description || '该议题来自当前有效评论中的关键词信号。',
+      };
+    }
+    if (sentiment === 'negative') {
+      return { id: 'unclassified-negative', label: '未归类低评分', description: '低评分证据尚未命中现有主题规则,需要人工复核。' };
+    }
+    if (sentiment === 'neutral') {
+      return { id: 'neutral-experience', label: '中评体验', description: '三星评价作为观察样本,暂不推断具体问题主题。' };
+    }
+    if (sentiment === 'positive') {
+      return { id: 'positive-experience', label: '高分反馈', description: '四至五星评价作为正向证据,需结合正文复核。' };
+    }
+    return { id: 'unrated-feedback', label: '未评分反馈', description: '当前反馈没有有效评分,仅保留正文证据。' };
+  }
+
+  private confidence(sampleCount: number): FeedbackConfidenceView {
+    if (sampleCount >= 20) {
+      return { level: 'strong', label: '较强证据', description: `同商品下有 ${sampleCount} 条同类样本,仍需结合采集覆盖率判断。` };
+    }
+    if (sampleCount >= 8) {
+      return { level: 'moderate', label: '中等证据', description: `同商品下有 ${sampleCount} 条同类样本,可用于形成待验证假设。` };
+    }
+    if (sampleCount >= 3) {
+      return { level: 'early', label: '初步信号', description: `同商品下有 ${sampleCount} 条同类样本,建议继续补充证据。` };
+    }
+    return { level: 'single', label: '单条证据', description: '当前仅有少量同类样本,不宜外推为普遍结论。' };
+  }
+
+  private sentiment(rating: number): FeedbackSentiment {
+    if (rating >= 4) return 'positive';
+    if (rating === 3) return 'neutral';
+    if (rating > 0) return 'negative';
+    return 'unknown';
+  }
+
+  private sentimentSampleCount(reviews: DomesticReview[], sentiment: FeedbackSentiment): number {
+    return reviews.filter((review) => this.sentiment(review.rating) === sentiment).length;
+  }
+
+  private sentimentOrder(sentiment: FeedbackSentiment): number {
+    return sentiment === 'negative' ? 0 : sentiment === 'neutral' ? 1 : sentiment === 'unknown' ? 2 : 3;
+  }
+
+  private sourceLabel(role: FeedbackSourceRole, platform: string): string {
+    const platformLabel = platform.toLowerCase() === 'jd' ? '京东评价' : `${platform.toUpperCase()} 评价`;
+    if (role === 'own') return `本品 · ${platformLabel}`;
+    if (role === 'competitor') return `竞品 · ${platformLabel}`;
+    return `待确认 · ${platformLabel}`;
+  }
+
+  private dateLabel(value?: string): string {
+    if (!value) return '日期待补';
+    const parsed = new Date(value);
+    if (Number.isNaN(parsed.getTime())) return value.slice(0, 10);
+    return parsed.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
+  }
+}

+ 42 - 0
src/modules/feedback-inbox/feedback-inbox.models.ts

@@ -0,0 +1,42 @@
+import { DomesticProduct, DomesticReview } from '../../app/core/models/domestic.models';
+
+export type FeedbackSourceRole = 'own' | 'competitor' | 'unknown';
+export type FeedbackSentiment = 'negative' | 'neutral' | 'positive' | 'unknown';
+export type FeedbackConfidenceLevel = 'single' | 'early' | 'moderate' | 'strong';
+
+export interface FeedbackConfidenceView {
+  level: FeedbackConfidenceLevel;
+  label: string;
+  description: string;
+}
+
+export interface FeedbackInboxItem {
+  id: string;
+  review: DomesticReview;
+  product: DomesticProduct | null;
+  sourceRole: FeedbackSourceRole;
+  sourceLabel: string;
+  sentiment: FeedbackSentiment;
+  topicId: string;
+  topicLabel: string;
+  topicDescription: string;
+  sampleCount: number;
+  confidence: FeedbackConfidenceView;
+  dateLabel: string;
+  priorityLabel: string;
+}
+
+export interface FeedbackActionDraft {
+  feedbackId: string;
+  reviewId: string;
+  productId: string;
+  productKey: string;
+  productTitle: string;
+  topicId: string;
+  topicLabel: string;
+  evidence: string;
+  source: string;
+  sampleCount: number;
+  confidence: FeedbackConfidenceLevel;
+  createdAt: string;
+}

+ 216 - 0
src/modules/knowledge-center/knowledge-center.component.html

@@ -0,0 +1,216 @@
+<section class="knowledge-center-page">
+  <app-page-header
+    title="商品知识库"
+    description="统一管理本店商品、重点商品与竞品资料"
+    icon="▤"
+    [sampleSize]="ownCount + competitorCount"
+    [lastUpdated]="dataset?.generatedAt ? formatDate(dataset!.generatedAt) : ''"
+    density="compact">
+    <button pageHeaderActions type="button" class="header-button" [disabled]="loading" (click)="load()" title="刷新知识库">
+      <span [class.spinning]="loading">↻</span> 刷新
+    </button>
+  </app-page-header>
+
+  @if (error) { <div class="status-banner error" role="alert">{{ error }}</div> }
+  @if (notice) { <div class="status-banner success" role="status">{{ notice }}</div> }
+
+  <section class="asset-status-band" aria-label="数据资产状态">
+    <div class="asset-status-heading">
+      <span class="asset-status-kicker">数据资产</span>
+      <strong>知识库数据链路</strong>
+      <small>页面数据统一经本地业务接口读取</small>
+    </div>
+    <div class="asset-status-item ready">
+      <span class="status-dot"></span>
+      <div><strong>本地业务服务</strong><small>{{ loading ? '连接中' : '已连接 · API 正常' }}</small></div>
+    </div>
+    <div class="asset-status-item ready">
+      <span class="status-dot"></span>
+      <div><strong>经营数据快照</strong><small>{{ dataset?.enrichment ? dataset?.enrichment?.successfulQueries + '/' + dataset?.enrichment?.queryCount + ' 组竞品检索成功' : (dataset?.source?.sourceFile || '等待导入') }}</small></div>
+    </div>
+    <div class="asset-status-item" [class.ready]="activeKnowledgeCount > 0" [class.pending]="activeKnowledgeCount === 0">
+      <span class="status-dot"></span>
+      <div><strong>知识元数据</strong><small>{{ activeKnowledgeCount }} 条已维护 · 本地可写</small></div>
+    </div>
+    <div class="asset-status-item" [class.ready]="reviewCount > 0" [class.pending]="reviewCount === 0">
+      <span class="status-dot"></span>
+      <div><strong>竞品评价证据</strong><small>{{ competitorCount }} 款档案 · {{ reviewCount ? formatNumber(reviewCount) + ' 条评价' : '评价待采集' }}</small></div>
+    </div>
+  </section>
+
+  <div class="knowledge-metrics">
+    @for (metric of metricCards; track metric.label) {
+      <app-summary-metric-card
+        [label]="metric.label"
+        [value]="metric.value"
+        [unit]="metric.unit"
+        [description]="metric.description"
+        [tone]="metric.tone"
+        size="sm">
+      </app-summary-metric-card>
+    }
+  </div>
+
+  <div class="library-tabs" role="tablist" aria-label="知识库类型">
+    <button type="button" role="tab" [attr.aria-selected]="activeTab === 'own'" [class.active]="activeTab === 'own'" (click)="selectTab('own')">
+      本店商品库 <span>{{ formatNumber(ownCount) }}</span>
+    </button>
+    <button type="button" role="tab" [attr.aria-selected]="activeTab === 'featured'" [class.active]="activeTab === 'featured'" (click)="selectTab('featured')">
+      重点商品库 <span>{{ formatNumber(featuredCount) }}</span>
+    </button>
+    <button type="button" role="tab" [attr.aria-selected]="activeTab === 'competitor'" [class.active]="activeTab === 'competitor'" (click)="selectTab('competitor')">
+      竞品资料库 <span>{{ formatNumber(competitorCount) }}</span>
+    </button>
+  </div>
+
+  <div class="dataset-context" aria-label="当前数据范围">
+    <div><span>统计周期</span><strong>{{ dateRangeLabel }}</strong></div>
+    <div><span>数据来源</span><strong [title]="dataset?.source?.sourceFile || ''">{{ dataset?.source?.sourceFile || '-' }}</strong></div>
+    <div><span>平台</span><strong>{{ platformLabel }}</strong></div>
+    <div><span>快照时间</span><strong>{{ formatDate(dataset?.generatedAt) }}</strong></div>
+    <div><span>当前结果</span><strong>{{ formatNumber(filteredProducts.length) }} 款</strong></div>
+  </div>
+
+  <app-crud-table-workbench
+    [title]="title"
+    [subtitle]="tableSubtitle"
+    [query]="query"
+    queryPlaceholder="搜索商品ID、型号、标题、品牌、标签或备注"
+    [filters]="filters"
+    [columns]="columns"
+    [total]="filteredProducts.length"
+    [page]="page"
+    [pageSize]="pageSize"
+    [pageSizeOptions]="[20, 50, 100]"
+    [density]="tableDensity"
+    [loading]="loading"
+    [showExport]="true"
+    [selectedCount]="selectedCount"
+    [bulkActions]="bulkActions"
+    emptyTitle="当前资料库暂无匹配商品"
+    emptyDescription="调整筛选条件,或从本店/竞品资料中加入重点商品。"
+    (queryChange)="query = $event; page = 1"
+    (filtersChange)="onFiltersChange($event)"
+    (columnsChange)="columns = $event"
+    (densityChange)="tableDensity = $event"
+    (pageChange)="onPageChange($event)"
+    (pageSizeChange)="onPageSizeChange($event)"
+    (apply)="page = 1"
+    (reset)="resetFilters()"
+    (refresh)="load()"
+    (exportRequested)="exportProducts()"
+    (bulkAction)="handleBulkAction($event)"
+    (clearSelection)="clearSelection()">
+
+    <table class="knowledge-table" [class.compact]="tableDensity === 'compact'">
+      <thead><tr>
+        <th class="selection-cell">
+          <input type="checkbox" [checked]="allPageSelected" (change)="togglePageSelection($any($event.target).checked)" aria-label="选择当前页商品" />
+        </th>
+        @if (isColumnVisible('product')) { <th>商品</th> }
+        @if (isColumnVisible('role')) { <th>类型</th> }
+        @if (isColumnVisible('category')) { <th>品类</th> }
+        @if (isColumnVisible('gmv')) { <th class="numeric">成交金额</th> }
+        @if (isColumnVisible('sales')) { <th [class.numeric]="activeTab !== 'competitor'">{{ activeTab === 'competitor' ? '销量信号' : '成交件数' }}</th> }
+        @if (isColumnVisible('price')) { <th class="numeric">平均单价</th> }
+        @if (isColumnVisible('refund')) { <th class="numeric">退款率</th> }
+        @if (isColumnVisible('relations')) { <th class="numeric">关联数</th> }
+        @if (isColumnVisible('reviews')) { <th class="numeric">评价 / 评分</th> }
+        @if (isColumnVisible('tags')) { <th>知识标签 / 备注</th> }
+        @if (isColumnVisible('owner')) { <th>负责人</th> }
+        @if (isColumnVisible('completeness')) { <th>资料状态</th> }
+        @if (isColumnVisible('updated')) { <th>维护时间</th> }
+        @if (isColumnVisible('actions')) { <th class="actions">操作</th> }
+      </tr></thead>
+      <tbody>
+        @for (product of pagedProducts; track product.productKey) {
+          <tr [class.selected]="isSelected(product)">
+            <td class="selection-cell">
+              <input type="checkbox" [checked]="isSelected(product)" (change)="toggleSelection(product, $any($event.target).checked)" [attr.aria-label]="'选择商品 ' + product.productId" />
+            </td>
+            @if (isColumnVisible('product')) {
+              <td class="product-cell">
+                <app-product-identity
+                  [imageUrl]="productImage(product)"
+                  [title]="product.title || product.brand || '未命名商品'"
+                  [asin]="product.productId"
+                  [productSku]="product.model"
+                  [subtitle]="product.brand"
+                  [detailRoute]="productRoute(product)"
+                  [detailQueryParams]="productQuery(product)"
+                  size="sm">
+                </app-product-identity>
+              </td>
+            }
+            @if (isColumnVisible('role')) { <td><span class="role-badge" [class.competitor]="product.role === 'competitor'">{{ product.role === 'own' ? '本店' : '竞品' }}</span></td> }
+            @if (isColumnVisible('category')) { <td class="category-cell" [title]="category(product)">{{ category(product) }}</td> }
+            @if (isColumnVisible('gmv')) { <td class="numeric strong">{{ product.summary.gmv > 0 ? formatCurrency(product.summary.gmv) : '-' }}</td> }
+            @if (isColumnVisible('sales')) {
+              <td [class.numeric]="product.role === 'own'" [class.sales-signal-cell]="product.role === 'competitor'">
+                {{ product.role === 'competitor' ? (product.market?.monthSalesText || product.market?.salesText || '-') : (product.summary.soldUnits > 0 ? formatNumber(product.summary.soldUnits) : '-') }}
+              </td>
+            }
+            @if (isColumnVisible('price')) { <td class="numeric">{{ product.summary.averageUnitPrice > 0 ? formatCurrency(product.summary.averageUnitPrice) : '-' }}</td> }
+            @if (isColumnVisible('refund')) { <td class="numeric">{{ product.summary.refundToGmvRate > 0 ? formatPercent(product.summary.refundToGmvRate) : '-' }}</td> }
+            @if (isColumnVisible('relations')) { <td class="numeric"><span class="relation-count" [class.active]="relationCount(product) > 0">{{ relationCount(product) }}</span></td> }
+            @if (isColumnVisible('reviews')) {
+              <td class="numeric evidence-cell">
+                <strong>{{ formatNumber(reviewCounts.get(product.productId) || 0) }}</strong>
+                <small>{{ averageRating(product) === '-' ? '待采集' : averageRating(product) + ' 分' }}</small>
+              </td>
+            }
+            @if (isColumnVisible('tags')) {
+              <td class="knowledge-cell">
+                @if (item(product); as knowledge) {
+                  <div class="tag-list">
+                    @for (tag of knowledge.tags; track tag) { <span>{{ tag }}</span> }
+                    @if (!knowledge.tags.length) { <em>未设置标签</em> }
+                  </div>
+                  <p [title]="knowledge.note">{{ knowledge.note || '暂无备注' }}</p>
+                } @else { <span class="unmaintained">待维护</span> }
+              </td>
+            }
+            @if (isColumnVisible('owner')) { <td>{{ item(product)?.ownerUserId || '-' }}</td> }
+            @if (isColumnVisible('completeness')) { <td><span class="completeness-badge" [ngClass]="completenessTone(product)">{{ completenessLabel(product) }}</span></td> }
+            @if (isColumnVisible('updated')) { <td class="date-cell">{{ formatDate(item(product)?.updatedAt) }}</td> }
+            @if (isColumnVisible('actions')) {
+              <td class="actions">
+                <button type="button" class="icon-action favorite" [class.active]="item(product)?.featured" [disabled]="saving" [title]="item(product)?.featured ? '移出重点商品库' : '加入重点商品库'" (click)="toggleFeatured(product, $event)">★</button>
+                <button type="button" class="icon-action" title="编辑知识信息" (click)="openEditor(product)">✎</button>
+              </td>
+            }
+          </tr>
+        }
+      </tbody>
+    </table>
+  </app-crud-table-workbench>
+</section>
+
+@if (editorProduct) {
+  <div class="dialog-backdrop" role="presentation" (mousedown)="closeEditor()">
+    <section class="knowledge-dialog" role="dialog" aria-modal="true" aria-labelledby="knowledge-dialog-title" (mousedown)="$event.stopPropagation()">
+      <header>
+        <div>
+          <h2 id="knowledge-dialog-title">维护商品知识</h2>
+          <p>{{ editorProduct.title || editorProduct.productId }}</p>
+        </div>
+        <button type="button" class="dialog-close" aria-label="关闭" title="关闭" (click)="closeEditor()">×</button>
+      </header>
+      <div class="dialog-product">
+        <app-product-identity [imageUrl]="productImage(editorProduct)" [title]="editorProduct.title" [asin]="editorProduct.productId" [productSku]="editorProduct.model" [subtitle]="category(editorProduct)" size="md"></app-product-identity>
+      </div>
+      <form (ngSubmit)="saveEditor()">
+        <label class="featured-toggle"><input type="checkbox" [(ngModel)]="editorFeatured" name="featured" /><span>纳入重点商品库</span></label>
+        <label><span>知识标签</span><input type="text" [(ngModel)]="editorTags" name="tags" maxlength="500" placeholder="例如:经营TOP,蒸烤箱,重点观察" /><small>使用逗号分隔,最多 20 个</small></label>
+        <label><span>负责人</span><input type="text" [(ngModel)]="editorOwner" name="owner" maxlength="200" placeholder="输入负责人名称或账号" /></label>
+        <label><span>维护备注</span><textarea [(ngModel)]="editorNote" name="note" maxlength="5000" rows="6" placeholder="记录商品定位、关注问题、竞品结论或下一步动作"></textarea></label>
+        <footer>
+          @if (item(editorProduct)) { <button type="button" class="danger-button" [disabled]="saving" (click)="archiveEditor()">归档</button> }
+          <span class="footer-spacer"></span>
+          <button type="button" class="secondary-button" [disabled]="saving" (click)="closeEditor()">取消</button>
+          <button type="submit" class="primary-button" [disabled]="saving">{{ saving ? '保存中...' : '保存' }}</button>
+        </footer>
+      </form>
+    </section>
+  </div>
+}

+ 236 - 1006
src/modules/knowledge-center/knowledge-center.component.scss

@@ -1,1017 +1,247 @@
-.knowledge-center-container {
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  background: #F8F9FA;
-  overflow: hidden;
-
-  // Data Sources Connection Status
-  .data-sources-section {
-    padding: 10px 16px 0;
-    flex-shrink: 0;
-
-    .data-sources-header {
-      display: flex;
-      align-items: flex-start;
-      justify-content: space-between;
-      gap: 12px;
-      margin-bottom: 8px;
-
-      h2 {
-        margin: 0 0 2px;
-        font-size: 14px;
-        font-weight: 700;
-        color: #111827;
-      }
-
-      p {
-        margin: 0;
-        font-size: 11px;
-        color: #6B7280;
-        line-height: 1.35;
-      }
-
-      .ghost-btn {
-        flex-shrink: 0;
-        padding: 6px 12px;
-        border: 1px solid #E9ECEF;
-        background: #fff;
-        border-radius: 8px;
-        font-size: 11px;
-        font-weight: 600;
-        color: #343A40;
-        cursor: pointer;
-        transition: border-color 0.15s, background 0.15s;
-
-        &:hover {
-          border-color: #343A40;
-          background: #F8FAFC;
-        }
-      }
-    }
-
-    app-entry-card {
-      ::ng-deep .entry-card {
-        min-height: 94px;
-        padding: 10px 12px;
-        gap: 6px;
-        border-radius: 12px;
-      }
-
-      ::ng-deep .entry-card-head,
-      ::ng-deep .entry-card-head-main {
-        gap: 8px;
-      }
-
-      ::ng-deep .entry-card-icon {
-        width: 28px;
-        height: 28px;
-        border-radius: 9px;
-        font-size: 14px;
-      }
-
-      ::ng-deep .entry-card-title {
-        font-size: 13px;
-        line-height: 1.2;
-      }
-
-      ::ng-deep .entry-card-subtitle {
-        font-size: 10px;
-        line-height: 1.25;
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-      }
-
-      ::ng-deep .entry-card-badge {
-        padding: 2px 8px;
-        font-size: 10px;
-      }
-
-      ::ng-deep .entry-card-metrics {
-        grid-template-columns: repeat(3, minmax(0, 1fr));
-        gap: 4px 8px;
-      }
-
-      ::ng-deep .entry-card-metric {
-        dt {
-          font-size: 10px;
-          line-height: 1.2;
-        }
-
-        dd {
-          margin-top: 1px;
-
-          strong {
-            font-size: 13px;
-            line-height: 1;
-          }
-        }
-      }
-    }
-  }
-
-  // Navigation Panel
-  .navigation-panel {
-    background: #FFFFFF;
-    border-bottom: 1px solid #E9ECEF;
-    padding: 10px 16px;
-    flex-shrink: 0;
-
-    .panel-header {
-      display: flex;
-      justify-content: space-between;
-      align-items: center;
-      margin-bottom: 0;
-
-      h1 {
-        font-size: 16px;
-        font-weight: 600;
-        color: #343A40;
-        margin: 0;
-      }
-
-      .search-box {
-        display: flex;
-        gap: 8px;
-        align-items: center;
-
-        .search-input {
-          width: 300px;
-          padding: 8px 16px;
-          border: 1px solid #E9ECEF;
-          border-radius: 6px;
-          font-size: 13px;
-          transition: all 0.2s;
-
-          &:focus {
-            outline: none;
-            border-color: #868E96;
-            box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
-          }
-        }
-
-        .search-btn {
-          padding: 8px 16px;
-          background: #343A40;
-          color: white;
-          border: none;
-          border-radius: 6px;
-          font-size: 14px;
-          cursor: pointer;
-          transition: all 0.2s;
-
-          &:hover {
-            background: #1E1E20;
-            transform: translateY(-1px);
-          }
-        }
-      }
-    }
-  }
-
-  // Overview Section
-  .overview-section {
-    background: #FFFFFF;
-    padding: 10px 16px;
-    border-bottom: 1px solid #E9ECEF;
-    flex-shrink: 0;
-
-    .stats-grid {
-      display: grid;
-      grid-template-columns: repeat(6, 1fr);
-      gap: 10px;
-
-      .knowledge-stat-card {
-        display: block;
-        border-radius: 14px;
-
-        ::ng-deep .summary-metric-card {
-          min-height: 78px;
-          padding: 10px 12px;
-        }
-
-        ::ng-deep .metric-topline {
-          font-size: 11px;
-        }
-
-        ::ng-deep .metric-icon {
-          width: 24px;
-          height: 24px;
-          border-radius: 9px;
-          font-size: 12px;
-        }
-
-        ::ng-deep .metric-value-row {
-          margin-top: 8px;
-
-          strong {
-            font-size: 20px;
-          }
-        }
-
-        ::ng-deep .metric-footer {
-          margin-top: 8px;
-          font-size: 10px;
-        }
-
-        &.active {
-          ::ng-deep .summary-metric-card {
-            border-color: #111827;
-            box-shadow: 0 0 0 2px rgba(17, 24, 39, 0.08), 0 10px 24px rgba(15, 23, 42, 0.10);
-            transform: translateY(-1px);
-          }
-        }
-      }
-    }
-  }
-
-  // Data Section
-  .data-section {
-    flex: 1;
-    display: flex;
-    flex-direction: column;
-    background: #FFFFFF;
-    margin: 10px 16px 12px;
-    border-radius: 8px;
-    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
-    border: 1px solid #E9ECEF;
-    overflow: hidden;
-    min-height: 0;
-
-    .data-header {
-      display: flex;
-      justify-content: space-between;
-      align-items: center;
-      padding: 10px 16px;
-      border-bottom: 1px solid #E9ECEF;
-
-      .data-title {
-        display: flex;
-        align-items: baseline;
-        gap: 12px;
-        min-width: 0;
-
-        h2 {
-          font-size: 15px;
-          font-weight: 600;
-          color: #343A40;
-          margin: 0;
-          white-space: nowrap;
-        }
-
-        .last-updated {
-          font-size: 12px;
-          color: #8a94a6;
-          font-weight: 500;
-          white-space: nowrap;
-        }
-      }
-
-      .data-actions {
-        display: flex;
-        gap: 10px;
-
-        button {
-          padding: 8px 16px;
-          border-radius: 6px;
-          font-size: 13px;
-          font-weight: 500;
-          cursor: pointer;
-          transition: all 0.3s;
-          border: none;
-
-          &.btn-filter {
-            background: #F1F3F5;
-            color: #868E96;
-
-            &:hover {
-              background: #E9ECEF;
-            }
-          }
-
-          &.btn-export {
-            background: white;
-            color: #343A40;
-            border: 1px solid #E9ECEF;
-
-            &:hover {
-              background: #F1F3F5;
-            }
-          }
-
-          &.btn-video-search {
-            background: linear-gradient(135deg, #fe2c55, #ff6f91);
-            color: white;
-            border: none;
-
-            &:hover {
-              opacity: 0.9;
-              transform: translateY(-1px);
-            }
-          }
-
-          &.btn-add {
-            background: #343A40;
-            color: white;
-
-            &:hover {
-              background: #1E1E20;
-              transform: translateY(-1px);
-            }
-          }
-        }
-      }
-    }
-
-    .filter-panel {
-      padding: 14px 20px;
-      background: #F8F9FA;
-      border-bottom: 1px solid #E9ECEF;
-
-      .filter-row {
-        display: flex;
-        gap: 14px;
-        align-items: flex-end;
-        flex-wrap: wrap;
-
-        .filter-group {
-          display: flex;
-          flex-direction: column;
-          gap: 4px;
-
-          label {
-            font-size: 11px;
-            font-weight: 600;
-            color: #868E96;
-            text-transform: uppercase;
-            letter-spacing: 0.5px;
-          }
-        }
-
-        .category-filter-group {
-          min-width: 280px;
-
-          .knowledge-category-filter {
-            display: block;
-
-            ::ng-deep .filter-bar {
-              margin: 0;
-              padding: 0;
-              border: none;
-              box-shadow: none;
-              background: transparent;
-              gap: 8px;
-            }
-
-            ::ng-deep .filter-scroll-track {
-              gap: 8px;
-            }
-
-            ::ng-deep .filter-section {
-              flex: 1 1 130px;
-            }
-
-            ::ng-deep .filter-label {
-              font-size: 11px;
-              font-weight: 600;
-              color: #868E96;
-              text-transform: uppercase;
-              letter-spacing: 0.5px;
-            }
-          }
-        }
-
-        .filter-select, .filter-input {
-          padding: 7px 12px;
-          border: 1px solid #E9ECEF;
-          border-radius: 6px;
-          font-size: 13px;
-          background: white;
-          transition: all 0.2s;
-          height: 34px;
-          box-sizing: border-box;
-
-          &:focus {
-            outline: none;
-            border-color: #868E96;
-            box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.06);
-          }
-
-          &::placeholder {
-            color: #ADB5BD;
-          }
-        }
-
-        .filter-select {
-          min-width: 100px;
-        }
-
-        .filter-input {
-          width: 140px;
-
-          &.wide {
-            width: 200px;
-          }
-
-          &.small {
-            width: 80px;
-          }
-        }
-
-        .range-inputs {
-          display: flex;
-          align-items: center;
-          gap: 6px;
-
-          .range-sep {
-            color: #ADB5BD;
-            font-size: 13px;
-          }
-        }
-
-        .btn-reset {
-          padding: 7px 16px;
-          background: #fff;
-          color: #C9544A;
-          border: 1px solid #C9544A;
-          border-radius: 6px;
-          font-size: 12px;
-          font-weight: 500;
-          cursor: pointer;
-          transition: all 0.2s;
-          height: 34px;
-
-          &:hover {
-            background: #C9544A;
-            color: white;
-          }
-        }
-      }
-    }
-
-    .data-table-container {
-      flex: 1;
-      overflow-y: auto;
-      min-height: 0;
-
-      app-data-table-shell {
-        display: block;
-        height: 100%;
-
-        ::ng-deep .data-table-shell {
-          height: 100%;
-          min-height: 0;
-          padding: 0;
-          border: 0;
-          border-radius: 0;
-          box-shadow: none;
-        }
-
-        ::ng-deep .data-table-shell-body {
-          min-height: 0;
-        }
-
-        ::ng-deep .data-table-shell-table-wrap {
-          min-height: 0;
-        }
-      }
-
-      &::-webkit-scrollbar {
-        width: 8px;
-        height: 8px;
-      }
-
-      &::-webkit-scrollbar-track {
-        background: #F1F3F5;
-      }
-
-      &::-webkit-scrollbar-thumb {
-        background: #CED4DA;
-        border-radius: 4px;
-
-        &:hover {
-          background: #ADB5BD;
-        }
-      }
-
-      .data-table {
-        width: 100%;
-        border-collapse: collapse;
-
-        thead {
-          position: sticky;
-          top: 0;
-          background: #F8F9FA;
-          z-index: 10;
-
-          tr {
-            th {
-              padding: 8px 12px;
-              text-align: left;
-              font-size: 11px;
-              font-weight: 600;
-              color: #868E96;
-              border-bottom: 1px solid #E9ECEF;
-              text-transform: uppercase;
-              letter-spacing: 0.3px;
-              white-space: nowrap;
-            }
-          }
-        }
-
-        tbody {
-          tr {
-            transition: all 0.2s;
-            cursor: pointer;
-
-            &:hover {
-              background: #F1F3F5;
-            }
-
-            td {
-              padding: 8px 12px;
-              font-size: 12px;
-              color: #343A40;
-              border-bottom: 1px solid #F1F3F5;
-
-              &.brand-name, &.keyword, &.pattern-name, &.product-name {
-                font-weight: 600;
-                color: #343A40;
-                max-width: 240px;
-                overflow: hidden;
-                text-overflow: ellipsis;
-                white-space: nowrap;
-              }
-
-              &.influencer-info-cell {
-                max-width: 260px;
-                .influencer-info-row {
-                  display: flex;
-                  align-items: center;
-                  gap: 10px;
-                }
-                .influencer-avatar {
-                  width: 36px;
-                  height: 36px;
-                  border-radius: 50%;
-                  object-fit: cover;
-                  flex-shrink: 0;
-                }
-                .influencer-avatar-placeholder {
-                  width: 36px;
-                  height: 36px;
-                  border-radius: 50%;
-                  flex-shrink: 0;
-                  background: #343A40;
-                  color: #fff;
-                  display: flex;
-                  align-items: center;
-                  justify-content: center;
-                  font-size: 14px;
-                  font-weight: 600;
-                }
-                .influencer-name-text {
-                  font-weight: 600;
-                  color: #343A40;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                  max-width: 180px;
-                }
-              }
-
-              &.asin-cell {
-                font-family: 'SF Mono', 'Consolas', monospace;
-                font-size: 12px;
-                color: #868E96;
-                letter-spacing: 0.3px;
-              }
-
-              &.product-info-cell {
-                max-width: 320px;
-                .product-info-row {
-                  display: flex;
-                  align-items: center;
-                  gap: 10px;
-                }
-                .product-thumb {
-                  width: 34px;
-                  height: 34px;
-                  border-radius: 6px;
-                  object-fit: cover;
-                  flex-shrink: 0;
-                }
-                .product-thumb-placeholder {
-                  width: 34px;
-                  height: 34px;
-                  border-radius: 6px;
-                  background: #f3f4f6;
-                  display: flex;
-                  align-items: center;
-                  justify-content: center;
-                  font-size: 16px;
-                  flex-shrink: 0;
-                }
-                .product-text {
-                  display: flex;
-                  flex-direction: column;
-                  overflow: hidden;
-                  gap: 1px;
-                }
-                .product-name {
-                  font-size: 12px;
-                  font-weight: 600;
-                  color: #343A40;
-                  white-space: nowrap;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  max-width: 220px;
-                }
-                .product-brand {
-                  font-size: 11px;
-                  color: #6366f1;
-                  font-weight: 500;
-                }
-                .product-asin {
-                  font-size: 10px;
-                  color: #ADB5BD;
-                  font-family: 'SF Mono', 'Consolas', monospace;
-                }
-                .product-title {
-                  font-size: 12px;
-                  color: #343A40;
-                  white-space: nowrap;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  max-width: 200px;
-                }
-              }
-
-              &.number {
-                font-weight: 600;
-                color: #27ae60;
-              }
-
-              &.rating {
-                color: #f39c12;
-                font-weight: 500;
-              }
-
-              &.price {
-                font-weight: 600;
-                color: #e67e22;
-              }
-
-              &.engagement {
-                font-weight: 600;
-                color: #3498db;
-              }
-
-              &.conversion {
-                font-weight: 600;
-                color: #9b59b6;
-              }
-
-              &.image-count {
-                color: #ADB5BD;
-              }
-
-              &.bsr-rank {
-                .rank-badge {
-                  display: inline-block;
-                  padding: 4px 10px;
-                  background: linear-gradient(135deg, #f39c12, #e67e22);
-                  color: white;
-                  border-radius: 6px;
-                  font-weight: 600;
-                  font-size: 12px;
-                }
-              }
-
-              .keyword-category-tag {
-                font-size: 10px;
-                color: #9ca3af;
-                margin-top: 2px;
-                max-width: 200px;
-                white-space: nowrap;
-                overflow: hidden;
-                text-overflow: ellipsis;
-              }
-
-              .type-badge {
-                display: inline-block;
-                padding: 2px 8px;
-                border-radius: 10px;
-                font-size: 11px;
-                font-weight: 600;
-
-                &.type-traffic { background: #eff6ff; color: #3b82f6; }
-                &.type-conversion { background: #f0fdf4; color: #16a34a; }
-                &.type-blueocean { background: #ede9fe; color: #7c3aed; }
-                &.type-brand { background: #fff7ed; color: #ea580c; }
-              }
-
-              .competition-badge {
-                display: inline-block;
-                padding: 3px 8px;
-                border-radius: 4px;
-                font-size: 11px;
-                font-weight: 600;
-
-                &.level-low {
-                  background: #e8f5e9;
-                  color: #4caf50;
-                }
-
-                &.level-medium {
-                  background: #fff3e0;
-                  color: #ff9800;
-                }
-
-                &.level-high {
-                  background: #ffebee;
-                  color: #f44336;
-                }
-              }
-
-              .trend {
-                font-weight: 600;
-
-                &.up {
-                  color: #27ae60;
-                }
-
-                &.down {
-                  color: #e74c3c;
-                }
-              }
-
-              .seo-bar {
-                display: inline-block;
-                width: 60px;
-                height: 6px;
-                background: #e5e7eb;
-                border-radius: 3px;
-                overflow: hidden;
-                vertical-align: middle;
-                margin-right: 4px;
-
-                .seo-fill {
-                  height: 100%;
-                  border-radius: 3px;
-                  transition: width 0.3s;
-                  &.seo-high { background: #16a34a; }
-                  &.seo-mid { background: #f59e0b; }
-                  &.seo-low { background: #d1d5db; }
-                }
-              }
-
-              .seo-val {
-                font-size: 11px;
-                font-weight: 600;
-                color: #6b7280;
-              }
-
-              .fit-badge {
-                display: inline-block;
-                padding: 2px 8px;
-                border-radius: 10px;
-                font-size: 11px;
-                font-weight: 600;
-
-                &.fit-slim { background: #eff6ff; color: #3b82f6; }
-                &.fit-regular { background: #f0fdf4; color: #16a34a; }
-                &.fit-loose { background: #fefce8; color: #ca8a04; }
-                &.fit-oversized { background: #fef2f2; color: #dc2626; }
-              }
-
-              .popularity-bar {
-                display: inline-block;
-                width: 80px;
-                height: 6px;
-                background: #E9ECEF;
-                border-radius: 3px;
-                overflow: hidden;
-                vertical-align: middle;
-                margin-right: 8px;
-
-                .bar {
-                  height: 100%;
-                  background: linear-gradient(90deg, #868E96, #343A40);
-                  transition: width 0.3s;
-                }
-              }
-
-              .popularity-value {
-                font-size: 12px;
-                font-weight: 600;
-                color: #343A40;
-              }
-
-              .btn-action {
-                padding: 4px 10px;
-                background: #F1F3F5;
-                color: #343A40;
-                border: 1px solid #E9ECEF;
-                border-radius: 4px;
-                font-size: 12px;
-                font-weight: 500;
-                cursor: pointer;
-                transition: all 0.2s;
-                margin-right: 6px;
-
-                &:hover {
-                  background: #343A40;
-                  color: white;
-                  border-color: #343A40;
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-
-    .pagination {
-      display: flex;
-      justify-content: center;
-      align-items: center;
-      gap: 10px;
-      padding: 8px 16px;
-      border-top: 1px solid #E9ECEF;
-
-      .page-btn {
-        padding: 5px 12px;
-        background: white;
-        color: #343A40;
-        border: 1px solid #E9ECEF;
-        border-radius: 6px;
-        font-size: 12px;
-        font-weight: 500;
-        cursor: pointer;
-        transition: all 0.2s;
-
-        &:hover:not(:disabled) {
-          background: #343A40;
-          color: white;
-        }
-
-        &:disabled {
-          opacity: 0.5;
-          cursor: not-allowed;
-        }
-      }
-
-      .page-info {
-        font-size: 12px;
-        color: #868E96;
-        font-weight: 500;
-      }
-
-      .page-size-select {
-        padding: 5px 8px;
-        background: white;
-        color: #343A40;
-        border: 1px solid #E9ECEF;
-        border-radius: 6px;
-        font-size: 12px;
-        cursor: pointer;
-      }
-    }
-
-    // #19 竞品库等长表:横向滚动时固定首列(产品/关键词/红人标识列)
-    .data-table {
-      th:first-child,
-      td:first-child {
-        position: sticky;
-        left: 0;
-        z-index: 2;
-        background: #fff;
-      }
-      thead th:first-child {
-        z-index: 3;
-        background: #F8F9FA;
-      }
-    }
-  }
-
-
-  // Responsive Design
-  @media (max-width: 1400px) {
-    .overview-section {
-      .stats-grid {
-        grid-template-columns: repeat(3, 1fr);
-      }
-    }
-  }
-
-  @media (max-width: 1024px) {
-    .navigation-panel {
-      .panel-header {
-        flex-direction: column;
-        gap: 12px;
-        align-items: flex-start;
-
-        .search-box {
-          width: 100%;
-
-          .search-input {
-            flex: 1;
-          }
-        }
-      }
-
-    }
-
-    .overview-section {
-      .stats-grid {
-        grid-template-columns: repeat(2, 1fr);
-      }
-    }
-
-    .data-section {
-      margin: 12px;
-
-      .data-table-container {
-        .data-table {
-          font-size: 12px;
-
-          thead tr th,
-          tbody tr td {
-            padding: 10px 12px;
-          }
-        }
-      }
-    }
-  }
-
-  @media (max-width: 768px) {
-    .navigation-panel {
-      padding: 12px 16px;
-
-      .panel-header {
-        h1 {
-          font-size: 20px;
-        }
-      }
-
-    }
-
-    .overview-section {
-      padding: 16px;
-
-      .stats-grid {
-        grid-template-columns: 1fr;
-        gap: 12px;
-      }
-    }
-
-    .data-section {
-      .data-header {
-        flex-direction: column;
-        gap: 12px;
-        align-items: flex-start;
+:host { display: block; min-width: 0; }
+
+.knowledge-center-page {
+  display: grid;
+  min-width: 0;
+  min-height: 100%;
+  padding: 24px 24px 84px;
+  gap: 18px;
+}
 
-        .data-actions {
-          width: 100%;
+button, input, textarea { font: inherit; }
+
+.header-button {
+  display: inline-flex;
+  min-height: 34px;
+  padding: 0 14px;
+  align-items: center;
+  gap: 7px;
+  border: 1px solid #dbe2ea;
+  border-radius: 6px;
+  color: #334155;
+  background: #fff;
+  font-size: 12px;
+  font-weight: 700;
+  cursor: pointer;
+}
 
-          button {
-            flex: 1;
-          }
-        }
-      }
+.header-button:hover:not(:disabled) { border-color: #93c5fd; color: #1d4ed8; background: #eff6ff; }
+.header-button:disabled { opacity: .55; cursor: wait; }
+.spinning { display: inline-block; animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.status-banner {
+  min-height: 36px;
+  padding: 9px 12px;
+  border: 1px solid;
+  border-radius: 6px;
+  font-size: 12px;
+  line-height: 16px;
+}
+.status-banner.error { border-color: #fecaca; color: #991b1b; background: #fef2f2; }
+.status-banner.success { border-color: #bbf7d0; color: #166534; background: #f0fdf4; }
+
+.asset-status-band {
+  display: grid;
+  grid-template-columns: minmax(210px, 1.25fr) repeat(4, minmax(150px, 1fr));
+  min-width: 0;
+  border: 1px solid #dbe2ea;
+  border-radius: 6px;
+  background: #fff;
+  overflow: hidden;
+}
 
-      .filter-panel {
-        .filter-row {
-          flex-wrap: wrap;
+.asset-status-heading,
+.asset-status-item {
+  display: flex;
+  min-width: 0;
+  min-height: 70px;
+  padding: 12px 14px;
+  align-items: center;
+}
 
-          .filter-select, .filter-input {
-            flex: 1 1 calc(50% - 6px);
-            min-width: 0;
-          }
+.asset-status-heading {
+  position: relative;
+  flex-direction: column;
+  align-items: flex-start;
+  justify-content: center;
+  border-right: 1px solid #e5eaf0;
+  background: #f8fafc;
+}
 
-          .btn-reset {
-            width: 100%;
-          }
-        }
-      }
+.asset-status-heading strong { color: #1f2937; font-size: 13px; line-height: 20px; }
+.asset-status-heading small { max-width: 100%; overflow: hidden; color: #64748b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.asset-status-kicker { color: #2563eb; font-size: 9px; font-weight: 800; letter-spacing: .08em; }
+
+.asset-status-item { gap: 9px; border-right: 1px solid #eef2f7; }
+.asset-status-item:last-child { border-right: 0; }
+.asset-status-item > div { display: grid; min-width: 0; gap: 2px; }
+.asset-status-item strong { color: #334155; font-size: 11px; line-height: 16px; }
+.asset-status-item small { max-width: 100%; overflow: hidden; color: #64748b; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; }
+.status-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: #94a3b8; box-shadow: 0 0 0 3px #f1f5f9; }
+.asset-status-item.ready .status-dot { background: #16a34a; box-shadow: 0 0 0 3px #dcfce7; }
+.asset-status-item.pending .status-dot { background: #d97706; box-shadow: 0 0 0 3px #fef3c7; }
+
+.knowledge-metrics {
+  display: grid;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+  gap: 12px;
+}
 
-      .data-table-container {
-        .data-table {
-          display: block;
-          overflow-x: auto;
-          white-space: nowrap;
-        }
-      }
-    }
+.library-tabs {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  gap: 2px;
+  padding: 4px;
+  border-bottom: 1px solid #dbe2ea;
+  background: #fff;
+}
 
-  }
+.library-tabs button {
+  display: inline-flex;
+  min-height: 38px;
+  padding: 0 14px;
+  align-items: center;
+  gap: 8px;
+  border: 0;
+  border-bottom: 2px solid transparent;
+  color: #64748b;
+  background: transparent;
+  font-size: 13px;
+  font-weight: 700;
+  cursor: pointer;
 }
 
+.library-tabs button:hover { color: #1f2937; background: #f8fafc; }
+.library-tabs button.active { border-bottom-color: #2563eb; color: #1d4ed8; }
+.library-tabs span { min-width: 22px; padding: 1px 6px; border-radius: 4px; color: #64748b; background: #f1f5f9; font-size: 10px; text-align: center; }
+.library-tabs button.active span { color: #1d4ed8; background: #dbeafe; }
+
+.dataset-context {
+  display: grid;
+  grid-template-columns: 1.05fr 1.8fr .65fr 1fr .7fr;
+  min-width: 0;
+  border: 1px solid #e5eaf0;
+  border-radius: 6px;
+  background: #fff;
+}
 
-// #18 未补齐(缺失值与真实零区分):灰色斜体,避免被误读为真实 0
-.data-table td.kc-uncollected {
-  color: #adb5bd;
-  font-style: italic;
+.dataset-context > div { display: grid; min-width: 0; padding: 9px 12px; gap: 2px; border-right: 1px solid #eef2f7; }
+.dataset-context > div:last-child { border-right: 0; }
+.dataset-context span { color: #94a3b8; font-size: 9px; font-weight: 700; }
+.dataset-context strong { overflow: hidden; color: #334155; font-size: 11px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
+
+.knowledge-table { width: 100%; min-width: 1480px; border-collapse: collapse; table-layout: fixed; }
+.knowledge-table th, .knowledge-table td { padding: 10px 12px; border-bottom: 1px solid #eef2f7; text-align: left; vertical-align: middle; }
+.knowledge-table th { color: #64748b; background: #f8fafc; font-size: 11px; font-weight: 700; white-space: nowrap; }
+.knowledge-table td { color: #334155; font-size: 12px; }
+.knowledge-table tbody tr:hover { background: #f8fafc; }
+.knowledge-table tbody tr.selected { background: #eff6ff; }
+.knowledge-table th:nth-child(2), .knowledge-table td.product-cell { width: 290px; }
+.knowledge-table th:nth-last-child(2), .knowledge-table td:nth-last-child(2) { width: 108px; }
+.knowledge-table th.actions, .knowledge-table td.actions { width: 84px; text-align: right; }
+.knowledge-table th.numeric, .knowledge-table td.numeric { width: 98px; text-align: right; font-variant-numeric: tabular-nums; }
+.knowledge-table td.strong { color: #111827; font-weight: 700; }
+.knowledge-table.compact th, .knowledge-table.compact td { padding-top: 7px; padding-bottom: 7px; }
+
+.knowledge-table th.selection-cell,
+.knowledge-table td.selection-cell { width: 42px; padding-right: 6px; padding-left: 12px; text-align: center; }
+.selection-cell input { width: 14px; height: 14px; margin: 0; accent-color: #2563eb; cursor: pointer; }
+
+.product-cell { min-width: 0; }
+.category-cell { max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.role-badge { display: inline-block; min-width: 42px; padding: 3px 7px; border-radius: 4px; color: #1d4ed8; background: #dbeafe; font-size: 10px; font-weight: 700; text-align: center; }
+.role-badge.competitor { color: #7e22ce; background: #f3e8ff; }
+
+.relation-count { display: inline-grid; min-width: 24px; height: 22px; place-items: center; border-radius: 4px; color: #94a3b8; background: #f1f5f9; font-size: 10px; font-weight: 700; }
+.relation-count.active { color: #1d4ed8; background: #dbeafe; }
+.evidence-cell strong, .evidence-cell small { display: block; }
+.evidence-cell strong { color: #334155; font-size: 11px; }
+.evidence-cell small { margin-top: 1px; color: #94a3b8; font-size: 9px; font-weight: 500; }
+.sales-signal-cell { width: 126px; overflow: hidden; color: #475569; font-size: 10px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
+
+.completeness-badge { display: inline-block; max-width: 118px; padding: 3px 7px; overflow: hidden; border-radius: 4px; color: #475569; background: #f1f5f9; font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
+.completeness-badge.complete { color: #166534; background: #dcfce7; }
+.completeness-badge.ready { color: #1d4ed8; background: #dbeafe; }
+.completeness-badge.pending { color: #a16207; background: #fef3c7; }
+
+.knowledge-cell { width: 250px; }
+.tag-list { display: flex; min-width: 0; flex-wrap: wrap; gap: 4px; }
+.tag-list span { max-width: 96px; padding: 2px 6px; overflow: hidden; border-radius: 4px; color: #475569; background: #f1f5f9; font-size: 10px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
+.tag-list em, .unmaintained { color: #94a3b8; font-size: 11px; font-style: normal; }
+.knowledge-cell p { max-width: 230px; margin: 5px 0 0; overflow: hidden; color: #64748b; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.date-cell { color: #64748b; white-space: nowrap; }
+
+.icon-action {
+  display: inline-grid;
+  width: 30px;
+  height: 30px;
+  margin-left: 4px;
+  padding: 0;
+  place-items: center;
+  border: 1px solid #dbe2ea;
+  border-radius: 5px;
+  color: #64748b;
+  background: #fff;
+  font-size: 15px;
+  cursor: pointer;
+}
+.icon-action:hover { border-color: #93c5fd; color: #1d4ed8; background: #eff6ff; }
+.icon-action.favorite { color: #94a3b8; }
+.icon-action.favorite.active { border-color: #fcd34d; color: #b45309; background: #fffbeb; }
+.icon-action:disabled { opacity: .5; cursor: wait; }
+
+.dialog-backdrop { position: fixed; z-index: 2000; inset: 0; display: grid; padding: 24px; place-items: center; background: rgba(15, 23, 42, .42); }
+.knowledge-dialog { width: min(620px, 100%); max-height: calc(100vh - 48px); overflow-y: auto; border: 1px solid #dbe2ea; border-radius: 8px; background: #fff; box-shadow: 0 24px 60px rgba(15, 23, 42, .2); }
+.knowledge-dialog > header { display: flex; padding: 18px 20px; align-items: flex-start; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eef2f7; }
+.knowledge-dialog h2 { margin: 0; color: #1f2937; font-size: 17px; line-height: 24px; }
+.knowledge-dialog header p { max-width: 500px; margin: 3px 0 0; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.dialog-close { display: grid; width: 30px; height: 30px; padding: 0; place-items: center; border: 0; border-radius: 5px; color: #64748b; background: transparent; font-size: 22px; cursor: pointer; }
+.dialog-close:hover { color: #111827; background: #f1f5f9; }
+.dialog-product { padding: 14px 20px; border-bottom: 1px solid #eef2f7; background: #f8fafc; }
+.knowledge-dialog form { display: grid; padding: 18px 20px 20px; gap: 15px; }
+.knowledge-dialog label { display: grid; gap: 6px; color: #334155; font-size: 12px; font-weight: 700; }
+.knowledge-dialog label > input[type='text'], .knowledge-dialog textarea { width: 100%; box-sizing: border-box; padding: 9px 10px; border: 1px solid #dbe2ea; border-radius: 6px; color: #1f2937; background: #fff; font-size: 12px; font-weight: 400; resize: vertical; }
+.knowledge-dialog label > input:focus, .knowledge-dialog textarea:focus { border-color: #2563eb; outline: 0; box-shadow: 0 0 0 2px rgba(37, 99, 235, .1); }
+.knowledge-dialog small { color: #94a3b8; font-size: 10px; font-weight: 400; }
+.knowledge-dialog .featured-toggle { display: flex; min-height: 38px; padding: 0 11px; align-items: center; gap: 9px; border: 1px solid #dbe2ea; border-radius: 6px; background: #f8fafc; cursor: pointer; }
+.featured-toggle input { width: 15px; height: 15px; accent-color: #2563eb; }
+.knowledge-dialog footer { display: flex; padding-top: 4px; align-items: center; gap: 8px; }
+.footer-spacer { flex: 1; }
+.primary-button, .secondary-button, .danger-button { min-height: 34px; padding: 0 15px; border: 1px solid transparent; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer; }
+.primary-button { color: #fff; background: #2563eb; }
+.primary-button:hover { background: #1d4ed8; }
+.secondary-button { border-color: #dbe2ea; color: #475569; background: #fff; }
+.danger-button { border-color: #fecaca; color: #b91c1c; background: #fff; }
+.danger-button:hover { background: #fef2f2; }
+.primary-button:disabled, .secondary-button:disabled, .danger-button:disabled { opacity: .5; cursor: wait; }
+
+@media (max-width: 1440px) {
+  .asset-status-band { grid-template-columns: minmax(190px, 1.15fr) repeat(4, minmax(135px, 1fr)); }
+}
+@media (max-width: 1100px) {
+  .knowledge-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .asset-status-band { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .asset-status-heading { grid-column: 1 / -1; border-right: 0; border-bottom: 1px solid #e5eaf0; }
+  .asset-status-item:nth-child(odd) { border-right: 0; }
+  .dataset-context { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .dataset-context > div:nth-child(3) { border-right: 0; }
+  .dataset-context > div:nth-child(n + 4) { border-top: 1px solid #eef2f7; }
+}
+@media (max-width: 720px) {
+  .knowledge-center-page { padding: 16px 14px 72px; gap: 14px; }
+  .knowledge-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
+  .asset-status-band { grid-template-columns: 1fr; }
+  .asset-status-item { min-height: 56px; border-right: 0; border-bottom: 1px solid #eef2f7; }
+  .asset-status-item:last-child { border-bottom: 0; }
+  .library-tabs { overflow-x: auto; }
+  .library-tabs button { flex: 0 0 auto; padding: 0 10px; font-size: 12px; }
+  .dataset-context { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .dataset-context > div { border-top: 1px solid #eef2f7; }
+  .dataset-context > div:first-child, .dataset-context > div:nth-child(2) { border-top: 0; }
+  .dataset-context > div:nth-child(even) { border-right: 0; }
+  .dialog-backdrop { padding: 12px; align-items: end; }
+  .knowledge-dialog { max-height: calc(100vh - 24px); }
+  .knowledge-dialog footer { flex-wrap: wrap; }
 }

+ 486 - 1287
src/modules/knowledge-center/knowledge-center.component.ts

@@ -1,1417 +1,616 @@
-import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
+import { Component, OnDestroy, OnInit } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { Router } from '@angular/router';
+import { forkJoin, Subject, take, takeUntil } from 'rxjs';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import { DomesticDataset, DomesticProduct } from '../../app/core/models/domestic.models';
 import {
-  KnowledgeCenterDataService,
-  KnowledgeDataSourceConnectionStatus,
-  KnowledgeDataSourceStatus
-} from '../shared/services/knowledge-center-data.service';
-import { MsqDropdownComponent, MsqDropdownOption } from '../shared/components/msq-dropdown/msq-dropdown.component';
-import { MsqSearchBoxComponent } from '../shared/components/msq-search-box/msq-search-box.component';
-import { FetchCountDialogComponent, FetchDialogResult } from './fetch-count-dialog/fetch-count-dialog.component';
-import { CompetitorBackfillDialogComponent, BackfillScope, BackfillPreview, BackfillRunResult } from './competitor-backfill-dialog/competitor-backfill-dialog.component';
-import { ExportReviewsDialogComponent } from './export-reviews-dialog/export-reviews-dialog.component';
-import { PatternEditDialogComponent } from './pattern-edit-dialog/pattern-edit-dialog.component';
-import {
-  KnowledgeBaseStats,
-  CompetitorKnowledge,
-  InfluencerKnowledge,
-  KeywordKnowledge,
-  PatternKnowledge,
-  HotProductKnowledge,
-  KnowledgeBaseFilter
-} from '../shared/interfaces/knowledge-center-interfaces';
-import { NeuButtonComponent } from '../shared/components/neu-button/neu-button.component';
-import { EntryCardComponent, EntryCardMetric } from '../shared/components/entry-card/entry-card.component';
-import { EntryCardGridComponent } from '../shared/components/entry-card/entry-card-grid.component';
+  CrudTableBulkAction,
+  CrudTableColumn,
+  CrudTableFilter,
+  CrudTableWorkbenchComponent,
+} from '../shared/components/crud-table-workbench/crud-table-workbench.component';
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
+import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
-import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
-import { FilterBarComponent } from '../shared/components/filter-bar/filter-bar.component';
-import { FilterOptions } from '../shared/interfaces/voc-data.interface';
-
-type KnowledgeBaseType = 'competitor' | 'influencer' | 'keyword' | 'pattern' | 'hotproduct';
-
-type DataSourceItem = KnowledgeDataSourceConnectionStatus;
-
-interface DictionaryItem {
-  id: string;
-  name: string;
-  termCount: number;
-  lastUpdated: string;
-  owner: string;
+import {
+  ProductKnowledgeItem,
+  ProductKnowledgeService,
+  ProductKnowledgeWrite,
+} from '../shared/services/product-knowledge.service';
+
+type KnowledgeTab = 'own' | 'featured' | 'competitor';
+
+interface KnowledgeMetric {
+  label: string;
+  value: string | number;
+  unit: string;
+  description: string;
+  tone: 'default' | 'blue' | 'green' | 'amber' | 'red' | 'purple';
 }
 
 @Component({
   selector: 'app-knowledge-center',
   standalone: true,
-  imports: [CommonModule, FormsModule, FetchCountDialogComponent, CompetitorBackfillDialogComponent, ExportReviewsDialogComponent, PatternEditDialogComponent, MsqDropdownComponent, MsqSearchBoxComponent, NeuButtonComponent, EntryCardComponent, EntryCardGridComponent, SummaryMetricCardComponent, DataTableShellComponent, FilterBarComponent],
-  template: `
-    <div class="knowledge-center-container">
-      <!-- Navigation and Search -->
-      <div class="navigation-panel">
-        <div class="panel-header">
-          <h1>数据中心 - 自动化知识库</h1>
-          <div class="search-box">
-            <msq-search-box placeholder="搜索知识库内容..." [(ngModel)]="searchQuery" (valueChange)="onSearch()" (search)="onSearch()"></msq-search-box>
-          </div>
-        </div>
-      </div>
-
-      <!-- Data Sources Connection Status -->
-      <section class="data-sources-section">
-        <header class="data-sources-header">
-          <div>
-            <h2>数据源连接状态</h2>
-            <p>统一聚合 VOC / 市场 / 监测关键数据源,异常状态一目了然。</p>
-          </div>
-          <button class="ghost-btn" (click)="refreshDataSourceStatus()" [disabled]="isRefreshingDataSourceStatus">
-            {{ isRefreshingDataSourceStatus ? '检测中...' : '刷新状态' }}
-          </button>
-        </header>
-        <app-entry-card-grid columns="4" gap="sm">
-          @for (source of dataSources; track source.id) {
-            <app-entry-card
-              [title]="source.name"
-              [subtitle]="source.message"
-              [icon]="getDataSourceIcon(source.status)"
-              [badge]="getDataSourceStatusLabel(source.status)"
-              [tone]="getDataSourceTone(source.status)"
-              [compact]="true"
-              [metrics]="getDataSourceMetrics(source)">
-            </app-entry-card>
-          }
-        </app-entry-card-grid>
-      </section>
-
-      <!-- Overview Statistics -->
-      <div class="overview-section">
-        <div class="stats-grid">
-          <app-summary-metric-card
-            class="knowledge-stat-card"
-            [class.active]="selectedKnowledgeBase === 'competitor'"
-            tone="blue" icon="🏢" label="竞品库"
-            size="sm"
-            [compact]="true"
-            [clickable]="true"
-            [value]="stats?.competitorCount || 0"
-            [description]="'本周新增 ' + (stats?.competitorWeeklyGrowth || 0)"
-            (activate)="selectKnowledgeBase('competitor')">
-          </app-summary-metric-card>
-          <app-summary-metric-card
-            class="knowledge-stat-card"
-            [class.active]="selectedKnowledgeBase === 'influencer'"
-            tone="purple" icon="👤" label="红人库"
-            size="sm"
-            [compact]="true"
-            [clickable]="true"
-            [value]="stats?.influencerCount || 0"
-            [description]="'本周新增 ' + (stats?.influencerWeeklyGrowth || 0)"
-            (activate)="selectKnowledgeBase('influencer')">
-          </app-summary-metric-card>
-          <app-summary-metric-card
-            class="knowledge-stat-card"
-            [class.active]="selectedKnowledgeBase === 'keyword'"
-            tone="amber" icon="🔑" label="关键词库"
-            size="sm"
-            [compact]="true"
-            [clickable]="true"
-            [value]="stats?.keywordCount || 0"
-            [description]="'本周新增 ' + (stats?.keywordWeeklyGrowth || 0)"
-            (activate)="selectKnowledgeBase('keyword')">
-          </app-summary-metric-card>
-          <app-summary-metric-card
-            class="knowledge-stat-card"
-            [class.active]="selectedKnowledgeBase === 'pattern'"
-            tone="green" icon="📐" label="版型库"
-            size="sm"
-            [compact]="true"
-            [clickable]="true"
-            [value]="stats?.patternCount || 0"
-            [description]="'本周新增 ' + (stats?.patternWeeklyGrowth || 0)"
-            (activate)="selectKnowledgeBase('pattern')">
-          </app-summary-metric-card>
-          <app-summary-metric-card
-            class="knowledge-stat-card"
-            [class.active]="selectedKnowledgeBase === 'hotproduct'"
-            tone="red" icon="🔥" label="爆款库(BSR400)"
-            size="sm"
-            [compact]="true"
-            [clickable]="true"
-            [value]="stats?.hotProductCount || 0"
-            [description]="'本周新增 ' + (stats?.hotProductWeeklyGrowth || 0)"
-            (activate)="selectKnowledgeBase('hotproduct')">
-          </app-summary-metric-card>
-          <app-summary-metric-card
-            class="knowledge-stat-card"
-            tone="default" icon="📊" label="总数据量"
-            size="sm"
-            [compact]="true"
-            [value]="formatNumber(stats?.totalRecords || 0)"
-            [description]="'最后更新: ' + (stats?.lastUpdate || '-')">
-          </app-summary-metric-card>
-        </div>
-      </div>
-
-      <!-- Data Table -->
-      <div class="data-section">
-        <div class="data-header">
-          <div class="data-title">
-            <h2>{{ getKnowledgeBaseTitle() }}</h2>
-            @if (selectedKnowledgeBase === 'competitor') {
-              <span class="last-updated">
-                最后更新时间:{{ competitorLastUpdated ? formatDateTime(competitorLastUpdated) : '-' }}
-              </span>
-            }
-            @if (selectedKnowledgeBase === 'influencer') {
-              <span class="last-updated">
-                最后更新时间:{{ influencerLastUpdated ? formatDateTime(influencerLastUpdated) : '-' }}
-              </span>
-            }
-          </div>
-          <div class="data-actions">
-            <app-neu-button size="sm" (click)="toggleFilter()">
-              筛选 {{ showFilter ? '▲' : '▼' }}
-            </app-neu-button>
-            <app-neu-button size="sm" color="primary" (click)="openExportReviewsDialog()">导出评论</app-neu-button>
-            @if (selectedKnowledgeBase === 'influencer') {
-              <app-neu-button size="sm" (click)="goVideoSearch()">🎬 视频搜索</app-neu-button>
-            }
-            @if (selectedKnowledgeBase === 'competitor') {
-              <app-neu-button size="sm" (click)="onOpenBackfill()">补齐数据</app-neu-button>
-            }
-            <app-neu-button
-              size="sm"
-              color="primary"
-              (click)="onAddData()"
-              [disabled]="!canFetchByAddButton()">
-              {{ (isFetchingCompetitor || isFetchingInfluencer) && selectedKnowledgeBase !== 'pattern' ? '拉取中...' : '+ 添加数据' }}
-            </app-neu-button>
-          </div>
-        </div>
-
-        @if (showFilter) {
-          <div class="filter-panel">
-            @if (selectedKnowledgeBase === 'competitor') {
-              <div class="filter-row">
-                <div class="filter-group">
-                  <label>搜索</label>
-                  <input
-                    type="text"
-                    [(ngModel)]="filters.searchText"
-                    (input)="applyFilters()"
-                    placeholder="ASIN / 品牌 / 产品名"
-                    class="filter-input wide">
-                </div>
-                <div class="filter-group category-filter-group">
-                  <app-filter-bar
-                    class="knowledge-category-filter"
-                    [filters]="competitorCategoryFilters"
-                    [enableCategory]="true"
-                    [showSite]="false"
-                    [showStore]="false"
-                    [showTimeRange]="false"
-                    [showDatePicker]="false"
-                    [showActions]="false"
-                    (filtersChange)="onCompetitorCategoryFilterChange($event)">
-                  </app-filter-bar>
-                </div>
-                <div class="filter-group">
-                  <label>评分</label>
-                  <msq-dropdown [options]="ratingDropdownOptions" placeholder="不限" (valueChange)="filters.minRating = $event; applyFilters()"></msq-dropdown>
-                </div>
-                <div class="filter-group">
-                  <label>价格区间</label>
-                  <div class="range-inputs">
-                    <input type="number" [(ngModel)]="filters.minPrice" (change)="applyFilters()" placeholder="最低" class="filter-input small">
-                    <span class="range-sep">-</span>
-                    <input type="number" [(ngModel)]="filters.maxPrice" (change)="applyFilters()" placeholder="最高" class="filter-input small">
-                  </div>
-                </div>
-                <div class="filter-group">
-                  <label>数据完整度</label>
-                  <msq-dropdown [options]="completenessDropdownOptions" placeholder="全部" (valueChange)="onCompletenessChange($event)"></msq-dropdown>
-                </div>
-                <button class="btn-reset" (click)="resetFilters()">重置</button>
-              </div>
-            }
-            @if (selectedKnowledgeBase === 'influencer') {
-              <div class="filter-row">
-                <div class="filter-group">
-                  <label>平台</label>
-                  <msq-dropdown [options]="platformDropdownOptions" placeholder="全部" (valueChange)="filters.platform = $event; applyFilters()"></msq-dropdown>
-                </div>
-                <div class="filter-group">
-                  <label>最低粉丝</label>
-                  <input
-                    type="number"
-                    [(ngModel)]="filters.minFollowers"
-                    (change)="applyFilters()"
-                    placeholder="如 10000"
-                    class="filter-input">
-                </div>
-                <button class="btn-reset" (click)="resetFilters()">重置</button>
-              </div>
-            }
-            @if (selectedKnowledgeBase === 'keyword') {
-              <div class="filter-row">
-                <div class="filter-group">
-                  <label>关键词</label>
-                  <input
-                    type="text"
-                    [(ngModel)]="filters.searchText"
-                    (input)="applyFilters()"
-                    placeholder="搜索关键词"
-                    class="filter-input wide">
-                </div>
-                <div class="filter-group">
-                  <label>最低搜索量</label>
-                  <input
-                    type="number"
-                    [(ngModel)]="filters.minSearchVolume"
-                    (change)="applyFilters()"
-                    placeholder="如 1000"
-                    class="filter-input">
-                </div>
-                <button class="btn-reset" (click)="resetFilters()">重置</button>
-              </div>
-            }
-          </div>
-        }
-
-        <div class="data-table-container">
-          @if (selectedKnowledgeBase === 'competitor') {
-            <app-data-table-shell variant="admin" [count]="displayedCompetitorData.length">
-              <table class="data-table">
-                <thead>
-                  <tr>
-                    <th>产品</th>
-                    <th>品牌</th>
-                    <th>类目</th>
-                    <th>月销量</th>
-                    <th>评分</th>
-                    <th>价格</th>
-                    <th>操作</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  @for (item of displayedCompetitorData; track item.id) {
-                    <tr (click)="viewCompetitor(item, $event)">
-                      <td class="product-info-cell">
-                        <div class="product-info-row">
-                          @if (item.photo) { <img [src]="proxyImg(item.photo)" class="product-thumb" alt="" /> }
-                          <div class="product-text">
-                            <span class="product-asin">{{ item.asin }}</span>
-                            <span class="product-title" [title]="item.name">{{ item.name || '-' }}</span>
-                          </div>
-                        </div>
-                      </td>
-                      <td>{{ item.brandName || '-' }}</td>
-                      <td [title]="getCompetitorCategoryTitle(item)">{{ item.category || '-' }}</td>
-                      @if (item.hasMonthlySales === false) {
-                        <td class="number kc-uncollected" title="数据源未返回月销量(未补齐),不计入均值与占比">未补齐</td>
-                      } @else {
-                        <td class="number">{{ formatNumber(item.monthlySales) }}</td>
-                      }
-                      <td class="rating">⭐ {{ item.rating }}</td>
-                      @if (item.hasPrice === false) {
-                        <td class="price kc-uncollected" title="数据源未返回价格(未补齐 / $0.00),不计入均值">未补齐</td>
-                      } @else {
-                        <td class="price">{{ formatPrice(item.price) }}</td>
-                      }
-                      <td>
-                        <button class="btn-action" (click)="viewCompetitor(item, $event)">查看</button>
-                      </td>
-                    </tr>
-                  }
-                </tbody>
-              </table>
-            </app-data-table-shell>
-          }
-
-          @if (selectedKnowledgeBase === 'influencer') {
-            <app-data-table-shell variant="admin" [count]="displayedInfluencerData.length">
-              <table class="data-table">
-                <thead>
-                  <tr>
-                    <th>红人名称</th>
-                    <th>平台</th>
-                    <th>粉丝数</th>
-                    <th>互动率</th>
-                    <th>主要类目</th>
-                    <th>合作价格</th>
-                    <th>操作</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  @for (item of displayedInfluencerData; track item.id) {
-                    <tr (click)="viewInfluencer(item, $event)">
-                      <td class="influencer-info-cell">
-                        <div class="influencer-info-row">
-                          @if (item.avatar) {
-                            <img [src]="proxyImg(item.avatar)" class="influencer-avatar" alt="" />
-                          } @else {
-                            <div class="influencer-avatar-placeholder">{{ (item.name || '?').charAt(1) || '?' }}</div>
-                          }
-                          <span class="influencer-name-text" [title]="item.name">{{ item.name }}</span>
-                        </div>
-                      </td>
-                      <td>{{ item.platform }}</td>
-                      <td class="number">{{ formatNumber(item.followers) }}</td>
-                      <td class="engagement">{{ item.engagementRate }}%</td>
-                      <td>{{ item.mainCategory }}</td>
-                      <td class="price">¥{{ formatNumber(item.cooperationPrice) }}</td>
-                      <td>
-                        <button class="btn-action" (click)="viewInfluencer(item, $event)">查看</button>
-                      </td>
-                    </tr>
-                  }
-                </tbody>
-              </table>
-            </app-data-table-shell>
-          }
-
-          @if (selectedKnowledgeBase === 'keyword') {
-            <app-data-table-shell variant="admin" [count]="displayedKeywordData.length">
-              <table class="data-table">
-                <thead>
-                  <tr>
-                    <th>关键词</th>
-                    <th>类型</th>
-                    <th>搜索量</th>
-                    <th>竞争度</th>
-                    <th>CPC</th>
-                    <th>转化率</th>
-                    <th>趋势</th>
-                    <th>SEO机会</th>
-                    <th>操作</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  @for (item of displayedKeywordData; track item.id) {
-                    <tr (click)="navigateToKeywordResearch(item.keyword)">
-                      <td class="keyword">
-                        {{ item.keyword }}
-                        <div class="keyword-category-tag">{{ item.category }}</div>
-                      </td>
-                      <td>
-                        <span class="type-badge" [class]="'type-' + item.type">
-                          {{ item.type === 'conversion' ? '转化词' : item.type === 'blueocean' ? '蓝海词' : item.type === 'brand' ? '品牌词' : '流量词' }}
-                        </span>
-                      </td>
-                      <td class="number">{{ formatNumber(item.searchVolume) }}</td>
-                      <td>
-                        <span class="competition-badge" [class]="'level-' + item.competition">
-                          {{ item.competition === 'high' ? '高' : item.competition === 'medium' ? '中' : '低' }}
-                        </span>
-                      </td>
-                      <td class="number">\${{ item.cpc.toFixed(2) }}</td>
-                      <td class="conversion">{{ item.conversionRate > 0 ? item.conversionRate + '%' : '-' }}</td>
-                      <td>
-                        <span class="trend" [class]="item.trend > 0 ? 'up' : item.trend < 0 ? 'down' : ''">
-                          {{ item.trend > 0 ? '↑' : item.trend < 0 ? '↓' : '-' }}
-                          @if (item.trend !== 0) { {{ (Math.abs(item.trend) * 100).toFixed(1) }}% }
-                        </span>
-                      </td>
-                      <td>
-                        <div class="seo-bar">
-                          <div class="seo-fill" [style.width.%]="item.seoOpportunity" [class.seo-high]="item.seoOpportunity >= 70" [class.seo-mid]="item.seoOpportunity >= 40 && item.seoOpportunity < 70" [class.seo-low]="item.seoOpportunity < 40"></div>
-                        </div>
-                        <span class="seo-val">{{ item.seoOpportunity }}</span>
-                      </td>
-                      <td>
-                        <button class="btn-action" (click)="navigateToKeywordResearch(item.keyword); $event.stopPropagation()">查看</button>
-                        <button class="btn-action" (click)="navigateToKeywordAiAnalysis(item.keyword); $event.stopPropagation()">AI 分析</button>
-                      </td>
-                    </tr>
-                  }
-                </tbody>
-              </table>
-            </app-data-table-shell>
-          }
-
-          @if (selectedKnowledgeBase === 'pattern') {
-            <app-data-table-shell variant="admin" [count]="displayedPatternData.length">
-              <table class="data-table">
-                <thead>
-                  <tr>
-                    <th>版型名称</th>
-                    <th>类目</th>
-                    <th>版型</th>
-                    <th>适用场景</th>
-                    <th>流行度</th>
-                    <th>季节</th>
-                    <th>参考图片</th>
-                    <th>操作</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  @for (item of displayedPatternData; track item.id) {
-                    <tr (click)="openPatternEdit(item)">
-                      <td class="pattern-name">{{ item.name }}</td>
-                      <td>{{ item.category }}</td>
-                      <td>
-                        <span class="fit-badge" [class]="'fit-' + item.fitType">
-                          {{ item.fitType === 'slim' ? '修身' : item.fitType === 'regular' ? '常规' : item.fitType === 'loose' ? '宽松' : '超大' }}
-                        </span>
-                      </td>
-                      <td>{{ item.applicableScenarios.join(', ') }}</td>
-                      <td>
-                        <div class="popularity-bar">
-                          <div class="bar" [style.width.%]="item.popularity"></div>
-                        </div>
-                        <span class="popularity-value">{{ item.popularity }}%</span>
-                      </td>
-                      <td>{{ item.season }}</td>
-                      <td class="image-count">{{ item.referenceImages.length }}张</td>
-                      <td>
-                        <button class="btn-action" (click)="openPatternEdit(item); $event.stopPropagation()">编辑</button>
-                      </td>
-                    </tr>
-                  }
-                </tbody>
-              </table>
-            </app-data-table-shell>
-          }
-
-          @if (selectedKnowledgeBase === 'hotproduct') {
-            <app-data-table-shell variant="admin" [count]="displayedHotProductData.length">
-              <table class="data-table">
-                <thead>
-                  <tr>
-                    <th>产品</th>
-                    <th>BSR排名</th>
-                    <th>类目</th>
-                    <th>价格</th>
-                    <th>评分</th>
-                    <th>评论数</th>
-                    <th>月销量</th>
-                    <th>操作</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  @for (item of displayedHotProductData; track item.id) {
-                    <tr (click)="viewHotProduct(item, $event)">
-                      <td class="product-info-cell">
-                        <div class="product-info-row">
-                          @if (item.photo) {
-                            <img [src]="proxyImg(item.photo)" class="product-thumb" alt="" />
-                          } @else {
-                            <div class="product-thumb-placeholder">📦</div>
-                          }
-                          <div class="product-text">
-                            <div class="product-name">{{ item.productName || item.title }}</div>
-                            @if (item.brand) {
-                              <div class="product-brand">{{ item.brand }}</div>
-                            }
-                            <div class="product-asin">{{ item.asin }}</div>
-                          </div>
-                        </div>
-                      </td>
-                      <td class="bsr-rank">
-                        <span class="rank-badge">{{ item.bsrRank || '-' }}</span>
-                      </td>
-                      <td>{{ item.category }}</td>
-                      <td class="price">\${{ item.price.toFixed(2) }}</td>
-                      <td class="rating">⭐ {{ item.rating > 0 ? item.rating.toFixed(1) : '-' }}</td>
-                      <td class="number">{{ formatNumber(item.reviewCount) }}</td>
-                      <td class="number">{{ item.monthlySales > 0 ? formatNumber(item.monthlySales) : '-' }}</td>
-                      <td>
-                        <button class="btn-action" (click)="viewHotProduct(item, $event)">查看</button>
-                      </td>
-                    </tr>
-                  }
-                </tbody>
-              </table>
-            </app-data-table-shell>
-          }
-        </div>
-
-        <!-- Pagination -->
-        <div class="pagination">
-          <button 
-            class="page-btn" 
-            [disabled]="currentPage === 1"
-            (click)="previousPage()">
-            上一页
-          </button>
-          <span class="page-info">
-            第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
-          </span>
-          <button 
-            class="page-btn" 
-            [disabled]="currentPage === totalPages"
-            (click)="nextPage()">
-            下一页
-          </button>
-          <select class="page-size-select" [value]="pageSize" (change)="setPageSize(+$any($event.target).value)" title="每页条数">
-            <option value="20">20 条/页</option>
-            <option value="50">50 条/页</option>
-            <option value="100">100 条/页</option>
-          </select>
-        </div>
-      </div>
-
-
-      <!-- Export Reviews Dialog -->
-      @if (showExportReviewsDialog) {
-        <app-export-reviews-dialog
-          [competitors]="competitorDataForExport"
-          (cancelExport)="showExportReviewsDialog = false"
-          (exportComplete)="showExportReviewsDialog = false">
-        </app-export-reviews-dialog>
-      }
-
-      <!-- Fetch Count Dialog -->
-      @if (showFetchCountDialog) {
-        <app-fetch-count-dialog
-          [fetchType]="pendingFetchType || 'competitor'"
-          (confirmFetch)="onConfirmFetch($event)"
-          (cancelFetch)="showFetchCountDialog = false">
-        </app-fetch-count-dialog>
-      }
-
-      <!-- Competitor Backfill Dialog -->
-      @if (showBackfillDialog) {
-        <app-competitor-backfill-dialog
-          [brandOptions]="backfillBrandOptions"
-          [categoryOptions]="backfillCategoryOptions"
-          [running]="backfillRunning"
-          [preview]="backfillPreview"
-          [runResult]="backfillRunResult"
-          (previewBackfill)="onPreviewBackfill($event)"
-          (confirmBackfill)="onConfirmBackfill($event)"
-          (cancelBackfill)="onCloseBackfill()">
-        </app-competitor-backfill-dialog>
-      }
-
-      <!-- Pattern Edit Dialog -->
-      @if (showPatternEditDialog) {
-        <app-pattern-edit-dialog
-          [pattern]="editingPattern"
-          (save)="onPatternSave($event)"
-          (cancel)="showPatternEditDialog = false">
-        </app-pattern-edit-dialog>
-      }
-    </div>
-  `,
-  styleUrls: ['./knowledge-center.component.scss']
+  imports: [
+    CommonModule,
+    FormsModule,
+    PageHeaderComponent,
+    SummaryMetricCardComponent,
+    CrudTableWorkbenchComponent,
+    ProductIdentityComponent,
+  ],
+  templateUrl: './knowledge-center.component.html',
+  styleUrls: ['./knowledge-center.component.scss'],
 })
-export class KnowledgeCenterComponent implements OnInit {
-  // Data properties
-  stats: {
-    competitorCount: number;
-    competitorWeeklyGrowth: number;
-    influencerCount: number;
-    influencerWeeklyGrowth: number;
-    keywordCount: number;
-    keywordWeeklyGrowth: number;
-    patternCount: number;
-    patternWeeklyGrowth: number;
-    hotProductCount: number;
-    hotProductWeeklyGrowth: number;
-    totalRecords: number;
-    lastUpdate: string;
-  } | null = null;
-
-  competitorData: CompetitorKnowledge[] = [];
-  influencerData: InfluencerKnowledge[] = [];
-  keywordData: KeywordKnowledge[] = [];
-  patternData: PatternKnowledge[] = [];
-  hotProductData: HotProductKnowledge[] = [];
-  dataSources: DataSourceItem[] = [
-    { id: 'ds-parse', name: 'Parse 主库', status: 'warning', updateFrequency: '实时', lastSync: '-', owner: '数据工程师', message: '等待检测' },
-    { id: 'ds-sorftime', name: 'Sorftime API', status: 'warning', updateFrequency: '每 2 小时', lastSync: '-', owner: '分析师', message: '等待检测' },
-    { id: 'ds-amazon', name: 'Amazon 数据源', status: 'warning', updateFrequency: '每日', lastSync: '-', owner: '运营经理', message: '等待检测' },
-    { id: 'ds-tikhub', name: 'TikHub 社媒源', status: 'warning', updateFrequency: '每 1 小时', lastSync: '-', owner: '内容运营', message: '等待检测' }
-  ];
-  dictionaries: DictionaryItem[] = [
-    { id: 'dic-industry', name: '行业词典', termCount: 1320, lastUpdated: '-', owner: '数据分析师' },
-    { id: 'dic-synonym', name: '同义词词典', termCount: 860, lastUpdated: '-', owner: '算法工程师' },
-    { id: 'dic-painpoint', name: '痛点词典', termCount: 540, lastUpdated: '-', owner: '产品经理' }
-  ];
-
-  competitorLastUpdated: Date | null = null;
-  isFetchingCompetitor = false;
-  influencerLastUpdated: Date | null = null;
-  isFetchingInfluencer = false;
-  isRefreshingDataSourceStatus = false;
-
-  // UI state
-  selectedKnowledgeBase: KnowledgeBaseType = 'competitor';
-  // 已加载过的知识库类型,切换卡片时复用已有数据,避免重复请求
-  private loadedTypes = new Set<KnowledgeBaseType>();
-  searchQuery: string = '';
-  showFilter: boolean = false;
-  showFetchCountDialog: boolean = false;
-  showExportReviewsDialog: boolean = false;
-  showPatternEditDialog: boolean = false;
-  editingPattern: PatternKnowledge | null = null;
-  pendingFetchType: 'competitor' | 'influencer' | null = null;
-
-  // 竞品「补齐数据」弹窗
-  showBackfillDialog = false;
-  backfillRunning = false;
-  backfillPreview: BackfillPreview | null = null;
-  backfillRunResult: BackfillRunResult | null = null;
-  backfillBrandOptions: string[] = [];
-  backfillCategoryOptions: string[] = [];
-
-  // Pagination
-  currentPage: number = 1;
-  pageSize: number = 50;
-  totalPages: number = 1;
-
-  // Filters
-  filters: KnowledgeBaseFilter = {};
-  competitorCategoryFilters: FilterOptions = { timeRange: '7days' };
-
-  readonly ratingDropdownOptions: MsqDropdownOption[] = [
-    { value: '4.5', label: '4.5+' },
-    { value: '4', label: '4.0+' },
-    { value: '3.5', label: '3.5+' },
-    { value: '3', label: '3.0+' }
-  ];
-
-  readonly platformDropdownOptions: MsqDropdownOption[] = [
-    { value: 'tiktok', label: 'TikTok' },
-    { value: 'instagram', label: 'Instagram' }
-  ];
-
-  // #18 竞品数据完整度筛选
-  competitorCompleteness: 'all' | 'hasSales' | 'hasPrice' | 'incomplete' = 'all';
-  readonly completenessDropdownOptions: MsqDropdownOption[] = [
-    { value: 'all', label: '全部' },
-    { value: 'hasSales', label: '已采集销量' },
-    { value: 'hasPrice', label: '已采集价格' },
-    { value: 'incomplete', label: '存在未补齐项' }
+export class KnowledgeCenterComponent implements OnInit, OnDestroy {
+  private readonly destroy$ = new Subject<void>();
+  dataset: DomesticDataset | null = null;
+  knowledgeItems: ProductKnowledgeItem[] = [];
+  knowledgeByKey = new Map<string, ProductKnowledgeItem>();
+  reviewCounts = new Map<string, number>();
+  ratingByProduct = new Map<string, number>();
+  relationCounts = new Map<string, number>();
+  selectedKeys = new Set<string>();
+  loading = true;
+  saving = false;
+  error = '';
+  notice = '';
+  activeTab: KnowledgeTab = 'own';
+  query = '';
+  page = 1;
+  pageSize = 20;
+  tableDensity: 'default' | 'compact' = 'compact';
+  editorProduct: DomesticProduct | null = null;
+  editorTags = '';
+  editorNote = '';
+  editorOwner = '';
+  editorFeatured = false;
+
+  filters: CrudTableFilter[] = [];
+  columns: CrudTableColumn[] = [
+    { key: 'product', label: '商品', visible: true, locked: true },
+    { key: 'role', label: '类型', visible: true },
+    { key: 'category', label: '品类', visible: true },
+    { key: 'gmv', label: '成交金额', visible: true },
+    { key: 'sales', label: '成交件数', visible: true },
+    { key: 'price', label: '平均单价', visible: true },
+    { key: 'refund', label: '退款率', visible: false },
+    { key: 'relations', label: '关联数', visible: true },
+    { key: 'reviews', label: '评价 / 评分', visible: true },
+    { key: 'tags', label: '知识标签', visible: true },
+    { key: 'owner', label: '负责人', visible: false },
+    { key: 'completeness', label: '资料状态', visible: true },
+    { key: 'updated', label: '维护时间', visible: true },
+    { key: 'actions', label: '操作', visible: true, locked: true },
   ];
 
-  // Math for template
-  Math = Math;
-
   constructor(
-    private knowledgeCenterService: KnowledgeCenterDataService,
-    private router: Router
+    private readonly datasetService: DomesticDatasetService,
+    private readonly knowledgeService: ProductKnowledgeService,
   ) {}
 
   ngOnInit(): void {
-    this.touchConfigUpdateTime();
-    this.loadStats();
-    this.loadKnowledgeBaseData();
-    this.refreshDataSourceStatus();
+    this.rebuildFilters();
+    this.load();
   }
 
-  private loadStats(): void {
-    this.knowledgeCenterService.getKnowledgeBaseStatsFromDB().subscribe({
-      next: (r) => {
-        this.stats = {
-          competitorCount: r.competitorCount,
-          competitorWeeklyGrowth: r.competitorWeekly,
-          influencerCount: r.influencerCount,
-          influencerWeeklyGrowth: r.influencerWeekly,
-          keywordCount: r.keywordCount,
-          keywordWeeklyGrowth: r.keywordWeekly,
-          patternCount: r.patternCount,
-          patternWeeklyGrowth: r.patternWeekly,
-          hotProductCount: r.hotProductCount,
-          hotProductWeeklyGrowth: r.hotProductWeekly,
-          totalRecords: r.competitorCount + r.influencerCount + r.keywordCount + r.patternCount + r.hotProductCount,
-          lastUpdate: new Date().toLocaleString('zh-CN', {
-            year: 'numeric', month: '2-digit', day: '2-digit',
-            hour: '2-digit', minute: '2-digit'
-          })
-        };
-      },
-      error: () => {
-        this.stats = {
-          competitorCount: 0, competitorWeeklyGrowth: 0,
-          influencerCount: 0, influencerWeeklyGrowth: 0,
-          keywordCount: 0, keywordWeeklyGrowth: 0,
-          patternCount: 0, patternWeeklyGrowth: 0,
-          hotProductCount: 0, hotProductWeeklyGrowth: 0,
-          totalRecords: 0, lastUpdate: '-'
-        };
-      }
-    });
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
   }
 
-  private loadKnowledgeBaseData(force = false): void {
-    const type = this.selectedKnowledgeBase;
-    // 已加载过的类型直接复用已有数据(仅重算分页),切换卡片时不再重新请求
-    if (!force && this.loadedTypes.has(type)) {
-      this.recalcTotalPages();
-      return;
-    }
-    this.loadedTypes.add(type);
-    switch (this.selectedKnowledgeBase) {
-      case 'competitor':
-        // 进入页面优先查数据库(不自动调用 Sorftime 接口)
-        this.competitorData = [];
-        this.totalPages = 1;
-        this.competitorLastUpdated = null;
-
-        this.knowledgeCenterService.loadCompetitorFromDB().subscribe(dbData => {
-          if (dbData?.length) {
-            this.setCompetitorData(dbData);
-            return;
-          }
-          // 数据库为空时回退 mock(不触发接口)
-          this.setCompetitorData(this.knowledgeCenterService.getCompetitorKnowledge(this.filters));
-        });
-        break;
-      case 'influencer':
-        // 进入页面优先查数据库(不自动调用接口)
-        this.influencerData = [];
-        this.totalPages = 1;
-        this.influencerLastUpdated = null;
-
-        this.knowledgeCenterService.loadInfluencerFromDB().subscribe(dbData => {
-          if (dbData?.length) {
-            this.setInfluencerData(dbData);
-            return;
-          }
-          // 数据库为空时回退 mock(不触发接口)
-          this.setInfluencerData(this.knowledgeCenterService.getInfluencerKnowledge(this.filters));
-        });
-        break;
-      case 'keyword':
-        // 先显示 mock 作为占位
-        this.keywordData = this.knowledgeCenterService.getKeywordKnowledge(this.filters);
-        this.totalPages = Math.ceil(this.keywordData.length / this.pageSize);
-        // 优先从 KeywordSummary 表加载真实数据(关键词调研模块维护的主数据源)
-        this.knowledgeCenterService.loadKeywordFromKeywordSummary().subscribe(realData => {
-          if (realData?.length) {
-            this.keywordData = realData;
-            this.totalPages = Math.ceil(realData.length / this.pageSize);
-            return;
-          }
-          // KeywordSummary 为空时,回退到 KnowledgeKeyword 表
-          this.knowledgeCenterService.loadKeywordFromDB().subscribe(dbData => {
-            if (dbData?.length) {
-              this.keywordData = dbData;
-              this.totalPages = Math.ceil(dbData.length / this.pageSize);
-              return;
-            }
-            // 都为空时尝试 Sorftime API
-            this.knowledgeCenterService.fetchKeywordData().subscribe(apiData => {
-              if (apiData?.length) {
-                this.keywordData = apiData;
-                this.totalPages = Math.ceil(apiData.length / this.pageSize);
-              }
-            });
-          });
-        });
-        break;
-      case 'pattern':
-        this.patternData = this.knowledgeCenterService.getPatternKnowledge(this.filters);
-        this.totalPages = Math.ceil(this.patternData.length / this.pageSize);
-        // 保存 mock 数据到数据库
-        this.knowledgeCenterService.savePatternData(this.patternData);
-        // 从数据库加载
-        this.knowledgeCenterService.loadPatternFromDB().subscribe(dbData => {
-          if (dbData?.length) {
-            this.patternData = dbData;
-            this.totalPages = Math.ceil(dbData.length / this.pageSize);
-          }
-        });
-        break;
-      case 'hotproduct':
-        // 先显示 mock 占位
-        this.hotProductData = this.knowledgeCenterService.getHotProductKnowledge(this.filters);
-        this.totalPages = Math.ceil(this.hotProductData.length / this.pageSize);
-        // 优先从 AmazonProduct 表加载真实数据(竞品监控模块维护的主数据源)
-        this.knowledgeCenterService.loadHotProductFromAmazonProduct().subscribe(realData => {
-          if (realData?.length) {
-            this.hotProductData = realData;
-            this.totalPages = Math.ceil(realData.length / this.pageSize);
-            return;
-          }
-          // AmazonProduct 为空时,回退到 KnowledgeHotProduct 表
-          this.knowledgeCenterService.loadHotProductFromDB().subscribe(dbData => {
-            if (dbData?.length) {
-              this.hotProductData = dbData;
-              this.totalPages = Math.ceil(dbData.length / this.pageSize);
-            }
-          });
-        });
-        break;
-    }
+  get ownCount(): number {
+    return this.dataset?.products.filter((product) => product.role === 'own').length ?? 0;
   }
 
-  selectKnowledgeBase(type: KnowledgeBaseType): void {
-    this.selectedKnowledgeBase = type;
-    this.currentPage = 1;
-    this.filters = {};
-    this.competitorCategoryFilters = { timeRange: '7days' };
-    this.searchQuery = '';
-    this.showFilter = false;
-    this.loadKnowledgeBaseData();
+  get competitorCount(): number {
+    return this.dataset?.products.filter((product) => product.role === 'competitor').length ?? 0;
   }
 
-  getKnowledgeBaseTitle(): string {
-    const titles: { [key in KnowledgeBaseType]: string } = {
-      competitor: '竞品库数据',
-      influencer: '红人库数据',
-      keyword: '关键词库数据',
-      pattern: '版型库数据',
-      hotproduct: '爆款库数据(BSR400)'
-    };
-    return titles[this.selectedKnowledgeBase];
+  get featuredCount(): number {
+    return this.knowledgeItems.filter((item) => item.status === 'active' && item.featured).length;
   }
 
-  canFetchByAddButton(): boolean {
-    if (this.selectedKnowledgeBase === 'competitor') return !this.isFetchingCompetitor;
-    if (this.selectedKnowledgeBase === 'influencer') return !this.isFetchingInfluencer;
-    if (this.selectedKnowledgeBase === 'pattern') return true;
-    return false;
+  get reviewCount(): number {
+    return this.dataset?.reviews?.length ?? 0;
   }
 
-  onAddData(): void {
-    if (!this.canFetchByAddButton()) return;
-
-    if (this.selectedKnowledgeBase === 'pattern') {
-      this.editingPattern = null;
-      this.showPatternEditDialog = true;
-      return;
+  get tabProducts(): DomesticProduct[] {
+    const products = this.dataset?.products ?? [];
+    if (this.activeTab === 'featured') {
+      return products.filter((product) => {
+        const item = this.knowledgeByKey.get(product.productKey);
+        return item?.featured && item.status === 'active';
+      });
     }
-
-    // 显示请求次数选择对话框
-    if (this.selectedKnowledgeBase === 'competitor' || this.selectedKnowledgeBase === 'influencer') {
-      this.pendingFetchType = this.selectedKnowledgeBase;
-      this.showFetchCountDialog = true;
+    return products.filter((product) => product.role === this.activeTab);
+  }
+
+  get metricCards(): KnowledgeMetric[] {
+    const products = this.tabProducts;
+    const maintained = products.filter((product) => this.knowledgeByKey.has(product.productKey)).length;
+    const categories = new Set(products.map((product) => this.category(product)).filter(Boolean)).size;
+    if (this.activeTab === 'featured') {
+      const items = products.map((product) => this.knowledgeByKey.get(product.productKey)).filter(Boolean) as ProductKnowledgeItem[];
+      return [
+        { label: '重点商品', value: products.length, unit: '款', description: '纳入持续经营跟踪', tone: 'amber' },
+        { label: '本店商品', value: products.filter((product) => product.role === 'own').length, unit: '款', description: '本店重点经营商品', tone: 'blue' },
+        { label: '竞品档案', value: products.filter((product) => product.role === 'competitor').length, unit: '款', description: '重点关注竞品', tone: 'purple' },
+        { label: '知识标签', value: new Set(items.flatMap((item) => item.tags)).size, unit: '个', description: '已沉淀业务标签', tone: 'green' },
+        { label: '负责人已填', value: items.filter((item) => item.ownerUserId.trim()).length, unit: '款', description: '已明确维护责任人', tone: 'default' },
+        { label: '备注已填', value: items.filter((item) => item.note.trim()).length, unit: '款', description: '已记录经营关注事项', tone: 'default' },
+      ];
     }
-  }
-
-  onConfirmFetch(result: FetchDialogResult): void {
-    this.showFetchCountDialog = false;
-
-    if (this.pendingFetchType === 'competitor') {
-      this.isFetchingCompetitor = true;
-
-      if (result.mode === 'asin' && result.asins?.length) {
-        // 按 ASIN 精确查询
-        this.knowledgeCenterService.fetchCompetitorByAsins(result.asins).subscribe({
-          next: (data) => {
-            if (data?.length) {
-              // 合并到现有数据(去重)
-              const existingAsins = new Set(this.competitorData.map(d => d.asin));
-              const newItems = data.filter(d => !existingAsins.has(d.asin));
-              if (newItems.length) {
-                this.setCompetitorData([...newItems, ...this.competitorData]);
-              } else {
-                this.setCompetitorData(data);
-              }
-            }
-          },
-          error: () => {},
-          complete: () => { this.isFetchingCompetitor = false; }
-        });
-      } else {
-        // 按关键词搜索
-        const keyword = result.keyword || 'women fashion';
-        this.knowledgeCenterService.fetchCompetitorData(keyword, result.pageCount || 1).subscribe({
-          next: (data) => {
-            if (data?.length) {
-              // 合并到现有数据(去重)
-              const existingAsins = new Set(this.competitorData.map(d => d.asin));
-              const newItems = data.filter(d => !existingAsins.has(d.asin));
-              if (newItems.length) {
-                this.setCompetitorData([...newItems, ...this.competitorData]);
-              } else if (!this.competitorData.length) {
-                this.setCompetitorData(data);
-              }
-            }
-          },
-          error: () => {},
-          complete: () => { this.isFetchingCompetitor = false; }
-        });
-      }
-    } else if (this.pendingFetchType === 'influencer') {
-      this.isFetchingInfluencer = true;
-      const keyword = result.keyword || result.asins?.[0] || 'fashion';
-      this.knowledgeCenterService.fetchInfluencerData(keyword, result.pageCount || 1).subscribe({
-        next: (data) => {
-          if (data?.length) this.setInfluencerData(data);
-        },
-        error: () => {},
-        complete: () => { this.isFetchingInfluencer = false; }
-      });
+    if (this.activeTab === 'competitor') {
+      const brands = new Set(products.map((product) => product.brand).filter(Boolean)).size;
+      const ownProducts = new Set((this.dataset?.relations ?? []).map((relation) => relation.ownProductId)).size;
+      return [
+        { label: '竞品档案', value: products.length, unit: '款', description: '来自竞品关系表', tone: 'purple' },
+        { label: '关联本店商品', value: ownProducts, unit: '款', description: '已建立竞品映射', tone: 'blue' },
+        { label: '竞品品牌', value: brands, unit: '个', description: '已识别品牌范围', tone: 'amber' },
+        { label: '覆盖品类', value: categories, unit: '类', description: '竞品关系覆盖品类', tone: 'green' },
+        { label: '评价证据', value: this.reviewCount, unit: '条', description: this.reviewCount ? '可用于竞品 VOC 分析' : '待数据源补充采集', tone: this.reviewCount ? 'green' : 'amber' },
+        { label: '市场快照', value: products.filter((product) => !!product.market).length, unit: '款', description: '含价格、店铺和销量信号', tone: 'default' },
+      ];
     }
-
-    this.pendingFetchType = null;
-  }
-
-  // ============ 竞品「补齐数据」 ============
-  onOpenBackfill(): void {
-    this.backfillPreview = null;
-    this.backfillRunResult = null;
-    this.backfillRunning = false;
-    this.backfillBrandOptions = this.collectBackfillBrands();
-    this.backfillCategoryOptions = this.collectBackfillCategories();
-    this.showBackfillDialog = true;
-  }
-
-  onCloseBackfill(): void {
-    if (this.backfillRunning) return;
-    this.showBackfillDialog = false;
+    const totalGmv = products.reduce((sum, product) => sum + product.summary.gmv, 0);
+    const totalSales = products.reduce((sum, product) => sum + product.summary.soldUnits, 0);
+    const relatedCompetitors = new Set((this.dataset?.relations ?? []).map((relation) => relation.competitorProductId)).size;
+    return [
+      { label: '商品主数据', value: products.length, unit: '款', description: '本店经营商品档案', tone: 'blue' },
+      { label: '成交金额', value: this.formatCompactCurrency(totalGmv), unit: '', description: this.dateRangeLabel, tone: 'green' },
+      { label: '成交件数', value: this.formatCompactNumber(totalSales), unit: '件', description: '所选周期累计销量', tone: 'amber' },
+      { label: '覆盖品类', value: categories, unit: '类', description: '三级/二级有效品类', tone: 'purple' },
+      { label: '已关联竞品', value: relatedCompetitors, unit: '款', description: `${this.dataset?.relations.length ?? 0} 条竞品关系`, tone: 'default' },
+      { label: '已维护档案', value: maintained, unit: '款', description: `${this.knowledgeCoveragePercent}% 商品已维护`, tone: 'default' },
+    ];
   }
 
-  onPreviewBackfill(scope: BackfillScope): void {
-    this.backfillRunning = true;
-    this.backfillRunResult = null;
-    this.knowledgeCenterService.backfillCompetitor(scope, true).subscribe({
-      next: (res) => {
-        const r = res?.result;
-        this.backfillPreview = r ? {
-          scopeTotal: r.scopeTotal ?? 0,
-          incomplete: r.incomplete ?? 0,
-          willProcess: r.willProcess ?? 0
-        } : null;
-      },
-      error: () => { this.backfillRunning = false; },
-      complete: () => { this.backfillRunning = false; }
-    });
+  get activeKnowledgeCount(): number {
+    return this.knowledgeItems.filter((item) => item.status === 'active').length;
   }
 
-  onConfirmBackfill(scope: BackfillScope): void {
-    this.backfillRunning = true;
-    this.knowledgeCenterService.backfillCompetitor(scope, false).subscribe({
-      next: (res) => {
-        const r = res?.result;
-        this.backfillRunResult = r ? {
-          requested: r.requested ?? 0,
-          succeeded: r.succeeded ?? 0,
-          failed: r.failed ?? 0,
-          filled: r.filled ?? 0,
-          noData: r.noData ?? 0
-        } : null;
-        // 补齐后重新从数据库加载竞品数据以反映更新
-        if (r && (r.filled ?? 0) > 0) {
-          this.knowledgeCenterService.loadCompetitorFromDB().subscribe(dbData => {
-            if (dbData?.length) this.setCompetitorData(dbData);
-          });
-        }
-      },
-      error: () => { this.backfillRunning = false; },
-      complete: () => { this.backfillRunning = false; }
-    });
+  get knowledgeCoveragePercent(): number {
+    return this.ownCount
+      ? Math.round((this.knowledgeItems.filter((item) => item.status === 'active' && item.productRole === 'own').length / this.ownCount) * 1_000) / 10
+      : 0;
   }
 
-  private collectBackfillBrands(): string[] {
-    const set = new Set<string>();
-    for (const d of this.competitorData) {
-      const b = (d.brandName || '').trim();
-      if (b) set.add(b);
-    }
-    return Array.from(set).sort();
+  get dateRangeLabel(): string {
+    const range = this.dataset?.source.dateRange;
+    if (!range?.start || !range.end) return '暂无周期信息';
+    return `${range.start.replaceAll('-', '.')} - ${range.end.replaceAll('-', '.')}`;
   }
 
-  private collectBackfillCategories(): string[] {
-    const set = new Set<string>();
-    for (const d of this.competitorData) {
-      const c = (d.category || '').trim();
-      if (c) set.add(c);
-    }
-    return Array.from(set).sort();
+  get platformLabel(): string {
+    return this.dataset?.platform === 'jd' ? '京东' : (this.dataset?.platform || '-');
   }
 
-  onSearch(): void {
-    // 搜索视为主动刷新,强制重新加载当前类型数据
-    this.loadKnowledgeBaseData(true);
+  get tableSubtitle(): string {
+    if (this.activeTab === 'competitor') return '竞品关系来自导入表格;评价、价格与趋势仅在采集成功后展示';
+    if (this.activeTab === 'featured') return '集中管理需持续跟踪的本店商品与竞品知识';
+    return '按成交金额排序,统一维护商品标签、责任人与经营备注';
   }
 
-  goVideoSearch(): void {
-    this.router.navigate(['/knowledge-center/video-search']);
+  get selectedCount(): number {
+    return this.selectedKeys.size;
   }
 
-  toggleFilter(): void {
-    this.showFilter = !this.showFilter;
+  get bulkActions(): CrudTableBulkAction[] {
+    return this.activeTab === 'featured'
+      ? [{ key: 'remove-featured', label: '移出重点商品库', tone: 'danger' }]
+      : [{ key: 'add-featured', label: '加入重点商品库' }];
   }
 
-  applyFilters(): void {
-    this.currentPage = 1;
-    this.recalcTotalPages();
+  get allPageSelected(): boolean {
+    return this.pagedProducts.length > 0 && this.pagedProducts.every((product) => this.selectedKeys.has(product.productKey));
   }
 
-  resetFilters(): void {
-    this.filters = {};
-    this.competitorCategoryFilters = { timeRange: '7days' };
-    this.competitorCompleteness = 'all';
-    this.currentPage = 1;
-    this.recalcTotalPages();
+  get filteredProducts(): DomesticProduct[] {
+    const dataset = this.dataset;
+    if (!dataset) return [];
+    const category = this.filterValue('category').toLowerCase();
+    const maintenance = this.filterValue('maintenance');
+    const role = this.filterValue('role');
+    const tag = this.filterValue('tag').toLowerCase();
+    const query = this.query.trim().toLowerCase();
+    return dataset.products
+      .filter((product) => {
+        const item = this.knowledgeByKey.get(product.productKey);
+        if (this.activeTab === 'own' && product.role !== 'own') return false;
+        if (this.activeTab === 'competitor' && product.role !== 'competitor') return false;
+        if (this.activeTab === 'featured' && (!item?.featured || item.status !== 'active')) return false;
+        if (role && product.role !== role) return false;
+        const productCategory = this.category(product).toLowerCase();
+        if (category && productCategory !== category) return false;
+        if (maintenance === 'maintained' && !item) return false;
+        if (maintenance === 'unmaintained' && item) return false;
+        if (tag && !item?.tags.some((value) => value.toLowerCase().includes(tag))) return false;
+        if (!query) return true;
+        return [
+          product.productId, product.model, product.title, product.brand, productCategory,
+          item?.tags.join(' '), item?.note, item?.ownerUserId,
+        ].filter(Boolean).join(' ').toLowerCase().includes(query);
+      })
+      .sort((left, right) => {
+        if (this.activeTab === 'featured') {
+          return (this.knowledgeByKey.get(right.productKey)?.updatedAt ?? '')
+            .localeCompare(this.knowledgeByKey.get(left.productKey)?.updatedAt ?? '');
+        }
+        if (this.activeTab === 'competitor') {
+          return (this.reviewCounts.get(right.productId) ?? 0) - (this.reviewCounts.get(left.productId) ?? 0);
+        }
+        return right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits;
+      });
   }
 
-  onCompletenessChange(value: string): void {
-    const allowed: ReadonlyArray<string> = ['all', 'hasSales', 'hasPrice', 'incomplete'];
-    this.competitorCompleteness = (allowed.includes(value) ? value : 'all') as 'all' | 'hasSales' | 'hasPrice' | 'incomplete';
-    this.applyFilters();
+  get pagedProducts(): DomesticProduct[] {
+    const start = (this.page - 1) * this.pageSize;
+    return this.filteredProducts.slice(start, start + this.pageSize);
   }
 
-  onCompetitorCategoryFilterChange(filters: FilterOptions): void {
-    const categoryNodeIds = Array.from(new Set((filters.categoryNodeIds || []).filter(Boolean)));
-    this.competitorCategoryFilters = { ...filters, categoryNodeIds };
-    this.filters = {
-      ...this.filters,
-      category: undefined,
-      categoryNodeIds: categoryNodeIds.length ? categoryNodeIds : undefined
-    };
-    this.applyFilters();
+  get title(): string {
+    if (this.activeTab === 'featured') return '重点商品库';
+    if (this.activeTab === 'competitor') return '竞品资料库';
+    return '本店商品库';
   }
 
-  private recalcTotalPages(): void {
-    let len = 0;
-    switch (this.selectedKnowledgeBase) {
-      case 'competitor':
-        len = this.filterCompetitorData(this.competitorData).length;
-        break;
-      case 'influencer':
-        len = this.influencerData.length;
-        break;
-      case 'keyword':
-        len = this.keywordData.length;
-        break;
-      case 'pattern':
-        len = this.patternData.length;
-        break;
-      case 'hotproduct':
-        len = this.hotProductData.length;
-        break;
-    }
-    this.totalPages = Math.max(1, Math.ceil(len / this.pageSize));
+  load(): void {
+    this.loading = true;
+    this.error = '';
+    forkJoin({
+      dataset: this.datasetService.dataset$.pipe(take(1)),
+      knowledge: this.knowledgeService.listAll('active'),
+    }).pipe(takeUntil(this.destroy$)).subscribe({
+      next: ({ dataset, knowledge }) => {
+        this.dataset = dataset;
+        this.knowledgeItems = knowledge;
+        this.rebuildIndexes();
+        this.rebuildFilters();
+        this.loading = false;
+      },
+      error: () => {
+        this.error = '知识库数据加载失败,请检查本地后端连接。';
+        this.loading = false;
+      },
+    });
   }
 
-  navigateToKeywordResearch(keyword: string): void {
-    this.router.navigate(['/market-insight/keyword'], { queryParams: { keyword } });
+  selectTab(tab: KnowledgeTab): void {
+    this.activeTab = tab;
+    this.page = 1;
+    this.query = '';
+    this.clearSelection();
+    this.rebuildFilters();
   }
 
-  navigateToKeywordAiAnalysis(keyword: string): void {
-    this.router.navigate(['/market-insight/keyword'], { queryParams: { keyword, tab: 'ai' } });
+  onFiltersChange(filters: CrudTableFilter[]): void {
+    this.filters = filters;
+    this.page = 1;
   }
 
-  viewHotProduct(item: HotProductKnowledge, event: Event): void {
-    event.stopPropagation();
-    if (item.asin) {
-      this.router.navigate(['/knowledge-center/competitor', item.asin]);
-    }
+  resetFilters(): void {
+    this.query = '';
+    this.page = 1;
+    this.clearSelection();
+    this.rebuildFilters();
   }
 
-  openPatternEdit(pattern: PatternKnowledge): void {
-    this.editingPattern = pattern;
-    this.showPatternEditDialog = true;
+  onPageChange(page: number): void {
+    this.page = page;
   }
 
-  onPatternSave(updated: PatternKnowledge): void {
-    // 持久化到数据库
-    this.knowledgeCenterService.saveOnePattern(updated).subscribe(() => {
-      // 更新本地列表
-      const idx = this.patternData.findIndex(p => p.id === updated.id);
-      if (idx >= 0) {
-        this.patternData[idx] = updated;
-      } else {
-        this.patternData.unshift(updated);
-      }
-      this.showPatternEditDialog = false;
-      this.editingPattern = null;
-    });
+  onPageSizeChange(pageSize: number): void {
+    this.pageSize = pageSize;
+    this.page = 1;
   }
 
-  setPageSize(size: number): void {
-    if (!size || size <= 0) return;
-    this.pageSize = size;
-    this.currentPage = 1;
-    this.recalcTotalPages();
+  openEditor(product: DomesticProduct): void {
+    const item = this.knowledgeByKey.get(product.productKey);
+    this.editorProduct = product;
+    this.editorTags = item?.tags.join(',') ?? '';
+    this.editorNote = item?.note ?? '';
+    this.editorOwner = item?.ownerUserId ?? '';
+    this.editorFeatured = item?.featured ?? false;
+    this.error = '';
   }
 
-  previousPage(): void {
-    if (this.currentPage > 1) {
-      this.currentPage--;
-    }
+  closeEditor(): void {
+    if (this.saving) return;
+    this.editorProduct = null;
   }
 
-  nextPage(): void {
-    if (this.currentPage < this.totalPages) {
-      this.currentPage++;
-    }
+  saveEditor(): void {
+    const product = this.editorProduct;
+    if (!product) return;
+    this.save(product, {
+      featured: this.editorFeatured,
+      tags: this.parseTags(this.editorTags),
+      note: this.editorNote,
+      ownerUserId: this.editorOwner,
+    }, true);
   }
 
-  viewInfluencer(item: InfluencerKnowledge, event: Event): void {
-    event.stopPropagation();
-    // Extract uniqueId from name (remove @ prefix)
-    const uniqueId = item.name.replace(/^@/, '');
-    const platform = item.platform || 'tiktok';
-    this.router.navigate(['/knowledge-center/influencer', platform, uniqueId]);
+  toggleFeatured(product: DomesticProduct, event?: Event): void {
+    event?.stopPropagation();
+    const current = this.knowledgeByKey.get(product.productKey);
+    this.save(product, {
+      featured: !current?.featured,
+      tags: current?.tags ?? [],
+      note: current?.note ?? '',
+      ownerUserId: current?.ownerUserId ?? '',
+    });
   }
 
-  viewCompetitor(item: CompetitorKnowledge, event: Event): void {
-    event.stopPropagation();
-    if (!item.asin) return;
-    this.router.navigate(['/knowledge-center/competitor', item.asin]);
+  toggleSelection(product: DomesticProduct, checked: boolean): void {
+    const next = new Set(this.selectedKeys);
+    if (checked) next.add(product.productKey);
+    else next.delete(product.productKey);
+    this.selectedKeys = next;
   }
 
-  formatNumber(num: number): string {
-    if (num >= 10000) {
-      return (num / 10000).toFixed(1) + 'w';
+  togglePageSelection(checked: boolean): void {
+    const next = new Set(this.selectedKeys);
+    for (const product of this.pagedProducts) {
+      if (checked) next.add(product.productKey);
+      else next.delete(product.productKey);
     }
-    return num.toLocaleString();
+    this.selectedKeys = next;
+  }
+
+  clearSelection(): void {
+    this.selectedKeys = new Set<string>();
+  }
+
+  handleBulkAction(action: string): void {
+    const featured = action === 'add-featured';
+    const products = (this.dataset?.products ?? []).filter((product) => this.selectedKeys.has(product.productKey));
+    if (!products.length) return;
+    this.saving = true;
+    this.error = '';
+    const requests = products.map((product) => {
+      const current = this.knowledgeByKey.get(product.productKey);
+      return this.knowledgeService.upsert({
+        productKey: product.productKey,
+        productId: product.productId,
+        productRole: product.role,
+        featured,
+        status: 'active',
+        tags: current?.tags ?? [],
+        note: current?.note ?? '',
+        ownerUserId: current?.ownerUserId ?? '',
+      });
+    });
+    forkJoin(requests).pipe(takeUntil(this.destroy$)).subscribe({
+      next: (items) => {
+        for (const item of items) this.upsertLocal(item);
+        this.notice = featured ? `已将 ${items.length} 款商品加入重点商品库` : `已将 ${items.length} 款商品移出重点商品库`;
+        this.saving = false;
+        this.clearSelection();
+      },
+      error: () => {
+        this.error = '批量维护失败,请检查本地后端连接后重试。';
+        this.saving = false;
+      },
+    });
   }
 
-  formatDateTime(d: Date): string {
-    try {
-      return d.toLocaleString('zh-CN', {
-        year: 'numeric',
-        month: '2-digit',
-        day: '2-digit',
-        hour: '2-digit',
-        minute: '2-digit'
-      });
-    } catch {
-      return String(d);
-    }
+  exportProducts(): void {
+    const headers = ['商品ID', '型号', '商品名称', '品牌', '品类', '类型', '成交金额', '成交件数', '平均单价', '退款率', '关联数', '评价数', '知识标签', '负责人', '资料状态'];
+    const rows = this.filteredProducts.map((product) => {
+      const item = this.item(product);
+      return [
+        product.productId, product.model, product.title, product.brand, this.category(product),
+        product.role === 'own' ? '本店商品' : '竞品', product.summary.gmv, product.summary.soldUnits,
+        product.summary.averageUnitPrice, product.summary.refundToGmvRate, this.relationCount(product),
+        this.reviewCounts.get(product.productId) ?? 0, item?.tags.join('|') ?? '', item?.ownerUserId ?? '',
+        this.completenessLabel(product),
+      ];
+    });
+    const csv = [headers, ...rows].map((row) => row.map((value) => `"${String(value ?? '').replaceAll('"', '""')}"`).join(',')).join('\r\n');
+    const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }));
+    const anchor = document.createElement('a');
+    anchor.href = url;
+    anchor.download = `${this.title}-${new Date().toISOString().slice(0, 10)}.csv`;
+    anchor.click();
+    URL.revokeObjectURL(url);
+    this.notice = `已导出 ${rows.length} 条当前筛选结果`;
+  }
+
+  archiveEditor(): void {
+    const product = this.editorProduct;
+    if (!product || !this.knowledgeByKey.has(product.productKey)) return;
+    this.saving = true;
+    this.knowledgeService.archive(product.productKey).pipe(takeUntil(this.destroy$)).subscribe({
+      next: (item) => {
+        this.upsertLocal(item);
+        this.notice = '知识条目已归档';
+        this.saving = false;
+        this.editorProduct = null;
+      },
+      error: () => {
+        this.error = '归档失败,请稍后重试。';
+        this.saving = false;
+      },
+    });
   }
 
-  private setCompetitorData(data: CompetitorKnowledge[]): void {
-    this.competitorData = data || [];
-    this.totalPages = Math.max(1, Math.ceil(this.competitorData.length / this.pageSize));
-    this.competitorLastUpdated = this.pickLatestUpdate(this.competitorData);
+  item(product: DomesticProduct): ProductKnowledgeItem | undefined {
+    return this.knowledgeByKey.get(product.productKey);
   }
 
-  private pickLatestUpdate(rows: CompetitorKnowledge[]): Date | null {
-    if (!rows?.length) return null;
-    let latest = rows[0]?.lastUpdate ? new Date(rows[0].lastUpdate) : null;
-    for (const r of rows) {
-      const t = r?.lastUpdate ? new Date(r.lastUpdate) : null;
-      if (!t || isNaN(t.getTime())) continue;
-      if (!latest || t.getTime() > latest.getTime()) latest = t;
-    }
-    return latest;
+  isColumnVisible(key: string): boolean {
+    return this.columns.find((column) => column.key === key)?.visible ?? false;
   }
 
-  private setInfluencerData(data: InfluencerKnowledge[]): void {
-    this.influencerData = data || [];
-    this.totalPages = Math.max(1, Math.ceil(this.influencerData.length / this.pageSize));
-    this.influencerLastUpdated = this.pickLatestUpdateFromInfluencer(this.influencerData);
+  isSelected(product: DomesticProduct): boolean {
+    return this.selectedKeys.has(product.productKey);
   }
 
-  // ---- Pagination + Filtering getters ----
-  get displayedCompetitorData(): CompetitorKnowledge[] {
-    const filtered = this.filterCompetitorData(this.competitorData);
-    return this.paginate(filtered);
+  relationCount(product: DomesticProduct): number {
+    return this.relationCounts.get(product.productKey) ?? product.relationCount ?? 0;
   }
 
-  get displayedInfluencerData(): InfluencerKnowledge[] {
-    let data = this.influencerData;
-    if (this.filters.platform) {
-      data = data.filter(d => d.platform === this.filters.platform);
-    }
-    if (this.filters.minFollowers) {
-      data = data.filter(d => d.followers >= (this.filters.minFollowers || 0));
-    }
-    return this.paginate(data);
+  averageRating(product: DomesticProduct): string {
+    const rating = this.ratingByProduct.get(product.productId);
+    return rating ? rating.toFixed(1) : '-';
   }
 
-  get displayedKeywordData(): KeywordKnowledge[] {
-    let data = this.keywordData;
-    if (this.filters.searchText) {
-      const q = this.filters.searchText.toLowerCase();
-      data = data.filter(d => d.keyword.toLowerCase().includes(q));
-    }
-    if (this.filters.minSearchVolume) {
-      data = data.filter(d => d.searchVolume >= (this.filters.minSearchVolume || 0));
-    }
-    return this.paginate(data);
+  completenessLabel(product: DomesticProduct): string {
+    const reviews = this.reviewCounts.get(product.productId) ?? 0;
+    if (product.role === 'competitor') return reviews > 0 ? '证据已采集' : '关系已建 · 待采集';
+    if (this.knowledgeByKey.has(product.productKey)) return '知识已维护';
+    if (product.summary.gmv > 0 || product.summary.soldUnits > 0) return '经营数据完整';
+    return '基础档案';
   }
 
-  get displayedPatternData(): PatternKnowledge[] {
-    return this.paginate(this.patternData);
+  completenessTone(product: DomesticProduct): string {
+    const label = this.completenessLabel(product);
+    if (label === '证据已采集' || label === '知识已维护') return 'complete';
+    if (label.includes('待采集')) return 'pending';
+    return 'ready';
   }
 
-  get displayedHotProductData(): HotProductKnowledge[] {
-    return this.paginate(this.hotProductData);
+  category(product: DomesticProduct): string {
+    return product.category3 || product.category2 || product.category1 || '未分类';
   }
 
-  private filterCompetitorData(data: CompetitorKnowledge[]): CompetitorKnowledge[] {
-    let result = data;
-    const q = (this.filters.searchText || '').trim().toLowerCase();
-    if (q) {
-      result = result.filter(d =>
-        (d.asin || '').toLowerCase().includes(q) ||
-        (d.brandName || '').toLowerCase().includes(q) ||
-        (d.name || '').toLowerCase().includes(q)
-      );
-    }
-    const cat = (this.filters.category || '').trim().toLowerCase();
-    if (cat) {
-      result = result.filter(d => [d.category, ...(d.categoryCandidates || [])]
-        .some(category => String(category || '').toLowerCase().includes(cat)));
-    }
-    const categoryNodeIds = (this.filters.categoryNodeIds || []).filter(Boolean);
-    if (categoryNodeIds.length) {
-      const selectedIds = new Set(categoryNodeIds);
-      result = result.filter(d => (d.categoryNodeIds || []).some(id => selectedIds.has(id)));
-    }
-    if (this.filters.minRating) {
-      const minR = parseFloat(this.filters.minRating);
-      if (!isNaN(minR)) result = result.filter(d => d.rating >= minR);
-    }
-    if (this.filters.minPrice != null && this.filters.minPrice !== undefined) {
-      result = result.filter(d => d.price >= (this.filters.minPrice || 0));
-    }
-    if (this.filters.maxPrice != null && this.filters.maxPrice !== undefined) {
-      result = result.filter(d => d.price <= (this.filters.maxPrice || Infinity));
-    }
-    // #18 数据完整度筛选(未补齐项不与真实值混淆)
-    switch (this.competitorCompleteness) {
-      case 'hasSales': result = result.filter(d => d.hasMonthlySales !== false); break;
-      case 'hasPrice': result = result.filter(d => d.hasPrice !== false); break;
-      case 'incomplete': result = result.filter(d => d.hasMonthlySales === false || d.hasPrice === false); break;
-    }
-    return result;
+  productImage(product: DomesticProduct): string {
+    return product.detail?.imageUrl || 'assets/images/placeholder.svg';
   }
 
-  private paginate<T>(data: T[]): T[] {
-    const start = (this.currentPage - 1) * this.pageSize;
-    return data.slice(start, start + this.pageSize);
+  productRoute(product: DomesticProduct): any[] {
+    return product.role === 'competitor'
+      ? ['/domestic/competitors', product.productId]
+      : ['/voc-insight/detail'];
   }
 
-  getCompetitorCategoryTitle(item: CompetitorKnowledge): string {
-    const values = [item.category, ...(item.categoryCandidates || [])]
-      .map(v => String(v || '').trim())
-      .filter(Boolean);
-    return Array.from(new Set(values)).join(' / ') || '-';
+  productQuery(product: DomesticProduct): Record<string, string> | null {
+    return product.role === 'own' ? { productId: product.productId } : null;
   }
 
-  openExportReviewsDialog(): void {
-    this.showExportReviewsDialog = true;
+  formatNumber(value: number): string {
+    return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
   }
 
-  getDataSourceIcon(status: KnowledgeDataSourceStatus): string {
-    switch (status) {
-      case 'healthy': return '✓';
-      case 'warning': return '!';
-      case 'offline': return '✕';
-      default: return '◎';
-    }
+  formatCurrency(value: number): string {
+    return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
   }
 
-  getDataSourceStatusLabel(status: KnowledgeDataSourceStatus): string {
-    switch (status) {
-      case 'healthy': return '健康';
-      case 'warning': return '警告';
-      case 'offline': return '离线';
-      default: return '-';
-    }
+  formatCompactCurrency(value: number): string {
+    if (value >= 100_000_000) return `¥${(value / 100_000_000).toFixed(2)}亿`;
+    if (value >= 10_000) return `¥${(value / 10_000).toFixed(1)}万`;
+    return this.formatCurrency(value);
   }
 
-  getDataSourceTone(status: KnowledgeDataSourceStatus): 'green' | 'amber' | 'red' | 'default' {
-    switch (status) {
-      case 'healthy': return 'green';
-      case 'warning': return 'amber';
-      case 'offline': return 'red';
-      default: return 'default';
-    }
+  formatCompactNumber(value: number): string {
+    if (value >= 10_000) return `${(value / 10_000).toFixed(1)}万`;
+    return this.formatNumber(value);
   }
 
-  getDataSourceMetrics(source: DataSourceItem): EntryCardMetric[] {
-    const metrics: EntryCardMetric[] = [
-      { label: '更新频率', value: source.updateFrequency },
-      { label: '最后检测', value: this.formatDataSourceSyncTime(source.lastSync) }
-    ];
-    if (source.latency !== undefined) {
-      metrics.push({ label: '响应耗时', value: `${source.latency}ms` });
-    }
-    return metrics;
+  formatPercent(value: number): string {
+    if (!Number.isFinite(value)) return '-';
+    const normalized = value > 1 ? value : value * 100;
+    return `${normalized.toFixed(1)}%`;
   }
 
-  formatDataSourceSyncTime(value: string): string {
-    if (!value || value === '-') return '-';
-    const parts = value.split(' ');
-    return parts.length > 1 ? parts[1] : value;
+  formatDate(value: string | undefined): string {
+    if (!value) return '-';
+    return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+      .format(new Date(value));
   }
 
-  refreshDataSourceStatus(): void {
-    if (this.isRefreshingDataSourceStatus) return;
-    this.isRefreshingDataSourceStatus = true;
-    this.knowledgeCenterService.getDataSourceConnectionStatus().subscribe({
-      next: sources => {
-        this.dataSources = sources;
-        this.isRefreshingDataSourceStatus = false;
+  private save(
+    product: DomesticProduct,
+    patch: Pick<ProductKnowledgeWrite, 'featured' | 'tags' | 'note' | 'ownerUserId'>,
+    closeOnSuccess = false,
+  ): void {
+    const current = this.knowledgeByKey.get(product.productKey);
+    this.saving = true;
+    this.error = '';
+    this.knowledgeService.upsert({
+      productKey: product.productKey,
+      productId: product.productId,
+      productRole: product.role,
+      featured: patch.featured,
+      status: 'active',
+      tags: patch.tags,
+      note: patch.note,
+      ownerUserId: patch.ownerUserId,
+    }).pipe(takeUntil(this.destroy$)).subscribe({
+      next: (item) => {
+        this.upsertLocal(item);
+        this.notice = closeOnSuccess
+          ? '知识信息已保存'
+          : item.featured ? '已加入重点商品库' : current?.featured ? '已移出重点商品库' : '知识信息已保存';
+        this.saving = false;
+        if (closeOnSuccess) this.editorProduct = null;
       },
       error: () => {
-        const checkedAt = this.getNowText();
-        this.dataSources = this.dataSources.map(item => ({
-          ...item,
-          status: 'offline',
-          lastSync: checkedAt,
-          message: '状态检测失败'
-        }));
-        this.isRefreshingDataSourceStatus = false;
-      }
-    });
-  }
-
-  addDictionary(): void {
-    const name = window.prompt('请输入词典名称(如:品牌词典)', '');
-    if (!name) return;
-    const trimmed = name.trim();
-    if (!trimmed) return;
-    const id = `dic-${Date.now()}`;
-    this.dictionaries = [
-      {
-        id,
-        name: trimmed,
-        termCount: 0,
-        lastUpdated: this.getNowText(),
-        owner: '系统管理员'
+        this.error = '保存失败,请检查后端连接后重试。';
+        this.saving = false;
       },
-      ...this.dictionaries
-    ];
-  }
-
-  get competitorDataForExport(): { asin: string; name: string }[] {
-    return this.competitorData
-      .filter(d => d.asin)
-      .map(d => ({ asin: d.asin, name: d.name || '' }));
-  }
-
-  formatPrice(val: any): string {
-    const n = Number(val || 0);
-    return n > 100 ? (n / 100).toFixed(2) : n.toFixed(2);
+    });
   }
 
-  /** 将 TikTok CDN 图片转为代理 URL,避免跨域/防盗链问题 */
-  proxyImg(url: string): string {
-    if (!url) return '';
-    if (url.includes('tiktokcdn') || url.includes('tiktokcdn-us') || url.includes('tiktokcdn.com')) {
-      return 'https://wsrv.nl/?url=' + encodeURIComponent(url);
+  private upsertLocal(item: ProductKnowledgeItem): void {
+    const index = this.knowledgeItems.findIndex((candidate) => candidate.productKey === item.productKey);
+    if (index >= 0) this.knowledgeItems = this.knowledgeItems.map((candidate, i) => i === index ? item : candidate);
+    else this.knowledgeItems = [...this.knowledgeItems, item];
+    this.knowledgeByKey = new Map(this.knowledgeItems
+      .filter((candidate) => candidate.status === 'active')
+      .map((candidate) => [candidate.productKey, candidate]));
+  }
+
+  private rebuildIndexes(): void {
+    this.knowledgeByKey = new Map(this.knowledgeItems
+      .filter((item) => item.status === 'active')
+      .map((item) => [item.productKey, item]));
+    const counts = new Map<string, number>();
+    const ratingTotals = new Map<string, number>();
+    for (const review of this.dataset?.reviews ?? []) {
+      counts.set(review.productId, (counts.get(review.productId) ?? 0) + 1);
+      ratingTotals.set(review.productId, (ratingTotals.get(review.productId) ?? 0) + review.rating);
+    }
+    this.reviewCounts = counts;
+    this.ratingByProduct = new Map([...ratingTotals].map(([productId, total]) => [productId, total / (counts.get(productId) ?? 1)]));
+    const relationCounts = new Map<string, number>();
+    for (const relation of this.dataset?.relations ?? []) {
+      relationCounts.set(relation.ownProductKey, (relationCounts.get(relation.ownProductKey) ?? 0) + 1);
+      relationCounts.set(relation.competitorProductKey, (relationCounts.get(relation.competitorProductKey) ?? 0) + 1);
     }
-    return url;
+    this.relationCounts = relationCounts;
   }
 
-  private pickLatestUpdateFromInfluencer(rows: InfluencerKnowledge[]): Date | null {
-    if (!rows?.length) return null;
-    let latest = rows[0]?.lastUpdate ? new Date(rows[0].lastUpdate) : null;
-    for (const r of rows) {
-      const t = (r as any)?.lastUpdate ? new Date((r as any).lastUpdate) : null;
-      if (!t || isNaN(t.getTime())) continue;
-      if (!latest || t.getTime() > latest.getTime()) latest = t;
-    }
-    return latest;
+  private rebuildFilters(): void {
+    const categories = [...new Set((this.dataset?.products ?? [])
+      .filter((product) => this.activeTab === 'featured' || product.role === this.activeTab)
+      .map((product) => this.category(product)))]
+      .sort((left, right) => left.localeCompare(right, 'zh-CN'));
+    this.filters = [
+      { key: 'category', label: '品类', value: '', options: categories.map((value) => ({ value, label: value })) },
+      {
+        key: 'maintenance', label: '维护状态', value: '', options: [
+          { value: 'maintained', label: '已维护' },
+          { value: 'unmaintained', label: '待维护' },
+        ],
+      },
+      { key: 'tag', label: '知识标签', value: '', type: 'text', placeholder: '输入标签' },
+      ...(this.activeTab === 'featured' ? [{
+        key: 'role', label: '商品类型', value: '', options: [
+          { value: 'own', label: '本店商品' },
+          { value: 'competitor', label: '竞品' },
+        ],
+      } as CrudTableFilter] : []),
+    ];
   }
 
-  private touchConfigUpdateTime(): void {
-    const now = this.getNowText();
-    this.dataSources = this.dataSources.map(item => ({ ...item, lastSync: now }));
-    this.dictionaries = this.dictionaries.map(item => ({ ...item, lastUpdated: now }));
+  private filterValue(key: string): string {
+    return this.filters.find((filter) => filter.key === key)?.value ?? '';
   }
 
-  private getNowText(): string {
-    return new Date().toLocaleString('zh-CN', {
-      year: 'numeric',
-      month: '2-digit',
-      day: '2-digit',
-      hour: '2-digit',
-      minute: '2-digit'
-    });
+  private parseTags(value: string): string[] {
+    return [...new Set(value.split(/[,,;;\n]/).map((tag) => tag.trim()).filter(Boolean))].slice(0, 20);
   }
 }

+ 29 - 1
src/modules/monitoring/pages/category-voc/category-voc.component.ts

@@ -4,6 +4,8 @@ import { FormsModule } from '@angular/forms';
 import { Subject, Subscription } from 'rxjs';
 import { takeUntil } from 'rxjs/operators';
 import { DomesticMonitoringViewService } from '../../../../app/core/services/domestic-monitoring-view.service';
+import { DomesticDatasetService } from '../../../../app/core/services/domestic-dataset.service';
+import { DomesticDataset } from '../../../../app/core/models/domestic.models';
 import { CategoryVocDensityItem } from '../../../shared/interfaces/monitoring-interfaces';
 import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
@@ -60,6 +62,9 @@ interface TableGroup {
         <app-summary-metric-card label="品类总数" [value]="categories.length" tone="blue" icon="📦"></app-summary-metric-card>
         <app-summary-metric-card label="产品总数" [value]="totalProducts | number" tone="blue" icon="📊"></app-summary-metric-card>
         <app-summary-metric-card label="评论总数" [value]="totalReviews | number" tone="default" icon="📝"></app-summary-metric-card>
+        @if (dataset$ | async; as dataset) {
+          <app-summary-metric-card label="竞品参照评论" [value]="competitorReviewCount(dataset) | number" tone="purple" icon="◉" [description]="competitorCategoryCount(dataset) + ' 个竞品品类可参照'"></app-summary-metric-card>
+        }
         <app-summary-metric-card label="平均情绪得分" [value]="totalReviews ? avgEmotionScore : '-'" [tone]="totalReviews ? (avgEmotionScore >= 75 ? 'green' : avgEmotionScore >= 50 ? 'amber' : 'red') : 'default'" icon="😀"></app-summary-metric-card>
         <app-summary-metric-card label="平均密度(条/产品)" [value]="totalReviews ? avgDensity : '-'" tone="default" icon="📈"></app-summary-metric-card>
       </div>
@@ -383,7 +388,7 @@ interface TableGroup {
   styles: [`
     .category-voc-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
 
-    .overview-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 20px; }
+    .overview-row { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; }
     .score-warn { color: #f59e0b !important; }
     .score-bad { color: #ef4444 !important; }
 
@@ -484,6 +489,11 @@ interface TableGroup {
       .overview-row { grid-template-columns: repeat(3, 1fr); }
       .rank-extra { display: none; }
     }
+    @media (max-width: 640px) {
+      .category-voc-container { padding: 16px 14px 72px; }
+      .overview-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+      .overview-row app-summary-metric-card:last-child { grid-column: 1 / -1; }
+    }
   `]
 })
 export class CategoryVocComponent implements OnInit, OnDestroy {
@@ -540,10 +550,28 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
 
   constructor(
     private monitoringService: DomesticMonitoringViewService,
+    private datasetService: DomesticDatasetService,
     private ngZone: NgZone,
     private cdr: ChangeDetectorRef
   ) {}
 
+  get dataset$() {
+    return this.datasetService.dataset$;
+  }
+
+  competitorReviewCount(dataset: DomesticDataset): number {
+    const competitorIds = new Set(dataset.products.filter((product) => product.role === 'competitor').map((product) => product.productId));
+    return (dataset.reviews ?? []).filter((review) => competitorIds.has(review.productId)).length;
+  }
+
+  competitorCategoryCount(dataset: DomesticDataset): number {
+    const reviewedIds = new Set((dataset.reviews ?? []).map((review) => review.productId));
+    return new Set(dataset.products
+      .filter((product) => product.role === 'competitor' && reviewedIds.has(product.productId))
+      .map((product) => product.category3 || product.category2 || product.category1)
+      .filter(Boolean)).size;
+  }
+
   ngOnInit(): void {}
 
   ngOnDestroy(): void {

+ 93 - 42
src/modules/monitoring/pages/risk-alert/risk-alert.component.ts

@@ -3,11 +3,10 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Router, RouterModule } from '@angular/router';
 import { Subject, forkJoin } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { take, takeUntil } from 'rxjs/operators';
 import { DomesticMonitoringViewService } from '../../../../app/core/services/domestic-monitoring-view.service';
 import { RiskAlertItem, ScenarioComplaintCluster, SentimentSpikeEvent } from '../../../shared/interfaces/monitoring-interfaces';
 import { MsqSearchBoxComponent } from '../../../shared/components/msq-search-box/msq-search-box.component';
-import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
@@ -22,14 +21,16 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
 @Component({
   selector: 'app-risk-alert',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterModule, MsqSearchBoxComponent, FilterBarComponent, PageHeaderComponent, BoardTabsComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, NeuButtonComponent, ProductIdentityComponent, SummaryMetricCardComponent],
+  imports: [CommonModule, FormsModule, RouterModule, MsqSearchBoxComponent, PageHeaderComponent, BoardTabsComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, NeuButtonComponent, ProductIdentityComponent, SummaryMetricCardComponent],
   template: `
     <div class="risk-alert-container">
       <app-page-header
         title="风险预警看板"
         eyebrow="Monitoring"
-        description="在系统性差评爆发前实现自动拦截与止损"
-        icon="🛡️">
+        description="复用原预警工作台,按国内经营数据定位退款异常,并保留本品 VOC 风险入口。"
+        icon="🛡️"
+        [badges]="[{ label: '经营风险已接通', tone: 'green' }, { label: '本品 VOC 风险待接入', tone: 'amber' }]"
+        [meta]="riskHeaderMeta">
         <div pageHeaderActions class="header-right">
           <msq-search-box placeholder="搜索商品ID或名称" [(ngModel)]="searchQuery"></msq-search-box>
           <app-neu-button size="sm" [disabled]="loading" (btnClick)="refreshData()">
@@ -42,15 +43,10 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
         <app-loading-spinner text="加载预警数据中..." size="sm"></app-loading-spinner>
       }
 
-      <app-filter-bar
-        [filters]="currentFilters"
-        [autoLoadOptions]="false"
-        [showSite]="false"
-        [showStore]="false"
-        [showActions]="false"
-        [showDatePicker]="false"
-        (filtersChange)="onFilterChange($event)">
-      </app-filter-bar>
+      <div class="scope-strip">
+        <div><strong>固定数据周期</strong><span>{{ riskPeriodLabel }}</span></div>
+        <p>风险率 = 同期退款金额 / 同期成交金额,用于经营复核,不直接等同订单退款率或产品质量归因。</p>
+      </div>
 
       @if (loadError) {
         <app-error-state
@@ -63,7 +59,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
       <div class="alert-summary-row">
         <app-summary-metric-card label="严重" [value]="criticalCount" icon="!" tone="red" [clickable]="true" [emphasis]="severityFilter === 'critical' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('critical')"></app-summary-metric-card>
         <app-summary-metric-card label="警告" [value]="warningCount" icon="△" tone="amber" [clickable]="true" [emphasis]="severityFilter === 'warning' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('warning')"></app-summary-metric-card>
-        <app-summary-metric-card label="正常" [value]="normalCount" icon="✓" tone="green" [clickable]="true" [emphasis]="severityFilter === 'normal' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('normal')"></app-summary-metric-card>
+        <app-summary-metric-card label="观察" [value]="normalCount" icon="✓" tone="green" [clickable]="true" [emphasis]="severityFilter === 'normal' ? 'primary' : 'default'" (activate)="toggleSeverityFilter('normal')"></app-summary-metric-card>
         <app-summary-metric-card label="全部监控" [value]="riskItems.length" icon="◎" tone="blue" [clickable]="true" [emphasis]="severityFilter === null ? 'primary' : 'default'" (activate)="severityFilter = null"></app-summary-metric-card>
       </div>
 
@@ -114,7 +110,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                     (identityClick)="openProductDetail(item.asin)">
                   </app-product-identity>
                   <span class="rp-severity-badge" [class]="item.severity">
-                    {{ item.severity === 'critical' ? '🔴 严重' : item.severity === 'warning' ? '🟡 警告' : '🟢 正常' }}
+                    {{ item.severity === 'critical' ? '严重' : item.severity === 'warning' ? '警告' : '观察' }}
                   </span>
                   <app-neu-button size="sm" (btnClick)="toggleExpand(item.asin)">
                     {{ expandedAsin === item.asin ? '收起 ▲' : '详情 ▼' }}
@@ -128,7 +124,17 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                   </div>
                 }
                 <div class="rp-stats">
-                  @if (item.negativeRateBasis === 'insufficient') {
+                  @if (item.riskKind === 'refund') {
+                    <span class="rp-stat-item">
+                      同期退款金额占比
+                      <b [style.color]="item.negativeRate >= 20 ? '#dc2626' : item.negativeRate >= 10 ? '#d97706' : '#16a34a'">{{ item.negativeRate | number:'1.1-1' }}%</b>
+                      <span class="neg-bar-mini"><span class="neg-bar-fill" [style.width.%]="Math.min(item.negativeRate, 100)" [style.background]="item.negativeRate >= 20 ? '#ef4444' : item.negativeRate >= 10 ? '#f59e0b' : '#22c55e'"></span></span>
+                    </span>
+                    <span>退款金额 <b>{{ currency(item.refundAmount || 0) }}</b></span>
+                    <span>成交金额 <b>{{ currency(item.gmv || 0) }}</b></span>
+                    <span>退款订单 <b>{{ (item.refundOrders || 0) | number }}</b></span>
+                    <span>访客转化 <b>{{ percent(item.conversionRate || 0) }}</b></span>
+                  } @else if (item.negativeRateBasis === 'insufficient') {
                     <span class="rp-stat-item">
                       差评率 <b class="rp-insufficient" title="可分析评论样本不足,无法计算有效负向率">样本不足</b>
                     </span>
@@ -145,11 +151,13 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                       </span>
                     </span>
                   }
-                  <span>评分 <b>{{ item.avgStar }}</b></span>
-                  @if (item.negativeRateBasis === 'reviews') {
-                    <span>可分析评论 <b>{{ item.analyzableReviews | number }}</b></span>
-                  } @else {
-                    <span title="平台评分计数,非可分析评论样本">平台评分 <b>{{ item.ratingsCount | number }}</b></span>
+                  @if (item.riskKind !== 'refund') {
+                    <span>评分 <b>{{ item.avgStar }}</b></span>
+                    @if (item.negativeRateBasis === 'reviews') {
+                      <span>可分析评论 <b>{{ item.analyzableReviews | number }}</b></span>
+                    } @else {
+                      <span title="平台评分计数,非可分析评论样本">平台评分 <b>{{ item.ratingsCount | number }}</b></span>
+                    }
                   }
                 </div>
                 @if (item.alerts.length > 0) {
@@ -167,13 +175,19 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                 @if (expandedAsin === item.asin) {
                   <div class="rp-detail-panel">
                     <div class="rp-detail-actions">
-                      <app-neu-button size="sm" color="primary" (btnClick)="navigateToVocAnalysis(item.asin)">VOC 深度分析</app-neu-button>
-                      <app-neu-button size="sm" (btnClick)="navigateToEmotionAnalysis(item.asin)">情感分析</app-neu-button>
-                      <app-neu-button size="sm" (btnClick)="openProductDetail(item.asin)">商品经营详情</app-neu-button>
+                      @if (item.riskKind === 'refund') {
+                        <app-neu-button size="sm" color="primary" (btnClick)="navigateToReturnAnalysis(item.asin)">退款分析</app-neu-button>
+                        <app-neu-button size="sm" (btnClick)="openProductDetail(item.asin)">商品经营详情</app-neu-button>
+                      } @else {
+                        <app-neu-button size="sm" color="primary" (btnClick)="navigateToVocAnalysis(item.asin)">VOC 深度分析</app-neu-button>
+                        <app-neu-button size="sm" (btnClick)="navigateToEmotionAnalysis(item.asin)">情感分析</app-neu-button>
+                        <app-neu-button size="sm" (btnClick)="openProductDetail(item.asin)">商品经营详情</app-neu-button>
+                      }
                     </div>
                     <div class="rp-detail-info">
                       <span>预警数: <b>{{ item.alerts.length }}</b></span>
-                      <span>风险等级: <b [class]="item.severity">{{ item.severity === 'critical' ? '严重' : item.severity === 'warning' ? '警告' : '正常' }}</b></span>
+                      <span>风险等级: <b [class]="item.severity">{{ item.severity === 'critical' ? '严重' : item.severity === 'warning' ? '警告' : '观察' }}</b></span>
+                      @if (item.evidenceBoundary) { <span>口径: <b>{{ item.evidenceBoundary }}</b></span> }
                     </div>
                   </div>
                 }
@@ -187,7 +201,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
       @if (activeTab === 'scenarios') {
         <div class="scenario-grid">
           @if (scenarios.length === 0 && !loading) {
-            <app-empty-state title="暂无场景化投诉数据" icon="📭" size="sm"></app-empty-state>
+            <app-empty-state title="本品评论未接入,暂不生成投诉场景" description="竞品评论不会混入德玛仕投诉结论;接入本品评论后在此恢复场景聚类和原声证据。" actionText="查看数据接入" icon="⌖" size="sm" (action)="openDataStatus()"></app-empty-state>
           }
           @for (sc of sortedScenarios; track sc.scenario; let i = $index) {
             <div class="scenario-card" [class.top-scenario]="i === 0" (click)="toggleScenarioExpand(sc.scenario)">
@@ -228,7 +242,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
       @if (activeTab === 'spikes') {
         <div class="spike-list">
           @if (spikes.length === 0 && !loading) {
-            <app-empty-state title="未检测到异常激增事件" icon="📭" size="sm"></app-empty-state>
+            <app-empty-state title="暂无可判定的本品舆情序列" description="需要连续的本品评论日期与情绪样本后才能识别异常激增。" actionText="查看数据接入" icon="↗" size="sm" (action)="openDataStatus()"></app-empty-state>
           }
           @for (spike of spikes; track spike.date; let i = $index) {
             <div class="spike-card" [class]="spike.negRate > 30 ? 'critical' : spike.negRate > 15 ? 'warning' : ''" (click)="toggleSpikeExpand(spike.date)">
@@ -244,7 +258,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
                 <div class="spike-asins">
                   <span class="spike-asins-label">涉及商品:</span>
                   @for (asin of spike.affectedAsins.slice(0, expandedSpike === spike.date ? 20 : 3); track asin) {
-                    <a class="spike-asin-tag" [routerLink]="['/domestic/product']" [queryParams]="{ productId: asin }" (click)="$event.stopPropagation()">{{ asin }}</a>
+                    <a class="spike-asin-tag" [routerLink]="['/voc-insight/detail']" [queryParams]="{ productId: asin }" (click)="$event.stopPropagation()">{{ asin }}</a>
                   }
                   @if (spike.affectedAsins.length > 3 && expandedSpike !== spike.date) {
                     <span class="spike-more">+{{ spike.affectedAsins.length - 3 }} 更多</span>
@@ -261,6 +275,12 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
   styles: [`
     .risk-alert-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
 
+    .scope-strip { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 20px; align-items: center; margin-bottom: 18px; padding: 13px 16px; border: 1px solid #dbe5f1; border-left: 4px solid #2563eb; border-radius: 10px; background: #fff; }
+    .scope-strip div { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
+    .scope-strip strong { color: #1f2937; font-size: 12px; }
+    .scope-strip span { color: #2563eb; font-size: 13px; font-weight: 800; }
+    .scope-strip p { margin: 0; color: #64748b; font-size: 12px; line-height: 1.5; }
+
     .alert-summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px; }
     .alert-summary-row app-summary-metric-card { min-width: 0; }
 
@@ -374,6 +394,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
 
     @media (max-width: 768px) {
       .alert-summary-row { grid-template-columns: repeat(2, 1fr); }
+      .scope-strip { grid-template-columns: 1fr; gap: 6px; }
       .header-right { width: 100%; }
       .search-input { flex: 1; }
     }
@@ -385,13 +406,13 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
   loadError = false;
   activeTab: RiskAlertTabKey = 'products';
   severityFilter: 'critical' | 'warning' | 'normal' | null = null;
-  sortField: 'negativeRate' | 'totalReviews' | 'avgStar' = 'negativeRate';
+  sortField: 'negativeRate' | 'totalReviews' | 'avgStar' = 'totalReviews';
   searchQuery = '';
   expandedAsin: string | null = null;
   expandedScenario: string | null = null;
   expandedSpike: string | null = null;
   Math = Math;
-  currentFilters: any = { timeRange: '30days' };
+  currentFilters: any = { timeRange: '7days' };
   private riskDataRevision = 0;
   private sortedRiskItemsCacheKey = '';
   private sortedRiskItemsCache: RiskAlertItem[] = [];
@@ -415,28 +436,42 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
     return [
       {
         key: 'products',
-        label: '异常波动预警',
+        label: '经营风险预警',
         badges: this.criticalCount > 0 ? [{ label: this.criticalCount, tone: 'red' }] : []
       },
       {
         key: 'scenarios',
-        label: '场景化投诉',
+        label: '本品投诉场景',
         badges: this.scenarios.length > 0 ? [{ label: this.scenarios.length }] : []
       },
       {
         key: 'spikes',
-        label: '舆情早期信号',
+        label: '本品舆情信号',
         badges: this.spikes.length > 0 ? [{ label: this.spikes.length, tone: 'amber' }] : []
       }
     ];
   }
 
   readonly sortTabs: BoardTabItem[] = [
-    { key: 'negativeRate', label: '差评率' },
-    { key: 'totalReviews', label: '评论数' },
-    { key: 'avgStar', label: '评分' },
+    { key: 'negativeRate', label: '退款占比' },
+    { key: 'totalReviews', label: '退款金额' },
+    { key: 'avgStar', label: '成交金额' },
   ];
 
+  get riskHeaderMeta(): Array<{ label: string; value: string }> {
+    return [
+      { label: '案例', value: this.riskItems[0]?.userAccount || '德玛仕' },
+      { label: '平台', value: this.riskItems[0]?.site || 'jd' },
+      { label: '数据周期', value: this.riskPeriodLabel },
+      { label: '监控商品', value: `${this.riskItems.length} 个` },
+    ];
+  }
+
+  get riskPeriodLabel(): string {
+    const item = this.riskItems[0];
+    return item?.analysisStart && item?.analysisEnd ? `${item.analysisStart} 至 ${item.analysisEnd}` : '加载中';
+  }
+
   setActiveTab(key: string): void {
     if (key === 'products' || key === 'scenarios' || key === 'spikes') {
       this.activeTab = key;
@@ -511,7 +546,23 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
   }
 
   openProductDetail(productId: string): void {
-    this.router.navigate(['/domestic/product'], { queryParams: { productId } });
+    this.router.navigate(['/voc-insight/detail'], { queryParams: { productId } });
+  }
+
+  navigateToReturnAnalysis(productId: string): void {
+    this.router.navigate(['/return-analysis/overview'], { queryParams: { productId } });
+  }
+
+  openDataStatus(): void {
+    this.router.navigateByUrl('/data-status');
+  }
+
+  currency(value: number): string {
+    return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
+  }
+
+  percent(value: number): string {
+    return new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value || 0);
   }
 
   get sortedRiskItems(): RiskAlertItem[] {
@@ -540,7 +591,7 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
     this.sortedRiskItemsCache = [...items].sort((a, b) => {
       if (this.sortField === 'negativeRate') return b.negativeRate - a.negativeRate;
       if (this.sortField === 'totalReviews') return b.totalReviews - a.totalReviews;
-      return a.avgStar - b.avgStar;
+      return b.avgStar - a.avgStar;
     });
     return this.sortedRiskItemsCache;
   }
@@ -557,9 +608,9 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
     this.loading = true;
     this.loadError = false;
     forkJoin({
-      risks: this.monitoringService.getRiskAlertItems(this.currentFilters),
-      scenarios: this.monitoringService.getScenarioComplaints(this.currentFilters),
-      spikes: this.monitoringService.getSentimentSpikeEvents(this.currentFilters)
+      risks: this.monitoringService.getRiskAlertItems(this.currentFilters).pipe(take(1)),
+      scenarios: this.monitoringService.getScenarioComplaints(this.currentFilters).pipe(take(1)),
+      spikes: this.monitoringService.getSentimentSpikeEvents(this.currentFilters).pipe(take(1))
     }).pipe(takeUntil(this.destroy$)).subscribe({
       next: ({ risks, scenarios, spikes }) => {
         this.riskItems = risks;

+ 274 - 102
src/modules/monitoring/pages/voc-overview/voc-overview.component.ts

@@ -2,11 +2,10 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, RouterModule } from '@angular/router';
 import { Subject, forkJoin } from 'rxjs';
-import { takeUntil, catchError } from 'rxjs/operators';
+import { take, takeUntil, catchError } from 'rxjs/operators';
 import { of } from 'rxjs';
 import { DomesticMonitoringViewService } from '../../../../app/core/services/domestic-monitoring-view.service';
 import { EmotionHealthScore, CoreIssueSummary, KeywordCloud, DesignerRiskRankingItem } from '../../../shared/interfaces/monitoring-interfaces';
-import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
 import { BoardTabItem, BoardTabsComponent } from '../../../shared/components/board-tabs/board-tabs.component';
@@ -16,42 +15,43 @@ import { EntryCardComponent } from '../../../shared/components/entry-card/entry-
 import { EntryCardGridComponent } from '../../../shared/components/entry-card/entry-card-grid.component';
 import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
 import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { ReviewDataUtils } from '../../../shared/services/review-data-utils';
+
+/** 可信度三档:N<10 观察值;10≤N<30 方向性参考;N≥30 结论可信 */
+const CONFIDENCE_OK_MIN = 30;
 
 @Component({
   selector: 'app-voc-overview',
   standalone: true,
-  imports: [CommonModule, RouterModule, FilterBarComponent, PageHeaderComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, EmptyStateComponent, EntryCardComponent, EntryCardGridComponent, ErrorStateComponent, LoadingSpinnerComponent],
+  imports: [CommonModule, RouterModule, PageHeaderComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, EmptyStateComponent, EntryCardComponent, EntryCardGridComponent, ErrorStateComponent, LoadingSpinnerComponent],
   template: `
     <div class="voc-overview-container">
       <app-page-header
         title="VOC 趋势总览"
         eyebrow="Monitoring"
-        description="一屏总览 VOC 全局健康度,快速识别需要关注的方向"
-        icon="◉">
+        description="沿用原版监测总览结构,分开呈现本品 VOC 就绪度、竞品参照主题与经营风险。"
+        icon="◉"
+        [badges]="[{ label: '本品 VOC 待接入', tone: 'amber' }, { label: '竞品参照已接通', tone: 'purple' }, { label: '经营风险已接通', tone: 'green' }]">
       </app-page-header>
 
       @if (loading) {
         <app-loading-spinner text="加载数据中..." size="sm"></app-loading-spinner>
       }
 
-      <!-- 顶部指标行 -->
-      <div class="metrics-row">
-        <app-summary-metric-card label="VOC 总量" [value]="vocCount | number" tone="blue" icon="📊"></app-summary-metric-card>
-        <app-summary-metric-card label="环比变化" [value]="(vocTrend > 0 ? '+' : '') + vocTrend + '%'" [tone]="vocTrend > 0 ? 'blue' : vocTrend < 0 ? 'red' : 'default'" [trendDirection]="vocTrend > 0 ? 'up' : vocTrend < 0 ? 'down' : 'flat'" icon="📈"></app-summary-metric-card>
-        <app-summary-metric-card label="情绪健康度" [value]="healthScore?.score || 0" [tone]="getHealthTone()" [description]="healthScore?.trend === 'up' ? '↑ 改善' : healthScore?.trend === 'down' ? '↓ 下降' : '— 稳定'" icon="💚"></app-summary-metric-card>
-        <app-summary-metric-card label="预警数量" [value]="warningCount" [tone]="warningCount > 0 ? 'amber' : 'green'" icon="⚠️"></app-summary-metric-card>
-        <app-summary-metric-card label="核心问题数" [value]="negativeIssueCount" [tone]="negativeIssueCount > 3 ? 'red' : 'default'" description="负面话题集中度" icon="📝"></app-summary-metric-card>
+      <!-- 顶部指标行:4 列(每卡更宽),5 张卡片第 5 张自然换行并居中 -->
+      <div class="metrics-row" role="group" aria-label="核心指标">
+        <app-summary-metric-card label="本品 VOC 样本" [value]="vocCount | number" tone="default" icon="▤" description="德玛仕评论结论口径"></app-summary-metric-card>
+        <app-summary-metric-card label="本品趋势" [value]="vocCount ? (vocTrend > 0 ? '+' : '') + vocTrend + '%' : '-'" tone="amber" icon="↗" description="等待连续本品评论"></app-summary-metric-card>
+        <app-summary-metric-card label="本品情绪健康" [value]="healthScore?.totalReviews ? healthScore?.score || 0 : '-'" tone="amber" icon="◉" description="不使用竞品评分代替"></app-summary-metric-card>
+        <app-summary-metric-card label="经营风险商品" [value]="warningCount" [tone]="warningCount > 0 ? 'red' : 'green'" icon="!" description="成交 ≥ ¥5,000 且同期比 ≥ 10%"></app-summary-metric-card>
+        <app-summary-metric-card label="竞品参照主题" [value]="negativeIssueCount" tone="purple" description="基础规则命中的负向维度" icon="⌖"></app-summary-metric-card>
       </div>
 
-      <app-filter-bar
-        [filters]="currentFilters"
-        [autoLoadOptions]="false"
-        [showSite]="false"
-        [showStore]="false"
-        [showActions]="false"
-        [showDatePicker]="false"
-        (filtersChange)="onFilterChange($event)">
-      </app-filter-bar>
+      <!-- 顶部状态/口径条:数据从哪来、是否就绪 -->
+      <div class="scope-strip" role="status">
+        <strong>数据口径</strong>
+        <span>本品评论 {{ vocCount }} 条({{ ownSampleReadiness }});竞品主题只作类目参照,不代表本品痛点;经营风险来自{{ periodLabel }}商品汇总。</span>
+      </div>
 
       @if (loadError) {
         <app-error-state
@@ -79,8 +79,8 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
                   @if (item.topAsins.length) {
                     <div class="designer-risk-asins">
                       @for (asin of item.topAsins; track asin.asin) {
-                        <a [routerLink]="['/domestic/product']" [queryParams]="{ productId: asin.asin }" class="risk-asin-tag">
-                          商品ID {{ asin.asin }} · {{ asin.negativeRate }}%
+                    <a [routerLink]="['/voc-insight/detail']" [queryParams]="{ productId: asin.asin }" class="risk-asin-tag">
+                          商品ID {{ asin.asin }} · {{ asin.negativeRate | number:'1.0-1' }}%
                         </a>
                       }
                     </div>
@@ -90,7 +90,7 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
                   <span class="risk-metric critical">{{ item.criticalCount }} 严重</span>
                   <span class="risk-metric warning">{{ item.warningCount }} 警告</span>
                   <span class="risk-metric">{{ item.productCount }} 商品</span>
-                  <span class="risk-rate">{{ item.avgNegativeRate }}%</span>
+                  <span class="risk-rate">{{ item.avgNegativeRate | number:'1.0-1' }}%</span>
                 </div>
               </div>
             }
@@ -101,55 +101,71 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
       <!-- 情绪健康度仪表盘 + 核心问题集中度 -->
       <div class="dashboard-row">
         <!-- 左:健康度仪表盘 -->
-        <app-content-card class="health-gauge-panel" title="情绪健康度仪表盘" density="compact" variant="muted">
+        <app-content-card class="health-gauge-panel" title="本品情绪健康度" subtitle="仅使用德玛仕本品评论生成" density="compact" variant="muted">
           @if (healthScore && healthScore.totalReviews > 0) {
             <div class="gauge-wrapper">
               <div class="gauge-ring">
-                <svg viewBox="0 0 120 120" class="gauge-svg">
-                  <circle cx="60" cy="60" r="52" fill="none" stroke="#f3f4f6" stroke-width="10"/>
-                  <circle cx="60" cy="60" r="52" fill="none" [attr.stroke]="getHealthColor()"
-                          stroke-width="10" stroke-linecap="round"
-                          [attr.stroke-dasharray]="getGaugeDash()"
-                          stroke-dashoffset="0"
-                          transform="rotate(-90 60 60)"/>
-                </svg>
-                <div class="gauge-center">
-                  <span class="gauge-score" [style.color]="getHealthColor()">{{ healthScore.score }}</span>
-                  <span class="gauge-unit">/ 100</span>
+                <div class="gauge-svg">
+                  <svg viewBox="0 0 120 120">
+                    <circle cx="60" cy="60" r="52" fill="none" stroke="#f3f4f6" stroke-width="10"/>
+                    <circle cx="60" cy="60" r="52" fill="none" [attr.stroke]="getHealthColor()"
+                            stroke-width="10" stroke-linecap="round"
+                            [attr.stroke-dasharray]="getGaugeDash()"
+                            stroke-dashoffset="0"
+                            transform="rotate(-90 60 60)"/>
+                  </svg>
+                  <div class="gauge-center">
+                    <span class="gauge-score" [style.color]="getHealthColor()">{{ healthScore.score }}</span>
+                    <span class="gauge-unit">/ 100</span>
+                  </div>
                 </div>
               </div>
-              <div class="gauge-details">
-                <div class="gauge-item">
-                  <span class="gauge-dot pos"></span>
-                  <span>正向 {{ healthScore.positiveRate }}%</span>
-                </div>
-                <div class="gauge-item">
-                  <span class="gauge-dot neg"></span>
-                  <span>负向 {{ healthScore.negativeRate }}%</span>
+
+              <!-- 情绪分布:三色水平分段条(正向 / 中性 / 负向) -->
+              <div class="emotion-strip" role="img" [attr.aria-label]="emotionStripAria">
+                <span class="emotion-seg pos" [style.width.%]="healthScore.positiveRate"></span>
+                <span class="emotion-seg neu" [style.width.%]="healthScore.neutralRate"></span>
+                <span class="emotion-seg neg" [style.width.%]="healthScore.negativeRate"></span>
+              </div>
+              <div class="emotion-legend">
+                <span class="emo-item pos"><i class="emo-dot"></i>正向 {{ positiveCount }} 条 · {{ healthScore.positiveRate | number:'1.0-1' }}%</span>
+                <span class="emo-item neu"><i class="emo-dot"></i>中性 {{ neutralCount }} 条 · {{ healthScore.neutralRate | number:'1.0-1' }}%</span>
+                <span class="emo-item neg"><i class="emo-dot"></i>负向 {{ negativeCount }} 条 · {{ healthScore.negativeRate | number:'1.0-1' }}%</span>
+              </div>
+
+              <!-- 可信度三档:样本量决定结论强度 -->
+              <div class="sample-note" [class.tier-insufficient]="sampleTier === 'insufficient'" [class.tier-directional]="sampleTier === 'directional'" [class.tier-ok]="sampleTier === 'ok'">
+                {{ sampleConfidenceText }}
+              </div>
+
+              <!-- 情绪健康度:三段色带(预警 / 关注 / 健康)+ 当前分值定位 -->
+              <div class="health-band-wrap">
+                <div class="health-band">
+                  <span class="health-zone bad"></span>
+                  <span class="health-zone warn"></span>
+                  <span class="health-zone good"></span>
+                  <span class="health-band-marker" [style.left.%]="healthScorePos"></span>
                 </div>
-                <div class="gauge-item">
-                  <span class="gauge-dot neu"></span>
-                  <span>中性 {{ healthScore.neutralRate }}%</span>
+                <div class="health-zone-scale">
+                  <span>0</span><span>50</span><span>75</span><span>100</span>
                 </div>
-                <div class="gauge-item">
-                  <span class="gauge-dot total"></span>
-                  <span>评论 {{ healthScore.totalReviews | number }} 条</span>
+                <div class="health-zone-legend">
+                  <span class="zone-item bad"><i></i>&lt;50 预警</span>
+                  <span class="zone-item warn"><i></i>50-74 关注</span>
+                  <span class="zone-item good"><i></i>≥75 健康</span>
                 </div>
               </div>
-              <!-- 情绪分段说明 -->
-              <div class="health-legend">
-                <span class="legend-item good">≥75 健康</span>
-                <span class="legend-item warn">50-74 关注</span>
-                <span class="legend-item bad">&lt;50 预警</span>
-              </div>
+
+              <div class="gauge-total">样本共 {{ healthScore.totalReviews | number }} 条 · 周期{{ periodLabel }} · 健康分较上期{{ healthTrendLabel }}</div>
+              <div class="gauge-scope-note">口径:占比按有效评论文本分组 · 来源:本品评论池 · 周期:{{ periodLabel }}</div>
             </div>
           } @else {
-            <app-empty-state title="暂无情绪样本" description="页面结构已就绪,当前保持真实空状态。" icon="◌" size="sm"></app-empty-state>
+            <app-empty-state title="接入本品评论以生成健康度" description="暂无本品情绪样本;接入德玛仕评论后即可恢复健康度、正负向占比与趋势对比。" actionText="查看数据接入" icon="◌" size="sm" (action)="navigate('/data-status')"></app-empty-state>
           }
         </app-content-card>
 
         <!-- 右:核心问题集中度 -->
-        <app-content-card class="issues-panel" title="核心问题集中度" [hasHeaderActions]="true" density="compact" variant="muted">
+        <app-content-card class="issues-panel" title="竞品问题参照(非本品)" subtitle="基础规则命中,可在竞品详情核验原声" [hasHeaderActions]="true" density="compact" variant="muted">
           <div contentCardActions>
             <app-board-tabs
               [tabs]="issueTabs"
@@ -157,9 +173,17 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
               density="compact"
               (activeKeyChange)="setIssueFilter($event)">
             </app-board-tabs>
+            <input
+              type="search"
+              class="issue-search"
+              placeholder="筛选主题关键词"
+              [value]="issueSearch"
+              (input)="onIssueSearch($event)"
+              (keydown.enter)="$event.preventDefault()"
+              aria-label="筛选问题主题">
           </div>
           @if (filteredIssues.length === 0 && !loading) {
-            <app-empty-state title="暂无核心问题数据" icon="📭" size="sm"></app-empty-state>
+            <app-empty-state title="查看竞品问题原声" description="当前筛选范围内暂无规则命中的竞品问题主题,可前往竞品关系页核验原声。" actionText="查看竞品关系" icon="⌖" size="sm" (action)="navigate('/domestic/competitors')"></app-empty-state>
           }
           <div class="issues-list">
             @for (issue of filteredIssues; track issue.topic; let i = $index) {
@@ -173,19 +197,20 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
                   <div class="issue-bar-fill" [class]="issue.sentiment"
                        [style.width.%]="issue.pct"></div>
                 </div>
-                <span class="issue-pct">{{ issue.pct }}%</span>
+                <span class="issue-pct">{{ issue.pct | number:'1.0-1' }}%</span>
                 <span class="issue-count">({{ issue.count }}条)</span>
               </div>
             }
           </div>
+          <div class="panel-scope-note">来源:竞品评论池 · 周期:{{ periodLabel }} · 命中仅作类目参照,不代表本品痛点</div>
         </app-content-card>
       </div>
 
       <!-- 关键词热度 TOP -->
       <div class="dashboard-row">
-        <app-content-card class="keyword-panel" title="正向热词 Top5" density="compact" variant="muted" tone="green">
+        <app-content-card class="keyword-panel" title="本品正向主题" subtitle="等待德玛仕评论原声" density="compact" variant="muted" tone="green">
           @if (topPositiveKeywords.length === 0 && !loading) {
-            <app-empty-state title="暂无数据" icon="📭" size="sm"></app-empty-state>
+            <app-empty-state title="接入本品评论以生成正向主题" description="暂无本品正向主题;接入德玛仕评论原声后展示。" actionText="查看数据接入" icon="◌" size="sm" (action)="navigate('/data-status')"></app-empty-state>
           }
           <div class="kw-list">
             @for (kw of topPositiveKeywords; track kw.text; let i = $index) {
@@ -200,9 +225,9 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
             }
           </div>
         </app-content-card>
-        <app-content-card class="keyword-panel" title="负向热词 Top5" density="compact" variant="muted" tone="red">
+        <app-content-card class="keyword-panel" title="竞品负向主题参照" subtitle="规则命中次数,不代表德玛仕痛点" density="compact" variant="muted" tone="red">
           @if (topNegativeKeywords.length === 0 && !loading) {
-            <app-empty-state title="暂无数据" icon="📭" size="sm"></app-empty-state>
+            <app-empty-state title="查看竞品负向主题" description="暂无竞品负向主题参照;命中次数不代表德玛仕痛点,可先接入竞品评论原声。" actionText="查看竞品关系" icon="📭" size="sm" (action)="navigate('/domestic/competitors')"></app-empty-state>
           }
           <div class="kw-list">
             @for (kw of topNegativeKeywords; track kw.text; let i = $index) {
@@ -223,7 +248,7 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
       <app-entry-card-grid columns="3">
         <app-entry-card
           title="风险预警看板"
-          description="异常波动 · 场景化投诉 · 舆情信号"
+          description="退款异常 · 金额影响 · 商品下钻"
           icon="🛡️"
           [badge]="warningCount > 0 ? warningCount + ' 项预警' : ''"
           cta="查看预警"
@@ -232,7 +257,7 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
         </app-entry-card>
         <app-entry-card
           title="类目 VOC"
-          description="品类 VOC 密度 · 类目情绪排行"
+          description="品类经营密度 · VOC 就绪度"
           icon="📦"
           cta="查看类目"
           tone="blue"
@@ -240,7 +265,7 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
         </app-entry-card>
         <app-entry-card
           title="用户声音分类"
-          description="情绪分类 · 评论样本 · 用户表达"
+          description="本品声音分类 · 评论证据接入"
           icon="◍"
           cta="查看声音"
           tone="purple"
@@ -253,7 +278,17 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
   styles: [`
     .voc-overview-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
 
-    .metrics-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 24px; }
+    /* KPI 区:5 列改 4 列(每卡更宽),gap 16px */
+    .metrics-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 24px; }
+    /* 5 张卡片第 5 张换行到第二行并居中,保持对称 */
+    .metrics-row app-summary-metric-card:last-child { grid-column: 2 / span 2; }
+    /* 指标数值:共享卡已是 30px,补 tabular-nums 数字对齐 */
+    :host ::ng-deep .metrics-row .summary-metric-card .metric-value-row strong { font-variant-numeric: tabular-nums; }
+
+    /* 卡片配方:8px 圆角 + 1px 边框 + 浅阴影(页面内卡片容器统一) */
+    .scope-strip { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #dbe5f1; border-left: 4px solid #2563eb; border-radius: 8px; background: #fff; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); }
+    .scope-strip strong { color: #1f2937; font-size: 12px; white-space: nowrap; }
+    .scope-strip span { color: #64748b; font-size: 12px; line-height: 1.5; }
 
     .dashboard-row { display: grid; grid-template-columns: 1fr 1.5fr; gap: 20px; margin-bottom: 24px; }
     .dashboard-row app-content-card { min-width: 0; }
@@ -270,45 +305,75 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
     .designer-risk-bar { height: 7px; background: #f3f4f6; border-radius: 999px; overflow: hidden; }
     .designer-risk-fill { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, #fbbf24, #ef4444); }
     .designer-risk-asins { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
-    .risk-asin-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #fef2f2; color: #dc2626; text-decoration: none; font-family: monospace; }
+    .risk-asin-tag { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #fef2f2; color: #dc2626; text-decoration: none; font-family: monospace; }
     .risk-asin-tag:hover { background: #fee2e2; }
     .designer-risk-metrics { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; justify-content: flex-end; min-width: 260px; }
-    .risk-metric { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #f3f4f6; color: #4b5563; }
+    .risk-metric { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: #f3f4f6; color: #4b5563; }
     .risk-metric.critical { background: #fee2e2; color: #dc2626; }
     .risk-metric.warning { background: #fef3c7; color: #d97706; }
-    .risk-rate { font-size: 16px; font-weight: 800; color: #ef4444; min-width: 54px; text-align: right; }
+    .risk-rate { font-size: 16px; font-weight: 800; color: #ef4444; min-width: 54px; text-align: right; font-variant-numeric: tabular-nums; }
 
     .gauge-wrapper { display: flex; flex-direction: column; align-items: center; gap: 16px; }
-    .gauge-ring { position: relative; width: 150px; height: 150px; }
-    .gauge-svg { width: 100%; height: 100%; }
+    .gauge-ring { width: 100%; max-width: 220px; }
+    .gauge-svg { position: relative; }
+    .gauge-svg svg { width: 100%; height: auto; display: block; }
     .gauge-center { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; }
-    .gauge-score { font-size: 34px; font-weight: 800; display: block; }
+    .gauge-score { font-size: 30px; font-weight: 600; display: block; font-variant-numeric: tabular-nums; line-height: 1.1; }
     .gauge-unit { font-size: 12px; color: #9ca3af; }
-    .gauge-details { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; }
-    .gauge-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #374151; }
-    .gauge-dot { width: 8px; height: 8px; border-radius: 50%; }
-    .gauge-dot.pos { background: #22c55e; }
-    .gauge-dot.neg { background: #ef4444; }
-    .gauge-dot.neu { background: #f59e0b; }
-    .gauge-dot.total { background: #6366f1; }
-    .health-legend { display: flex; gap: 12px; margin-top: 4px; }
-    .legend-item { font-size: 11px; padding: 2px 10px; border-radius: 8px; font-weight: 600; }
-    .legend-item.good { background: #d1fae5; color: #059669; }
-    .legend-item.warn { background: #fef3c7; color: #d97706; }
-    .legend-item.bad { background: #fee2e2; color: #dc2626; }
+    /* 情绪分布:三色水平分段条 */
+    .emotion-strip { display: flex; width: 100%; height: 14px; border-radius: 999px; overflow: hidden; background: #f3f4f6; gap: 2px; }
+    .emotion-seg { height: 100%; min-width: 0; transition: width 0.5s ease; }
+    .emotion-seg.pos { background: #22c55e; }
+    .emotion-seg.neu { background: #f59e0b; }
+    .emotion-seg.neg { background: #ef4444; }
+    .emotion-legend { display: flex; width: 100%; justify-content: space-between; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
+    .emo-item { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #4b5563; }
+    .emo-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+    .emo-item.pos .emo-dot { background: #22c55e; }
+    .emo-item.neu .emo-dot { background: #f59e0b; }
+    .emo-item.neg .emo-dot { background: #ef4444; }
+
+    /* 可信度三档:样本量不足(红)/ 方向性参考(琥珀)/ 结论可信(绿) */
+    .sample-note { width: 100%; margin-top: 12px; font-size: 12px; line-height: 1.5; border-radius: 6px; padding: 6px 10px; text-align: center; }
+    .sample-note.tier-insufficient { color: #b91c1c; background: #fef2f2; border: 1px dashed #fecaca; }
+    .sample-note.tier-directional { color: #92400e; background: #fffbeb; border: 1px dashed #fde68a; }
+    .sample-note.tier-ok { color: #166534; background: #f0fdf4; border: 1px dashed #bbf7d0; }
+
+    /* 情绪健康度:三段色带(预警 / 关注 / 健康)+ 当前分值定位 */
+    .health-band-wrap { width: 100%; margin-top: 12px; }
+    .health-band { position: relative; display: flex; height: 10px; border-radius: 999px; overflow: hidden; box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); }
+    .health-zone.bad { flex: 0 0 50%; background: #ef4444; }
+    .health-zone.warn { flex: 0 0 25%; background: #f59e0b; }
+    .health-zone.good { flex: 0 0 25%; background: #22c55e; }
+    .health-band-marker { position: absolute; top: -3px; bottom: -3px; width: 3px; border-radius: 2px; background: #1f2937; transform: translateX(-50%); box-shadow: 0 0 0 2px #f8fafc; }
+    .health-zone-scale { display: flex; justify-content: space-between; margin-top: 4px; font-size: 12px; color: #9ca3af; }
+    .health-zone-legend { display: flex; justify-content: center; gap: 14px; margin-top: 8px; }
+    .zone-item { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #4b5563; }
+    .zone-item i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
+    .zone-item.bad i { background: #ef4444; }
+    .zone-item.warn i { background: #f59e0b; }
+    .zone-item.good i { background: #22c55e; }
+
+    .gauge-total { margin-top: 12px; font-size: 12px; color: #6b7280; text-align: center; }
+    .gauge-scope-note { margin-top: 6px; font-size: 12px; color: #9ca3af; text-align: center; }
+
+    .issue-search { min-width: 150px; height: 28px; padding: 0 10px; font-size: 12px; color: #374151; background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; }
+    .issue-search::placeholder { color: #9ca3af; }
+    .issue-search:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); outline: none; }
 
     .issues-list { display: flex; flex-direction: column; gap: 8px; }
     .issue-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
     .issue-row.top-issue { background: #fef2f2; margin: 0 -8px; padding: 6px 8px; border-radius: 8px; }
-    .issue-rank { font-size: 11px; font-weight: 700; color: #9ca3af; width: 18px; text-align: center; }
+    .issue-rank { font-size: 12px; font-weight: 700; color: #9ca3af; width: 18px; text-align: center; }
     .issue-icon { font-size: 12px; width: 18px; text-align: center; }
     .issue-name { font-size: 13px; color: #374151; min-width: 70px; font-weight: 500; }
     .issue-bar-track { flex: 1; height: 8px; background: #f3f4f6; border-radius: 4px; overflow: hidden; }
     .issue-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
     .issue-bar-fill.negative { background: linear-gradient(90deg, #fca5a5, #ef4444); }
     .issue-bar-fill.positive { background: linear-gradient(90deg, #86efac, #22c55e); }
-    .issue-pct { font-size: 12px; color: #6b7280; font-weight: 600; min-width: 32px; text-align: right; }
-    .issue-count { font-size: 11px; color: #9ca3af; min-width: 40px; }
+    .issue-pct { font-size: 12px; color: #6b7280; font-weight: 600; min-width: 32px; text-align: right; font-variant-numeric: tabular-nums; }
+    .issue-count { font-size: 12px; color: #9ca3af; min-width: 40px; }
+    .panel-scope-note { margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e5e7eb; font-size: 12px; color: #9ca3af; }
 
     .kw-list { display: flex; flex-direction: column; gap: 8px; }
     .kw-row { display: flex; align-items: center; gap: 10px; }
@@ -320,14 +385,21 @@ import { LoadingSpinnerComponent } from '../../../shared/components/loading-spin
     .kw-bar-fill { height: 100%; border-radius: 3.5px; transition: width 0.5s; }
     .kw-bar-fill.pos { background: linear-gradient(90deg, #86efac, #22c55e); }
     .kw-bar-fill.neg { background: linear-gradient(90deg, #fca5a5, #ef4444); }
-    .kw-weight { font-size: 13px; font-weight: 700; min-width: 30px; text-align: right; }
+    .kw-weight { font-size: 13px; font-weight: 700; min-width: 30px; text-align: right; font-variant-numeric: tabular-nums; }
     .pos-text { color: #22c55e; }
     .neg-text { color: #ef4444; }
 
-    @media (max-width: 900px) {
-      .metrics-row { grid-template-columns: repeat(3, 1fr); }
+    /* 响应式:KPI 区 ≤960px 降 2 列、≤560px 降 1 列 */
+    @media (max-width: 960px) {
+      .metrics-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+      .metrics-row app-summary-metric-card:last-child { grid-column: 1 / -1; }
       .dashboard-row { grid-template-columns: 1fr; }
     }
+    @media (max-width: 560px) {
+      .voc-overview-container { padding: 16px 14px 72px; }
+      .metrics-row { grid-template-columns: repeat(1, minmax(0, 1fr)); gap: 10px; }
+      .metrics-row app-summary-metric-card:last-child { grid-column: auto; }
+    }
   `]
 })
 export class VocOverviewComponent implements OnInit, OnDestroy {
@@ -335,10 +407,10 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
   loading = true;
   loadError = false;
   issueFilter: 'all' | 'negative' | 'positive' = 'all';
+  issueSearch = '';
   readonly issueTabs: BoardTabItem[] = [
-    { key: 'all', label: '全部' },
-    { key: 'negative', label: '负面' },
-    { key: 'positive', label: '正面' },
+    { key: 'all', label: '全部主题' },
+    { key: 'negative', label: '负向参照' },
   ];
   currentFilters: any = { timeRange: '30days' };
 
@@ -384,11 +456,11 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
     this.loading = true;
     this.loadError = false;
     forkJoin({
-      metrics: this.monitoringService.getVocMetrics(this.currentFilters),
-      health: this.monitoringService.getEmotionHealthScore(this.currentFilters),
-      issues: this.monitoringService.getCoreIssueSummary(this.currentFilters),
-      keywords: this.monitoringService.getKeywordCloud({ ...this.currentFilters, storeOnly: true }).pipe(catchError(() => of([]))),
-      designerRisks: this.monitoringService.getDesignerRiskRanking(this.currentFilters)
+      metrics: this.monitoringService.getVocMetrics(this.currentFilters).pipe(take(1)),
+      health: this.monitoringService.getEmotionHealthScore(this.currentFilters).pipe(take(1)),
+      issues: this.monitoringService.getCoreIssueSummary(this.currentFilters).pipe(take(1)),
+      keywords: this.monitoringService.getKeywordCloud({ ...this.currentFilters, storeOnly: true }).pipe(take(1), catchError(() => of([]))),
+      designerRisks: this.monitoringService.getDesignerRiskRanking(this.currentFilters).pipe(take(1))
     }).pipe(takeUntil(this.destroy$)).subscribe({
       next: ({ metrics, health, issues, keywords, designerRisks }) => {
         this.vocCount = metrics.vocCount;
@@ -420,19 +492,119 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
     }
   }
 
+  onIssueSearch(event: Event): void {
+    this.issueSearch = (event.target as HTMLInputElement).value;
+  }
+
   navigate(route: string): void {
     this.router.navigateByUrl(route);
   }
 
   get filteredIssues(): CoreIssueSummary[] {
-    if (this.issueFilter === 'all') return this.coreIssues;
-    return this.coreIssues.filter(i => i.sentiment === this.issueFilter);
+    const base = this.issueFilter === 'all'
+      ? this.coreIssues
+      : this.coreIssues.filter(i => i.sentiment === this.issueFilter);
+    const q = this.issueSearch.trim().toLocaleLowerCase();
+    return q ? base.filter(i => i.topic.toLocaleLowerCase().includes(q)) : base;
   }
 
   get negativeIssueCount(): number {
     return this.coreIssues.filter(i => i.sentiment === 'negative').length;
   }
 
+  /** 最小可分析评论样本阈值(低于该值仅输出观察参考,不输出确定性结论) */
+  get minSample(): number {
+    return ReviewDataUtils.MIN_ANALYZABLE_SAMPLE;
+  }
+
+  /** 有样本但样本量不足:需明确提示"数据不足"而非渲染成确定结论 */
+  get sampleInsufficient(): boolean {
+    return !!this.healthScore
+      && this.healthScore.totalReviews > 0
+      && !ReviewDataUtils.hasSufficientSample(this.healthScore.totalReviews);
+  }
+
+  /** 可信度三档:insufficient(N<10)/ directional(10≤N<30)/ ok(N≥30) */
+  get sampleTier(): 'insufficient' | 'directional' | 'ok' {
+    const n = this.healthScore?.totalReviews ?? 0;
+    if (n < ReviewDataUtils.MIN_ANALYZABLE_SAMPLE) return 'insufficient';
+    if (n < CONFIDENCE_OK_MIN) return 'directional';
+    return 'ok';
+  }
+
+  /** 可信度三档标准文案 */
+  get sampleConfidenceText(): string {
+    const n = this.healthScore?.totalReviews ?? 0;
+    switch (this.sampleTier) {
+      case 'insufficient':
+        return `样本 ${n} 条 < ${this.minSample},仅展示观察值,不作比例/满意度结论`;
+      case 'directional':
+        return `达到基础分析阈值(${this.minSample} 条),结论为方向性参考`;
+      default:
+        return `样本 ${n} 条,结论可信`;
+    }
+  }
+
+  /** 本品评论就绪度(顶部状态条用) */
+  get ownSampleReadiness(): string {
+    if (!this.vocCount) return '未接入';
+    return this.vocCount >= this.minSample ? '样本充足' : '样本不足,仅展示观察值';
+  }
+
+  /** 当前筛选周期的人类可读标签 */
+  get periodLabel(): string {
+    const r = this.currentFilters?.timeRange;
+    switch (r) {
+      case '7days': return '近 7 天';
+      case '14days': return '近 14 天';
+      case '90days': return '近 90 天';
+      case 'ytd': return '今年以来';
+      case 'custom': {
+        const c = this.currentFilters?.customDateRange;
+        return c && c.start && c.end ? `${c.start} 至 ${c.end}` : '自定义周期';
+      }
+      default: return '近 30 天';
+    }
+  }
+
+  get positiveCount(): number {
+    return this.healthScore
+      ? Math.round(this.healthScore.totalReviews * this.healthScore.positiveRate / 100)
+      : 0;
+  }
+
+  get neutralCount(): number {
+    return this.healthScore
+      ? Math.round(this.healthScore.totalReviews * this.healthScore.neutralRate / 100)
+      : 0;
+  }
+
+  get negativeCount(): number {
+    return this.healthScore
+      ? Math.round(this.healthScore.totalReviews * this.healthScore.negativeRate / 100)
+      : 0;
+  }
+
+  /** 健康分在 0-100 色带上的定位(钳制到边界内) */
+  get healthScorePos(): number {
+    return Math.max(0, Math.min(100, this.healthScore?.score ?? 0));
+  }
+
+  get healthTrendLabel(): string {
+    switch (this.healthScore?.trend) {
+      case 'up': return '上升';
+      case 'down': return '下降';
+      default: return '平稳';
+    }
+  }
+
+  get emotionStripAria(): string {
+    const s = this.healthScore;
+    return s
+      ? `情绪分布:正向 ${s.positiveRate.toFixed(1)}%,中性 ${s.neutralRate.toFixed(1)}%,负向 ${s.negativeRate.toFixed(1)}%`
+      : '';
+  }
+
   getHealthTone(): 'green' | 'amber' | 'red' {
     const s = this.healthScore?.score || 0;
     if (s >= 75) return 'green';

+ 158 - 0
src/modules/shared/components/action-create-panel/action-create-panel.component.html

@@ -0,0 +1,158 @@
+<div class="action-panel-backdrop" (click)="onBackdropClick($event)">
+  <aside
+    #panel
+    class="action-panel"
+    role="dialog"
+    aria-modal="true"
+    aria-labelledby="action-create-title"
+    aria-describedby="action-create-subtitle">
+    <header class="panel-header">
+      <div class="header-icon" aria-hidden="true">
+        <lucide-icon [img]="icons.clipboard" [size]="20" [strokeWidth]="1.8"></lucide-icon>
+      </div>
+      <div class="header-copy">
+        <h2 id="action-create-title">创建行动项</h2>
+        <p id="action-create-subtitle">将当前洞察转为可跟进的执行任务</p>
+      </div>
+      <button
+        type="button"
+        class="icon-button"
+        aria-label="关闭创建行动项侧栏"
+        title="关闭"
+        [disabled]="isSubmitting"
+        (click)="cancel()">
+        <lucide-icon [img]="icons.x" [size]="19" [strokeWidth]="1.8"></lucide-icon>
+      </button>
+    </header>
+
+    @if (sourceLabel || productKey) {
+      <div class="source-context" aria-label="行动项来源">
+        @if (sourceLabel) {
+          <span class="context-item">
+            <lucide-icon [img]="icons.tag" [size]="14" [strokeWidth]="1.8"></lucide-icon>
+            <span>{{ sourceLabel }}</span>
+          </span>
+        }
+        @if (productKey) {
+          <span class="context-separator" aria-hidden="true"></span>
+          <span class="product-key" title="关联商品">{{ productKey }}</span>
+        }
+      </div>
+    }
+
+    <form class="panel-form" [formGroup]="form" (ngSubmit)="submit()" novalidate>
+      <div class="form-body">
+        @if (submissionState === 'success' || submissionState === 'error') {
+          <div
+            class="status-banner"
+            [class.status-success]="submissionState === 'success'"
+            [class.status-error]="submissionState === 'error'"
+            role="status"
+            aria-live="polite">
+            @if (submissionState === 'success') {
+              <lucide-icon [img]="icons.check" [size]="17" [strokeWidth]="2"></lucide-icon>
+            } @else {
+              <lucide-icon [img]="icons.alert" [size]="17" [strokeWidth]="2"></lucide-icon>
+            }
+            <span>{{ statusMessage }}</span>
+          </div>
+        }
+
+        <label class="form-field form-field-full">
+          <span class="field-label">标题 <b aria-hidden="true">*</b></span>
+          <input
+            #titleInput
+            type="text"
+            formControlName="title"
+            maxlength="120"
+            autocomplete="off"
+            placeholder="例如:优化高频差评中的包装防护"
+            aria-required="true"
+            [attr.aria-invalid]="form.controls.title.invalid && form.controls.title.touched"
+            [attr.aria-describedby]="form.controls.title.invalid && form.controls.title.touched ? 'action-title-error' : null" />
+          <span class="field-meta">
+            <span id="action-title-error" class="field-error">
+              @if (form.controls.title.touched && form.controls.title.hasError('required')) { 请输入行动项标题 }
+              @if (form.controls.title.touched && form.controls.title.hasError('maxlength')) { 标题不能超过 120 个字符 }
+            </span>
+            <span>{{ form.controls.title.value.length }}/120</span>
+          </span>
+        </label>
+
+        <label class="form-field form-field-full">
+          <span class="field-label">描述</span>
+          <textarea
+            formControlName="description"
+            rows="5"
+            maxlength="2000"
+            placeholder="补充问题背景、建议动作和预期结果"
+            [attr.aria-invalid]="form.controls.description.invalid && form.controls.description.touched"
+            [attr.aria-describedby]="form.controls.description.invalid && form.controls.description.touched ? 'action-description-error' : null"></textarea>
+          <span class="field-meta">
+            <span id="action-description-error" class="field-error">
+              @if (form.controls.description.touched && form.controls.description.hasError('maxlength')) { 描述不能超过 2000 个字符 }
+            </span>
+            <span>{{ form.controls.description.value.length }}/2000</span>
+          </span>
+        </label>
+
+        <div class="form-grid">
+          <label class="form-field">
+            <span class="field-label">优先级</span>
+            <span class="select-wrap">
+              <select formControlName="priority" aria-label="行动项优先级">
+                @for (priority of priorities; track priority.value) {
+                  <option [value]="priority.value">{{ priority.label }}</option>
+                }
+              </select>
+            </span>
+          </label>
+
+          <label class="form-field">
+            <span class="field-label">截止日期</span>
+            <span class="input-with-icon">
+              <lucide-icon [img]="icons.calendar" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+              <input type="date" formControlName="dueAt" aria-label="截止日期" />
+            </span>
+          </label>
+        </div>
+
+        <label class="form-field form-field-full">
+          <span class="field-label">负责人 <em>可选</em></span>
+          <span class="input-with-icon">
+            <lucide-icon [img]="icons.user" [size]="16" [strokeWidth]="1.8"></lucide-icon>
+            <input
+              type="text"
+              formControlName="assigneeUserId"
+              maxlength="120"
+              autocomplete="off"
+              placeholder="输入用户 ID"
+              [attr.aria-invalid]="form.controls.assigneeUserId.invalid && form.controls.assigneeUserId.touched"
+              [attr.aria-describedby]="form.controls.assigneeUserId.invalid && form.controls.assigneeUserId.touched ? 'action-assignee-error' : null" />
+          </span>
+          @if (form.controls.assigneeUserId.touched && form.controls.assigneeUserId.hasError('maxlength')) {
+            <span id="action-assignee-error" class="field-error standalone-error">负责人不能超过 120 个字符</span>
+          }
+        </label>
+      </div>
+
+      <footer class="panel-footer">
+        <button type="button" class="button button-secondary" [disabled]="isSubmitting" (click)="cancel()">
+          {{ isSubmitted ? '关闭' : '取消' }}
+        </button>
+        <button type="submit" class="button button-primary" [disabled]="form.invalid || isSubmitting || isSubmitted || !workspaceId.trim()">
+          @if (isSubmitting) {
+            <lucide-icon class="spinning" [img]="icons.loader" [size]="16" [strokeWidth]="2"></lucide-icon>
+            <span>创建中</span>
+          } @else if (isSubmitted) {
+            <lucide-icon [img]="icons.check" [size]="16" [strokeWidth]="2"></lucide-icon>
+            <span>已创建</span>
+          } @else {
+            <span>创建行动项</span>
+            <lucide-icon [img]="icons.arrowRight" [size]="16" [strokeWidth]="2"></lucide-icon>
+          }
+        </button>
+      </footer>
+    </form>
+  </aside>
+</div>

+ 472 - 0
src/modules/shared/components/action-create-panel/action-create-panel.component.scss

@@ -0,0 +1,472 @@
+@use '../../styles/tokens' as t;
+
+:host {
+  position: fixed;
+  z-index: 110000;
+  inset: 0;
+  display: block;
+  color: t.$color-text;
+  font-family: t.$font-family;
+}
+
+.action-panel-backdrop {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  justify-content: flex-end;
+  background: rgba(15, 23, 42, 0.42);
+  backdrop-filter: blur(2px);
+  animation: backdrop-in 160ms ease-out;
+}
+
+.action-panel {
+  display: flex;
+  width: min(480px, 100vw);
+  height: 100%;
+  min-width: 0;
+  flex-direction: column;
+  background: t.$color-surface;
+  border-left: 1px solid rgba(148, 163, 184, 0.34);
+  box-shadow: -18px 0 52px rgba(15, 23, 42, 0.16);
+  animation: panel-in 180ms ease-out;
+}
+
+.panel-header {
+  display: grid;
+  grid-template-columns: 36px minmax(0, 1fr) 34px;
+  gap: 12px;
+  align-items: center;
+  min-height: 76px;
+  padding: 14px 18px;
+  border-bottom: 1px solid t.$color-border-soft;
+}
+
+.header-icon {
+  display: inline-flex;
+  width: 36px;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #dbeafe;
+  border-radius: t.$radius-md;
+  color: t.$color-blue;
+  background: #eff6ff;
+}
+
+.header-copy {
+  min-width: 0;
+}
+
+.header-copy h2 {
+  margin: 0;
+  color: t.$color-text;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 1.35;
+  letter-spacing: 0;
+}
+
+.header-copy p {
+  margin: 3px 0 0;
+  overflow: hidden;
+  color: t.$color-text-muted;
+  font-size: 12px;
+  line-height: 1.45;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.icon-button {
+  display: inline-flex;
+  width: 34px;
+  height: 34px;
+  padding: 0;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid transparent;
+  border-radius: t.$radius-sm;
+  color: t.$color-text-muted;
+  background: transparent;
+  cursor: pointer;
+  transition: color t.$transition-fast, background t.$transition-fast, border-color t.$transition-fast;
+}
+
+.icon-button:hover:not(:disabled) {
+  border-color: t.$color-border;
+  color: t.$color-text;
+  background: t.$color-surface-muted;
+}
+
+.icon-button:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+.source-context {
+  display: flex;
+  min-height: 38px;
+  padding: 8px 18px;
+  align-items: center;
+  gap: 10px;
+  border-bottom: 1px solid t.$color-border-soft;
+  color: t.$color-text-secondary;
+  background: t.$color-surface-muted;
+  font-size: 12px;
+}
+
+.context-item {
+  display: inline-flex;
+  min-width: 0;
+  align-items: center;
+  gap: 6px;
+}
+
+.context-item svg {
+  flex: 0 0 auto;
+  color: t.$color-text-muted;
+}
+
+.context-item span,
+.product-key {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.context-separator {
+  width: 1px;
+  height: 14px;
+  flex: 0 0 auto;
+  background: t.$color-border;
+}
+
+.product-key {
+  color: t.$color-text-muted;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 11px;
+}
+
+.panel-form {
+  display: flex;
+  min-height: 0;
+  flex: 1;
+  flex-direction: column;
+}
+
+.form-body {
+  display: flex;
+  min-height: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 18px;
+  padding: 18px;
+  overflow-y: auto;
+}
+
+.status-banner {
+  display: grid;
+  grid-template-columns: 18px minmax(0, 1fr);
+  gap: 8px;
+  align-items: start;
+  padding: 10px 12px;
+  border: 1px solid;
+  border-radius: t.$radius-sm;
+  font-size: 12px;
+  line-height: 1.55;
+}
+
+.status-banner svg {
+  margin-top: 1px;
+}
+
+.status-success {
+  border-color: #a7f3d0;
+  color: #065f46;
+  background: #ecfdf5;
+}
+
+.status-error {
+  border-color: #fecaca;
+  color: #991b1b;
+  background: #fef2f2;
+}
+
+.form-grid {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 14px;
+}
+
+.form-field {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.field-label {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  color: #374151;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1.35;
+}
+
+.field-label b {
+  color: t.$color-red;
+  font-size: 13px;
+}
+
+.field-label em {
+  color: t.$color-text-subtle;
+  font-size: 11px;
+  font-style: normal;
+  font-weight: 500;
+}
+
+input,
+textarea,
+select {
+  width: 100%;
+  min-width: 0;
+  border: 1px solid #d5dce6;
+  border-radius: t.$radius-sm;
+  color: t.$color-text;
+  background: t.$color-surface;
+  font: inherit;
+  font-size: 13px;
+  transition: border-color t.$transition-fast, box-shadow t.$transition-fast, background t.$transition-fast;
+}
+
+input,
+select {
+  height: 38px;
+  padding: 0 10px;
+}
+
+textarea {
+  min-height: 112px;
+  padding: 9px 10px;
+  line-height: 1.6;
+  resize: vertical;
+}
+
+input::placeholder,
+textarea::placeholder {
+  color: #a5afbd;
+}
+
+input:hover:not(:disabled),
+textarea:hover:not(:disabled),
+select:hover:not(:disabled) {
+  border-color: #aeb9c8;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+  border-color: t.$color-blue;
+  outline: 0;
+  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
+}
+
+input.ng-invalid.ng-touched,
+textarea.ng-invalid.ng-touched,
+select.ng-invalid.ng-touched {
+  border-color: #ef4444;
+}
+
+input:disabled,
+textarea:disabled,
+select:disabled {
+  color: t.$color-text-muted;
+  background: #f1f5f9;
+  cursor: not-allowed;
+}
+
+.select-wrap {
+  position: relative;
+}
+
+.select-wrap::after {
+  position: absolute;
+  top: 50%;
+  right: 11px;
+  width: 6px;
+  height: 6px;
+  border-right: 1.5px solid #64748b;
+  border-bottom: 1.5px solid #64748b;
+  content: '';
+  pointer-events: none;
+  transform: translateY(-70%) rotate(45deg);
+}
+
+.select-wrap select {
+  padding-right: 30px;
+  appearance: none;
+}
+
+.input-with-icon {
+  position: relative;
+  display: block;
+}
+
+.input-with-icon > svg {
+  position: absolute;
+  z-index: 1;
+  top: 50%;
+  left: 10px;
+  color: #7b8797;
+  pointer-events: none;
+  transform: translateY(-50%);
+}
+
+.input-with-icon input {
+  padding-left: 34px;
+}
+
+.field-meta {
+  display: flex;
+  min-height: 16px;
+  justify-content: space-between;
+  gap: 12px;
+  color: t.$color-text-subtle;
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.field-error {
+  min-width: 0;
+  color: t.$color-red;
+}
+
+.standalone-error {
+  font-size: 10px;
+  line-height: 1.5;
+}
+
+.panel-footer {
+  display: flex;
+  min-height: 68px;
+  padding: 14px 18px;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 9px;
+  border-top: 1px solid t.$color-border-soft;
+  background: #fbfcfe;
+}
+
+.button {
+  display: inline-flex;
+  height: 38px;
+  padding: 0 15px;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  border: 1px solid transparent;
+  border-radius: t.$radius-sm;
+  font-size: 12px;
+  font-weight: 700;
+  line-height: 1;
+  cursor: pointer;
+  transition: border-color t.$transition-fast, color t.$transition-fast, background t.$transition-fast, box-shadow t.$transition-fast;
+}
+
+.button-secondary {
+  border-color: #d5dce6;
+  color: t.$color-text-secondary;
+  background: t.$color-surface;
+}
+
+.button-secondary:hover:not(:disabled) {
+  border-color: #aeb9c8;
+  color: t.$color-text;
+  background: t.$color-surface-muted;
+}
+
+.button-primary {
+  min-width: 126px;
+  color: #fff;
+  background: t.$color-blue;
+  box-shadow: 0 6px 16px rgba(37, 99, 235, 0.18);
+}
+
+.button-primary:hover:not(:disabled) {
+  background: #1d4ed8;
+  box-shadow: 0 8px 20px rgba(37, 99, 235, 0.24);
+}
+
+.button:disabled {
+  opacity: 0.55;
+  cursor: not-allowed;
+  box-shadow: none;
+}
+
+.button:focus-visible,
+.icon-button:focus-visible {
+  outline: 3px solid rgba(37, 99, 235, 0.24);
+  outline-offset: 2px;
+}
+
+.spinning {
+  animation: spin 800ms linear infinite;
+}
+
+@keyframes panel-in {
+  from { transform: translateX(24px); }
+  to { transform: translateX(0); }
+}
+
+@keyframes backdrop-in {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+@media (max-width: 560px) {
+  .action-panel {
+    width: 100vw;
+    border-left: 0;
+  }
+
+  .panel-header {
+    min-height: 70px;
+    padding: 12px 14px;
+  }
+
+  .source-context {
+    padding-right: 14px;
+    padding-left: 14px;
+  }
+
+  .form-body {
+    gap: 16px;
+    padding: 16px 14px;
+  }
+
+  .form-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .panel-footer {
+    min-height: 66px;
+    padding: 12px 14px max(12px, env(safe-area-inset-bottom));
+  }
+
+  .button {
+    flex: 1;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .action-panel-backdrop,
+  .action-panel,
+  .spinning {
+    animation-duration: 1ms;
+  }
+}

+ 270 - 0
src/modules/shared/components/action-create-panel/action-create-panel.component.ts

@@ -0,0 +1,270 @@
+import { CommonModule } from '@angular/common';
+import { HttpErrorResponse } from '@angular/common/http';
+import {
+  AfterViewInit,
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  DestroyRef,
+  ElementRef,
+  EventEmitter,
+  HostListener,
+  Input,
+  OnChanges,
+  Output,
+  SimpleChanges,
+  ViewChild,
+  inject,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+  AbstractControl,
+  FormBuilder,
+  ReactiveFormsModule,
+  ValidationErrors,
+  Validators,
+} from '@angular/forms';
+import {
+  ArrowRight,
+  CalendarDays,
+  CircleAlert,
+  CircleCheck,
+  ClipboardCheck,
+  LoaderCircle,
+  LucideAngularModule,
+  Tag,
+  UserRound,
+  X,
+} from 'lucide-angular';
+import { finalize } from 'rxjs';
+import {
+  SaasActionItem,
+  SaasPlatformService,
+} from '../../../../app/core/services/saas-platform.service';
+
+type ActionPriority = SaasActionItem['priority'];
+type SubmissionState = 'idle' | 'loading' | 'success' | 'error';
+
+function trimmedRequired(control: AbstractControl<string>): ValidationErrors | null {
+  return control.value.trim() ? null : { required: true };
+}
+
+@Component({
+  selector: 'app-action-create-panel',
+  standalone: true,
+  imports: [
+    CommonModule,
+    ReactiveFormsModule,
+    LucideAngularModule,
+  ],
+  templateUrl: './action-create-panel.component.html',
+  styleUrls: ['./action-create-panel.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ActionCreatePanelComponent implements AfterViewInit, OnChanges {
+  readonly icons = {
+    arrowRight: ArrowRight,
+    calendar: CalendarDays,
+    alert: CircleAlert,
+    check: CircleCheck,
+    clipboard: ClipboardCheck,
+    loader: LoaderCircle,
+    tag: Tag,
+    user: UserRound,
+    x: X,
+  };
+  private readonly formBuilder = inject(FormBuilder);
+  private readonly platformService = inject(SaasPlatformService);
+  private readonly changeDetectorRef = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+
+  @Input() workspaceId = '';
+  @Input() productKey = '';
+  @Input() suggestedTitle = '';
+  @Input() suggestedDescription = '';
+  @Input() sourceLabel = '';
+  @Input() sourceAnalysisId: string | null = null;
+  @Input() sourceInsightId: string | null = null;
+  @Input() sourceDecisionId: string | null = null;
+  @Input() sourceKind: NonNullable<SaasActionItem['sourceKind']> = 'raw_feedback';
+  @Input() creationKey: string | null = null;
+  @Input() actionType: SaasActionItem['actionType'] = 'general';
+  @Input() evidenceIds: string[] = [];
+  @Input() validationMetric = '';
+
+  @Output() readonly created = new EventEmitter<SaasActionItem>();
+  @Output() readonly cancelled = new EventEmitter<void>();
+
+  @ViewChild('panel') private panel?: ElementRef<HTMLElement>;
+  @ViewChild('titleInput') private titleInput?: ElementRef<HTMLInputElement>;
+
+  readonly priorities: ReadonlyArray<{ value: ActionPriority; label: string }> = [
+    { value: 'critical', label: '紧急' },
+    { value: 'high', label: '高' },
+    { value: 'medium', label: '中' },
+    { value: 'low', label: '低' },
+  ];
+
+  readonly form = this.formBuilder.nonNullable.group({
+    title: ['', [trimmedRequired, Validators.maxLength(120)]],
+    description: ['', [Validators.maxLength(2000)]],
+    priority: ['medium' as ActionPriority, [Validators.required]],
+    assigneeUserId: ['', [Validators.maxLength(120)]],
+    dueAt: [''],
+  });
+
+  submissionState: SubmissionState = 'idle';
+  statusMessage = '';
+
+  get isSubmitting(): boolean {
+    return this.submissionState === 'loading';
+  }
+
+  get isSubmitted(): boolean {
+    return this.submissionState === 'success';
+  }
+
+  ngAfterViewInit(): void {
+    queueMicrotask(() => this.titleInput?.nativeElement.focus());
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['suggestedTitle'] && this.form.controls.title.pristine) {
+      this.form.controls.title.setValue(this.suggestedTitle || '');
+    }
+
+    if (changes['suggestedDescription'] && this.form.controls.description.pristine) {
+      this.form.controls.description.setValue(this.suggestedDescription || '');
+    }
+  }
+
+  @HostListener('document:keydown', ['$event'])
+  onDocumentKeydown(event: KeyboardEvent): void {
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      this.cancel();
+      return;
+    }
+
+    if (event.key === 'Tab') {
+      this.keepFocusInPanel(event);
+    }
+  }
+
+  onBackdropClick(event: MouseEvent): void {
+    if (event.target === event.currentTarget) {
+      this.cancel();
+    }
+  }
+
+  cancel(): void {
+    if (this.isSubmitting) return;
+    this.cancelled.emit();
+  }
+
+  submit(): void {
+    if (this.isSubmitting || this.isSubmitted) return;
+
+    this.form.markAllAsTouched();
+    if (this.form.invalid) {
+      this.submissionState = 'error';
+      this.statusMessage = '请检查必填项和字段长度。';
+      this.focusFirstInvalidControl();
+      return;
+    }
+
+    const workspaceId = this.workspaceId.trim();
+    if (!workspaceId) {
+      this.submissionState = 'error';
+      this.statusMessage = '缺少工作区信息,请刷新页面后重试。';
+      return;
+    }
+
+    const value = this.form.getRawValue();
+    this.submissionState = 'loading';
+    this.statusMessage = '';
+
+    this.platformService.createAction(workspaceId, {
+      actionType: this.actionType,
+      title: value.title.trim(),
+      description: value.description.trim(),
+      priority: value.priority,
+      status: 'open',
+      productKey: this.productKey.trim() || null,
+      assigneeUserId: value.assigneeUserId.trim() || null,
+      dueAt: this.toDueAt(value.dueAt),
+      sourceAnalysisId: this.sourceAnalysisId?.trim() || null,
+      sourceInsightId: this.sourceInsightId?.trim() || null,
+      sourceDecisionId: this.sourceDecisionId?.trim() || null,
+      sourceKind: this.sourceKind,
+      creationKey: this.creationKey?.trim() || null,
+      evidenceIds: [...new Set(this.evidenceIds.map((id) => id.trim()).filter(Boolean))],
+      validationMetric: this.validationMetric.trim(),
+    }).pipe(
+      takeUntilDestroyed(this.destroyRef),
+      finalize(() => {
+        if (this.submissionState === 'loading') {
+          this.submissionState = 'idle';
+        }
+        this.changeDetectorRef.markForCheck();
+      }),
+    ).subscribe({
+      next: ({ action }) => {
+        this.submissionState = 'success';
+        this.statusMessage = '行动项已创建,可在行动中心继续跟进。';
+        this.form.disable({ emitEvent: false });
+        this.created.emit(action);
+      },
+      error: (error: unknown) => {
+        this.submissionState = 'error';
+        this.statusMessage = this.errorMessage(error);
+      },
+    });
+  }
+
+  private toDueAt(value: string): string | null {
+    if (!value) return null;
+    return new Date(`${value}T23:59:59.999`).toISOString();
+  }
+
+  private errorMessage(error: unknown): string {
+    if (error instanceof HttpErrorResponse) {
+      const body = error.error as { message?: unknown } | null;
+      if (typeof body?.message === 'string' && body.message.trim()) {
+        return body.message;
+      }
+      if (error.status === 0) {
+        return '服务暂时不可用,请检查网络后重试。';
+      }
+    }
+    return '创建失败,请稍后重试。';
+  }
+
+  private focusFirstInvalidControl(): void {
+    const invalidControl = this.panel?.nativeElement.querySelector<HTMLElement>(
+      'input.ng-invalid, textarea.ng-invalid, select.ng-invalid',
+    );
+    invalidControl?.focus();
+  }
+
+  private keepFocusInPanel(event: KeyboardEvent): void {
+    const root = this.panel?.nativeElement;
+    if (!root) return;
+
+    const focusable = Array.from(root.querySelectorAll<HTMLElement>(
+      'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
+    )).filter((element) => !element.hasAttribute('hidden'));
+
+    if (!focusable.length) return;
+    const first = focusable[0];
+    const last = focusable[focusable.length - 1];
+
+    if (event.shiftKey && document.activeElement === first) {
+      event.preventDefault();
+      last.focus();
+    } else if (!event.shiftKey && document.activeElement === last) {
+      event.preventDefault();
+      first.focus();
+    }
+  }
+}

+ 7 - 1
src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.html

@@ -7,7 +7,7 @@
   </div>
 
   <!-- #17 数据源异常时对 AI 结论降级提醒 -->
-  <app-data-source-alert context="ai"></app-data-source-alert>
+  <app-data-source-alert *ngIf="showDataSourceAlert" context="ai"></app-data-source-alert>
 
   <div class="ai-summary-card">
     <!-- Loading state (no text yet) -->
@@ -29,6 +29,12 @@
     <!-- Structured visual report -->
     <div class="ai-summary-body ai-summary-body-visual" *ngIf="visualReport">
       <app-ai-visual-report [report]="visualReport"></app-ai-visual-report>
+      <div class="ai-visual-streaming" *ngIf="visualReportStreaming">
+        <div class="ai-dots">
+          <span></span><span></span><span></span>
+        </div>
+        <span>AI 生成中,报告将随生成进度持续更新…</span>
+      </div>
     </div>
 
     <!-- Visual report stream placeholder (avoid showing partial JSON) -->

+ 9 - 0
src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.scss

@@ -136,6 +136,15 @@
   padding: 8px 0;
 }
 
+.ai-visual-streaming {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: #475467;
+  font-size: 13px;
+  padding: 8px 0;
+}
+
 .ai-icon-wrap {
   flex-shrink: 0;
   width: 28px;

+ 127 - 0
src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.spec.ts

@@ -0,0 +1,127 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { Subject } from 'rxjs';
+import { AiAnalysisPanelComponent } from './ai-analysis-panel.component';
+import { AiAnalysisService, AiStreamState } from '../../services/ai-analysis.service';
+import { AiPromptConfigService } from '../../services/ai-prompt-config.service';
+import { PageDataContextService } from '../../services/page-data-context.service';
+
+describe('AiAnalysisPanelComponent visual report streaming', () => {
+  let fixture: ComponentFixture<AiAnalysisPanelComponent>;
+  let component: AiAnalysisPanelComponent;
+  let streamSubject: Subject<AiStreamState>;
+
+  beforeEach(async () => {
+    streamSubject = new Subject<AiStreamState>();
+    const aiService = {
+      streamAnalysis: jasmine.createSpy('streamAnalysis').and.returnValue(streamSubject.asObservable()),
+    };
+    const promptConfig = {
+      getTextSnapshot: (_key: string, fallback: string) => fallback,
+      getTextForDefaultTemplate: (template: string) => template,
+      getPromptKeyForTemplateText: () => undefined,
+    };
+    const pageContext = {
+      buildFullPrompt: (prompt: string) => prompt,
+    };
+
+    await TestBed.configureTestingModule({
+      imports: [AiAnalysisPanelComponent],
+      schemas: [NO_ERRORS_SCHEMA],
+      providers: [
+        { provide: AiAnalysisService, useValue: aiService },
+        { provide: AiPromptConfigService, useValue: promptConfig },
+        { provide: PageDataContextService, useValue: pageContext },
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(AiAnalysisPanelComponent);
+    component = fixture.componentInstance;
+  });
+
+  afterEach(() => {
+    streamSubject.complete();
+    fixture.destroy();
+  });
+
+  function setVisualInputs(): void {
+    component.renderMode = 'visual-report';
+    component.usePageContext = false;
+    component.prompt = '分析竞品评论';
+    component.autoGenerate = false;
+  }
+
+  it('shows the streaming flag while a partial visual report is being streamed', () => {
+    setVisualInputs();
+    component.generate();
+
+    const partialJson = JSON.stringify({
+      visualReport: {
+        title: '报告',
+        blocks: [{ type: 'metric-grid', metrics: [{ label: '有效评论数', value: '3' }] }],
+      },
+    });
+    streamSubject.next({ text: partialJson, delta: '', loading: true, complete: false });
+
+    expect(component.visualReport).not.toBeNull();
+    expect(component.visualReportStreaming).toBeTrue();
+    expect(component.shouldShowMarkdownText).toBeFalse();
+  });
+
+  it('clears the streaming flag once the stream completes with a full report', () => {
+    setVisualInputs();
+    component.generate();
+
+    const partialJson = JSON.stringify({
+      visualReport: {
+        title: '报告',
+        blocks: [{ type: 'metric-grid', metrics: [{ label: '有效评论数', value: '3' }] }],
+      },
+    });
+    streamSubject.next({ text: partialJson, delta: '', loading: true, complete: false });
+    expect(component.visualReportStreaming).toBeTrue();
+
+    const fullJson = JSON.stringify({
+      visualReport: {
+        title: '报告',
+        blocks: [{ type: 'metric-grid', metrics: [{ label: '有效评论数', value: '3' }] }],
+        summary: '结论',
+      },
+    });
+    streamSubject.next({ text: fullJson, delta: '', loading: true, complete: true });
+
+    expect(component.visualReportStreaming).toBeFalse();
+  });
+
+  it('cleans up the partial report when the stream ends with an error', () => {
+    setVisualInputs();
+    component.generate();
+
+    const partialJson = JSON.stringify({
+      visualReport: {
+        title: '报告',
+        blocks: [{ type: 'metric-grid', metrics: [{ label: '有效评论数', value: '3' }] }],
+      },
+    });
+    streamSubject.next({ text: partialJson, delta: '', loading: true, complete: false });
+    expect(component.visualReport).not.toBeNull();
+
+    streamSubject.next({ text: partialJson, delta: '', loading: false, complete: true, error: 'AI 服务暂不可用: 超时' });
+
+    expect(component.visualReport).toBeNull();
+    expect(component.visualReportStreaming).toBeFalse();
+    expect(component.visualReportPending).toBeFalse();
+    expect(component.text).toBe('');
+    expect(component.error).toBe('AI 服务暂不可用: 超时');
+  });
+
+  it('keeps markdown text hidden while a partial visual report is shown', () => {
+    setVisualInputs();
+    component.generate();
+    expect(component.shouldShowMarkdownText).toBeFalse();
+
+    const partialJson = JSON.stringify({ visualReport: { title: '报告', blocks: [] } });
+    streamSubject.next({ text: partialJson, delta: '', loading: true, complete: false });
+    expect(component.shouldShowMarkdownText).toBeFalse();
+  });
+});

+ 25 - 2
src/modules/shared/components/ai-analysis-panel/ai-analysis-panel.component.ts

@@ -21,7 +21,7 @@ import { DataSourceAlertComponent } from '../data-source-alert/data-source-alert
 })
 export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
   @Input() prompt = '';
-  @Input() systemPrompt = '你是一名专业的亚马逊跨境电商运营分析师,请基于数据给出简洁专业的中文分析,使用Markdown格式输出。';
+  @Input() systemPrompt = '你是一名专业的国内电商 VOC 经营分析师。请严格区分本品、竞品和数据时间范围,只依据真实数据与评论证据给出简洁、专业、可落地的中文分析;数据不足时明确说明,使用 Markdown 格式输出。';
   @Input() title = 'AI 分析总结';
   @Input() promptKey: AiOutputStyleTargetKey | '' = '';
   @Input() temperature = 0.6;
@@ -29,6 +29,8 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
   @Input() size: 'full' | 'mini' = 'full';
   @Input() enablePhraseLinks = false;
   @Input() renderMode: 'auto' | 'markdown' | 'visual-report' = 'auto';
+  /** 国内数据页可关闭原跨境数据源健康提示。 */
+  @Input() showDataSourceAlert = true;
   /** 是否自动注入当前页面数据上下文到 prompt 中 */
   @Input() usePageContext = true;
   /** 开启联网搜索(实时行情/竞品数据) */
@@ -47,6 +49,8 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
   elapsedSeconds = 0;
   visualReport: AiVisualReport | null = null;
   visualReportPending = false;
+  /** 已解析出部分 visualReport、但流式尚未结束时的「生成中」标记。 */
+  visualReportStreaming = false;
 
   private subscription: Subscription | null = null;
   private timerInterval: any = null;
@@ -117,6 +121,7 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
     this.error = '';
     this.visualReport = null;
     this.visualReportPending = false;
+    this.visualReportStreaming = false;
     this.loading = true;
     this.startLoadingTimer();
     this.subscription?.unsubscribe();
@@ -143,7 +148,7 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
           this.text = state.text;
           this.updateVisualReportState(state.complete);
         }
-        if (state.error) { this.error = state.error; this.loading = false; this.stopLoadingTimer(); }
+        if (state.error) { this.error = state.error; this.loading = false; this.stopLoadingTimer(); this.cleanupOnError(); }
         if (state.complete) {
           this.updateVisualReportState(true);
           this.loading = false;
@@ -154,6 +159,7 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
         this.error = '分析请求失败,请重试';
         this.loading = false;
         this.stopLoadingTimer();
+        this.cleanupOnError();
       }
     });
   }
@@ -166,11 +172,13 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
     if (this.renderMode === 'markdown') {
       this.visualReport = null;
       this.visualReportPending = false;
+      this.visualReportStreaming = false;
       return;
     }
 
     const parsed = parseAiVisualReport(this.text);
     this.visualReport = parsed;
+    this.visualReportStreaming = !!parsed && !complete;
     if (parsed) {
       this.visualReportPending = false;
       return;
@@ -180,6 +188,21 @@ export class AiAnalysisPanelComponent implements OnChanges, OnDestroy {
     this.visualReportPending = likelyVisualReport && !complete;
   }
 
+  /** 出错时清理已渲染的部分结果:清空 visualReport/pending/streaming;可视化流同时清空文本,避免裸 JSON 以 Markdown 泄漏。 */
+  private cleanupOnError(): void {
+    const wasVisualStream = this.renderMode === 'visual-report'
+      || this.visualReport !== null
+      || this.visualReportPending
+      || this.visualReportStreaming
+      || looksLikeAiVisualReport(this.text);
+    if (wasVisualStream) {
+      this.text = '';
+    }
+    this.visualReport = null;
+    this.visualReportPending = false;
+    this.visualReportStreaming = false;
+  }
+
   /** 处理 AI 报告区域的点击事件(事件委托) */
   onContentClick(event: MouseEvent): void {
     if (!this.enablePhraseLinks) return;

+ 24 - 10
src/modules/shared/components/ai-chat-modal/ai-chat-modal.component.ts

@@ -63,7 +63,7 @@ export interface ChatMessage {
                 <div class="msg-avatar">✦</div>
                 <div class="msg-bubble">
                   @if (streamingLooksVisual()) {
-                    <div class="msg-visual-pending">正在生成可视化报告…</div>
+                    <div class="msg-visual-pending">正在整理回答…</div>
                   } @else {
                     <div class="msg-content ai-markdown" [innerHTML]="streamingText() | markdown:true" (click)="onMessageClick($event)"></div>
                     <span class="stream-cursor">|</span>
@@ -119,16 +119,16 @@ export class AiChatModalComponent implements OnChanges, OnDestroy, AfterViewChec
   @Input() title = 'AI 深度分析';
   @Input() contextPrompt = '';
   @Input() reportText = '';
-  @Input() systemPrompt = '你是一名专业的亚马逊跨境电商运营分析师。用户之前已获得一份AI分析报告,现在希望就报告内容进行深度讨论。请基于已有数据和报告,给出专业、具体的中文回答,使用Markdown格式。';
+  @Input() systemPrompt = '你是一名专业的国内电商 VOC 经营分析师。用户已经获得一份 AI 分析报告,现在希望围绕报告继续追问。请保留原始数据口径,明确区分本品与竞品、事实与待验证假设,只依据已有上下文和报告给出专业、具体的中文回答,使用 Markdown 格式。';
   @Input() promptKey = '';
   @Input() autoSend = '';
   @Input() websearch = false;
   @Input() thinking = false;
   @Input() quickQuestions: string[] = [
-    '哪些尺码/颜色的差评最集中?具体问题是什么?',
-    '基于评论数据,给出具体的Listing优化建议',
-    '哪些问题是供应链问题,哪些是Listing描述问题?',
-    '给出产品改良的优先级排序和具体建议'
+    '哪些规格的低分评论最集中?具体问题是什么?',
+    '基于评论证据,给出商品详情页优化建议',
+    '哪些问题更可能来自产品、履约或描述不符?',
+    '给出产品改良的优先级和验证方式'
   ];
 
   @Output() close = new EventEmitter<void>();
@@ -293,8 +293,8 @@ export class AiChatModalComponent implements OnChanges, OnDestroy, AfterViewChec
       temperature: 0.6,
       websearch: this.websearch,
       thinking: this.thinking,
-      applyOutputStyle: true,
-      outputStyleTargetKey: this.promptConfig.getChatOutputStyleTargetKey(this.promptKey)
+      outputMode: 'text',
+      applyOutputStyle: false
     }).subscribe({
       next: (state) => {
         if (state.text) {
@@ -303,7 +303,7 @@ export class AiChatModalComponent implements OnChanges, OnDestroy, AfterViewChec
           this.shouldScroll = true;
         }
         if (state.complete) {
-          const finalText = state.text || this.streamingText();
+          const finalText = this.unwrapFollowupText(state.text || this.streamingText());
           this.chatHistory.push({ role: 'assistant', content: finalText });
           this.displayMessages.update(msgs => [...msgs, { role: 'assistant', content: finalText, visualReport: parseAiVisualReport(finalText) }]);
           this.streaming.set(false);
@@ -348,7 +348,21 @@ export class AiChatModalComponent implements OnChanges, OnDestroy, AfterViewChec
 
   /** 流式过程中若输出疑似视觉报告 JSON,显示占位而非裸 JSON。 */
   streamingLooksVisual(): boolean {
-    return looksLikeAiVisualReport(this.streamingText());
+    const text = this.streamingText().trimStart();
+    return looksLikeAiVisualReport(text) || text.startsWith('{');
+  }
+
+  private unwrapFollowupText(value: string): string {
+    const text = String(value || '').trim();
+    if (!text.startsWith('{')) return value;
+    try {
+      const parsed = JSON.parse(text) as Record<string, unknown>;
+      for (const key of ['answer', 'content', 'message']) {
+        const candidate = parsed[key];
+        if (typeof candidate === 'string' && candidate.trim()) return candidate;
+      }
+    } catch {}
+    return value;
   }
 
   private scrollToBottom(): void {

+ 2 - 2
src/modules/shared/components/ai-visual-report/ai-visual-report.component.ts

@@ -757,7 +757,7 @@ export class AiVisualReportComponent {
   }
 
   evidenceMeta(item: AiVisualEvidenceItem): string {
-    return [item.asin, item.rating, item.sentiment, item.source].filter(Boolean).join(' · ');
+    return [item.productId || item.asin, item.rating, item.sentiment, item.source].filter(Boolean).join(' · ');
   }
 
   quadrantX(item: AiVisualQuadrantItem): number {
@@ -908,7 +908,7 @@ export class AiVisualReportComponent {
   }
 
   trackEvidence(index: number, item: AiVisualEvidenceItem): string {
-    return `${item.asin || item.source || ''}-${item.quote || item.text || ''}-${index}`;
+    return `${item.productId || item.asin || item.source || ''}-${item.quote || item.text || ''}-${index}`;
   }
 
   trackQuadrant(index: number, item: AiVisualQuadrantItem): string {

+ 26 - 2
src/modules/shared/components/alert-card/alert-card.component.scss

@@ -122,10 +122,34 @@ $tones: (
 
 @media (max-width: 768px) {
   .alert-card {
-    flex-direction: column;
+    display: grid;
+    grid-template-columns: auto minmax(0, 1fr);
+    gap: 8px 10px;
+    padding: 14px;
+  }
+
+  .alert-main {
+    grid-column: 2;
+  }
+
+  .alert-title-row {
+    align-items: flex-start;
+    flex-wrap: wrap;
+    gap: 6px;
+
+    strong {
+      font-size: 16px;
+    }
+  }
+
+  .alert-main p {
+    font-size: 12px;
+    line-height: 1.55;
   }
 
   .alert-actions {
-    width: 100%;
+    grid-column: 2;
+    width: auto;
+    justify-content: flex-start;
   }
 }

+ 15 - 9
src/modules/shared/components/all-reviews-modal/all-reviews-modal.component.ts

@@ -22,12 +22,14 @@ import { EmptyStateComponent } from '../empty-state/empty-state.component';
               @if (showAiButton) {
                 <button class="arm-btn arm-btn-ai" (click)="requestAiAnalysis.emit(asin)">🤖 AI分析</button>
               }
-              <button class="arm-btn arm-translate-btn" [class.active]="isTranslated()"
-                      [disabled]="isTranslating()" (click)="toggleTranslation()">
-                @if (isTranslating()) { 翻译中... }
-                @else if (isTranslated()) { 显示原文 }
-                @else { 🌐 翻译为中文 }
-              </button>
+              @if (showTranslateButton) {
+                <button class="arm-btn arm-translate-btn" [class.active]="isTranslated()"
+                        [disabled]="isTranslating()" (click)="toggleTranslation()">
+                  @if (isTranslating()) { 翻译中... }
+                  @else if (isTranslated()) { 显示原文 }
+                  @else { 🌐 翻译为中文 }
+                </button>
+              }
               <button class="arm-close" (click)="close.emit()">&times;</button>
             </div>
           </div>
@@ -44,7 +46,9 @@ import { EmptyStateComponent } from '../empty-state/empty-state.component';
               }
             </div>
             <div class="arm-right-filters">
-              <button class="arm-btn" [class.active]="vpOnly()" (click)="toggleVpOnly()">仅已购买</button>
+              @if (showVpFilter) {
+                <button class="arm-btn" [class.active]="vpOnly()" (click)="toggleVpOnly()">仅已购买</button>
+              }
               @if (showCacheInfo) {
                 <span class="arm-cache-info">缓存 ({{ reviews.length }}条)</span>
               }
@@ -98,6 +102,8 @@ export class AllReviewsModalComponent implements OnChanges {
   @Input() loading = false;
   @Input() showAiButton = true;
   @Input() showCacheInfo = true;
+  @Input() showTranslateButton = true;
+  @Input() showVpFilter = true;
   @Output() close = new EventEmitter<void>();
   @Output() selectReview = new EventEmitter<any>();
   @Output() requestAiAnalysis = new EventEmitter<string>();
@@ -144,7 +150,7 @@ export class AllReviewsModalComponent implements OnChanges {
     const vp = this.vpOnly();
     if (sf > 0) {
       list = list.filter(r => {
-        const star = Number(r.Star || r.star || 0);
+        const star = Number(r.Star || r.star || r.Rating || r.rating || 0);
         return Math.round(star) === sf;
       });
     }
@@ -170,7 +176,7 @@ export class AllReviewsModalComponent implements OnChanges {
     if (!raw) return raw;
     return {
       ...raw,
-      Star: raw.Star || raw.star || 0,
+      Star: raw.Star || raw.star || raw.Rating || raw.rating || 0,
       Title: raw.Title || raw.title || '',
       Content: raw.Content || raw.content || '',
       Asin: raw.Asin || raw.asin || '',

+ 3 - 2
src/modules/shared/components/content-card/content-card.component.scss

@@ -27,7 +27,8 @@
   }
 
   &.variant-elevated {
-    @include c.surface-card(20px, t.$radius-xl);
+    @include c.surface-card(20px);
+    box-shadow: t.$shadow-float;
   }
 
   &.tone-blue {
@@ -53,7 +54,7 @@
   &.visual-section {
     .content-card-copy h2 {
       font-size: 20px;
-      font-weight: 800;
+      font-weight: 700;
       letter-spacing: -0.02em;
     }
   }

+ 395 - 0
src/modules/shared/components/crud-table-workbench/crud-table-workbench.component.scss

@@ -0,0 +1,395 @@
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.crud-utilities {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+button {
+  font: inherit;
+}
+
+.icon-button,
+.page-button {
+  display: inline-grid;
+  width: 34px;
+  height: 34px;
+  padding: 0;
+  place-items: center;
+  border: 1px solid #dbe2ea;
+  border-radius: 6px;
+  color: #475569;
+  background: #fff;
+  cursor: pointer;
+  transition: border-color 0.15s, color 0.15s, background 0.15s;
+}
+
+.icon-button:hover:not(:disabled),
+.page-button:hover:not(:disabled),
+.icon-button.active {
+  border-color: #2563eb;
+  color: #1d4ed8;
+  background: #eff6ff;
+}
+
+.icon-button:disabled,
+.page-button:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+.utility-popover-wrap {
+  position: relative;
+}
+
+.column-popover {
+  position: absolute;
+  z-index: 40;
+  top: calc(100% + 8px);
+  right: 0;
+  width: 230px;
+  padding: 12px;
+  border: 1px solid #dbe2ea;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
+}
+
+.popover-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-bottom: 9px;
+  border-bottom: 1px solid #eef2f7;
+}
+
+.popover-head strong {
+  color: #1f2937;
+  font-size: 13px;
+}
+
+.popover-head span,
+.column-options small {
+  color: #94a3b8;
+  font-size: 11px;
+}
+
+.column-options {
+  display: grid;
+  gap: 2px;
+  padding-top: 8px;
+}
+
+.column-options label {
+  display: grid;
+  grid-template-columns: 18px minmax(0, 1fr) auto;
+  gap: 8px;
+  align-items: center;
+  min-height: 32px;
+  padding: 0 6px;
+  border-radius: 5px;
+  color: #334155;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+.column-options label:hover {
+  background: #f8fafc;
+}
+
+.column-options label.locked {
+  cursor: default;
+}
+
+.crud-filter-stack {
+  display: grid;
+  width: 100%;
+  gap: 10px;
+}
+
+.crud-query-row,
+.advanced-filter-row {
+  display: grid;
+  grid-template-columns: minmax(240px, 1.4fr) repeat(3, minmax(150px, 0.75fr)) auto auto;
+  gap: 10px;
+  align-items: end;
+  width: 100%;
+}
+
+.advanced-filter-row {
+  grid-template-columns: repeat(4, minmax(160px, 1fr));
+  padding-top: 10px;
+  border-top: 1px dashed #e2e8f0;
+}
+
+.query-search,
+.query-filter {
+  display: grid;
+  min-width: 0;
+  gap: 5px;
+}
+
+.query-search msq-search-box,
+.query-filter msq-dropdown {
+  width: 100%;
+  min-width: 0;
+}
+
+.query-input {
+  width: 100%;
+  min-width: 0;
+  height: 34px;
+  padding: 0 10px;
+  border: 1px solid #dbe2ea;
+  border-radius: 6px;
+  color: #334155;
+  background: #fff;
+  font: inherit;
+  font-size: 12px;
+}
+
+.query-input:focus {
+  border-color: #2563eb;
+  outline: 0;
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
+}
+
+.control-label {
+  color: #64748b;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.query-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+}
+
+.primary-command,
+.secondary-command,
+.more-filter-button {
+  display: inline-flex;
+  min-height: 34px;
+  padding: 0 14px;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  border: 1px solid transparent;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 700;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.primary-command {
+  color: #fff;
+  background: #2563eb;
+}
+
+.primary-command:hover {
+  background: #1d4ed8;
+}
+
+.secondary-command,
+.more-filter-button {
+  border-color: #dbe2ea;
+  color: #475569;
+  background: #fff;
+}
+
+.secondary-command:hover:not(:disabled),
+.more-filter-button:hover,
+.more-filter-button.active {
+  border-color: #93c5fd;
+  color: #1d4ed8;
+  background: #eff6ff;
+}
+
+.secondary-command:disabled {
+  opacity: 0.45;
+  cursor: not-allowed;
+}
+
+.more-filter-button span {
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  border-radius: 9px;
+  color: #fff;
+  background: #2563eb;
+  font-size: 10px;
+  line-height: 18px;
+}
+
+.more-filter-button b {
+  font-size: 11px;
+}
+
+.active-filter-row,
+.batch-action-row {
+  display: flex;
+  min-height: 32px;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 7px;
+}
+
+.active-filter-label {
+  color: #64748b;
+  font-size: 11px;
+  font-weight: 700;
+}
+
+.filter-chip {
+  display: inline-flex;
+  min-height: 26px;
+  padding: 0 9px;
+  align-items: center;
+  gap: 7px;
+  border: 1px solid #bfdbfe;
+  border-radius: 13px;
+  color: #1d4ed8;
+  background: #eff6ff;
+  font-size: 11px;
+  cursor: pointer;
+}
+
+.filter-chip b {
+  font-size: 13px;
+}
+
+.clear-filter-link {
+  padding: 4px 2px;
+  border: 0;
+  color: #64748b;
+  background: transparent;
+  font-size: 11px;
+  cursor: pointer;
+}
+
+.clear-filter-link:hover {
+  color: #dc2626;
+}
+
+.batch-action-row {
+  padding: 8px 10px;
+  border: 1px solid #bfdbfe;
+  border-radius: 6px;
+  color: #1e40af;
+  background: #eff6ff;
+  font-size: 12px;
+}
+
+.batch-action-row button {
+  min-height: 28px;
+  padding: 0 10px;
+  border: 1px solid #93c5fd;
+  border-radius: 5px;
+  color: #1d4ed8;
+  background: #fff;
+  font-size: 11px;
+  font-weight: 700;
+  cursor: pointer;
+}
+
+.batch-action-row button.danger {
+  border-color: #fecaca;
+  color: #b91c1c;
+}
+
+.batch-action-row .batch-clear {
+  margin-left: auto;
+  border-color: transparent;
+  background: transparent;
+}
+
+.projected-table {
+  min-width: 0;
+}
+
+.projected-table.density-compact ::ng-deep table th,
+.projected-table.density-compact ::ng-deep table td {
+  height: 50px;
+  padding-top: 8px;
+  padding-bottom: 8px;
+}
+
+.result-summary {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.crud-pagination {
+  display: inline-flex;
+  align-items: center;
+  gap: 7px;
+}
+
+.page-size-select {
+  width: 104px;
+}
+
+.page-indicator {
+  color: #64748b;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.page-indicator strong {
+  color: #1f2937;
+}
+
+.spinning {
+  display: inline-block;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+@media (max-width: 1180px) {
+  .crud-query-row {
+    grid-template-columns: minmax(240px, 1.3fr) repeat(2, minmax(150px, 0.8fr)) auto;
+  }
+
+  .query-actions {
+    grid-column: 1 / -1;
+    justify-content: flex-end;
+  }
+}
+
+@media (max-width: 720px) {
+  .crud-query-row,
+  .advanced-filter-row {
+    grid-template-columns: 1fr;
+  }
+
+  .query-actions {
+    grid-column: auto;
+    justify-content: stretch;
+  }
+
+  .query-actions button {
+    flex: 1;
+  }
+
+  .more-filter-button {
+    width: 100%;
+  }
+
+  .crud-pagination {
+    width: 100%;
+    justify-content: space-between;
+  }
+
+  .batch-action-row .batch-clear {
+    margin-left: 0;
+  }
+}

+ 397 - 0
src/modules/shared/components/crud-table-workbench/crud-table-workbench.component.ts

@@ -0,0 +1,397 @@
+import { CommonModule } from '@angular/common';
+import {
+  ChangeDetectionStrategy,
+  Component,
+  ElementRef,
+  EventEmitter,
+  HostListener,
+  Input,
+  Output,
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { DataTableShellComponent } from '../data-table-shell/data-table-shell.component';
+import { MsqDropdownComponent, MsqDropdownOption } from '../msq-dropdown/msq-dropdown.component';
+import { MsqSearchBoxComponent } from '../msq-search-box/msq-search-box.component';
+
+export interface CrudTableFilter {
+  key: string;
+  label: string;
+  value: string;
+  placeholder?: string;
+  options?: MsqDropdownOption[];
+  type?: 'select' | 'text' | 'date';
+}
+
+export interface CrudTableColumn {
+  key: string;
+  label: string;
+  visible: boolean;
+  locked?: boolean;
+}
+
+export interface CrudTableBulkAction {
+  key: string;
+  label: string;
+  tone?: 'default' | 'danger';
+}
+
+@Component({
+  selector: 'app-crud-table-workbench',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    DataTableShellComponent,
+    MsqDropdownComponent,
+    MsqSearchBoxComponent,
+  ],
+  template: `
+    <app-data-table-shell
+      [title]="title"
+      [subtitle]="subtitle"
+      [count]="total"
+      variant="admin"
+      [density]="density"
+      [scrollable]="true"
+      [hasToolbar]="true"
+      [hasFilters]="true"
+      [hasFooter]="true"
+      [empty]="total === 0"
+      [emptyTitle]="emptyTitle"
+      [emptyDescription]="emptyDescription"
+      [hasPagination]="total > 0"
+      [loading]="loading">
+
+      <div dataTableToolbar class="crud-utilities">
+        <button
+          type="button"
+          class="icon-button"
+          [class.active]="density === 'compact'"
+          title="切换列表密度"
+          aria-label="切换列表密度"
+          (click)="toggleDensity()">
+          ≡
+        </button>
+
+        @if (columns.length) {
+          <div class="utility-popover-wrap">
+            <button
+              type="button"
+              class="icon-button"
+              [class.active]="columnsOpen"
+              title="管理显示列"
+              aria-label="管理显示列"
+              (click)="toggleColumns($event)">
+              ☷
+            </button>
+            @if (columnsOpen) {
+              <div class="column-popover" role="dialog" aria-label="显示列设置" (click)="$event.stopPropagation()">
+                <div class="popover-head">
+                  <strong>显示列</strong>
+                  <span>{{ visibleColumnCount }} / {{ columns.length }}</span>
+                </div>
+                <div class="column-options">
+                  @for (column of columns; track column.key) {
+                    <label [class.locked]="column.locked">
+                      <input
+                        type="checkbox"
+                        [checked]="column.visible"
+                        [disabled]="column.locked"
+                        (change)="toggleColumn(column.key)" />
+                      <span>{{ column.label }}</span>
+                      @if (column.locked) { <small>固定</small> }
+                    </label>
+                  }
+                </div>
+              </div>
+            }
+          </div>
+        }
+
+        @if (showRefresh) {
+          <button type="button" class="icon-button" title="刷新列表" aria-label="刷新列表" [disabled]="loading" (click)="refresh.emit()">
+            <span [class.spinning]="loading">↻</span>
+          </button>
+        }
+        @if (showExport) {
+          <button type="button" class="icon-button" title="导出当前结果" aria-label="导出当前结果" (click)="exportRequested.emit('all')">⇩</button>
+        }
+        @if (showCreate) {
+          <button type="button" class="primary-command" (click)="create.emit()"><span>+</span>{{ createLabel }}</button>
+        }
+      </div>
+
+      <div dataTableFilters class="crud-filter-stack">
+        <div class="crud-query-row">
+          <div class="query-search">
+            <span class="control-label">关键词</span>
+            <msq-search-box
+              [ngModel]="query"
+              [placeholder]="queryPlaceholder"
+              (valueChange)="onQueryValue($event)"
+              (search)="apply.emit()">
+            </msq-search-box>
+          </div>
+
+          @for (filter of filters.slice(0, maxInlineFilters); track filter.key) {
+            <label class="query-filter">
+              <span class="control-label">{{ filter.label }}</span>
+              @if (filter.type === 'text' || filter.type === 'date') {
+                <input
+                  class="query-input"
+                  [type]="filter.type"
+                  [ngModel]="filter.value"
+                  [placeholder]="filter.placeholder || '请输入' + filter.label"
+                  (ngModelChange)="updateFilter(filter.key, $event)" />
+              } @else {
+                <msq-dropdown
+                  [ngModel]="filter.value"
+                  [options]="filter.options || []"
+                  [placeholder]="filter.placeholder || '全部' + filter.label"
+                  (valueChange)="updateFilter(filter.key, $event)">
+                </msq-dropdown>
+              }
+            </label>
+          }
+
+          @if (filters.length > maxInlineFilters) {
+            <button type="button" class="more-filter-button" [class.active]="advancedOpen" (click)="advancedOpen = !advancedOpen">
+              更多筛选
+              @if (hiddenActiveCount) { <span>{{ hiddenActiveCount }}</span> }
+              <b>{{ advancedOpen ? '⌃' : '⌄' }}</b>
+            </button>
+          }
+
+          <div class="query-actions">
+            <button type="button" class="secondary-command" [disabled]="!hasCriteria" (click)="clearAll()">重置</button>
+            <button type="button" class="primary-command" (click)="apply.emit()">查询</button>
+          </div>
+        </div>
+
+        @if (advancedOpen && filters.length > maxInlineFilters) {
+          <div class="advanced-filter-row">
+            @for (filter of filters.slice(maxInlineFilters); track filter.key) {
+              <label class="query-filter">
+                <span class="control-label">{{ filter.label }}</span>
+                @if (filter.type === 'text' || filter.type === 'date') {
+                  <input
+                    class="query-input"
+                    [type]="filter.type"
+                    [ngModel]="filter.value"
+                    [placeholder]="filter.placeholder || '请输入' + filter.label"
+                    (ngModelChange)="updateFilter(filter.key, $event)" />
+                } @else {
+                  <msq-dropdown
+                    [ngModel]="filter.value"
+                    [options]="filter.options || []"
+                    [placeholder]="filter.placeholder || '全部' + filter.label"
+                    (valueChange)="updateFilter(filter.key, $event)">
+                  </msq-dropdown>
+                }
+              </label>
+            }
+          </div>
+        }
+
+        @if (activeFilters.length || query) {
+          <div class="active-filter-row">
+            <span class="active-filter-label">已选条件</span>
+            @if (query) {
+              <button type="button" class="filter-chip" (click)="clearQuery()">关键词:{{ query }} <b>×</b></button>
+            }
+            @for (filter of activeFilters; track filter.key) {
+              <button type="button" class="filter-chip" (click)="updateFilter(filter.key, '')">
+                {{ filter.label }}:{{ optionLabel(filter) }} <b>×</b>
+              </button>
+            }
+            <button type="button" class="clear-filter-link" (click)="clearAll()">清空全部</button>
+          </div>
+        }
+
+        @if (selectedCount > 0) {
+          <div class="batch-action-row">
+            <span>已选择 <strong>{{ selectedCount }}</strong> 项</span>
+            @for (action of bulkActions; track action.key) {
+              <button type="button" [class.danger]="action.tone === 'danger'" (click)="bulkAction.emit(action.key)">{{ action.label }}</button>
+            }
+            <button type="button" class="batch-clear" (click)="clearSelection.emit()">取消选择</button>
+          </div>
+        }
+      </div>
+
+      <div class="projected-table" [class.density-compact]="density === 'compact'">
+        <ng-content></ng-content>
+      </div>
+
+      <div dataTableFooter class="result-summary">
+        @if (total > 0) {
+          显示第 {{ resultStart }}–{{ resultEnd }} 条,共 {{ total }} 条
+        } @else {
+          当前条件下没有数据
+        }
+      </div>
+
+      <div dataTablePagination class="crud-pagination">
+        <msq-dropdown
+          class="page-size-select"
+          [ngModel]="String(pageSize)"
+          [options]="pageSizeDropdownOptions"
+          (valueChange)="setPageSize($event)">
+        </msq-dropdown>
+        <button type="button" class="page-button" title="上一页" aria-label="上一页" [disabled]="page <= 1" (click)="setPage(page - 1)">‹</button>
+        <span class="page-indicator">第 <strong>{{ page }}</strong> / {{ pageCount }} 页</span>
+        <button type="button" class="page-button" title="下一页" aria-label="下一页" [disabled]="page >= pageCount" (click)="setPage(page + 1)">›</button>
+      </div>
+    </app-data-table-shell>
+  `,
+  styleUrls: ['./crud-table-workbench.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CrudTableWorkbenchComponent {
+  @Input() title = '数据列表';
+  @Input() subtitle = '';
+  @Input() query = '';
+  @Input() queryPlaceholder = '搜索关键字段';
+  @Input() filters: CrudTableFilter[] = [];
+  @Input() columns: CrudTableColumn[] = [];
+  @Input() bulkActions: CrudTableBulkAction[] = [];
+  @Input() selectedCount = 0;
+  @Input() total = 0;
+  @Input() page = 1;
+  @Input() pageSize = 20;
+  @Input() pageSizeOptions = [10, 20, 50, 100];
+  @Input() maxInlineFilters = 3;
+  @Input() density: 'default' | 'compact' = 'default';
+  @Input() loading = false;
+  @Input() showRefresh = true;
+  @Input() showExport = true;
+  @Input() showCreate = false;
+  @Input() createLabel = '新增';
+  @Input() emptyTitle = '没有匹配的数据';
+  @Input() emptyDescription = '调整关键词或筛选条件后重试。';
+
+  @Output() queryChange = new EventEmitter<string>();
+  @Output() filtersChange = new EventEmitter<CrudTableFilter[]>();
+  @Output() columnsChange = new EventEmitter<CrudTableColumn[]>();
+  @Output() densityChange = new EventEmitter<'default' | 'compact'>();
+  @Output() pageChange = new EventEmitter<number>();
+  @Output() pageSizeChange = new EventEmitter<number>();
+  @Output() apply = new EventEmitter<void>();
+  @Output() reset = new EventEmitter<void>();
+  @Output() refresh = new EventEmitter<void>();
+  @Output() exportRequested = new EventEmitter<'all' | 'selected'>();
+  @Output() create = new EventEmitter<void>();
+  @Output() bulkAction = new EventEmitter<string>();
+  @Output() clearSelection = new EventEmitter<void>();
+
+  advancedOpen = false;
+  columnsOpen = false;
+  readonly String = String;
+
+  constructor(private readonly elementRef: ElementRef<HTMLElement>) {}
+
+  get activeFilters(): CrudTableFilter[] {
+    return this.filters.filter((filter) => !!filter.value);
+  }
+
+  get hiddenActiveCount(): number {
+    return this.filters.slice(this.maxInlineFilters).filter((filter) => !!filter.value).length;
+  }
+
+  get hasCriteria(): boolean {
+    return !!this.query.trim() || this.activeFilters.length > 0;
+  }
+
+  get visibleColumnCount(): number {
+    return this.columns.filter((column) => column.visible).length;
+  }
+
+  get pageCount(): number {
+    return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize)));
+  }
+
+  get resultStart(): number {
+    return this.total ? (this.page - 1) * this.pageSize + 1 : 0;
+  }
+
+  get resultEnd(): number {
+    return Math.min(this.total, this.page * this.pageSize);
+  }
+
+  get pageSizeDropdownOptions(): MsqDropdownOption[] {
+    return this.pageSizeOptions.map((size) => ({ value: String(size), label: `${size} 条/页` }));
+  }
+
+  onQueryValue(value: string): void {
+    this.query = value;
+    this.queryChange.emit(value);
+    this.pageChange.emit(1);
+  }
+
+  updateFilter(key: string, value: string): void {
+    const next = this.filters.map((filter) => filter.key === key ? { ...filter, value } : filter);
+    this.filters = next;
+    this.filtersChange.emit(next);
+    this.pageChange.emit(1);
+  }
+
+  optionLabel(filter: CrudTableFilter): string {
+    return filter.options?.find((option) => option.value === filter.value)?.label || filter.value;
+  }
+
+  clearQuery(): void {
+    this.query = '';
+    this.queryChange.emit('');
+    this.pageChange.emit(1);
+  }
+
+  clearAll(): void {
+    this.query = '';
+    this.queryChange.emit('');
+    const next = this.filters.map((filter) => ({ ...filter, value: '' }));
+    this.filters = next;
+    this.filtersChange.emit(next);
+    this.pageChange.emit(1);
+    this.reset.emit();
+  }
+
+  toggleDensity(): void {
+    this.density = this.density === 'compact' ? 'default' : 'compact';
+    this.densityChange.emit(this.density);
+  }
+
+  toggleColumns(event: MouseEvent): void {
+    event.stopPropagation();
+    this.columnsOpen = !this.columnsOpen;
+  }
+
+  toggleColumn(key: string): void {
+    const target = this.columns.find((column) => column.key === key);
+    if (!target || target.locked) return;
+    const visibleCount = this.visibleColumnCount;
+    if (target.visible && visibleCount <= 1) return;
+    const next = this.columns.map((column) => column.key === key ? { ...column, visible: !column.visible } : column);
+    this.columns = next;
+    this.columnsChange.emit(next);
+  }
+
+  setPage(page: number): void {
+    const next = Math.min(this.pageCount, Math.max(1, page));
+    if (next !== this.page) this.pageChange.emit(next);
+  }
+
+  setPageSize(value: string): void {
+    const size = Number(value);
+    if (!Number.isFinite(size) || size <= 0) return;
+    this.pageSizeChange.emit(size);
+    this.pageChange.emit(1);
+  }
+
+  @HostListener('document:click', ['$event'])
+  closePopovers(event: MouseEvent): void {
+    const wrapper = this.elementRef.nativeElement.querySelector('.utility-popover-wrap');
+    if (this.columnsOpen && !wrapper?.contains(event.target as Node)) {
+      this.columnsOpen = false;
+    }
+  }
+}

+ 167 - 0
src/modules/shared/components/global-context-bar/global-context-bar.component.html

@@ -0,0 +1,167 @@
+<section class="global-context-bar" aria-label="工作台上下文">
+  <div class="context-fields">
+    <label class="context-field scope-field">
+      <span>工作空间</span>
+      <select
+        aria-label="选择工作空间"
+        [disabled]="disabled"
+        [value]="context.workspaceId"
+        (change)="onSelection('workspaceId', $event)"
+      >
+        <option value="">请选择</option>
+        @if (isMissingOption(workspaceOptions, context.workspaceId)) {
+          <option [value]="context.workspaceId">{{ context.workspaceId }}</option>
+        }
+        @for (option of workspaceOptions; track option.value) {
+          <option [value]="option.value" [disabled]="option.disabled">{{ option.label }}</option>
+        }
+      </select>
+    </label>
+
+    <label class="context-field compact-field">
+      <span>平台</span>
+      <select
+        aria-label="选择平台"
+        [disabled]="disabled"
+        [value]="context.platform"
+        (change)="onSelection('platform', $event)"
+      >
+        <option value="">请选择</option>
+        @if (isMissingOption(platformOptions, context.platform)) {
+          <option [value]="context.platform">{{ context.platform }}</option>
+        }
+        @for (option of platformOptions; track option.value) {
+          <option [value]="option.value" [disabled]="option.disabled">{{ option.label }}</option>
+        }
+      </select>
+    </label>
+
+    <span class="context-divider" aria-hidden="true"></span>
+
+    <label class="context-field">
+      <span>店铺</span>
+      <select
+        aria-label="选择店铺"
+        [disabled]="disabled"
+        [value]="context.shopId"
+        (change)="onSelection('shopId', $event)"
+      >
+        <option value="">全部店铺</option>
+        @if (isMissingOption(shopOptions, context.shopId)) {
+          <option [value]="context.shopId">{{ context.shopId }}</option>
+        }
+        @for (option of shopOptions; track option.value) {
+          <option [value]="option.value" [disabled]="option.disabled">{{ option.label }}</option>
+        }
+      </select>
+    </label>
+
+    <label class="context-field">
+      <span>类目</span>
+      <select
+        aria-label="选择类目"
+        [disabled]="disabled"
+        [value]="context.categoryId"
+        (change)="onSelection('categoryId', $event)"
+      >
+        <option value="">全部类目</option>
+        @if (isMissingOption(categoryOptions, context.categoryId)) {
+          <option [value]="context.categoryId">{{ context.categoryId }}</option>
+        }
+        @for (option of categoryOptions; track option.value) {
+          <option [value]="option.value" [disabled]="option.disabled">{{ option.label }}</option>
+        }
+      </select>
+    </label>
+
+    <label class="context-field product-field">
+      <span>商品</span>
+      <select
+        aria-label="选择商品"
+        [disabled]="disabled"
+        [value]="context.productId"
+        (change)="onSelection('productId', $event)"
+      >
+        <option value="">全部商品</option>
+        @if (isMissingOption(productOptions, context.productId)) {
+          <option [value]="context.productId">{{ context.productId }}</option>
+        }
+        @for (option of productOptions; track option.value) {
+          <option [value]="option.value" [disabled]="option.disabled">{{ option.label }}</option>
+        }
+      </select>
+    </label>
+
+    <div class="context-field date-field">
+      <span>日期</span>
+      @for (renderKey of dateRangeRenderKeys; track renderKey) {
+        <app-date-range-picker
+          [initialRange]="initialDateRange"
+          (rangeConfirm)="onDateRangeConfirm($event)"
+        />
+      }
+    </div>
+
+    <label class="context-field comparison-field">
+      <span>对比</span>
+      <select
+        aria-label="选择对比周期"
+        [disabled]="disabled"
+        [value]="context.comparisonPeriod"
+        (change)="onComparisonChange($event)"
+      >
+        @for (option of comparisonOptions; track option.value) {
+          <option [value]="option.value">{{ option.label }}</option>
+        }
+      </select>
+    </label>
+  </div>
+
+  <div class="context-meta">
+    <div
+      class="availability"
+      [attr.data-status]="effectiveStatus || 'unknown'"
+      [title]="effectiveStatusDetail || statusLabel"
+    >
+      @if (effectiveStatus === 'ready') {
+        <lucide-icon [img]="icons.check" [size]="15" aria-hidden="true" />
+      } @else if (effectiveStatus === 'stale') {
+        <lucide-icon [img]="icons.clock" [size]="15" aria-hidden="true" />
+      } @else {
+        <lucide-icon [img]="icons.alert" [size]="15" aria-hidden="true" />
+      }
+      <span>{{ statusLabel }}</span>
+    </div>
+
+    <span class="last-updated">
+      @if (effectiveLastUpdated) {
+        更新于 {{ effectiveLastUpdated | date:'yyyy-MM-dd HH:mm' }}
+      } @else {
+        更新时间未上报
+      }
+    </span>
+
+    <div class="context-actions">
+      <button
+        type="button"
+        class="icon-command"
+        title="清空筛选"
+        aria-label="清空筛选"
+        [disabled]="disabled || !hasActiveFilters"
+        (click)="clearFilters()"
+      >
+        <lucide-icon [img]="icons.reset" [size]="16" aria-hidden="true" />
+      </button>
+      <button
+        type="button"
+        class="refresh-command"
+        [class.is-loading]="refreshing"
+        [disabled]="disabled || refreshing"
+        (click)="requestRefresh()"
+      >
+        <lucide-icon [img]="icons.refresh" [size]="16" aria-hidden="true" />
+        <span>{{ refreshing ? '刷新中' : '刷新' }}</span>
+      </button>
+    </div>
+  </div>
+</section>

+ 273 - 0
src/modules/shared/components/global-context-bar/global-context-bar.component.scss

@@ -0,0 +1,273 @@
+:host {
+  display: block;
+  min-width: 0;
+  color: #111827;
+}
+
+.global-context-bar {
+  display: grid;
+  gap: 10px;
+  padding: 12px 16px;
+  border: 1px solid #dfe5ec;
+  border-radius: 6px;
+  background: #fff;
+}
+
+.context-fields {
+  display: grid;
+  grid-template-columns:
+    minmax(150px, 1.15fr)
+    minmax(100px, 0.72fr)
+    1px
+    minmax(135px, 1fr)
+    minmax(135px, 1fr)
+    minmax(175px, 1.35fr)
+    minmax(250px, 1.7fr)
+    minmax(145px, 0.95fr);
+  gap: 10px;
+  align-items: end;
+  min-width: 0;
+}
+
+.context-field {
+  display: grid;
+  gap: 5px;
+  min-width: 0;
+  margin: 0;
+
+  > span {
+    color: #667085;
+    font-size: 11px;
+    font-weight: 600;
+    line-height: 1;
+  }
+
+  select {
+    width: 100%;
+    height: 34px;
+    min-width: 0;
+    padding: 0 28px 0 10px;
+    border: 1px solid #d8e0e9;
+    border-radius: 5px;
+    background: #fff;
+    color: #1f2937;
+    font-size: 12px;
+    line-height: 32px;
+    text-overflow: ellipsis;
+    cursor: pointer;
+
+    &:hover:not(:disabled) {
+      border-color: #9aa9ba;
+    }
+
+    &:focus-visible {
+      border-color: #2563eb;
+      outline: 2px solid rgba(37, 99, 235, 0.16);
+      outline-offset: 0;
+    }
+
+    &:disabled {
+      background: #f5f7fa;
+      color: #98a2b3;
+      cursor: not-allowed;
+    }
+  }
+}
+
+.context-divider {
+  width: 1px;
+  height: 34px;
+  background: #e5eaf0;
+}
+
+.date-field {
+  app-date-range-picker {
+    display: block;
+    min-width: 0;
+  }
+
+  ::ng-deep .drp-input-group {
+    min-height: 34px;
+    padding: 4px 9px;
+    border-radius: 5px;
+  }
+
+  ::ng-deep .drp-input-box {
+    min-width: 88px;
+  }
+
+  ::ng-deep .drp-display-text {
+    font-size: 12px;
+  }
+}
+
+.context-meta {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  padding-top: 9px;
+  border-top: 1px solid #edf1f5;
+}
+
+.availability {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  min-width: 0;
+  color: #667085;
+  font-size: 12px;
+  font-weight: 600;
+
+  &[data-status='ready'] { color: #087a55; }
+  &[data-status='stale'] { color: #b25b09; }
+  &[data-status='partial'] { color: #9a6700; }
+  &[data-status='error'] { color: #c4322b; }
+}
+
+.last-updated {
+  margin-left: 12px;
+  padding-left: 12px;
+  border-left: 1px solid #dfe5ec;
+  color: #7b8797;
+  font-size: 11px;
+  white-space: nowrap;
+}
+
+.context-actions {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-left: auto;
+}
+
+.icon-command,
+.refresh-command {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 32px;
+  border: 1px solid #d8e0e9;
+  border-radius: 5px;
+  background: #fff;
+  color: #344054;
+  cursor: pointer;
+  transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
+
+  &:hover:not(:disabled) {
+    border-color: #9aa9ba;
+    background: #f8fafc;
+  }
+
+  &:disabled {
+    color: #a6afbd;
+    cursor: not-allowed;
+  }
+}
+
+.icon-command {
+  width: 32px;
+  padding: 0;
+}
+
+.refresh-command {
+  gap: 6px;
+  min-width: 76px;
+  padding: 0 11px;
+  border-color: #1f2937;
+  background: #1f2937;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 600;
+
+  &:hover:not(:disabled) {
+    border-color: #111827;
+    background: #111827;
+  }
+
+  &:disabled {
+    border-color: #cbd3dd;
+    background: #cbd3dd;
+    color: #fff;
+  }
+
+  &.is-loading lucide-icon {
+    animation: context-spin 900ms linear infinite;
+  }
+}
+
+@keyframes context-spin {
+  to { transform: rotate(360deg); }
+}
+
+@media (max-width: 1260px) {
+  .context-fields {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+  }
+
+  .context-divider {
+    display: none;
+  }
+
+  .date-field {
+    grid-column: span 2;
+  }
+}
+
+@media (max-width: 760px) {
+  .global-context-bar {
+    padding: 12px;
+  }
+
+  .context-fields {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .date-field {
+    grid-column: 1 / -1;
+
+    ::ng-deep .drp-input-group {
+      width: 100%;
+    }
+
+    ::ng-deep .drp-input-box {
+      flex: 1;
+      min-width: 0;
+    }
+
+    ::ng-deep .drp-dropdown {
+      left: 0;
+      width: min(320px, calc(100vw - 50px));
+      min-width: 0;
+    }
+  }
+
+  .context-meta {
+    flex-wrap: wrap;
+    gap: 7px 0;
+  }
+
+  .context-actions {
+    width: 100%;
+    padding-top: 7px;
+    border-top: 1px solid #edf1f5;
+  }
+
+  .refresh-command {
+    flex: 1;
+  }
+}
+
+@media (max-width: 420px) {
+  .context-fields {
+    grid-template-columns: 1fr;
+  }
+
+  .date-field {
+    grid-column: auto;
+  }
+
+  .last-updated {
+    margin-left: 8px;
+    padding-left: 8px;
+  }
+}

+ 61 - 0
src/modules/shared/components/global-context-bar/global-context-bar.component.spec.ts

@@ -0,0 +1,61 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Router, provideRouter } from '@angular/router';
+import { WorkbenchContextService } from '../../../../app/core/services/workbench-context.service';
+import { GlobalContextBarComponent } from './global-context-bar.component';
+
+describe('GlobalContextBarComponent', () => {
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [GlobalContextBarComponent],
+      providers: [provideRouter([])],
+    }).compileComponents();
+  });
+
+  it('renders only caller-provided business options', () => {
+    const fixture = TestBed.createComponent(GlobalContextBarComponent);
+    fixture.componentInstance.workspaceOptions = [
+      { value: 'workspace-real', label: '业务空间 A' },
+    ];
+    fixture.componentInstance.platformOptions = [
+      { value: 'jd', label: '京东' },
+    ];
+    fixture.detectChanges();
+
+    const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
+    expect(text).toContain('业务空间 A');
+    expect(text).toContain('京东');
+    expect(text).not.toContain('示例店铺');
+  });
+
+  it('writes a selection to the shared context and URL', fakeAsync(() => {
+    const fixture = TestBed.createComponent(GlobalContextBarComponent);
+    const service = TestBed.inject(WorkbenchContextService);
+    const router = TestBed.inject(Router);
+    const select = document.createElement('select');
+    const option = document.createElement('option');
+    option.value = 'workspace-real';
+    select.append(option);
+    select.value = 'workspace-real';
+
+    fixture.componentInstance.onSelection('workspaceId', { target: select } as unknown as Event);
+    tick();
+
+    expect(service.snapshot.workspaceId).toBe('workspace-real');
+    expect(router.parseUrl(router.url).queryParams['workspaceId']).toBe('workspace-real');
+  }));
+
+  it('emits refresh without inventing a last-updated timestamp', () => {
+    const fixture = TestBed.createComponent(GlobalContextBarComponent);
+    const service = TestBed.inject(WorkbenchContextService);
+    let componentRefreshes = 0;
+    let serviceRefreshes = 0;
+    fixture.componentInstance.refreshRequested.subscribe(() => componentRefreshes++);
+    service.refreshRequested$.subscribe(() => serviceRefreshes++);
+
+    fixture.componentInstance.requestRefresh();
+
+    expect(componentRefreshes).toBe(1);
+    expect(serviceRefreshes).toBe(1);
+    expect(service.availabilitySnapshot.lastUpdated).toBeNull();
+  });
+});

+ 202 - 0
src/modules/shared/components/global-context-bar/global-context-bar.component.ts

@@ -0,0 +1,202 @@
+import { CommonModule } from '@angular/common';
+import {
+  ChangeDetectionStrategy,
+  ChangeDetectorRef,
+  Component,
+  EventEmitter,
+  Input,
+  Output,
+  inject,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+  CircleAlert,
+  CircleCheck,
+  Clock3,
+  LucideAngularModule,
+  RefreshCw,
+  RotateCcw,
+} from 'lucide-angular';
+import {
+  WorkbenchAvailability,
+  WorkbenchAvailabilityStatus,
+  WorkbenchComparisonPeriod,
+  WorkbenchContext,
+  WorkbenchContextOption,
+  WorkbenchContextService,
+} from '../../../../app/core/services/workbench-context.service';
+import {
+  DateRange,
+  DateRangePickerComponent,
+} from '../date-range-picker/date-range-picker.component';
+
+type ContextSelectField = 'workspaceId' | 'platform' | 'shopId' | 'categoryId' | 'productId';
+
+interface ComparisonOption {
+  value: WorkbenchComparisonPeriod;
+  label: string;
+}
+
+const EMPTY_AVAILABILITY: WorkbenchAvailability = {
+  status: null,
+  lastUpdated: null,
+  detail: '',
+};
+
+@Component({
+  selector: 'app-global-context-bar',
+  standalone: true,
+  imports: [CommonModule, DateRangePickerComponent, LucideAngularModule],
+  templateUrl: './global-context-bar.component.html',
+  styleUrls: ['./global-context-bar.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class GlobalContextBarComponent {
+  private readonly contextService = inject(WorkbenchContextService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private dateRangeKey = '';
+
+  readonly icons = {
+    alert: CircleAlert,
+    check: CircleCheck,
+    clock: Clock3,
+    refresh: RefreshCw,
+    reset: RotateCcw,
+  };
+  readonly comparisonOptions: readonly ComparisonOption[] = [
+    { value: 'none', label: '不对比' },
+    { value: 'previous-period', label: '对比上一周期' },
+    { value: 'previous-year', label: '对比去年同期' },
+  ];
+
+  @Input() workspaceOptions: readonly WorkbenchContextOption[] = [];
+  @Input() platformOptions: readonly WorkbenchContextOption[] = [];
+  @Input() shopOptions: readonly WorkbenchContextOption[] = [];
+  @Input() categoryOptions: readonly WorkbenchContextOption[] = [];
+  @Input() productOptions: readonly WorkbenchContextOption[] = [];
+  @Input() disabled = false;
+  @Input() refreshing = false;
+  @Input() status: WorkbenchAvailabilityStatus | null = null;
+  @Input() lastUpdated: string | Date | null = null;
+  @Input() statusDetail = '';
+
+  @Output() readonly contextChange = new EventEmitter<WorkbenchContext>();
+  @Output() readonly refreshRequested = new EventEmitter<void>();
+
+  context: WorkbenchContext = this.contextService.snapshot;
+  availability: WorkbenchAvailability = EMPTY_AVAILABILITY;
+  dateRangeRenderKeys: readonly number[] = [0];
+
+  constructor() {
+    this.contextService.context$.pipe(takeUntilDestroyed()).subscribe((context) => {
+      this.context = context;
+      const nextDateRangeKey = `${context.dateFrom}:${context.dateTo}`;
+      if (this.dateRangeKey !== nextDateRangeKey) {
+        this.dateRangeKey = nextDateRangeKey;
+        this.dateRangeRenderKeys = [this.dateRangeRenderKeys[0] + 1];
+      }
+      this.cdr.markForCheck();
+    });
+
+    this.contextService.availability$.pipe(takeUntilDestroyed()).subscribe((availability) => {
+      this.availability = availability;
+      this.cdr.markForCheck();
+    });
+  }
+
+  get hasActiveFilters(): boolean {
+    return this.contextService.hasActiveFilters(this.context);
+  }
+
+  get initialDateRange(): DateRange {
+    return {
+      start: this.parseDate(this.context.dateFrom),
+      end: this.parseDate(this.context.dateTo),
+    };
+  }
+
+  get effectiveStatus(): WorkbenchAvailabilityStatus | null {
+    return this.status ?? this.availability.status;
+  }
+
+  get effectiveLastUpdated(): string | Date | null {
+    return this.lastUpdated ?? this.availability.lastUpdated;
+  }
+
+  get effectiveStatusDetail(): string {
+    return this.statusDetail || this.availability.detail;
+  }
+
+  get statusLabel(): string {
+    switch (this.effectiveStatus) {
+      case 'ready': return '数据正常';
+      case 'stale': return '数据陈旧';
+      case 'partial': return '部分可用';
+      case 'error': return '数据异常';
+      default: return '状态待确认';
+    }
+  }
+
+  onSelection(field: ContextSelectField, event: Event): void {
+    const value = (event.target as HTMLSelectElement).value;
+    let patch: Partial<WorkbenchContext> = { [field]: value };
+
+    if (field === 'workspaceId' || field === 'platform') {
+      patch = { ...patch, shopId: '', categoryId: '', productId: '' };
+    } else if (field === 'shopId') {
+      patch = { ...patch, categoryId: '', productId: '' };
+    } else if (field === 'categoryId') {
+      patch = { ...patch, productId: '' };
+    }
+
+    this.commitContext(patch);
+  }
+
+  onDateRangeConfirm(range: DateRange): void {
+    this.commitContext({
+      dateFrom: range.start ? this.formatDate(range.start) : '',
+      dateTo: range.end ? this.formatDate(range.end) : '',
+    });
+  }
+
+  onComparisonChange(event: Event): void {
+    this.commitContext({
+      comparisonPeriod: (event.target as HTMLSelectElement).value as WorkbenchComparisonPeriod,
+    });
+  }
+
+  clearFilters(): void {
+    if (this.disabled || !this.hasActiveFilters) return;
+    this.contextService.clearFilters();
+    this.contextChange.emit(this.contextService.snapshot);
+  }
+
+  requestRefresh(): void {
+    if (this.disabled || this.refreshing) return;
+    this.contextService.requestRefresh();
+    this.refreshRequested.emit();
+  }
+
+  isMissingOption(options: readonly WorkbenchContextOption[], value: string): boolean {
+    return Boolean(value && !options.some((option) => option.value === value));
+  }
+
+  private commitContext(patch: Partial<WorkbenchContext>): void {
+    if (this.disabled) return;
+    this.contextService.updateContext(patch);
+    this.contextChange.emit(this.contextService.snapshot);
+  }
+
+  private parseDate(value: string): Date | null {
+    if (!value) return null;
+    const [year, month, day] = value.split('-').map(Number);
+    return new Date(year, month - 1, day);
+  }
+
+  private formatDate(value: Date): string {
+    const year = value.getFullYear();
+    const month = String(value.getMonth() + 1).padStart(2, '0');
+    const day = String(value.getDate()).padStart(2, '0');
+    return `${year}-${month}-${day}`;
+  }
+}

+ 10 - 10
src/modules/shared/components/header/header.component.html

@@ -10,13 +10,13 @@
       [attr.aria-label]="isCollapsed ? '展开导航' : '收起导航'"
       [attr.aria-expanded]="!isCollapsed"
       (click)="toggleCollapse()">
-      <span class="toggle-icon" [innerHTML]="icon(isCollapsed ? 'panelLeftOpen' : 'panelLeftClose')"></span>
+      <lucide-icon class="toggle-icon" [img]="isCollapsed ? icons.panelLeftOpen : icons.panelLeftClose" [size]="17" [strokeWidth]="1.8"></lucide-icon>
     </button>
   </div>
 
   <!-- 全局搜索 -->
   <div class="header-search" [class.focused]="searchFocused" [class.has-results]="searchResults.length > 0">
-    <span class="search-icon" [innerHTML]="icon('search')"></span>
+    <lucide-icon class="search-icon" [img]="icons.search" [size]="17" [strokeWidth]="1.8"></lucide-icon>
     <input
       #searchInput
       class="search-input"
@@ -29,8 +29,8 @@
       (keydown)="onSearchKeydown($event)"
       (keydown.escape)="clearSearch()"
     />
-    <button class="search-clear" type="button" title="清空搜索" aria-label="清空搜索" *ngIf="searchKeyword" (mousedown)="clearSearch(); $event.preventDefault()">×</button>
-    <span class="search-loading" *ngIf="searchLoading" [innerHTML]="icon('loader')"></span>
+    <button class="search-clear" type="button" title="清空搜索" aria-label="清空搜索" *ngIf="searchKeyword" (mousedown)="clearSearch(); $event.preventDefault()"><lucide-icon [img]="icons.x" [size]="15" [strokeWidth]="1.8"></lucide-icon></button>
+    <lucide-icon class="search-loading" *ngIf="searchLoading" [img]="icons.loader" [size]="16" [strokeWidth]="1.8"></lucide-icon>
 
     <!-- 搜索历史下拉 -->
     <div class="search-dropdown" *ngIf="showHistory">
@@ -41,7 +41,7 @@
       <div class="search-history-list">
         <div class="search-history-item" *ngFor="let h of searchHistory"
              (mousedown)="useHistoryItem(h); $event.preventDefault()">
-          <span class="history-icon" [innerHTML]="icon('clock')"></span>
+          <lucide-icon class="history-icon" [img]="icons.clock" [size]="15" [strokeWidth]="1.8"></lucide-icon>
           <span class="history-text">{{ h }}</span>
         </div>
       </div>
@@ -74,11 +74,11 @@
               <span class="result-metric" *ngIf="item.metric">{{ item.metric.label }}: {{ item.metric.value }}</span>
             </div>
           </div>
-          <span class="result-arrow">→</span>
+          <lucide-icon class="result-arrow" [img]="icons.arrowRight" [size]="15" [strokeWidth]="1.8"></lucide-icon>
         </div>
       </div>
       <div class="search-empty" *ngIf="!searchLoading && searchKeyword.length >= 1 && searchResults.length === 0">
-        <span class="empty-icon" [innerHTML]="icon('searchEmpty')"></span>
+        <lucide-icon class="empty-icon" [img]="icons.searchEmpty" [size]="28" [strokeWidth]="1.6"></lucide-icon>
         <span>未找到与「{{ searchKeyword }}」相关的结果</span>
       </div>
       <div class="search-footer" *ngIf="filteredResults.length > 0">
@@ -100,14 +100,14 @@
       (keydown.space)="$event.preventDefault(); toggleUserMenu()"
       [class.active]="showUserMenu">
       <div class="avatar-circle">
-        <span class="icon header-svg-icon" [innerHTML]="icon('gear')"></span>
+        <lucide-icon class="icon header-svg-icon" [img]="icons.settings" [size]="17" [strokeWidth]="1.8"></lucide-icon>
       </div>
       <div class="dropdown-menu" *ngIf="showUserMenu">
         <div class="dropdown-item" (click)="openPasswordModal()">
-          <span class="icon header-svg-icon" [innerHTML]="icon('lock')"></span> 修改密码
+          <lucide-icon class="icon header-svg-icon" [img]="icons.lock" [size]="16" [strokeWidth]="1.8"></lucide-icon> 修改密码
         </div>
         <div class="dropdown-item danger" (click)="logout()">
-          <span class="icon header-svg-icon" [innerHTML]="icon('logOut')"></span> 退出登录
+          <lucide-icon class="icon header-svg-icon" [img]="icons.logOut" [size]="16" [strokeWidth]="1.8"></lucide-icon> 退出登录
         </div>
       </div>
     </div>

+ 159 - 0
src/modules/shared/components/header/header.component.scss

@@ -662,6 +662,165 @@ $h-dark:    rgba(0,0,0,0.4);
   }
 }
 
+/* Professional operations shell overrides. */
+.app-header {
+  height: 56px;
+  padding: 0 16px;
+  border-bottom: 1px solid #e2e8f0;
+  background: #ffffff;
+  background-image: none;
+  box-shadow: none;
+  color: #334155;
+}
+
+.header-left {
+  min-width: 216px;
+  gap: 12px;
+}
+
+.brand-title {
+  color: #0f172a;
+  font-size: 15px;
+  font-weight: 750;
+  letter-spacing: 0;
+}
+
+.collapse-toggle {
+  width: 34px;
+  height: 34px;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #ffffff;
+  box-shadow: none;
+  color: #64748b;
+
+  &:hover {
+    border-color: #cbd5e1;
+    background: #f8fafc;
+    box-shadow: none;
+    color: #0f172a;
+  }
+
+  &:active {
+    background: #f1f5f9;
+    box-shadow: none;
+    transform: none;
+  }
+
+  &:focus-visible {
+    outline: 2px solid rgba(37, 99, 235, 0.35);
+    outline-offset: 1px;
+  }
+}
+
+.header-search {
+  max-width: 600px;
+  height: 36px;
+  margin: 0 clamp(16px, 4vw, 56px);
+  padding: 0 11px;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #f8fafc;
+  box-shadow: none;
+
+  &.focused {
+    border-color: #93c5fd;
+    background: #ffffff;
+    box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
+  }
+
+  .search-icon,
+  &.focused .search-icon {
+    margin-right: 8px;
+    color: #64748b;
+    opacity: 1;
+  }
+
+  .search-input {
+    color: #0f172a;
+    font-size: 13px;
+    letter-spacing: 0;
+
+    &::placeholder {
+      color: #94a3b8;
+    }
+  }
+
+  .search-clear {
+    display: inline-grid;
+    width: 28px;
+    height: 28px;
+    place-items: center;
+    padding: 0;
+    border-radius: 5px;
+    color: #64748b;
+
+    &:hover {
+      background: #e2e8f0;
+      color: #0f172a;
+    }
+  }
+
+  .search-loading {
+    color: #64748b;
+  }
+}
+
+.search-dropdown {
+  border-radius: 8px;
+  border-color: #dbe3ec;
+  box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
+}
+
+.user-profile {
+  border: 1px solid transparent;
+  border-radius: 6px;
+
+  &:hover,
+  &.active {
+    border-color: #e2e8f0;
+    background: #f8fafc;
+    box-shadow: none;
+  }
+}
+
+.avatar-circle {
+  width: 34px;
+  height: 34px;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #ffffff;
+  box-shadow: none;
+  color: #475569;
+}
+
+.dropdown-menu {
+  border-radius: 8px;
+  border-color: #dbe3ec;
+  box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
+}
+
+@media (max-width: 720px) {
+  .app-header {
+    padding: 0 10px;
+  }
+
+  .header-left {
+    min-width: 0;
+  }
+
+  .brand-title {
+    max-width: 130px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .header-search {
+    margin: 0 8px;
+  }
+}
+
 @media (max-width: 720px) {
   .app-header {
     padding: 0 10px;

+ 28 - 8
src/modules/shared/components/header/header.component.ts

@@ -1,23 +1,48 @@
 import { Component, OnDestroy, HostListener, ViewChild, ElementRef, Injector } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router } from '@angular/router';
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import {
+  ArrowRight,
+  Clock3,
+  LoaderCircle,
+  LockKeyhole,
+  LogOut,
+  LucideAngularModule,
+  PanelLeftClose,
+  PanelLeftOpen,
+  Search,
+  SearchX,
+  Settings2,
+  X,
+} from 'lucide-angular';
 import { Observable, of, Subject } from 'rxjs';
 import { catchError, debounceTime, map, switchMap, takeUntil } from 'rxjs/operators';
 import { LayoutService } from '../../../../app/core/services/layout.service';
 import { DomesticDatasetService } from '../../../../app/core/services/domestic-dataset.service';
 import { MSQParse } from '../../../../app/parse';
 import type { GlobalSearchResult, SearchSeverity } from '../../services/global-search.service';
-import { NAV_ICONS } from '../navigation/nav-icons';
 
 @Component({
   selector: 'app-header',
   standalone: true,
-  imports: [CommonModule],
+  imports: [CommonModule, LucideAngularModule],
   templateUrl: './header.component.html',
   styleUrls: ['./header.component.scss']
 })
 export class HeaderComponent implements OnDestroy {
+  readonly icons = {
+    arrowRight: ArrowRight,
+    clock: Clock3,
+    loader: LoaderCircle,
+    lock: LockKeyhole,
+    logOut: LogOut,
+    panelLeftClose: PanelLeftClose,
+    panelLeftOpen: PanelLeftOpen,
+    search: Search,
+    searchEmpty: SearchX,
+    settings: Settings2,
+    x: X,
+  };
   @ViewChild('searchInput') searchInputRef!: ElementRef<HTMLInputElement>;
 
   isCollapsed = false;
@@ -52,7 +77,6 @@ export class HeaderComponent implements OnDestroy {
     private layoutService: LayoutService,
     private router: Router,
     private injector: Injector,
-    private sanitizer: DomSanitizer,
     private domesticDataset: DomesticDatasetService,
   ) {
     this.layoutService.isCollapsed$.pipe(
@@ -247,10 +271,6 @@ export class HeaderComponent implements OnDestroy {
     return item.id;
   }
 
-  icon(key: string): SafeHtml {
-    return this.sanitizer.bypassSecurityTrustHtml(NAV_ICONS[key] ?? '');
-  }
-
   toggleCollapse() {
     this.layoutService.toggleCollapse();
   }

+ 132 - 281
src/modules/shared/components/navigation/navigation.component.scss

@@ -1,370 +1,221 @@
-// ═══════════════════════════════════════
-// 拟物质感(Neumorphic / Liquid Glass)— 侧边导航
-// 复用共享变量 & Mixin → shared/styles/_neumorphic.scss
-// ═══════════════════════════════════════
-@use '../../styles/neumorphic' as neu;
-
-$neu-bg:       neu.$bg;
-$neu-light:    neu.$light;
-$neu-dark:     neu.$dark;
-$neu-surface:  neu.$surface;
-$neu-text:     neu.$text;
-$neu-active:   neu.$active;
-$neu-radius:   neu.$radius;
+:host {
+  display: block;
+}
 
-// ─── Sidebar Container(iOS 26 Liquid Glass)───
 .sidebar-navigation {
-  // 玻璃质感核心
-  background: rgba(255, 255, 255, 0.45);
-  backdrop-filter: blur(24px) saturate(180%);
-  -webkit-backdrop-filter: blur(24px) saturate(180%);
-
-  width: 220px;
-  height: calc(100vh - 56px);
   position: fixed;
+  z-index: 1000;
   top: 56px;
+  bottom: 0;
   left: 0;
   display: flex;
+  width: 232px;
   flex-direction: column;
-  transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
-  // 右边缘:极细分割线 + 柔和光晕
-  border-right: 1px solid rgba(255, 255, 255, 0.55);
-  box-shadow: 1px 0 8px rgba(0, 0, 0, 0.04);
-  overflow-y: auto;
-  overflow-x: hidden;
-  z-index: 1000;
-
-  // 自定义滚动条
-  &::-webkit-scrollbar { width: 3px; }
-  &::-webkit-scrollbar-track { background: transparent; }
-  &::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.08); border-radius: 3px; }
-  &::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.15); }
+  overflow: hidden;
+  border-right: 1px solid #e2e8f0;
+  background: #ffffff;
+  color: #334155;
+  transition: width 180ms ease, transform 180ms ease;
 
   &.collapsed {
     width: 64px;
 
     .nav-item {
+      width: 40px;
+      margin-inline: auto;
       justify-content: center;
-      padding: 10px 0;
-      margin: 3px 10px;
-
-      .nav-icon {
-        margin-right: 0;
-        &.nav-icon-svg ::ng-deep svg { width: 20px; height: 20px; }
-      }
+      padding-inline: 0;
     }
 
-    .nav-item.active {
-      padding: 10px 0;
-
-      &::after {
-        right: 4px;
-        width: 6px;
-        height: 6px;
-      }
+    .nav-icon {
+      margin: 0;
     }
 
-    .sub-menu { display: none; }
-    .arrow-icon { display: none; }
+    .sub-menu,
+    .arrow-icon {
+      display: none;
+    }
 
-    .sidebar-footer .nav-actions {
+    .nav-actions {
       flex-direction: column;
-      gap: 10px;
-      align-items: center;
     }
   }
 }
 
-// ─── Menu List ───
 .sidebar-menu {
-  flex: 1;
-  padding: 10px 0;
   display: flex;
+  min-height: 0;
+  flex: 1;
   flex-direction: column;
   gap: 2px;
+  overflow-y: auto;
+  padding: 12px 8px 18px;
+  scrollbar-width: thin;
+  scrollbar-color: #cbd5e1 transparent;
 }
 
 .nav-item-wrapper {
   display: flex;
   flex-direction: column;
+}
 
-  &.has-children + .has-children {
-    margin-top: 7px;
-  }
+.nav-item-wrapper:nth-child(3),
+.nav-item-wrapper:nth-child(6) {
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #eef2f6;
 }
 
-// ─── Nav Item(拟物凸起按钮)───
 .nav-item {
+  position: relative;
   display: flex;
+  width: 100%;
+  min-height: 40px;
   align-items: center;
-  padding: 9px 14px;
-  color: $neu-text;
-  text-decoration: none;
-  white-space: nowrap;
+  gap: 10px;
+  padding: 0 10px;
   overflow: hidden;
+  border: 1px solid transparent;
+  border-radius: 6px;
+  background: transparent;
+  color: #475569;
   cursor: pointer;
-  position: relative;
-  margin: 2px 10px;
-  border-radius: $neu-radius;
-  background: $neu-surface;
-  background-image: linear-gradient(145deg, rgba(#fff, 0.45), rgba(#c8cdd2, 0.25));
-  box-shadow:
-    3px 3px 6px $neu-dark,
-   -3px -3px 6px $neu-light;
-  transition:
-    box-shadow 0.25s ease,
-    background 0.25s ease,
-    color 0.25s ease,
-    transform 0.15s ease;
-
-  // 左侧选中指示条
-  &::before {
-    content: '';
-    position: absolute;
-    left: 0;
-    top: 50%;
-    transform: translateY(-50%) scaleY(0);
-    width: 2.5px;
-    height: 55%;
-    background: $neu-active;
-    border-radius: 0 2px 2px 0;
-    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-  }
+  font: inherit;
+  text-align: left;
+  text-decoration: none;
+  white-space: nowrap;
+  transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
 
   &:hover {
-    box-shadow:
-      2px 2px 4px $neu-dark,
-     -2px -2px 4px $neu-light;
-    color: $neu-active;
-
-    .nav-icon.nav-icon-svg { opacity: 1; }
+    background: #f8fafc;
+    color: #0f172a;
   }
 
-  &:active {
-    // 按下时 → 凹陷
-    box-shadow:
-      inset 2px 2px 5px $neu-dark,
-      inset -2px -2px 5px $neu-light;
-    background-image: linear-gradient(145deg, rgba(#c8cdd2, 0.3), rgba(#fff, 0.25));
-    transform: scale(0.98);
+  &:focus-visible {
+    outline: 2px solid rgba(37, 99, 235, 0.35);
+    outline-offset: 1px;
   }
 
   &.active {
-    padding-right: 28px;
-    border: 1px solid rgba(37, 99, 235, 0.24);
-    box-shadow:
-      inset 4px 4px 9px rgba(147, 197, 253, 0.32),
-      inset -4px -4px 9px rgba(255, 255, 255, 0.95),
-      0 0 0 1px rgba(37, 99, 235, 0.08);
-    background-image: linear-gradient(135deg, rgba(239, 246, 255, 0.98), rgba(219, 234, 254, 0.88));
+    border-color: #dbeafe;
+    background: #eff6ff;
     color: #1d4ed8;
     font-weight: 700;
 
     &::before {
-      background: #2563eb;
-      transform: translateY(-50%) scaleY(1);
-    }
-
-    &::after {
-      content: '';
       position: absolute;
-      right: 8px;
-      top: 50%;
-      transform: translateY(-50%);
-      width: 7px;
-      height: 7px;
-      border-radius: 999px;
+      top: 9px;
+      bottom: 9px;
+      left: 0;
+      width: 3px;
+      border-radius: 0 3px 3px 0;
       background: #2563eb;
-      box-shadow:
-        0 0 0 5px rgba(37, 99, 235, 0.13),
-        0 0 16px rgba(37, 99, 235, 0.55);
+      content: '';
     }
-
-    .nav-icon.nav-icon-svg { opacity: 1; }
   }
 }
 
-// ─── Sub Menu(玻璃内凹槽)───
-.sub-menu {
-  background: rgba(0, 0, 0, 0.03);
-  margin: 0 10px;
-  border-radius: 0 0 $neu-radius $neu-radius;
-  overflow: hidden;
-  animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-  box-shadow:
-    inset 1px 1px 3px rgba($neu-dark, 0.15),
-    inset -1px -1px 3px rgba($neu-light, 0.25);
-
-  .nav-item {
-    padding-left: 42px;
-    font-size: 13px;
-    margin: 0;
-    border-radius: 0;
-    padding-top: 7px;
-    padding-bottom: 7px;
-    background: transparent;
-    background-image: none;
-    box-shadow: none;
-
-    &::before { display: none; }
-
-    // 子菜单小圆点指示器
-    &::after {
-      content: '';
-      position: absolute;
-      left: 26px;
-      top: 50%;
-      transform: translateY(-50%);
-      width: 4px;
-      height: 4px;
-      border-radius: 50%;
-      background: rgba(0,0,0,0.25);
-      transition: all 0.3s ease;
-    }
-
-    &:hover {
-      background: rgba(#fff, 0.4);
-      box-shadow: none;
-      color: $neu-active;
-
-      &::after { background: $neu-active; }
-    }
-
-    &:active {
-      background: rgba($neu-dark, 0.12);
-      box-shadow: none;
-      transform: none;
-    }
-
-    &.active {
-      background: linear-gradient(135deg, rgba(239, 246, 255, 0.98), rgba(219, 234, 254, 0.88));
-      color: #1d4ed8;
-      font-weight: 700;
-      border: 1px solid rgba(37, 99, 235, 0.24);
-      box-shadow:
-        inset 4px 4px 9px rgba(147, 197, 253, 0.32),
-        inset -4px -4px 9px rgba(255, 255, 255, 0.95),
-        0 0 0 1px rgba(37, 99, 235, 0.08);
-      background-image: none;
-      border-radius: 0;
+.nav-icon {
+  width: 18px;
+  height: 18px;
+  flex: 0 0 auto;
+}
 
-      &::after {
-        background: #2563eb;
-        width: 6px;
-        height: 6px;
-        box-shadow:
-          0 0 0 5px rgba(37, 99, 235, 0.13),
-          0 0 16px rgba(37, 99, 235, 0.55);
-      }
-    }
-  }
+.nav-text {
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  font-size: 13px;
+  font-weight: 600;
+  letter-spacing: 0;
+  text-overflow: ellipsis;
 }
 
-@keyframes slideDown {
-  from { opacity: 0; transform: translateY(-3px); }
-  to   { opacity: 1; transform: translateY(0); }
+.arrow-icon {
+  width: 14px;
+  height: 14px;
+  flex: 0 0 auto;
+  color: #94a3b8;
 }
 
-// ─── Nav Icon ───
-.nav-icon {
-  margin-right: 10px;
-  font-size: 18px;
-  width: 22px;
-  text-align: center;
-  flex-shrink: 0;
+.sub-menu {
+  display: grid;
+  gap: 1px;
+  margin: 2px 0 6px 17px;
+  padding-left: 10px;
+  border-left: 1px solid #e2e8f0;
 
-  &.nav-icon-svg {
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    opacity: 0.8;
-    transition: opacity 0.3s ease;
+  .nav-item {
+    min-height: 34px;
+    padding: 0 9px;
+    color: #64748b;
+  }
+
+  .nav-item.active {
+    border-color: transparent;
+    background: #f1f5f9;
+    color: #1e40af;
 
-    ::ng-deep svg {
-      width: 17px;
-      height: 17px;
-      color: currentColor;
+    &::before {
+      display: none;
     }
   }
-}
 
-// ─── Arrow Icon ───
-.arrow-icon {
-  font-size: 10px;
-  margin-left: auto;
-  opacity: 0.4;
-  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
-
-  &.nav-icon-svg ::ng-deep svg {
-    width: 12px;
-    height: 12px;
+  .nav-icon {
+    width: 15px;
+    height: 15px;
   }
-}
 
-// ─── Nav Text ───
-.nav-text {
-  font-size: 13px;
-  flex: 1;
-  letter-spacing: 0.2px;
-  font-weight: 500;
+  .nav-text {
+    font-size: 12px;
+    font-weight: 600;
+  }
 }
 
-// ─── Footer ───
 .sidebar-footer {
+  flex: 0 0 auto;
   padding: 10px 12px;
-  border-top: 1px solid rgba(0, 0, 0, 0.06);
+  border-top: 1px solid #eef2f6;
+  background: #ffffff;
+}
 
-  .nav-actions {
-    display: flex;
-    justify-content: space-around;
-  }
+.nav-actions {
+  display: flex;
+  justify-content: space-between;
+  gap: 8px;
 }
 
 .action-btn {
-  background: $neu-surface;
-  border: none;
-  color: $neu-text;
+  display: inline-grid;
+  width: 36px;
+  height: 36px;
+  place-items: center;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  background: #ffffff;
+  color: #64748b;
   cursor: pointer;
-  padding: 8px;
-  border-radius: $neu-radius;
-  box-shadow:
-    2px 2px 5px $neu-dark,
-   -2px -2px 5px $neu-light;
-  transition: box-shadow 0.25s ease, color 0.25s ease, transform 0.15s ease;
 
   &:hover {
-    color: $neu-active;
-    box-shadow:
-      1px 1px 3px $neu-dark,
-     -1px -1px 3px $neu-light;
-    transform: translateY(-1px);
+    border-color: #cbd5e1;
+    background: #f8fafc;
+    color: #0f172a;
   }
 
-  &:active {
-    box-shadow:
-      inset 2px 2px 4px $neu-dark,
-      inset -2px -2px 4px $neu-light;
-    transform: scale(0.96);
-  }
-
-  .action-icon.nav-icon-svg ::ng-deep svg {
-    width: 17px;
-    height: 17px;
+  &:focus-visible {
+    outline: 2px solid rgba(37, 99, 235, 0.35);
+    outline-offset: 1px;
   }
 }
 
 @media (max-width: 720px) {
-  .sidebar-navigation {
-    width: min(84vw, 320px);
-    transform: translateX(0);
-    transition:
-      transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
-      width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
-    box-shadow: 10px 0 28px rgba(15, 23, 42, 0.18);
+  .sidebar-navigation,
+  .sidebar-navigation.collapsed {
+    width: min(86vw, 320px);
+    box-shadow: 12px 0 32px rgba(15, 23, 42, 0.16);
+  }
 
-    &.collapsed {
-      width: min(84vw, 320px);
-      transform: translateX(-105%);
-    }
+  .sidebar-navigation.collapsed {
+    transform: translateX(-105%);
   }
 }

+ 130 - 87
src/modules/shared/components/navigation/navigation.component.ts

@@ -1,14 +1,40 @@
-import { Component } from '@angular/core';
+import { Component, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
-import { RouterModule, Router } from '@angular/router';
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { NavigationEnd, RouterModule, Router } from '@angular/router';
+import { Subscription, filter } from 'rxjs';
+import {
+  Bell,
+  BrainCircuit,
+  BookOpen,
+  ChartColumn,
+  ChevronDown,
+  ChevronRight,
+  CircleGauge,
+  Database,
+  FlaskConical,
+  History,
+  Inbox,
+  Kanban,
+  LayoutDashboard,
+  Lightbulb,
+  ListChecks,
+  LucideAngularModule,
+  MessageSquareText,
+  PackageSearch,
+  Radar,
+  RefreshCw,
+  Settings,
+  ShieldAlert,
+  Tags,
+  Target,
+  TrendingUp,
+} from 'lucide-angular';
 import { LayoutService } from '../../../../app/core/services/layout.service';
-import { NAV_ICONS } from './nav-icons';
 
 interface NavItem {
   path: string;
   displayName: string;
-  icon: string;
+  icon: any;
   requiresAuth?: boolean;
   children?: NavItem[];
   expanded?: boolean;
@@ -17,24 +43,45 @@ interface NavItem {
 @Component({
   selector: 'app-navigation',
   standalone: true,
-  imports: [CommonModule, RouterModule],
+  imports: [CommonModule, RouterModule, LucideAngularModule],
   template: `
     <nav class="sidebar-navigation" [class.collapsed]="isCollapsed">
       <div class="sidebar-menu">
         <ng-container *ngFor="let item of navItems">
           <!-- Parent Item -->
           <div class="nav-item-wrapper" [class.has-children]="!!item.children">
-            <a
-              [routerLink]="item.children ? null : item.path"
+            <button
+              *ngIf="item.children; else leafItem"
+              type="button"
               [class.active]="isActive(item)"
               class="nav-item"
               [title]="isCollapsed ? item.displayName : ''"
+              [attr.aria-label]="isCollapsed ? item.displayName : null"
+              [attr.aria-expanded]="item.expanded"
               (click)="onNavItemClick(item)"
             >
-              <span class="nav-icon nav-icon-svg" [innerHTML]="getIcon(item.icon)"></span>
+              <lucide-icon class="nav-icon" [img]="item.icon" [size]="18" [strokeWidth]="1.8"></lucide-icon>
               <span class="nav-text" *ngIf="!isCollapsed">{{ item.displayName }}</span>
-              <span class="arrow-icon nav-icon-svg" *ngIf="!isCollapsed && item.children" [innerHTML]="getIcon(item.expanded ? 'chevronDown' : 'chevronRight')"></span>
-            </a>
+              <lucide-icon
+                *ngIf="!isCollapsed"
+                class="arrow-icon"
+                [img]="item.expanded ? ChevronDown : ChevronRight"
+                [size]="14"
+                [strokeWidth]="1.8"
+              ></lucide-icon>
+            </button>
+            <ng-template #leafItem>
+              <a
+                [routerLink]="item.path"
+                [class.active]="isActive(item)"
+                class="nav-item"
+                [title]="isCollapsed ? item.displayName : ''"
+                (click)="onNavItemClick(item)"
+              >
+                <lucide-icon class="nav-icon" [img]="item.icon" [size]="18" [strokeWidth]="1.8"></lucide-icon>
+                <span class="nav-text" *ngIf="!isCollapsed">{{ item.displayName }}</span>
+              </a>
+            </ng-template>
             
             <!-- Children Items -->
             <div class="sub-menu" *ngIf="!isCollapsed && item.children && item.expanded">
@@ -45,7 +92,7 @@ interface NavItem {
                 class="nav-item sub-nav-item"
                 (click)="closeMobileNavigation()"
               >
-                <span class="nav-icon nav-icon-svg" [innerHTML]="getIcon(child.icon)"></span>
+                <lucide-icon class="nav-icon" [img]="child.icon" [size]="16" [strokeWidth]="1.8"></lucide-icon>
                 <span class="nav-text">{{ child.displayName }}</span>
               </a>
             </div>
@@ -56,10 +103,10 @@ interface NavItem {
       <div class="sidebar-footer">
         <div class="nav-actions">
           <button class="action-btn" title="通知">
-            <span class="action-icon nav-icon-svg" [innerHTML]="getIcon('bell')"></span>
+            <lucide-icon [img]="Bell" [size]="17" [strokeWidth]="1.8"></lucide-icon>
           </button>
-          <button class="action-btn" title="设置">
-            <span class="action-icon nav-icon-svg" [innerHTML]="getIcon('settings')"></span>
+          <button class="action-btn" title="API 与 AI 设置" aria-label="打开 API 与 AI 设置" (click)="openSettings()">
+            <lucide-icon [img]="Settings" [size]="17" [strokeWidth]="1.8"></lucide-icon>
           </button>
         </div>
       </div>
@@ -67,117 +114,96 @@ interface NavItem {
   `,
   styleUrls: ['./navigation.component.scss']
 })
-export class NavigationComponent {
+export class NavigationComponent implements OnDestroy {
+  readonly ChevronDown = ChevronDown;
+  readonly ChevronRight = ChevronRight;
+  readonly Bell = Bell;
+  readonly Settings = Settings;
   isMobileMenuOpen = false;
   isCollapsed = false;
 
   navItems: NavItem[] = [
     {
-      path: '/domestic',
-      displayName: '经营分析',
-      icon: 'dashboard',
+      path: '/domestic/overview',
+      displayName: '工作台',
+      icon: LayoutDashboard,
       requiresAuth: false,
-      expanded: true,
-      children: [
-        { path: '/domestic/overview', displayName: '经营概览', icon: 'overview', requiresAuth: false },
-        { path: '/voc-insight/products', displayName: '商品列表', icon: 'product', requiresAuth: false },
-        { path: '/domestic/product', displayName: '商品经营详情', icon: 'productDefine', requiresAuth: false },
-      ],
     },
     {
-      path: '/domestic/market',
-      displayName: '市场与竞品',
-      icon: 'market',
+      path: '/feedback-inbox',
+      displayName: '反馈中心',
+      icon: Inbox,
       requiresAuth: false,
-      expanded: true,
-      children: [
-        { path: '/domestic/categories', displayName: '品类分析', icon: 'category', requiresAuth: false },
-        { path: '/domestic/category-voc', displayName: '类目 VOC', icon: 'category', requiresAuth: false },
-        { path: '/domestic/competitors', displayName: '竞品关系', icon: 'competitor', requiresAuth: false },
-      ],
-    },
-    {
-      path: '/domestic/monitoring',
-      displayName: 'VOC 全局监测',
-      icon: 'monitoring',
-      requiresAuth: false,
-      expanded: true,
-      children: [
-        { path: '/domestic/voc-overview', displayName: 'VOC 趋势总览', icon: 'trend', requiresAuth: false },
-        { path: '/domestic/voc-trend', displayName: 'VOC 数据趋势', icon: 'trend', requiresAuth: false },
-        { path: '/domestic/emotion', displayName: '情绪极性分布', icon: 'emotion', requiresAuth: false },
-        { path: '/domestic/store-monitoring', displayName: '店铺监测融合', icon: 'store', requiresAuth: false },
-        { path: '/domestic/risk-alerts', displayName: '风险预警', icon: 'alert', requiresAuth: false },
-      ],
     },
     {
-      path: '/voc-insight',
-      displayName: 'VOC 深度洞察',
-      icon: 'voc',
+      path: '/insights',
+      displayName: '洞察中心',
+      icon: Radar,
       requiresAuth: false,
       expanded: true,
       children: [
-        { path: '/voc-insight/voice', displayName: '用户声音分类', icon: 'voiceClassify', requiresAuth: false },
-        { path: '/voc-insight/pain-point', displayName: '痛点深度洞察', icon: 'painPoint', requiresAuth: false },
-        { path: '/voc-insight/feature', displayName: '功能满足度', icon: 'featureScore', requiresAuth: false },
-        { path: '/voc-insight/scenario', displayName: '使用场景', icon: 'scenario', requiresAuth: false },
+        { path: '/voc-insight/ai', displayName: 'AI 洞察', icon: BrainCircuit, requiresAuth: false },
+        { path: '/voc-insight/topics', displayName: '主题探索', icon: MessageSquareText, requiresAuth: false },
+        { path: '/voc-insight/runs', displayName: '分析运行', icon: History, requiresAuth: false },
+        { path: '/domestic/voc-overview', displayName: 'VOC 总览', icon: CircleGauge, requiresAuth: false },
+        { path: '/voc-insight/products', displayName: '商品洞察', icon: PackageSearch, requiresAuth: false },
+        { path: '/domestic/category-voc', displayName: '品类洞察', icon: Tags, requiresAuth: false },
+        { path: '/domestic/competitors', displayName: '竞品证据', icon: ChartColumn, requiresAuth: false },
       ],
     },
     {
-      path: '/return-analysis',
-      displayName: '退款深度分析',
-      icon: 'return',
+      path: '/action-suggestion',
+      displayName: '行动中心',
+      icon: Kanban,
       requiresAuth: false,
       expanded: false,
       children: [
-        { path: '/return-analysis/overview', displayName: '退款整体概况', icon: 'overview', requiresAuth: false },
-        { path: '/return-analysis/attribution', displayName: '退款归因下钻', icon: 'trend', requiresAuth: false },
-        { path: '/return-analysis/correlation', displayName: '退款特征关联', icon: 'category', requiresAuth: false },
+        { path: '/action-suggestion/kanban', displayName: '行动工作台', icon: ListChecks, requiresAuth: false },
+        { path: '/domestic/risk-alerts', displayName: '风险预警', icon: ShieldAlert, requiresAuth: false },
       ],
     },
     {
-      path: '/product-development',
-      displayName: '新品开发驱动',
-      icon: 'rocket',
+      path: '/validation',
+      displayName: '效果验证',
+      icon: Target,
       requiresAuth: false,
       expanded: false,
       children: [
-        { path: '/product-development/trend', displayName: '趋势洞察', icon: 'trend', requiresAuth: false },
-        { path: '/product-development/requirement', displayName: '需求挖掘', icon: 'overview', requiresAuth: false },
-        { path: '/product-development/competitor-benchmark', displayName: '竞品对标', icon: 'competitor', requiresAuth: false },
-        { path: '/product-development/product-definition', displayName: '产品定义', icon: 'product', requiresAuth: false },
-        { path: '/product-development/lifecycle', displayName: '生命周期复盘', icon: 'lifecycle', requiresAuth: false },
+        { path: '/validation', displayName: '验证中心', icon: Target, requiresAuth: false },
+        { path: '/return-analysis/overview', displayName: '退款验证', icon: FlaskConical, requiresAuth: false },
+        { path: '/domestic/voc-trend', displayName: '趋势验证', icon: ChartColumn, requiresAuth: false },
       ],
     },
     {
-      path: '/action-suggestion',
-      displayName: '行动建议',
-      icon: 'bulb',
+      path: '/data',
+      displayName: '数据与配置',
+      icon: Database,
       requiresAuth: false,
       expanded: false,
       children: [
-        { path: '/action-suggestion/kanban', displayName: '建议看板', icon: 'overview', requiresAuth: false },
-        { path: '/action-suggestion/experience', displayName: '体验优化提案', icon: 'emotion', requiresAuth: false },
-        { path: '/action-suggestion/iteration', displayName: '产品迭代方向', icon: 'product', requiresAuth: false },
-        { path: '/action-suggestion/strategy', displayName: '长期策略调整', icon: 'trend', requiresAuth: false },
+        { path: '/data-center', displayName: '数据中台', icon: Database, requiresAuth: false },
+        { path: '/knowledge-center', displayName: '商品知识库', icon: BookOpen, requiresAuth: false },
+        { path: '/system/api', displayName: '系统设置', icon: Settings, requiresAuth: false },
       ],
     },
-    {
-      path: '/data-status',
-      displayName: '数据接入状态',
-      icon: 'settings',
-      requiresAuth: false,
-    },
   ];
+  private readonly routerSubscription: Subscription;
 
   constructor(
     private router: Router,
     private layoutService: LayoutService,
-    private sanitizer: DomSanitizer
   ) {
     this.layoutService.isCollapsed$.subscribe(
       collapsed => this.isCollapsed = collapsed
     );
+    this.syncExpandedGroup(this.router.url);
+    this.routerSubscription = this.router.events
+      .pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd))
+      .subscribe((event) => this.syncExpandedGroup(event.urlAfterRedirects));
+  }
+
+  ngOnDestroy(): void {
+    this.routerSubscription.unsubscribe();
   }
 
   isActive(item: NavItem): boolean {
@@ -200,7 +226,10 @@ export class NavigationComponent {
     if (this.isCollapsed) {
       this.layoutService.setCollapse(false);
     }
-    item.expanded = !item.expanded;
+    const willExpand = !item.expanded;
+    this.navItems.forEach((candidate) => {
+      if (candidate.children) candidate.expanded = candidate === item ? willExpand : false;
+    });
   }
 
   onNavItemClick(item: NavItem): void {
@@ -217,8 +246,22 @@ export class NavigationComponent {
     }
   }
 
-  getIcon(key: string): SafeHtml {
-    const svg = NAV_ICONS[key] ?? '';
-    return this.sanitizer.bypassSecurityTrustHtml(svg);
+  openSettings(): void {
+    this.closeMobileNavigation();
+    this.router.navigateByUrl('/system/api');
   }
+
+  private syncExpandedGroup(url: string): void {
+    const cleanUrl = url.split('?')[0].split('#')[0];
+    const activeParent = this.navItems.find((item, index) => {
+      if (!item.children) return false;
+      if (index === 0 && (cleanUrl.startsWith('/voc-insight/products') || cleanUrl.startsWith('/voc-insight/detail'))) return true;
+      return item.children.some((child) => cleanUrl === child.path || cleanUrl.startsWith(`${child.path}/`));
+    });
+    if (!activeParent) return;
+    this.navItems.forEach((item) => {
+      if (item.children) item.expanded = item === activeParent;
+    });
+  }
+
 }

+ 30 - 0
src/modules/shared/components/page-header/page-header.component.scss

@@ -234,10 +234,40 @@ p {
 @media (max-width: 768px) {
   .shared-page-header {
     flex-direction: column;
+    gap: 10px;
+  }
+
+  .header-main {
+    width: 100%;
+    gap: 10px;
+  }
+
+  .header-icon {
+    width: 40px;
+    height: 40px;
+    border-radius: 14px;
+    font-size: 18px;
+  }
+
+  h1 {
+    font-size: 22px;
+  }
+
+  p {
+    margin-top: 6px;
+    font-size: 13px;
+    line-height: 1.55;
+  }
+
+  .header-meta {
+    gap: 6px 10px;
+    margin-top: 8px;
   }
 
   .header-actions {
     width: 100%;
     justify-content: flex-start;
+    gap: 8px;
+    margin-bottom: 0;
   }
 }

+ 9 - 6
src/modules/shared/components/review-detail-modal/review-detail-modal.component.ts

@@ -16,12 +16,14 @@ import { TranslationService } from '../../services/translation.service';
           <div class="rdm-header">
             <h2 class="rdm-title">评论详情</h2>
             <div class="rdm-header-actions">
-              <button class="rdm-translate-btn" [class.active]="isTranslated()"
-                      [disabled]="isTranslating()" (click)="toggleTranslation()">
-                @if (isTranslating()) { 翻译中... }
-                @else if (isTranslated()) { 显示原文 }
-                @else { 🌐 翻译为中文 }
-              </button>
+              @if (showTranslateButton) {
+                <button class="rdm-translate-btn" [class.active]="isTranslated()"
+                        [disabled]="isTranslating()" (click)="toggleTranslation()">
+                  @if (isTranslating()) { 翻译中... }
+                  @else if (isTranslated()) { 显示原文 }
+                  @else { 🌐 翻译为中文 }
+                </button>
+              }
               <button class="rdm-close" (click)="close.emit()">&times;</button>
             </div>
           </div>
@@ -142,6 +144,7 @@ export class ReviewDetailModalComponent implements OnChanges {
   @Input() visible = false;
   @Input() review: any = null;
   @Input() showAllReviewsBtn = false;
+  @Input() showTranslateButton = true;
   @Output() close = new EventEmitter<void>();
   @Output() viewAllReviews = new EventEmitter<string>();
 

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott