Просмотр исходного кода

feat: land SaaS UI shell, entity routes, and row-level navigation

Keep the existing product skeleton while adding Neutral shared chrome, entity subpages, and list-to-detail wiring without inventing metrics or comments.

Co-authored-by: Cursor <cursoragent@cursor.com>
gangvy 1 месяц назад
Родитель
Сommit
29bfed07f9
100 измененных файлов с 4191 добавлено и 1393 удалено
  1. 22 0
      .cursor/rules/saas-ui-agent.mdc
  2. 133 0
      docs/COMPONENT-INDEX.md
  3. 214 0
      docs/SAAS-UI-UPGRADE.md
  4. 164 0
      docs/SAAS-UPGRADE-PLAN.md
  5. 23 2
      src/app/app.component.scss
  6. 22 0
      src/app/app.routes.ts
  7. 131 0
      src/modules/action-suggestion/action-page.component.html
  8. 108 0
      src/modules/action-suggestion/action-page.component.scss
  9. 372 0
      src/modules/action-suggestion/action-page.component.ts
  10. 11 17
      src/modules/ai-voc-insight/ai-voc-insight.component.html
  11. 17 0
      src/modules/ai-voc-insight/ai-voc-insight.component.ts
  12. 12 2
      src/modules/ai-voc-insight/analysis-runs/analysis-run-components.spec.ts
  13. 9 3
      src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.html
  14. 17 2
      src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.scss
  15. 11 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.ts
  16. 12 19
      src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.html
  17. 4 5
      src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.scss
  18. 19 11
      src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.ts
  19. 37 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-page.component.html
  20. 25 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-page.component.scss
  21. 187 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run-page.component.ts
  22. 19 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run.data.ts
  23. 24 0
      src/modules/ai-voc-insight/analysis-runs/analysis-run.models.ts
  24. 8 15
      src/modules/data-center/data-center.component.html
  25. 15 1
      src/modules/data-center/data-center.component.ts
  26. 8 0
      src/modules/domestic-voc/category-analysis.component.scss
  27. 24 3
      src/modules/domestic-voc/category-analysis.component.ts
  28. 44 2
      src/modules/domestic-voc/competitor-detail.component.ts
  29. 20 2
      src/modules/domestic-voc/competitor-relations.component.ts
  30. 27 191
      src/modules/domestic-voc/framework/domestic-action-workbench.component.html
  31. 28 170
      src/modules/domestic-voc/framework/domestic-action-workbench.component.ts
  32. 26 1
      src/modules/domestic-voc/framework/domestic-framework.shared.scss
  33. 21 5
      src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.html
  34. 26 1
      src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.ts
  35. 28 7
      src/modules/domestic-voc/framework/domestic-product-development.component.html
  36. 32 1
      src/modules/domestic-voc/framework/domestic-product-development.component.ts
  37. 9 2
      src/modules/domestic-voc/framework/domestic-return-analysis.component.html
  38. 20 1
      src/modules/domestic-voc/framework/domestic-return-analysis.component.ts
  39. 153 160
      src/modules/domestic-voc/operating-overview.component.scss
  40. 60 30
      src/modules/domestic-voc/operating-overview.component.ts
  41. 60 48
      src/modules/feedback-inbox/feedback-inbox.component.html
  42. 64 0
      src/modules/feedback-inbox/feedback-inbox.component.scss
  43. 236 16
      src/modules/feedback-inbox/feedback-inbox.component.ts
  44. 11 7
      src/modules/knowledge-center/knowledge-center.component.html
  45. 1 0
      src/modules/knowledge-center/knowledge-center.component.scss
  46. 29 1
      src/modules/knowledge-center/knowledge-center.component.ts
  47. 42 7
      src/modules/monitoring/pages/category-voc/category-voc.component.ts
  48. 20 2
      src/modules/monitoring/pages/risk-alert/risk-alert.component.ts
  49. 20 2
      src/modules/monitoring/pages/voc-overview/voc-overview.component.ts
  50. 2 0
      src/modules/shared/components/content-card/content-card.component.scss
  51. 49 34
      src/modules/shared/components/empty-state/empty-state.component.ts
  52. 61 0
      src/modules/shared/components/entity-property-list/entity-property-list.component.scss
  53. 57 0
      src/modules/shared/components/entity-property-list/entity-property-list.component.ts
  54. 5 4
      src/modules/shared/components/entry-card/entry-card.component.scss
  55. 1 1
      src/modules/shared/components/header/header.component.html
  56. 149 293
      src/modules/shared/components/header/header.component.scss
  57. 54 46
      src/modules/shared/components/navigation/navigation.component.scss
  58. 7 1
      src/modules/shared/components/navigation/navigation.component.ts
  59. 60 62
      src/modules/shared/components/neu-button/neu-button.component.scss
  60. 46 0
      src/modules/shared/components/page-breadcrumb/page-breadcrumb.component.scss
  61. 35 0
      src/modules/shared/components/page-breadcrumb/page-breadcrumb.component.ts
  62. 50 15
      src/modules/shared/components/page-header/page-header.component.scss
  63. 18 1
      src/modules/shared/components/page-header/page-header.component.ts
  64. 87 0
      src/modules/shared/components/page-toolbar/page-toolbar.component.scss
  65. 41 0
      src/modules/shared/components/page-toolbar/page-toolbar.component.ts
  66. 29 0
      src/modules/shared/components/sparkline/sparkline.component.scss
  67. 101 0
      src/modules/shared/components/sparkline/sparkline.component.ts
  68. 15 2
      src/modules/shared/components/summary-metric-card/summary-metric-card.component.scss
  69. 30 35
      src/modules/shared/styles/_board-tabs.scss
  70. 20 10
      src/modules/shared/styles/_button.scss
  71. 7 5
      src/modules/shared/styles/_card.scss
  72. 6 5
      src/modules/shared/styles/_state.scss
  73. 30 27
      src/modules/shared/styles/_tokens.scss
  74. 4 1
      src/modules/system/pages/ai-prompt-management/ai-prompt-management.component.html
  75. 12 2
      src/modules/system/pages/ai-prompt-management/ai-prompt-management.component.ts
  76. 9 1
      src/modules/system/pages/api-management/api-management.component.html
  77. 12 2
      src/modules/system/pages/api-management/api-management.component.ts
  78. 7 2
      src/modules/system/pages/system-overview/system-overview.component.ts
  79. 24 39
      src/modules/validation-center/validation-center.component.html
  80. 7 1
      src/modules/validation-center/validation-center.component.scss
  81. 18 9
      src/modules/validation-center/validation-center.component.ts
  82. 19 0
      src/modules/validation-center/validation-center.data.ts
  83. 2 0
      src/modules/validation-center/validation-center.models.ts
  84. 12 5
      src/modules/validation-center/validation-detail.component.html
  85. 8 0
      src/modules/validation-center/validation-detail.component.scss
  86. 8 0
      src/modules/validation-center/validation-detail.component.ts
  87. 36 0
      src/modules/validation-center/validation-page.component.html
  88. 25 0
      src/modules/validation-center/validation-page.component.scss
  89. 210 0
      src/modules/validation-center/validation-page.component.ts
  90. 10 2
      src/modules/voc-insight/pages/feature-satisfaction.component.ts
  91. 10 2
      src/modules/voc-insight/pages/pain-point-insight.component.ts
  92. 10 2
      src/modules/voc-insight/pages/scenario-dashboard.component.ts
  93. 10 2
      src/modules/voc-insight/pages/voice-classification.component.ts
  94. 19 1
      src/modules/voc-insight/topic-explorer/topic-detail.component.html
  95. 29 0
      src/modules/voc-insight/topic-explorer/topic-detail.component.scss
  96. 50 6
      src/modules/voc-insight/topic-explorer/topic-detail.component.ts
  97. 18 28
      src/modules/voc-insight/topic-explorer/topic-explorer.component.html
  98. 5 5
      src/modules/voc-insight/topic-explorer/topic-explorer.component.scss
  99. 27 6
      src/modules/voc-insight/topic-explorer/topic-explorer.component.ts
  100. 5 0
      src/modules/voc-insight/voc-insight-detail/domestic-voc-detail.component.html

+ 22 - 0
.cursor/rules/saas-ui-agent.mdc

@@ -0,0 +1,22 @@
+---
+description: VOC SaaS UI 升级时先查组件索引和字段契约,禁止新造一套卡片/页头。
+alwaysApply: true
+---
+
+# SaaS UI / Agent 定位
+
+改页面或组件前先读:
+
+1. `docs/SAAS-UPGRADE-PLAN.md` — 三种页面、字段必填/预留、路由、波次文件所有权
+2. `docs/COMPONENT-INDEX.md` — 选择器 → 目录,禁止再造页头/指标卡/表格壳
+3. `docs/SAAS-UI-UPGRADE.md` — 视觉 token(shadcn Neutral,炭黑主按钮)
+
+定位:共享组件在 `src/modules/shared/components/<name>/`,路由在 `src/app/app.routes.ts`,色板在 `src/modules/shared/styles/_tokens.scss`。
+
+硬约束:
+
+- 预留字段为空显示 `—`,不编数字、不编评论
+- 实体用独立子路由;抽屉只留给创建/筛选/预览
+- 不执行 `npx shadcn@latest init`,不引入 Material / ng-zorro / Tailwind
+- 不改 `styles.scss` 里 `.ai-visual-report-html`
+- 新组件必须在 `COMPONENT-INDEX.md` 补一行

+ 133 - 0
docs/COMPONENT-INDEX.md

@@ -0,0 +1,133 @@
+# 组件索引(给 Agent 定位用)
+
+改 UI 前先查本表。新组件必须补一行。完整方案:`docs/SAAS-UPGRADE-PLAN.md`。
+
+## 怎么找
+
+| 你要做的事 | 先打开 |
+| --- | --- |
+| 改颜色/圆角/阴影 | `src/modules/shared/styles/_tokens.scss` |
+| 卡片/按钮/空态 mixin | `src/modules/shared/styles/_card.scss` `_button.scss` `_state.scss` |
+| 顶栏 / 侧栏 / 主区 | `header/` `navigation/` `src/app/app.component.ts` |
+| 页头、返回、面包屑 | `page-header/` `page-breadcrumb/` |
+| 周期/比较/命令按钮 | `page-toolbar/` `filter-bar/` |
+| 指标卡 / 迷你趋势 | `summary-metric-card/` `sparkline/` `metric-card/`(旧,优先 summary) |
+| 表格壳 | `data-table-shell/` `crud-table-workbench/` |
+| 实体右侧属性 | `entity-property-list/` |
+| 模块平级 Tab | `board-tabs/` |
+| VOC 页内子导航 | `src/modules/voc-insight/shared/components/voc-insight-rail/` |
+| 空/错/加载 | `empty-state/` `error-state/` `loading-spinner/` |
+| 按钮 | `neu-button/`(改皮不改名) |
+| 评论/行动 | `review-card/` `action-create-panel/` |
+| 路由 | `src/app/app.routes.ts` |
+| 导航项 | `navigation/navigation.component.ts` 的 `navItems` |
+
+基础路径:`src/modules/shared/components/<name>/`
+
+---
+
+## 壳层
+
+| 选择器 | 目录 | 何时用 | 不要用它做 |
+| --- | --- | --- | --- |
+| `app-header` | `header/` | 全局顶栏、搜索 | 页内标题 |
+| `app-navigation` | `navigation/` | 左导航 | 页内 Tab |
+| `app-global-context-bar` | `global-context-bar/` | 工作空间/商品上下文 | 替代页头 |
+| `app-page-header` | `page-header/` | 任何页标题;已有 `crumbs` `backLabel` `backRoute` `meta` `lastUpdated` `sampleSize` `confidence` | 再写一套 h1 |
+| `app-page-breadcrumb` | `page-breadcrumb/` | 可独立;通常由 page-header 带 | 侧栏路径 |
+| `app-page-toolbar` | `page-toolbar/` | Hub 周期/比较/主操作 | 筛选全集(用 filter-bar) |
+| `app-page-section` | `page-section/` | 简单分段 | 卡片 |
+
+## 数据展示
+
+| 选择器 | 目录 | 何时用 | 关键输入 |
+| --- | --- | --- | --- |
+| `app-summary-metric-card` | `summary-metric-card/` | Hub 指标,**优先这个** | label/value/trend*/clickable/emphasis/confidence/sampleSize |
+| `app-sparkline` | `sparkline/` | 指标或行内迷你趋势 | points, tone, height |
+| `app-metric-card` | `metric-card/` | 遗留页 | 新页不要新增依赖 |
+| `app-content-card` | `content-card/` | 图/表外框 | title/subtitle/tone |
+| `app-data-table-shell` | `data-table-shell/` | 硬表:加载/空/错/计数 | title/count/empty* |
+| `app-entity-property-list` | `entity-property-list/` | Entity 右侧:必填+预留 | items[{label,value,hint,reserved}] |
+| `app-entry-card` | `entry-card/` | 入口卡 | — |
+| `app-alert-card` | `alert-card/` | 警告条 | 不要当详情页 |
+| `app-review-card` | `review-card/` | 单条评论 | — |
+| `app-product-identity` | `product-identity/` | 商品身份 | — |
+| `app-board-tabs` | `board-tabs/` | Workspace 平级 | tabs/route |
+| `app-priority-accordion` | `priority-accordion/` | 优先级折叠 | — |
+| `app-split-panel` | `split-panel/` | 左右分栏 | — |
+
+## 筛选与输入
+
+| 选择器 | 目录 | 何时用 |
+| --- | --- | --- |
+| `app-filter-bar` | `filter-bar/` | 站点/时间/店铺/品类等业务筛选 |
+| `app-filter-panel` | `filter-panel/` | 侧滤 |
+| `app-date-range-picker` | `date-range-picker/` | 自定义日期 |
+| `app-date-picker` | `date-picker/` | 单日 |
+| `msq-dropdown` | `msq-dropdown/` | 下拉 |
+| `msq-search-box` | `msq-search-box/` | 搜索框 |
+| `app-neu-button` | `neu-button/` | 主/次按钮(视觉已去拟态) |
+
+## 状态与全局浮层
+
+| 选择器 | 目录 | 何时用 |
+| --- | --- | --- |
+| `app-empty-state` | `empty-state/` | 无数据 |
+| `app-error-state` | `error-state/` | 失败+重试 |
+| `app-loading-spinner` | `loading-spinner/` | 加载 |
+| `app-data-source-alert` | `data-source-alert/` | 数据源提示 |
+| `app-todo-banner` | `todo-banner/` | 待办条 |
+| `app-toast-container` | `toast-container/` | 全局 toast |
+| `app-dialog-container` | `dialog-container/` | 全局对话框 |
+| `app-action-create-panel` | `action-create-panel/` | **创建行动用抽屉/面板** |
+| `app-review-detail-modal` | `review-detail-modal/` | 评论预览,不是主题页 |
+| `app-all-reviews-modal` | `all-reviews-modal/` | 评论列表弹层 |
+
+## VOC 专用
+
+| 选择器 | 路径 | 何时用 |
+| --- | --- | --- |
+| `app-voc-insight-rail` | `src/modules/voc-insight/shared/components/voc-insight-rail/` | 商品 VOC 子分页 |
+| `app-voc-product-picker` | `.../voc-product-picker/` | 切换分析商品 |
+| `app-ai-analysis-panel` | `shared/components/ai-analysis-panel/` | AI 分析侧栏 |
+| `app-ai-visual-report` | `ai-visual-report/` | 报告 HTML;**不要改 styles.scss 里 .ai-visual-report-html** |
+
+## 页面 → 组件(改页先对这张表)
+
+| 路由 | 页面文件 | 应该用 |
+| --- | --- | --- |
+| `/domestic/overview` | `src/modules/domestic-voc/operating-overview.component.ts` | page-header + page-toolbar + 自研 kpi-cell + sparkline;信号整行走 go* |
+| `/feedback-inbox`、`/feedback-inbox/:reviewId` | `src/modules/feedback-inbox/` | 队列+详情;path 深链选中;`page-header` + `page-toolbar`;详情侧 `entity-property-list`(负责人/已读空则 —);创建行动用 `ActionCreatePanel` |
+| `/voc-insight/topics/:id` | `voc-insight/topic-explorer/topic-detail.component.ts` | page-header + property-list |
+| `/voc-insight/detail` | `voc-insight/voc-insight-detail/domestic-voc-detail.component.ts` | rail + page-header |
+| `/voc-insight/runs` | `ai-voc-insight/analysis-runs/analysis-run-list.component.ts` | 表;点行进 `runs/:id` |
+| `/voc-insight/runs/:id` | `ai-voc-insight/analysis-runs/analysis-run-page.component.ts` | page-header + entity-property-list + analysis-run-detail variant=page |
+| `/validation` | `validation-center/validation-center.component.ts` | 列表 |
+| `/validation/:id` | `validation-center/validation-page.component.ts` | page-header + entity-property-list + validation-detail variant=page |
+| `/domestic/competitors/:productId` | `domestic-voc/competitor-detail.component.ts` | page-header + entity-property-list(负责人/验证窗口空则 —) |
+| `/action-suggestion/kanban/:id` | `action-suggestion/action-page.component.ts` | page-header + entity-property-list + 状态/负责人/期限更新;创建仍用 `ActionCreatePanel` |
+| `/voc-insight/topics` | `voc-insight/topic-explorer/topic-explorer.component.ts` | page-header + page-toolbar;点行进 `/voc-insight/topics/:id` |
+| `/knowledge-center` | `knowledge-center/knowledge-center.component.ts` | page-header + page-toolbar;整行进商品/竞品详情 |
+| `/system/api` | `system/pages/api-management/` | page-header + page-toolbar |
+| `/system/ai-prompts` | `system/pages/ai-prompt-management/` | page-header + page-toolbar |
+| `/data-center` | `data-center/data-center.component.ts` | page-header + page-toolbar |
+| `/validation` | `validation-center/validation-center.component.ts` | page-header + page-toolbar;点行进 `/validation/:id` |
+| `/voc-insight/ai` | `ai-voc-insight/ai-voc-insight.component.ts` | page-header + page-toolbar |
+| `/action-suggestion/kanban` | `domestic-voc/framework/domestic-action-workbench.component.ts` | page-header + page-toolbar;点行进 `kanban/:id` |
+
+## 样式 token
+
+| 文件 | 作用 |
+| --- | --- |
+| `src/modules/shared/styles/_tokens.scss` | 唯一色板。primary=`#171717` |
+| `_layout.scss` `_card.scss` `_button.scss` `_table.scss` `_badge.scss` | mixin |
+| `src/styles.scss` | 全局;955 行后的 AI report **禁止改** |
+| `src/app/app.component.scss` | 主区画布 |
+
+## 新增组件检查表
+
+1. standalone + OnPush  
+2. 空值显示 `—`,不编数据  
+3. 在本文件补一行  
+4. 选择器 `app-*`  
+5. 不引入新 UI 库  

+ 214 - 0
docs/SAAS-UI-UPGRADE.md

@@ -0,0 +1,214 @@
+# SaaS 观感与页面逻辑升级规格
+
+**落地总案**(字段、路由、波次、组件抽取):`docs/SAAS-UPGRADE-PLAN.md`  
+**组件索引**(Agent 定位):`docs/COMPONENT-INDEX.md`
+
+**角色**:本文件管视觉 token 与壳层。实现按波次执行,先改壳层与共享组件,再改旗舰页与下钻路由。  
+**对标**:
+- 组件与 token:**[shadcn/ui](https://github.com/shadcn-ui/ui)**(New York / Neutral 默认)
+- 产品观感:Linear、Stripe Dashboard、Amplitude 的运营型 SaaS
+**不对标**:后台 4.0、Ant Design Pro 深色顶栏拟态、新拟态、把本仓改成 React/Tailwind。  
+**不要执行** `npx shadcn@latest init`:本仓是 Angular 17,没有 Tailwind。只借鉴其语义 token、圆角、按钮变体、侧栏分组和卡片结构,落到现有 SCSS / 共享组件。
+
+---
+
+## 1. 当前诊断
+
+1. **两套皮肤叠在一起**:`styles.scss` 的 `g-*` 高级灰、token 体系、以及 header / page-header / empty-state 残留的拟态阴影同时存在。
+2. **顶栏最像旧后台**:`header.component.scss` 仍是深色拟态条,和白底侧栏、浅色内容区割裂。
+3. **页面深度不够**:很多「详情」是抽屉/浮层(验证中心、分析运行),不像进入了属于该模块的子页面。
+4. **同模块切换像换皮**:退款 / 新品 / 行动 / 监测用同一个 workbench + `data.section`,缺少「进了一层」的面包屑与返回。
+5. **组件反馈弱**:卡片只有轻微阴影;空状态偏 emoji;按钮还带 neu 立体阴影。
+6. **已经做得对的部分不要推倒**:经营概览信息密度、商品 VOC 详情、主题 `topics/:id`、竞品 `competitors/:productId`、VOC rail 子分页。
+
+---
+
+## 2. 设计原则
+
+1. **一层画布、两层表面**:画布用 `background`(近白/浅灰),卡片用 `card` 白底 + hairline `border` + `shadow-xs`。禁止双层拟态光影。
+2. **主色跟 shadcn Neutral**:`--primary` 是炭黑(约 `#171717`),主按钮白字;`--secondary` / `--muted` / `--accent` 是浅灰底。蓝色只做链接、图表、信息态,不当默认 CTA。
+3. **可点击必须像可点击**:指标卡、表格行、信号行、入口卡都要有 hover `accent` 底 / 细边变化,并尽量带真实路由。
+4. **三种页面,不要混用**:
+   - **Hub**:工作台、列表总览。丰富、可下钻。
+   - **Workspace**:模块内平级页(BoardTabs / rail)。同级切换,不是下钻。
+   - **Entity**:商品、主题、验证项、分析运行、单条反馈、行动项。独立路由,占满主区,带返回 + 面包屑。
+5. **抽屉只承载次要动作**:创建、筛选、预览。主实体用独立子页。
+6. **只升级观感与信息架构,不改业务口径、不编造数据、不重写分析逻辑。**
+
+---
+
+## 3. 跳转规则
+
+| 场景 | 交互 | 路由形态 |
+| --- | --- | --- |
+| 模块内平级(趋势 / 情绪 / 店铺) | 顶部分段或 BoardTabs | 现有 sibling path,保留 |
+| 列表 → 实体 | 整行/整卡点击进入**新页面** | `/module/:id`,带 query 保留筛选 |
+| 工作台信号 / KPI | 进入对应实体或工作区,不是弹说明 | 已有 `go*` 接到真实页 |
+| 验证、分析运行 | 从抽屉改为独立子页 | `/validation/:id`、`/voc-insight/runs/:id` |
+| 反馈收件箱 | 桌面可保留分栏,但 URL 必须表达选中项;提供「展开为页面」 | `/feedback-inbox/:reviewId` |
+| 返回 | 页头返回到列表,保留 query | `PageHeader.backRoute` |
+
+面包屑示例:`洞察中心 / 分析运行 / 2026-08-19 批次`。侧栏高亮父级,不把每个实体推进侧栏。
+
+---
+
+## 4. 视觉 token(跟 shadcn Neutral 对齐)
+
+官方主题:https://ui.shadcn.com/docs/theming  
+仓库:https://github.com/shadcn-ui/ui
+
+语义对(Angular 里继续用 `$color-*`,但含义必须对上):
+
+| shadcn | 我们 | 近似值 |
+| --- | --- | --- |
+| `--background` / `--foreground` | `$color-bg` / `$color-text` | `#fafafa` / `#171717` |
+| `--card` | `$color-surface` | `#ffffff` |
+| `--muted` / `--muted-foreground` | `$color-surface-muted` / `$color-text-muted` | `#f4f4f5` / `#737373` |
+| `--accent` | hover / 选中浅底 | `#f4f4f5` |
+| `--primary` / `--primary-foreground` | `$color-primary` | `#171717` / `#fafafa` |
+| `--border` / `--input` / `--ring` | `$color-border` | `#e5e5e5`;focus ring 3px 半透明 |
+| `--destructive` | `$color-red` | 保持现有红 |
+| `--radius` | 基数 `10px`(`0.625rem`) | `$radius-card: 10px`;按钮 `8px` |
+
+在 `_tokens.scss` 落地:
+
+```scss
+$color-bg: #fafafa;
+$color-surface: #ffffff;
+$color-surface-muted: #f4f4f5;
+$color-border: #e5e5e5;
+$color-text: #171717;
+$color-text-muted: #737373;
+$color-primary: #171717;          // shadcn 默认主按钮,不是蓝
+$color-primary-hover: #262626;
+$color-primary-foreground: #fafafa;
+$color-accent: #f4f4f5;
+$color-blue: #2563eb;             // 仅链接 / 信息 / 图表
+$radius-card: 10px;
+$shadow-card-flat: 0 1px 2px rgba(15, 23, 42, 0.04); // shadow-xs
+$shadow-float: 0 8px 24px rgba(15, 23, 42, 0.08);
+```
+
+`focus-ring` 做成 shadcn 的 `ring-[3px] ring-black/20`,不要蓝光圈当默认。  
+`soft-neu-*` **保留 mixin 名**,视觉改为白底细边。  
+页面进入:`opacity + translateY(6px)` 180ms,只加在主内容第一层。
+
+---
+
+## 5. 壳层
+
+### 顶栏 `header`
+
+- 高度保持 56px。
+- **浅色顶栏**:`#ffffff` / `#fafafa`,底部分割线 `1px var(--border)`,去掉深色拟态。
+- 品牌字 15px、字重 600、`--foreground`。
+- 搜索做成居中 command pill(`h-9`、圆角 8–10px、`muted` 底、`border`,focus 用 3px ring)。**不要重写搜索逻辑**。
+- 右侧用户/设置用 `ghost` / `outline` 按钮,不要内凹拟态。
+
+### 侧栏 `navigation`
+
+对标 shadcn `Sidebar`:`SidebarGroup` + `SidebarGroupLabel` + `SidebarMenuButton`。
+
+- 分组小号灰色 **label**(工作、洞察、行动、数据)。
+- 默认透明;hover = `--sidebar-accent`(浅灰);选中 = 同色更深一点 + 字重 600。**不要左侧蓝条。**
+- 折叠 232 / 64 逻辑保持。
+- **不增删导航项,不改 path。**
+
+### 主区
+
+- `app.component.scss` 画布跟 token。
+- 内容区内边距统一由页面自己控制,不要再叠一层旧 `g-page` 大空白。
+
+---
+
+## 6. 共享组件(先改这些,全站会跟着变)
+
+| 组件 | 升级点 |
+| --- | --- |
+| `page-header` | 增加 `crumbs`、`backLabel`、`backRoute`;去掉 neu 图标凹槽;标题 22px / 字重 700 |
+| 新建 `page-breadcrumb` | 可独立使用;最后一级纯文本,前面可点击 |
+| `content-card` | 对标 shadcn Card:10px 圆角、细边、`shadow-xs`,header 与 body 分区 |
+| `entry-card` / `summary-metric-card` | hover 用 `accent` 浅底,不要上浮过大;可点击显示 chevron |
+| `board-tabs` | 对标 Tabs:底部分割线 + active 下划线(炭黑或前景色),不要蓝胶囊 |
+| `empty-state` / `_state.scss` | 淡底圆标 + muted 文案;主按钮用炭黑 primary |
+| `data-table-shell` | 行 hover `accent`,可点击行 cursor |
+| `neu-button` | **改皮不改名**:default=炭黑底白字;outline=白底细边;ghost=hover muted;高度约 36px |
+| `alert-card` | 保持左边色条,阴影改扁平 |
+
+---
+
+## 7. 波次
+
+### Wave 1 — 壳层与设计系统(先做,全站立刻像样)
+
+只动共享层:
+
+- `src/modules/shared/styles/_tokens.scss`
+- `src/modules/shared/styles/_card.scss` `_button.scss` `_state.scss` `_badge.scss` `_board-tabs.scss`(如需)
+- `src/app/app.component.scss`
+- `src/modules/shared/components/header/*`(样式为主,逻辑不动)
+- `src/modules/shared/components/navigation/*`(样式 + section label,path 不动)
+- `src/modules/shared/components/page-header/*`
+- 新建 `page-breadcrumb`
+- `board-tabs` `empty-state` `content-card` `entry-card` `summary-metric-card` `neu-button`
+- `src/styles.scss` 的 `g-*` 与新 token 对齐,**不要改** `.ai-visual-report-html` 大段
+
+验收:登录后顶栏/侧栏不再是深色拟态;主按钮是炭黑(shadcn default);卡片是细边扁平面;侧栏选中是浅灰 accent 不是蓝条。
+
+### Wave 2 — 旗舰页丰富度(已落地)
+
+在不改数据口径的前提下,把页面做成「工作区」而不是表单堆砌:
+
+1. **工作台** `operating-overview`:命令栏做成 sticky 工具条;KPI 点击已有下钻保持;信号行加 chevron。
+2. **反馈中心**:队列更像 Linear inbox(密度、未读点、选中高亮);详情区 sticky 头。
+3. **商品 VOC / 主题探索**:列表行进详情的 affordance 加强。
+4. **行动看板 / 验证中心**:卡片层级、空状态、筛选条统一。
+
+### Wave 3 — 真正的子页面(重点)
+
+把「抽屉详情」升级为「属于该页的子页」:
+
+| 现况 | 目标路由 | 体验 |
+| --- | --- | --- |
+| 验证详情抽屉 | `/validation` + `/validation/:id` | 独立页,返回验证中心 |
+| 分析运行抽屉 | `/voc-insight/runs` + `/voc-insight/runs/:id` | 独立页,返回运行列表 |
+| 反馈仅 query | `/feedback-inbox/:reviewId` | 深链可开;桌面可仍是分栏布局,但要有「仅此页」的头 |
+| 行动项多在看板上 | `/action-suggestion/kanban/:id` | **已落地**:提案详情子页(只读+状态),创建仍用面板 |
+
+已有且保持:`/voc-insight/topics/:id`、`/voc-insight/detail`、`/domestic/competitors/:productId`。
+
+实现约束:
+
+- 用现有 detail 组件迁到 routed 页,不要复制一份业务逻辑。
+- 列表页保持筛选 query;进子页用 `state` 或 query 把 `from` 带上。
+- 侧栏仍高亮父菜单。
+
+### Wave 4 — 其余模块收口(已落地)
+
+监测 workbench、退款、新品开发、知识库、数据中台、系统设置:套 Wave 1 组件,补面包屑,表格行能进已有详情就接线。
+
+已补:退款高退款商品、监测竞品/类目行、新品成交/对标行、品类分析行整行进入已有详情;证据链接改为 `/feedback-inbox/:reviewId`。
+
+---
+
+## 8. 验收清单
+
+- [x] 顶栏浅色、搜索是 pill、无拟态内凹
+- [x] 侧栏选中为浅灰 accent,分组有 label
+- [x] 主按钮炭黑,次按钮白底细边
+- [x] 卡片 10px 圆角、细边、浅阴影
+- [x] 页头支持返回 + 面包屑
+- [x] 验证 / 分析运行 / 行动项可从列表进入独立子页,浏览器后退能回列表
+- [x] 刷新实体 URL 仍能打开同一条
+- [x] 不改业务数字、不造评论、不删现有路由(只加子路由)
+- [x] `npx ng build --configuration=development` 通过
+
+---
+
+## 9. 禁止
+
+- 不要引入新 UI 框架(Material / ng-zorro / Tailwind),也不要在本仓执行 `npx shadcn@latest init`。
+- 不要重写 ECharts 业务 option,只允许容器样式。
+- 不要改 `docs/AI-VOC-NEXT-ITERATION.md` 里的业务闭环逻辑。
+- 不要动 Parse / 后端契约。
+- 不要为了好看把抽屉里的创建流程改成强制跳页。

+ 164 - 0
docs/SAAS-UPGRADE-PLAN.md

@@ -0,0 +1,164 @@
+# VOC SaaS 升级方案(落地版)
+
+**状态**:已完全落地(A–H)  
+**统筹结论**:不换产品骨架,不做成概念稿。在现有浅色运营壳上,补齐 **Stripe 式主次**、**可点进的硬表**、**实体独立子页**,并把可复用块抽成共享组件。  
+**视觉**:shadcn Neutral(炭黑主按钮,蓝只做链接/信息)。规格细节见 `docs/SAAS-UI-UPGRADE.md`。  
+**Agent 入口**:先读本文 + `docs/COMPONENT-INDEX.md` + `.cursor/rules/saas-ui-agent.mdc`。
+
+---
+
+## 0. 一句话
+
+经营概览继续是 Hub;反馈/行动学 Linear 队列;分析页学 Amplitude「左条件右主图」;点主题/验证/运行必须进入 **自己的路由**,不是抽屉。
+
+---
+
+## 1. 三种页面(禁止混用)
+
+| 类型 | 做什么 | 例子 | 组件 |
+| --- | --- | --- | --- |
+| Hub | 先回答「正不正常」,再下钻 | `/domestic/overview` | `page-header` + `page-toolbar` + `summary-metric-card` + 硬表 |
+| Workspace | 模块内平级切换 | 退款三页、监测三页、VOC rail | `board-tabs` / `voc-insight-rail` |
+| Entity | 整页属于这一条 | 主题、商品 VOC、验证、分析运行、单条反馈 | `page-header`(返回+面包屑)+ `entity-property-list` |
+
+抽屉只留给:创建行动、筛选、评论预览。
+
+---
+
+## 2. 路由(只加子路由,不删旧 path)
+
+| 现况 | 目标 | 行为 |
+| --- | --- | --- |
+| 已有 | `/voc-insight/topics/:id`、`/voc-insight/detail`、`/domestic/competitors/:productId` | 保持,补面包屑 |
+| 抽屉 | `/validation` + **`/validation/:id`** | 列表点行进子页;侧栏仍高亮验证中心 |
+| 抽屉 | `/voc-insight/runs` + **`/voc-insight/runs/:id`** | 同上 |
+| query | `/feedback-inbox` + **`/feedback-inbox/:reviewId`** | 桌面可仍分栏,URL 必须表达选中;提供「仅此页」头 |
+| 看板抽屉 | `/action-suggestion/kanban` + **`/action-suggestion/kanban/:id`** | 点行进独立页;创建仍用面板 |
+
+返回必须带回列表 query。侧栏不挂实体。
+
+---
+
+## 3. 字段契约
+
+**规则**
+
+1. **必填有数就展示**;没有就走空态文案,不编数字、不编评论。
+2. **预留字段**必须在模板里占位:值为空时显示 `—`,可用 `hint` 说明「有数据后再出现」。禁止用假数填满。
+3. 指标必须能回答「比上一期好还是坏」:`value` + `trendValue` + `trendState`。
+4. VOC 数字必须带 **样本 / 置信度 / 更新时间** 三者之一;缺哪项显示哪项为 `—`。
+5. 可点击行必须有:`id`、标题、类型、严重度或状态、**去哪**(route)。
+
+### 3.1 Hub · 经营概览 `/domestic/overview`
+
+| 区块 | 必填字段 | 预留(空则 —) | 交互 |
+| --- | --- | --- | --- |
+| 页头 | 标题「经营概览」 | eyebrow=工作台 | 无 |
+| 工具条 | `period.start/end`、`comparisonLabel` | 店铺、类目、负责人 | 数据口径 / 商品列表 / AI 洞察 |
+| 结论条 | `primaryMetric`、`text`、`tone` | 下次复盘时间 | 不点出弹窗,点进对应 Workspace/Entity |
+| 口径事实 | 明细行数、天数、商品数、VOC 本品/竞品条数、快照时间 | 数据新鲜度 SLA | — |
+| 指标 5 宫格 | 成交金额、退款率、成交转化、负向证据、可行动主题;各含 label/value/trend*/spark | 置信度、样本(经营指标可 —) | 退款率异常时视觉上作为 **primary**(`emphasis=primary`),其余缩小一档 |
+| 主图 | 7 日成交/转化/退款 | 对比期虚线(有上一窗才画) | 点系列不编新结论 |
+| 硬表 | 异常与机会:severity、label、title、detail、metric、action | owner、到期 | **整行**进商品/退款/类目/VOC |
+
+现有 `OverviewKpi` / `OverviewSignal` 继续用,只改展示层级,不改口径计算。
+
+### 3.2 Hub · 反馈收件箱 `/feedback-inbox`
+
+| 区块 | 必填 | 预留 | 交互 |
+| --- | --- | --- | --- |
+| 队列行 | id、dateLabel、sourceLabel、sentiment、topicLabel、product 标题、priorityLabel | 负责人、已读 | 选中改 URL `/:reviewId` |
+| 详情 | 原文、商品、主题、sampleCount、confidence | 关联分析 run、已建行动 | 「展开为页面」;创建行动仍用面板 |
+
+### 3.3 Entity · 验证 `/validation/:id`
+
+必填:`stageLabel`、行动标题、metric(`formattedValue` / `unavailableReason`)、checks、evidenceIds、analysisSummary。  
+预留:`assignee`、`dueAt`、`slaHours`(模型没有就 `—`)。  
+禁止把未测量写成「有效」。
+
+### 3.4 Entity · 分析运行 `/voc-insight/runs/:id`
+
+必填:status、headline/executiveSummary(空则空态)、sampleCount、evidenceCoverage、insights[]、timeline。  
+预留:模型名、提示词版本、费用(没有就 `—`)。
+
+### 3.5 Entity · 主题 / 商品 VOC
+
+已有页补:面包屑、返回、证据条数、置信度、关联商品。  
+预留:负责人、验证窗口。不重写分析逻辑。
+
+### 3.6 通用空值
+
+| 情况 | 展示 |
+| --- | --- |
+| 经营有、VOC 无 | 「部分数据 · 本品 0 / 竞品 N」 |
+| 样本 < 门槛 | 置信度低 + 不生成行动 CTA |
+| 预留未接 | `—` |
+| 加载失败 | `error-state` + 重试 |
+
+---
+
+## 4. 本轮要抽出的复用组件
+
+只新增,不改名已有组件。契约如下。
+
+### 4.1 `app-sparkline`
+
+路径:`src/modules/shared/components/sparkline/`  
+输入:`points`(polyline 字符串或 number[])、`tone: 'default'|'good'|'bad'`、`height`。  
+用途:指标卡、表格行。经营概览已有 `sparkPoints`,改为喂这个组件。
+
+### 4.2 `app-page-toolbar`
+
+路径:`src/modules/shared/components/page-toolbar/`  
+左:`fields: {label, value}[]`(周期、比较、店铺…)  
+右:`ng-content select="[pageToolbarActions]"`  
+预留字段走 `value: '—'`。
+
+### 4.3 `app-entity-property-list`
+
+路径:`src/modules/shared/components/entity-property-list/`  
+`items: { label, value, hint?, reserved? }[]`  
+`value` 空或 `reserved===true && !value` → 显示 `—`,hint 灰色。  
+用在所有 Entity 右侧栏。
+
+### 4.4 已有、必须复用(禁止再造一套)
+
+`page-header`(已有 crumbs/back)、`page-breadcrumb`、`summary-metric-card`、`content-card`、`data-table-shell`、`board-tabs`、`empty-state`、`error-state`、`neu-button`、`alert-card`、`filter-bar`。
+
+---
+
+## 5. 波次与文件所有权(并行时不要互相改)
+
+| 波次 | 谁改哪些 | 完成标准 |
+| --- | --- | --- |
+| A 组件 | 仅新增 4.1–4.3 目录;更新 `COMPONENT-INDEX.md` | **已完成** |
+| B Hub | 仅 `operating-overview.component.ts/scss` | **已完成**(退款异常 emphasis;信号整行 +「进入」) |
+| C 子页 | `app.routes.ts` + validation + analysis-runs | **已完成**(`/validation/:id`、`/voc-insight/runs/:id`) |
+| D 反馈 URL | 仅 `feedback-inbox/*` | **已完成**(`/feedback-inbox/:reviewId`,旧 query 会升到 path) |
+| E 收口 | 其它 Hub 接面包屑 | **已完成**(主题/商品 VOC/竞品详情 + 4 个 workbench) |
+| F 行动子页与 Hub 工具条 | `action-page` + 各 Hub `page-toolbar` | **已完成** |
+| G Wave 2/4 接线 | 反馈/知识库/验证/行动/类目 | **已完成**(队列工具条;知识库整行进详情;验证/行动空态;类目进商品洞察) |
+| H 收口补漏 | 工作台行进详情 + 反馈 path 深链 | **已完成**(退款/监测/新品/品类整行进已有详情;证据进 `/feedback-inbox/:reviewId`;返回保留 query;行动抽屉死代码已清) |
+
+禁止:新 UI 库、`npx shadcn@latest init`、改 Parse/后端契约、改 `.ai-visual-report-html`、编造评论。
+
+---
+
+## 6. 验收
+
+- [x] 经营概览能一眼看出「现在最该盯哪个数」(退款异常时 emphasis)
+- [x] 异常行进真实路由,不是 alert
+- [x] 验证/分析运行有独立 URL,浏览器后退回列表
+- [x] 预留字段为空是 `—`,没有假数
+- [x] `COMPONENT-INDEX.md` 能搜到新组件
+- [x] `npx ng build --configuration=development` 通过
+- [x] 反馈深链 `/feedback-inbox/:reviewId`
+- [x] 行动详情独立 URL `/action-suggestion/kanban/:id`,浏览器后退回工作台
+- [x] 主题列表点行进 `/voc-insight/topics/:id`
+- [x] 其余 Hub 接 `page-toolbar`,预留店铺/类目/负责人为 `—`
+- [x] 知识库整行进已有商品/竞品详情
+- [x] 类目 VOC 诊断可进入商品洞察
+- [x] 行动详情不再用抽屉,创建仍用面板
+- [x] 退款/监测/新品/品类表格整行进入已有商品或类目详情
+- [x] 主题/AI 洞察证据使用 `/feedback-inbox/:reviewId`,不再只靠 query
+- [x] 实体页返回与面包屑保留列表 query

+ 23 - 2
src/app/app.component.scss

@@ -1,7 +1,7 @@
 .app-layout {
   min-height: 100vh;
-  background: #f6f8fb;
-  color: #0f172a;
+  background: #fafafa;
+  color: #171717;
   font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
   display: flex;
   flex-direction: column;
@@ -30,6 +30,27 @@
   &.full-width {
     margin-left: 0;
   }
+
+  > *:not(router-outlet) {
+    animation: saas-page-enter 180ms cubic-bezier(0.16, 1, 0.3, 1);
+  }
+}
+
+@keyframes saas-page-enter {
+  from {
+    opacity: 0;
+    transform: translateY(6px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .main-content > *:not(router-outlet) {
+    animation: none;
+  }
 }
 
 * {

+ 22 - 0
src/app/app.routes.ts

@@ -81,6 +81,12 @@ export const routes: Routes = [
     title: '反馈收件箱',
     loadComponent: () => import('../modules/feedback-inbox/feedback-inbox.component').then((m) => m.FeedbackInboxComponent),
   },
+  {
+    path: 'feedback-inbox/:reviewId',
+    canActivate: protectedRoutes,
+    title: '反馈详情',
+    loadComponent: () => import('../modules/feedback-inbox/feedback-inbox.component').then((m) => m.FeedbackInboxComponent),
+  },
   {
     path: 'voc-insight',
     canActivate: protectedRoutes,
@@ -112,6 +118,11 @@ export const routes: Routes = [
         title: 'AI 分析运行',
         loadComponent: () => import('../modules/ai-voc-insight/analysis-runs/analysis-run-list.component').then((m) => m.AnalysisRunListComponent),
       },
+      {
+        path: 'runs/:id',
+        title: '分析运行详情',
+        loadComponent: () => import('../modules/ai-voc-insight/analysis-runs/analysis-run-page.component').then((m) => m.AnalysisRunPageComponent),
+      },
       {
         path: 'detail',
         title: '商品 VOC 详情',
@@ -162,6 +173,12 @@ export const routes: Routes = [
     title: '效果验证',
     loadComponent: () => import('../modules/validation-center/validation-center.component').then((m) => m.ValidationCenterComponent),
   },
+  {
+    path: 'validation/:id',
+    canActivate: protectedRoutes,
+    title: '验证详情',
+    loadComponent: () => import('../modules/validation-center/validation-page.component').then((m) => m.ValidationPageComponent),
+  },
   {
     path: 'system',
     canActivate: protectedRoutes,
@@ -252,6 +269,11 @@ export const routes: Routes = [
         data: { section: 'kanban' },
         loadComponent: () => import('../modules/domestic-voc/framework/domestic-action-workbench.component').then((m) => m.DomesticActionWorkbenchComponent),
       },
+      {
+        path: 'kanban/:id',
+        title: '行动详情',
+        loadComponent: () => import('../modules/action-suggestion/action-page.component').then((m) => m.ActionPageComponent),
+      },
       {
         path: 'experience',
         title: '体验优化提案',

+ 131 - 0
src/modules/action-suggestion/action-page.component.html

@@ -0,0 +1,131 @@
+<main class="entity-page">
+  <app-page-header
+    [title]="pageTitle"
+    backLabel="返回行动工作台"
+    [backRoute]="backRoute"
+    [crumbs]="crumbs"
+    [badges]="badges"
+    [meta]="headerMeta"
+  ></app-page-header>
+
+  @if (loading) {
+    <app-loading-spinner text="正在加载行动详情" subtext="按行动 ID 读取现有 ActionItem。"></app-loading-spinner>
+  } @else if (errorMessage) {
+    <app-error-state
+      title="行动详情加载失败"
+      [description]="errorMessage"
+      [showBack]="true"
+      (retry)="reload()"
+      (back)="goList()"
+    ></app-error-state>
+  } @else if (!action) {
+    <app-empty-state
+      title="未找到该行动项"
+      description="刷新后仍可能因记录不存在或尚未同步而无法打开。"
+      actionText="返回行动工作台"
+      (action)="goList()"
+    ></app-empty-state>
+  } @else {
+    <div class="entity-layout">
+      <div class="entity-main">
+        <app-content-card title="行动说明" density="compact" headingLevel="h3">
+          <p class="action-description">{{ action.description || '暂无补充说明。' }}</p>
+          @if (action.productKey) {
+            <p class="action-product">关联商品 {{ action.productKey }}</p>
+          }
+        </app-content-card>
+
+        <app-content-card title="执行信息" subtitle="更新后写入 ActionItem" density="compact" headingLevel="h3">
+          <div class="detail-form-grid">
+            <label>
+              <span>状态</span>
+              <select [(ngModel)]="editStatus">
+                @for (option of statusOptions; track option.value) {
+                  <option [ngValue]="option.value">{{ option.label }}</option>
+                }
+              </select>
+            </label>
+            <label>
+              <span>负责人</span>
+              <input
+                type="text"
+                list="action-page-owner-suggestions"
+                [(ngModel)]="editAssignee"
+                placeholder="输入工作空间用户 ID" />
+              <datalist id="action-page-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>
+          }
+          <div class="detail-actions">
+            <button type="button" class="primary-button" [disabled]="updating" (click)="saveAction()">
+              {{ updating ? '保存中' : '保存更新' }}
+            </button>
+          </div>
+        </app-content-card>
+
+        <app-content-card title="来源洞察" [subtitle]="sourceLabel(action)" density="compact" headingLevel="h3">
+          @if (insight; as item) {
+            <strong>{{ item.title || action.sourceInsightId }}</strong>
+            @if (item.finding) { <p>{{ item.finding }}</p> }
+            @if (item.recommendation) { <p class="muted">建议:{{ item.recommendation }}</p> }
+            @if (item.confidence) { <p class="muted">置信度 {{ item.confidence }}</p> }
+          } @else {
+            <p>{{ action.sourceInsightId || '无关联洞察' }}</p>
+          }
+          @if (action.sourceAnalysisId) {
+            <p class="muted">分析运行 {{ action.sourceAnalysisId }}</p>
+          }
+        </app-content-card>
+
+        <app-content-card title="证据" [subtitle]="(action.evidenceIds || []).length + ' 条'" density="compact" headingLevel="h3">
+          @if (action.evidenceIds?.length) {
+            <ul class="evidence-list">
+              @for (evidenceId of action.evidenceIds; track evidenceId) {
+                <li><code>{{ evidenceId }}</code></li>
+              }
+            </ul>
+          } @else {
+            <p class="muted">该行动项未关联证据 ID</p>
+          }
+        </app-content-card>
+
+        <app-content-card title="验证指标" density="compact" headingLevel="h3">
+          <p>{{ action.validationMetric || '待补充可量化的验证指标。' }}</p>
+        </app-content-card>
+
+        <app-content-card title="当前记录摘要" subtitle="非审计日志" density="compact" headingLevel="h3">
+          <ol class="status-timeline">
+            <li>
+              <strong>行动项已创建</strong>
+              <span>{{ dateTime(action.createdAt) }}</span>
+            </li>
+            @if (action.updatedAt !== action.createdAt) {
+              <li>
+                <strong>行动项已更新</strong>
+                <span>{{ dateTime(action.updatedAt) }}</span>
+              </li>
+            }
+            <li>
+              <strong>当前状态:{{ statusLabel(action.status) }}</strong>
+              <span>最后同步 {{ dateTime(action.updatedAt) }}</span>
+            </li>
+          </ol>
+        </app-content-card>
+      </div>
+
+      <app-content-card title="属性" density="compact" headingLevel="h3">
+        <app-entity-property-list [items]="propertyItems"></app-entity-property-list>
+      </app-content-card>
+    </div>
+  }
+</main>

+ 108 - 0
src/modules/action-suggestion/action-page.component.scss

@@ -0,0 +1,108 @@
+@use '../shared/styles/tokens' as t;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.entity-page {
+  padding: 8px 20px 48px;
+  color: t.$color-text;
+}
+
+.entity-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
+  gap: 20px;
+  align-items: start;
+  margin-top: 16px;
+}
+
+.entity-main {
+  display: grid;
+  gap: 16px;
+  min-width: 0;
+}
+
+.action-description,
+.action-product,
+.muted,
+.entity-main p {
+  margin: 0 0 8px;
+  line-height: 1.6;
+}
+
+.muted,
+.action-product {
+  color: t.$color-text-muted;
+}
+
+.detail-form-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.detail-form-grid label {
+  display: grid;
+  gap: 6px;
+  font-size: 12px;
+  color: t.$color-text-muted;
+}
+
+.detail-form-grid input,
+.detail-form-grid select {
+  height: 36px;
+  border: 1px solid t.$color-border;
+  border-radius: 8px;
+  padding: 0 10px;
+  background: t.$color-surface;
+  color: t.$color-text;
+}
+
+.detail-error {
+  margin: 12px 0 0;
+  color: t.$color-red;
+}
+
+.detail-actions {
+  margin-top: 16px;
+}
+
+.primary-button {
+  height: 36px;
+  border: 0;
+  border-radius: 8px;
+  padding: 0 14px;
+  background: t.$color-primary;
+  color: #fff;
+  cursor: pointer;
+}
+
+.primary-button:disabled {
+  opacity: 0.6;
+  cursor: wait;
+}
+
+.evidence-list,
+.status-timeline {
+  margin: 0;
+  padding-left: 18px;
+}
+
+.evidence-list li,
+.status-timeline li {
+  margin-bottom: 8px;
+}
+
+.status-timeline span {
+  display: block;
+  color: t.$color-text-muted;
+}
+
+@media (max-width: 960px) {
+  .entity-layout,
+  .detail-form-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}

+ 372 - 0
src/modules/action-suggestion/action-page.component.ts

@@ -0,0 +1,372 @@
+import { CommonModule } from '@angular/common';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { catchError, forkJoin, map, of, switchMap } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+import {
+  SaasActionItem,
+  SaasAnalysisRun,
+  SaasContext,
+  SaasPlatformService,
+} from '../../app/core/services/saas-platform.service';
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
+import { EntityPropertyItem, EntityPropertyListComponent } from '../shared/components/entity-property-list/entity-property-list.component';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
+import { PageHeaderBadge, PageHeaderComponent, PageHeaderCrumb, PageHeaderMetaItem } from '../shared/components/page-header/page-header.component';
+import { findCursorPageItem } from '../validation-center/validation-center.data';
+
+type ActionStatus = SaasActionItem['status'];
+
+interface InsightSummary {
+  title: string;
+  finding: string;
+  recommendation: string;
+  confidence: string;
+}
+
+@Component({
+  selector: 'app-action-page',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    PageHeaderComponent,
+    EntityPropertyListComponent,
+    ContentCardComponent,
+    EmptyStateComponent,
+    ErrorStateComponent,
+    LoadingSpinnerComponent,
+  ],
+  templateUrl: './action-page.component.html',
+  styleUrl: './action-page.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ActionPageComponent implements OnInit {
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly platform = inject(SaasPlatformService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly statusOptions: Array<{ value: ActionStatus; label: string }> = [
+    { value: 'open', label: '待评估' },
+    { value: 'planned', label: '待执行' },
+    { value: 'in_progress', label: '进行中' },
+    { value: 'blocked', label: '阻塞' },
+    { value: 'completed', label: '已完成' },
+    { value: 'cancelled', label: '已关闭' },
+  ];
+
+  entityId = '';
+  action: SaasActionItem | null = null;
+  analysis: SaasAnalysisRun | null = null;
+  context: SaasContext | null = null;
+  loading = true;
+  errorMessage = '';
+  updateError = '';
+  updating = false;
+  editStatus: ActionStatus = 'open';
+  editAssignee = '';
+  editDueDate = '';
+  private requestVersion = 0;
+
+  ngOnInit(): void {
+    this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+      this.entityId = params.get('id')?.trim() ?? '';
+      this.load();
+    });
+  }
+
+  get pageTitle(): string {
+    return this.action?.title?.trim() || '行动详情';
+  }
+
+  get backRoute(): string[] {
+    return this.listRoute;
+  }
+
+  get listRoute(): string[] {
+    const from = this.route.snapshot.queryParamMap.get('from');
+    if (from === 'experience' || from === 'iteration' || from === 'strategy') {
+      return ['/action-suggestion', from];
+    }
+    return ['/action-suggestion', 'kanban'];
+  }
+
+  get crumbs(): PageHeaderCrumb[] {
+    return [
+      { label: '行动中心', link: '/action-suggestion' },
+      { label: '行动工作台', link: this.listRoute.join('/') },
+      { label: this.pageTitle },
+    ];
+  }
+
+  get badges(): PageHeaderBadge[] {
+    if (!this.action) return [];
+    const badges: PageHeaderBadge[] = [
+      { label: this.statusLabel(this.action.status), tone: this.statusTone(this.action.status) },
+      { label: this.priorityLabel(this.action.priority), tone: this.priorityTone(this.action.priority) },
+    ];
+    if (this.isOverdue(this.action)) {
+      badges.push({ label: '已逾期', tone: 'red' });
+    }
+    return badges;
+  }
+
+  get headerMeta(): PageHeaderMetaItem[] {
+    if (!this.action) return [];
+    return [
+      { label: '类型', value: this.actionTypeLabel(this.action.actionType) },
+      { label: '来源', value: this.sourceLabel(this.action) },
+      { label: '负责人', value: this.ownerLabel(this.action.assigneeUserId) },
+    ];
+  }
+
+  get insight(): InsightSummary | null {
+    const action = this.action;
+    const analysis = this.analysis;
+    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'),
+    };
+  }
+
+  get ownerOptions(): ReadonlyArray<{ value: string; label: string }> {
+    const owners = new Set<string>();
+    if (this.action?.assigneeUserId) owners.add(this.action.assigneeUserId);
+    if (this.context?.principal.userId) owners.add(this.context.principal.userId);
+    return [...owners].map((value) => ({ value, label: this.ownerLabel(value) }));
+  }
+
+  get propertyItems(): EntityPropertyItem[] {
+    const action = this.action;
+    if (!action) return [];
+    return [
+      { label: '行动类型', value: this.actionTypeLabel(action.actionType) },
+      { label: '来源', value: this.sourceLabel(action) },
+      { label: '状态', value: this.statusLabel(action.status) },
+      { label: '优先级', value: this.priorityLabel(action.priority) },
+      { label: '关联商品', value: action.productKey },
+      { label: '证据', value: action.evidenceIds?.length ? `${action.evidenceIds.length} 条` : null },
+      { label: '验证指标', value: action.validationMetric },
+      { label: '来源分析', value: action.sourceAnalysisId },
+      { label: '来源洞察', value: action.sourceInsightId },
+      {
+        label: '负责人',
+        value: action.assigneeUserId,
+        reserved: true,
+        hint: action.assigneeUserId ? undefined : '有数据后再出现',
+      },
+      {
+        label: '到期',
+        value: this.formatOptionalDate(action.dueAt),
+        reserved: true,
+        hint: action.dueAt ? undefined : '有数据后再出现',
+      },
+      {
+        label: 'SLA',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+    ];
+  }
+
+  reload(): void {
+    this.load();
+  }
+
+  goList(): void {
+    void this.router.navigate(this.listRoute, { queryParamsHandling: 'preserve' });
+  }
+
+  saveAction(): void {
+    const action = this.action;
+    if (!action || this.updating) return;
+    this.updating = true;
+    this.updateError = '';
+    this.cdr.markForCheck();
+    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(takeUntilDestroyed(this.destroyRef)).subscribe({
+      next: ({ action: updated }) => {
+        this.action = updated;
+        this.editStatus = updated.status;
+        this.editAssignee = updated.assigneeUserId ?? '';
+        this.editDueDate = updated.dueAt?.slice(0, 10) ?? '';
+        this.updating = false;
+        this.cdr.markForCheck();
+      },
+      error: (error: unknown) => {
+        this.updateError = this.updateErrorMessage(error);
+        this.updating = false;
+        this.cdr.markForCheck();
+      },
+    });
+  }
+
+  statusLabel(status: ActionStatus): string {
+    return this.statusOptions.find((option) => option.value === status)?.label ?? status;
+  }
+
+  sourceLabel(action: SaasActionItem): string {
+    const kind = action.sourceKind ?? (action.sourceAnalysisId ? 'insight' : 'rule_action');
+    return ({ insight: 'AI 洞察', raw_feedback: '反馈原声', rule_action: '规则识别' } as const)[kind];
+  }
+
+  actionTypeLabel(type: SaasActionItem['actionType']): string {
+    return ({
+      general: '通用行动',
+      experience: '体验优化',
+      product: '产品迭代',
+      strategy: '长期策略',
+      data_quality: '补充证据',
+    } as const)[type];
+  }
+
+  priorityLabel(priority: SaasActionItem['priority']): string {
+    return ({ critical: '紧急', high: '高', medium: '中', low: '低' } as const)[priority];
+  }
+
+  ownerLabel(userId: string | null | undefined): 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();
+  }
+
+  dateTime(value: string | null | undefined): 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',
+      hour12: false,
+    }).format(date);
+  }
+
+  private load(): void {
+    const version = ++this.requestVersion;
+    this.loading = true;
+    this.errorMessage = '';
+    this.updateError = '';
+    this.action = null;
+    this.analysis = null;
+    this.cdr.markForCheck();
+
+    if (!this.entityId) {
+      this.loading = false;
+      this.cdr.markForCheck();
+      return;
+    }
+
+    findCursorPageItem((cursor) => this.platform.actions(this.workspaceId, '', cursor, 100), this.entityId).pipe(
+      switchMap((action) => {
+        if (!action) return of({ action: null, analysis: null, context: null, error: '' });
+        const analysisId = action.sourceAnalysisId?.trim() ?? '';
+        return forkJoin({
+          analysis: analysisId
+            ? findCursorPageItem((cursor) => this.platform.analyses(this.workspaceId, '', cursor, 100), analysisId)
+            : of(null),
+          context: this.platform.context().pipe(
+            catchError(() => of(null)),
+          ),
+        }).pipe(map(({ analysis, context }) => ({ action, analysis, context, error: '' })));
+      }),
+      catchError((error: unknown) => of({ action: null, analysis: null, context: null, error: this.errorText(error) })),
+      takeUntilDestroyed(this.destroyRef),
+    ).subscribe(({ action, analysis, context, error }) => {
+      if (version !== this.requestVersion) return;
+      this.loading = false;
+      this.action = action;
+      this.analysis = analysis;
+      this.context = context;
+      this.errorMessage = error;
+      if (action) {
+        this.editStatus = action.status;
+        this.editAssignee = action.assigneeUserId ?? '';
+        this.editDueDate = action.dueAt?.slice(0, 10) ?? '';
+      }
+      this.cdr.markForCheck();
+    });
+  }
+
+  private statusTone(status: ActionStatus): PageHeaderBadge['tone'] {
+    if (status === 'blocked') return 'red';
+    if (status === 'completed') return 'green';
+    if (status === 'in_progress') return 'blue';
+    if (status === 'cancelled') return 'default';
+    return 'amber';
+  }
+
+  private priorityTone(priority: SaasActionItem['priority']): PageHeaderBadge['tone'] {
+    if (priority === 'critical') return 'red';
+    if (priority === 'high') return 'amber';
+    if (priority === 'medium') return 'blue';
+    return 'default';
+  }
+
+  private formatOptionalDate(value: string | null | undefined): string | null {
+    if (!value?.trim()) return null;
+    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',
+    }).format(date);
+  }
+
+  private isRecord(value: unknown): value is Record<string, unknown> {
+    return typeof value === 'object' && value !== null && !Array.isArray(value);
+  }
+
+  private recordString(record: Record<string, unknown>, key: string): string {
+    const value = record[key];
+    return typeof value === 'string' ? value : '';
+  }
+
+  private errorText(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 '服务不可达';
+      return `HTTP ${error.status}`;
+    }
+    return error instanceof Error ? error.message : '行动详情加载失败';
+  }
+
+  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 '行动项更新失败,请稍后重试。';
+  }
+}

+ 11 - 17
src/modules/ai-voc-insight/ai-voc-insight.component.html

@@ -1,16 +1,9 @@
 <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">
+  <app-page-header
+    title="AI VOC 洞察工作台"
+    description="以真实评价证据生成可审计、可验证、可执行的用户洞察"
+    [crumbs]="crumbs">
+    <div pageHeaderActions 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>
@@ -30,7 +23,9 @@
         </button>
       }
     </div>
-  </header>
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
   @if (persistenceNotice || analysisHistoryError) {
     <section
@@ -199,8 +194,7 @@
             @for (evidence of preparedContext.evidence.slice(0, 8); track evidence.id) {
               <a
                 class="evidence-preview"
-                [routerLink]="['/feedback-inbox']"
-                [queryParams]="{ reviewId: evidence.id }"
+                [routerLink]="['/feedback-inbox', evidence.id]"
                 [attr.data-sentiment]="evidence.sentiment">
                 <span class="evidence-preview-meta">
                   <strong>{{ evidence.topicLabel }}</strong>
@@ -358,7 +352,7 @@
                   <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">
+                      <a [routerLink]="['/feedback-inbox', evidenceId]" [title]="'查看原始评价 ' + evidenceId">
                         {{ evidenceId }}
                         <lucide-icon [img]="icons.arrow" [size]="12" [strokeWidth]="2"></lucide-icon>
                       </a>
@@ -410,7 +404,7 @@
                               (change)="toggleEvidenceReview(insight, evidenceId, $any($event.target).checked)" />
                             <span>{{ evidenceId }}</span>
                           </label>
-                          <a [routerLink]="['/feedback-inbox']" [queryParams]="{ reviewId: evidenceId }">查看证据</a>
+                          <a [routerLink]="['/feedback-inbox', evidenceId]">查看证据</a>
                         </div>
                       }
                     </div>

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

@@ -49,6 +49,8 @@ import {
   SaasPlatformService,
 } from '../../app/core/services/saas-platform.service';
 import { ActionCreatePanelComponent } from '../shared/components/action-create-panel/action-create-panel.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../shared/components/page-toolbar/page-toolbar.component';
 import {
   AiVocConfidence,
   AiVocEvidenceReview,
@@ -72,6 +74,8 @@ import { AiVocInsightService } from './ai-voc-insight.service';
     RouterLink,
     LucideAngularModule,
     ActionCreatePanelComponent,
+    PageHeaderComponent,
+    PageToolbarComponent,
   ],
   templateUrl: './ai-voc-insight.component.html',
   styleUrl: './ai-voc-insight.component.scss',
@@ -111,6 +115,10 @@ export class AiVocInsightComponent implements OnInit {
   };
 
   readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '洞察中心' },
+    { label: 'AI 洞察' },
+  ];
   readonly scopeOptions: ReadonlyArray<{ value: AiVocScope; label: string }> = [
     { value: 'all', label: '全部评价' },
     { value: 'own', label: '本品评价' },
@@ -213,6 +221,15 @@ export class AiVocInsightComponent implements OnInit {
     return labels[this.gatewayState];
   }
 
+  get toolbarFields(): PageToolbarField[] {
+    return [
+      { label: '模型', value: this.gatewayStatus?.defaultModel || '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '费用', value: '—' },
+    ];
+  }
+
   get resultCoverage(): number {
     return Math.round((this.generation.result?.evidenceCoverage ?? 0) * 100);
   }

+ 12 - 2
src/modules/ai-voc-insight/analysis-runs/analysis-run-components.spec.ts

@@ -1,4 +1,5 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
 import { of } from 'rxjs';
 import {
   SaasAnalysisRun,
@@ -72,17 +73,22 @@ describe('Analysis run center', () => {
   };
 
   let service: jasmine.SpyObj<SaasPlatformService>;
+  let router: jasmine.SpyObj<Router>;
 
   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 }));
+    router = jasmine.createSpyObj<Router>('Router', ['navigate']);
   });
 
   it('renders the real run fields and opens the detail view', async () => {
     await TestBed.configureTestingModule({
       imports: [AnalysisRunListComponent],
-      providers: [{ provide: SaasPlatformService, useValue: service }],
+      providers: [
+        { provide: SaasPlatformService, useValue: service },
+        { provide: Router, useValue: router },
+      ],
     }).compileComponents();
 
     const fixture = TestBed.createComponent(AnalysisRunListComponent);
@@ -97,7 +103,11 @@ describe('Analysis run center', () => {
     const detailButton = fixture.nativeElement.querySelector('.row-action') as HTMLButtonElement;
     detailButton.click();
     fixture.detectChanges();
-    expect(fixture.nativeElement.querySelector('app-analysis-run-detail')).not.toBeNull();
+    expect(router.navigate).toHaveBeenCalledWith(
+      ['/voc-insight/runs', completedRun.id],
+      { queryParamsHandling: 'preserve' },
+    );
+    expect(fixture.nativeElement.querySelector('app-analysis-run-detail')).toBeNull();
   });
 
   it('renders evidence, human decisions and explicit missing fields', async () => {

+ 9 - 3
src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.html

@@ -1,6 +1,12 @@
 <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">
+  <button *ngIf="isDrawer" class="drawer-backdrop" type="button" aria-label="关闭运行详情" (click)="close()"></button>
+  <aside
+    #runDetail
+    class="run-detail"
+    [attr.role]="isDrawer ? 'dialog' : null"
+    [attr.aria-modal]="isDrawer ? true : null"
+    aria-labelledby="run-detail-title"
+  >
     <header class="detail-header">
       <div class="detail-title">
         <span class="title-icon" aria-hidden="true">
@@ -12,7 +18,7 @@
           <code>{{ currentRun.id }}</code>
         </div>
       </div>
-      <button class="close-button" type="button" title="关闭" aria-label="关闭运行详情" (click)="close()">
+      <button *ngIf="isDrawer" class="close-button" type="button" title="关闭" aria-label="关闭运行详情" (click)="close()">
         <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
       </button>
     </header>

+ 17 - 2
src/modules/ai-voc-insight/analysis-runs/analysis-run-detail.component.scss

@@ -1,9 +1,12 @@
 :host {
+  display: block;
+  letter-spacing: 0;
+}
+
+:host:not(.is-page) {
   position: fixed;
   z-index: 1200;
   inset: 0;
-  display: block;
-  letter-spacing: 0;
 }
 
 button {
@@ -35,6 +38,18 @@ button {
   box-shadow: -16px 0 42px rgba(15, 23, 42, 0.16);
 }
 
+:host.is-page .run-detail {
+  position: static;
+  display: flex;
+  width: 100%;
+  height: auto;
+  overflow: visible;
+  flex-direction: column;
+  border: 1px solid #e5e5e5;
+  border-radius: 10px;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+}
+
 .detail-header,
 .detail-title,
 .detail-status,

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

@@ -75,6 +75,9 @@ type AnalysisRunDetailTab = 'summary' | 'evidence' | 'timeline';
   templateUrl: './analysis-run-detail.component.html',
   styleUrl: './analysis-run-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
+  host: {
+    '[class.is-page]': 'variant === "page"',
+  },
 })
 export class AnalysisRunDetailComponent implements OnChanges, AfterViewInit, OnDestroy {
   private readonly saasService = inject(SaasPlatformService);
@@ -87,8 +90,13 @@ export class AnalysisRunDetailComponent implements OnChanges, AfterViewInit, OnD
 
   @Input() run: SaasAnalysisRun | null = null;
   @Input() workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  @Input() variant: 'drawer' | 'page' = 'drawer';
   @Output() readonly closed = new EventEmitter<void>();
 
+  get isDrawer(): boolean {
+    return this.variant !== 'page';
+  }
+
   readonly icons = {
     activity: Activity,
     alert: AlertCircle,
@@ -116,10 +124,12 @@ export class AnalysisRunDetailComponent implements OnChanges, AfterViewInit, OnD
   decisionsError = '';
 
   ngAfterViewInit(): void {
+    if (!this.isDrawer) return;
     queueMicrotask(() => this.runDetail?.nativeElement.querySelector<HTMLElement>('[aria-label="关闭运行详情"]')?.focus());
   }
 
   ngOnDestroy(): void {
+    if (!this.isDrawer) return;
     queueMicrotask(() => this.returnFocus?.focus());
   }
 
@@ -132,6 +142,7 @@ export class AnalysisRunDetailComponent implements OnChanges, AfterViewInit, OnD
 
   @HostListener('document:keydown', ['$event'])
   handleKeyboard(event: KeyboardEvent): void {
+    if (!this.isDrawer) return;
     if (event.key === 'Escape') {
       event.preventDefault();
       this.close();

+ 12 - 19
src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.html

@@ -1,20 +1,16 @@
-<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="刷新运行列表"
+<section class="analysis-runs-page">
+  <app-page-header
+    title="AI 分析运行中心"
+    [crumbs]="crumbs">
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields">
+    <button pageToolbarActions 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>
+  </app-page-toolbar>
 
   <div class="run-summary" aria-label="当前已加载运行统计">
     <div>
@@ -79,7 +75,7 @@
         </tr>
       </thead>
       <tbody>
-        <tr *ngFor="let run of visibleRuns; trackBy: trackRun" [class.is-selected]="selectedRun?.id === run.id">
+        <tr *ngFor="let run of visibleRuns; trackBy: trackRun" class="is-clickable" (click)="selectRun(run)">
           <td>
             <span class="status-badge" [attr.data-tone]="statusTone(run)">
               <span class="status-dot"></span>{{ statusLabel(run) }}
@@ -90,7 +86,7 @@
             <span class="secondary-cell">{{ run.analysisType }}</span>
           </td>
           <td>
-            <button class="target-button" type="button" (click)="selectRun(run)">
+            <button class="target-button" type="button" (click)="selectRun(run); $event.stopPropagation()">
               <span>{{ targetLabel(run) }}</span>
               <small>{{ targetKindLabel(run) }}</small>
             </button>
@@ -111,7 +107,7 @@
           </td>
           <td class="action-cell">
             <button class="row-action" type="button" title="查看运行详情" [attr.aria-label]="'查看运行 ' + run.id"
-              (click)="selectRun(run)">
+              (click)="selectRun(run); $event.stopPropagation()">
               <lucide-icon [img]="icons.eye" [size]="15"></lucide-icon>
               <lucide-icon [img]="icons.chevron" [size]="14"></lucide-icon>
             </button>
@@ -135,6 +131,3 @@
     </button>
   </footer>
 </section>
-
-<app-analysis-run-detail *ngIf="selectedRun" [run]="selectedRun" [workspaceId]="workspaceId"
-  (closed)="closeDetail()"></app-analysis-run-detail>

+ 4 - 5
src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.scss

@@ -250,13 +250,12 @@ select:focus-visible {
   vertical-align: middle;
 }
 
-.run-table tbody tr:hover,
-.run-table tbody tr.is-selected {
-  background: #f8fafb;
+.run-table tbody tr.is-clickable {
+  cursor: pointer;
 }
 
-.run-table tbody tr.is-selected {
-  box-shadow: inset 3px 0 #54758c;
+.run-table tbody tr:hover {
+  background: #f8fafb;
 }
 
 .primary-cell,

+ 19 - 11
src/modules/ai-voc-insight/analysis-runs/analysis-run-list.component.ts

@@ -27,12 +27,14 @@ import {
   Search,
   LucideAngularModule,
 } from 'lucide-angular';
+import { Router } from '@angular/router';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../../shared/components/page-toolbar/page-toolbar.component';
 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,
@@ -50,13 +52,14 @@ import {
 @Component({
   selector: 'app-analysis-run-list',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule, AnalysisRunDetailComponent],
+  imports: [CommonModule, FormsModule, LucideAngularModule, PageHeaderComponent, PageToolbarComponent],
   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 router = inject(Router);
   private readonly cdr = inject(ChangeDetectorRef);
   private readonly destroyRef = inject(DestroyRef);
   private initialized = false;
@@ -78,6 +81,10 @@ export class AnalysisRunListComponent implements OnInit, OnChanges {
     search: Search,
   };
   readonly statusOptions = ANALYSIS_RUN_STATUS_OPTIONS;
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '洞察中心', link: '/voc-insight' },
+    { label: '分析运行', link: '/voc-insight/runs' },
+  ];
 
   runs: SaasAnalysisRun[] = [];
   statusFilter: AnalysisRunStatusFilter = '';
@@ -86,7 +93,6 @@ export class AnalysisRunListComponent implements OnInit, OnChanges {
   loadingMore = false;
   error = '';
   nextCursor: string | null = null;
-  selectedRun: SaasAnalysisRun | null = null;
   loadedAt: string | null = null;
 
   ngOnInit(): void {
@@ -123,6 +129,15 @@ export class AnalysisRunListComponent implements OnInit, OnChanges {
     return this.runs.filter((run) => run.status === 'failed').length;
   }
 
+  get toolbarFields(): PageToolbarField[] {
+    return [
+      { label: '更新时间', value: this.loadedAt ? this.formatDate(this.loadedAt) : '' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   applyStatusFilter(): void {
     this.loadRuns(true);
   }
@@ -137,12 +152,8 @@ export class AnalysisRunListComponent implements OnInit, OnChanges {
   }
 
   selectRun(run: SaasAnalysisRun): void {
-    this.selectedRun = run;
     this.runSelected.emit(run);
-  }
-
-  closeDetail(): void {
-    this.selectedRun = null;
+    void this.router.navigate(['/voc-insight/runs', run.id], { queryParamsHandling: 'preserve' });
   }
 
   trackRun(_: number, run: SaasAnalysisRun): string {
@@ -215,9 +226,6 @@ export class AnalysisRunListComponent implements OnInit, OnChanges {
           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) => {

+ 37 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-page.component.html

@@ -0,0 +1,37 @@
+<main class="entity-page">
+  <app-page-header
+    [title]="pageTitle"
+    backLabel="返回分析运行列表"
+    [backRoute]="backRoute"
+    [crumbs]="crumbs"
+    [badges]="badges"
+    [meta]="headerMeta"
+    [sampleSize]="sampleSize"
+  ></app-page-header>
+
+  @if (loading) {
+    <app-loading-spinner text="正在加载分析运行" subtext="按运行 ID 读取现有分析记录。"></app-loading-spinner>
+  } @else if (errorMessage) {
+    <app-error-state
+      title="分析运行加载失败"
+      [description]="errorMessage"
+      [showBack]="true"
+      (retry)="reload()"
+      (back)="goList()"
+    ></app-error-state>
+  } @else if (!run) {
+    <app-empty-state
+      title="未找到该分析运行"
+      description="刷新后仍可能因记录不存在或尚未同步而无法打开。"
+      actionText="返回分析运行列表"
+      (action)="goList()"
+    ></app-empty-state>
+  } @else {
+    <div class="entity-layout">
+      <app-analysis-run-detail [run]="run" [workspaceId]="workspaceId" variant="page"></app-analysis-run-detail>
+      <app-content-card title="属性" density="compact" headingLevel="h3">
+        <app-entity-property-list [items]="propertyItems"></app-entity-property-list>
+      </app-content-card>
+    </div>
+  }
+</main>

+ 25 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-page.component.scss

@@ -0,0 +1,25 @@
+@use '../../shared/styles/tokens' as t;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.entity-page {
+  padding: 8px 20px 48px;
+  color: t.$color-text;
+}
+
+.entity-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
+  gap: 20px;
+  align-items: start;
+  margin-top: 16px;
+}
+
+@media (max-width: 960px) {
+  .entity-layout {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}

+ 187 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run-page.component.ts

@@ -0,0 +1,187 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, Router } from '@angular/router';
+import { catchError, of } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import { SaasAnalysisRun, SaasPlatformService } from '../../../app/core/services/saas-platform.service';
+import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
+import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
+import { EntityPropertyItem, EntityPropertyListComponent } from '../../shared/components/entity-property-list/entity-property-list.component';
+import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
+import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
+import { PageHeaderBadge, PageHeaderComponent, PageHeaderCrumb, PageHeaderMetaItem } from '../../shared/components/page-header/page-header.component';
+import { findCursorPageItem } from './analysis-run.data';
+import { AnalysisRunDetailComponent } from './analysis-run-detail.component';
+import {
+  analysisRunStatusLabel,
+  analysisRunStatusTone,
+  analysisRunTypeLabel,
+  analysisTargetLabel,
+  readAnalysisReservedMeta,
+  readAnalysisRunInput,
+  readAnalysisRunResult,
+} from './analysis-run.models';
+
+@Component({
+  selector: 'app-analysis-run-page',
+  standalone: true,
+  imports: [
+    CommonModule,
+    PageHeaderComponent,
+    AnalysisRunDetailComponent,
+    EntityPropertyListComponent,
+    ContentCardComponent,
+    EmptyStateComponent,
+    ErrorStateComponent,
+    LoadingSpinnerComponent,
+  ],
+  templateUrl: './analysis-run-page.component.html',
+  styleUrl: './analysis-run-page.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AnalysisRunPageComponent implements OnInit {
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly saasService = inject(SaasPlatformService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly backRoute = ['/voc-insight/runs'];
+
+  entityId = '';
+  run: SaasAnalysisRun | null = null;
+  loading = true;
+  errorMessage = '';
+  private requestVersion = 0;
+
+  ngOnInit(): void {
+    this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+      this.entityId = params.get('id')?.trim() ?? '';
+      this.load();
+    });
+  }
+
+  get pageTitle(): string {
+    if (!this.run) return '分析运行详情';
+    const result = readAnalysisRunResult(this.run);
+    return result?.headline?.trim() || analysisTargetLabel(this.run);
+  }
+
+  get crumbs(): PageHeaderCrumb[] {
+    return [
+      { label: '分析运行', link: '/voc-insight/runs' },
+      { label: this.pageTitle },
+    ];
+  }
+
+  get badges(): PageHeaderBadge[] {
+    if (!this.run) return [];
+    const toneMap: Record<string, PageHeaderBadge['tone']> = {
+      info: 'blue',
+      success: 'green',
+      warning: 'amber',
+      danger: 'red',
+      neutral: 'default',
+    };
+    return [{ label: analysisRunStatusLabel(this.run.status), tone: toneMap[analysisRunStatusTone(this.run.status)] }];
+  }
+
+  get headerMeta(): PageHeaderMetaItem[] {
+    if (!this.run) return [];
+    const result = readAnalysisRunResult(this.run);
+    const sample = readAnalysisRunInput(this.run).sampleCount ?? result?.sampleCount ?? null;
+    return [
+      { label: '状态', value: analysisRunStatusLabel(this.run.status) },
+      { label: '样本', value: sample === null ? '—' : sample.toLocaleString('zh-CN') },
+    ];
+  }
+
+  get sampleSize(): string | number | null {
+    if (!this.run) return null;
+    return readAnalysisRunInput(this.run).sampleCount ?? readAnalysisRunResult(this.run)?.sampleCount ?? null;
+  }
+
+  get propertyItems(): EntityPropertyItem[] {
+    const run = this.run;
+    if (!run) return [];
+    const input = readAnalysisRunInput(run);
+    const result = readAnalysisRunResult(run);
+    const reserved = readAnalysisReservedMeta(run);
+    const sample = input.sampleCount ?? result?.sampleCount ?? null;
+    const coverage = result?.evidenceCoverage;
+    return [
+      { label: '状态', value: analysisRunStatusLabel(run.status) },
+      { label: '分析类型', value: analysisRunTypeLabel(run.analysisType) },
+      { label: '目标', value: analysisTargetLabel(run) },
+      { label: '样本', value: sample },
+      {
+        label: '证据覆盖',
+        value: coverage == null || !Number.isFinite(coverage) ? null : `${Math.round(coverage * 100)}%`,
+      },
+      { label: '证据数', value: run.evidenceCount },
+      {
+        label: '模型名',
+        value: reserved.modelName,
+        reserved: true,
+        hint: reserved.modelName ? undefined : '有数据后再出现',
+      },
+      {
+        label: '提示词版本',
+        value: reserved.promptVersion,
+        reserved: true,
+        hint: reserved.promptVersion ? undefined : '有数据后再出现',
+      },
+      {
+        label: '费用',
+        value: reserved.cost,
+        reserved: true,
+        hint: reserved.cost ? undefined : '有数据后再出现',
+      },
+    ];
+  }
+
+  reload(): void {
+    this.load();
+  }
+
+  goList(): void {
+    void this.router.navigate(this.backRoute, { queryParamsHandling: 'preserve' });
+  }
+
+  private load(): void {
+    const version = ++this.requestVersion;
+    this.loading = true;
+    this.errorMessage = '';
+    this.run = null;
+    this.cdr.markForCheck();
+
+    if (!this.entityId || !this.workspaceId.trim()) {
+      this.loading = false;
+      if (!this.workspaceId.trim()) this.errorMessage = '工作空间 ID 未记录,运行详情未请求。';
+      this.cdr.markForCheck();
+      return;
+    }
+
+    findCursorPageItem((cursor) => this.saasService.analyses(this.workspaceId, '', cursor, 50), this.entityId).pipe(
+      catchError((error: unknown) => {
+        this.errorMessage = this.errorText(error);
+        return of(null);
+      }),
+      takeUntilDestroyed(this.destroyRef),
+    ).subscribe((run) => {
+      if (version !== this.requestVersion) return;
+      this.loading = false;
+      this.run = run;
+      this.cdr.markForCheck();
+    });
+  }
+
+  private errorText(error: unknown): string {
+    if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
+      return `分析运行加载失败:${error.message}`;
+    }
+    return '分析运行加载失败,请检查数据服务后重试。';
+  }
+}

+ 19 - 0
src/modules/ai-voc-insight/analysis-runs/analysis-run.data.ts

@@ -0,0 +1,19 @@
+import { EMPTY, Observable, expand, map, of, takeLast } from 'rxjs';
+import { CursorPage } from '../../../app/core/services/saas-platform.service';
+
+export function findCursorPageItem<T extends { id: string }>(
+  fetch: (cursor: string) => Observable<CursorPage<T>>,
+  id: string,
+): Observable<T | null> {
+  const target = id.trim();
+  if (!target) return of(null);
+  return fetch('').pipe(
+    expand((page) => (
+      page.items.some((item) => item.id === target) || !page.nextCursor
+        ? EMPTY
+        : fetch(page.nextCursor)
+    )),
+    takeLast(1),
+    map((page) => page.items.find((item) => item.id === target) ?? null),
+  );
+}

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

@@ -233,6 +233,19 @@ export function readAnalysisRunInput(run: SaasAnalysisRun): AnalysisRunInputView
   };
 }
 
+export function readAnalysisReservedMeta(run: SaasAnalysisRun): {
+  modelName: string | null;
+  promptVersion: string | null;
+  cost: string | null;
+} {
+  const buckets = [run.result, run.input].filter(isRecord);
+  return {
+    modelName: firstMetaValue(buckets, ['modelName', 'model', 'model_name']),
+    promptVersion: firstMetaValue(buckets, ['promptVersion', 'prompt_version', 'promptId', 'prompt_id']),
+    cost: firstMetaValue(buckets, ['cost', 'costUsd', 'cost_usd', 'costLabel', 'usageCost']),
+  };
+}
+
 export function readAnalysisRunResult(run: SaasAnalysisRun): AnalysisRunResultView | null {
   if (!isRecord(run.result)) return null;
   const result = run.result;
@@ -332,6 +345,17 @@ export function buildAnalysisDurations(run: SaasAnalysisRun): AnalysisRunDuratio
   };
 }
 
+function firstMetaValue(records: Record<string, unknown>[], keys: string[]): string | null {
+  for (const record of records) {
+    for (const key of keys) {
+      const value = record[key];
+      if (typeof value === 'string' && value.trim()) return value.trim();
+      if (typeof value === 'number' && Number.isFinite(value)) return String(value);
+    }
+  }
+  return null;
+}
+
 function isRecord(value: unknown): value is Record<string, unknown> {
   return !!value && typeof value === 'object' && !Array.isArray(value);
 }

+ 8 - 15
src/modules/data-center/data-center.component.html

@@ -1,19 +1,12 @@
 <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>
+  <app-page-header
+    title="数据中台"
+    eyebrow="数据资产"
+    [crumbs]="crumbs">
+  </app-page-header>
 
-    <div class="command-actions">
+  <app-page-toolbar [fields]="toolbarFields(vm.dataset)">
+    <div pageToolbarActions 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>
@@ -26,7 +19,7 @@
         新建同步
       </button>
     </div>
-  </header>
+  </app-page-toolbar>
 
   <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">

+ 15 - 1
src/modules/data-center/data-center.component.ts

@@ -45,6 +45,8 @@ import {
 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 { PageHeaderComponent, PageHeaderCrumb } from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../shared/components/page-toolbar/page-toolbar.component';
 import {
   DataCenterImportBatch,
   DataCenterPage,
@@ -165,7 +167,7 @@ export const DOMESTIC_METRIC_DICTIONARY: MetricDefinition[] = [
 @Component({
   selector: 'app-data-center',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule],
+  imports: [CommonModule, FormsModule, LucideAngularModule, PageHeaderComponent, PageToolbarComponent],
   templateUrl: './data-center.component.html',
   styleUrls: ['./data-center.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -182,6 +184,7 @@ export class DataCenterComponent {
 
   readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
   readonly platform = RUNTIME_CONFIG.domesticPlatform;
+  readonly crumbs: PageHeaderCrumb[] = [{ label: '数据中台', link: '/data-center' }];
   readonly icons = {
     alert: AlertTriangle,
     book: BookOpen,
@@ -308,6 +311,17 @@ export class DataCenterComponent {
     });
   }
 
+  toolbarFields(dataset: DomesticDataset): PageToolbarField[] {
+    const range = dataset.source?.dateRange;
+    const period = range?.start && range.end ? `${range.start} 至 ${range.end}` : '';
+    return [
+      { label: '周期', value: period },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   refreshAll(): void {
     this.refreshing = true;
     this.actionError = '';

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

@@ -2,6 +2,14 @@
   min-width: 880px;
 }
 
+.category-analysis-table tbody tr.is-clickable {
+  cursor: pointer;
+}
+
+.category-analysis-table tbody tr.is-clickable:hover {
+  background: #f4f4f5;
+}
+
 .strong-number {
   color: #1f2937 !important;
   font-weight: 700;

+ 24 - 3
src/modules/domestic-voc/category-analysis.component.ts

@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
 import { NgxEchartsModule } from 'ngx-echarts';
 import type { EChartsOption } from 'echarts';
+import { Router } from '@angular/router';
 import { Subject, takeUntil } from 'rxjs';
 import { DomesticAnalyticsService, DomesticCategorySummary } from '../../app/core/services/domestic-analytics.service';
 import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
@@ -14,15 +15,23 @@ import {
 } 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 { PageToolbarComponent } from '../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
 
 @Component({
   selector: 'app-domestic-category-analysis',
   standalone: true,
-  imports: [CommonModule, NgxEchartsModule, BoardTabsComponent, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, CrudTableWorkbenchComponent, LoadingSpinnerComponent],
+  imports: [CommonModule, NgxEchartsModule, BoardTabsComponent, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, ContentCardComponent, CrudTableWorkbenchComponent, LoadingSpinnerComponent],
   template: `
     <section class="domestic-page">
-      <app-page-header title="品类分析" eyebrow="Category Performance" description="按国内类目层级比较商品规模、成交贡献、转化效率和退款表现。" icon="◇"></app-page-header>
+      <app-page-header
+        title="品类分析"
+        eyebrow="Category Performance"
+        description="按国内类目层级比较商品规模、成交贡献、转化效率和退款表现。"
+        icon="◇"
+        [crumbs]="[{ label: '洞察中心' }, { label: '品类分析', link: '/domestic/categories' }]">
+      </app-page-header>
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
       @if (loading) {
         <app-loading-spinner variant="card" tone="blue" text="正在加载品类分析"></app-loading-spinner>
@@ -84,7 +93,7 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
             </thead>
             <tbody>
               @for (row of pagedRows; track row.name) {
-                <tr>
+                <tr class="is-clickable" (click)="openCategory(row)">
                   @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> }
@@ -110,6 +119,7 @@ import { SummaryMetricCardComponent } from '../shared/components/summary-metric-
 export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
   private readonly analytics = inject(DomesticAnalyticsService);
   private readonly dataset = inject(DomesticDatasetService);
+  private readonly router = inject(Router);
   private readonly cdr = inject(ChangeDetectorRef);
   private readonly destroy$ = new Subject<void>();
   private category2Rows: DomesticCategorySummary[] = [];
@@ -120,6 +130,11 @@ export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
     { key: 'category3', label: '三级类目', iconKey: 'trend' },
   ];
   dimension: 'category2' | 'category3' = 'category2';
+  readonly toolbarFields = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
   loading = true;
   rows: DomesticCategorySummary[] = [];
   filteredRows: DomesticCategorySummary[] = [];
@@ -183,6 +198,12 @@ export class DomesticCategoryAnalysisComponent implements OnInit, OnDestroy {
     this.destroy$.complete();
   }
 
+  openCategory(row: DomesticCategorySummary): void {
+    const category = row.name?.trim();
+    if (!category) return;
+    void this.router.navigate(['/voc-insight/products'], { queryParams: { category } });
+  }
+
   setDimension(dimension: string): void {
     if (dimension !== 'category2' && dimension !== 'category3') return;
     if (this.dimension === dimension) return;

+ 44 - 2
src/modules/domestic-voc/competitor-detail.component.ts

@@ -11,7 +11,8 @@ import { DataTableShellComponent } from '../shared/components/data-table-shell/d
 import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.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 { EntityPropertyItem, EntityPropertyListComponent } from '../shared/components/entity-property-list/entity-property-list.component';
+import { PageHeaderComponent, PageHeaderCrumb } 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';
@@ -41,6 +42,7 @@ interface OwnComparisonRow {
     CommonModule,
     NgxEchartsModule,
     PageHeaderComponent,
+    EntityPropertyListComponent,
     SummaryMetricCardComponent,
     ContentCardComponent,
     DataTableShellComponent,
@@ -62,7 +64,10 @@ interface OwnComparisonRow {
         title="竞品详情"
         eyebrow="Competitor Detail"
         description="复用原竞品详情框架,展示国内平台商品档案、关联本品与真实评论证据。"
-        icon="◇">
+        icon="◇"
+        backLabel="返回竞品关系"
+        [backRoute]="backRoute"
+        [crumbs]="crumbs">
         <app-neu-button pageHeaderActions size="sm" (btnClick)="goBack()">返回竞品关系</app-neu-button>
         <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="setActiveDetailTab('reviews')">评价工作台</app-neu-button>
       </app-page-header>
@@ -84,6 +89,7 @@ interface OwnComparisonRow {
               <div><dt>有效评论</dt><dd>{{ number(reviews.length) }} 条</dd></div>
               <div><dt>关联本品</dt><dd>{{ number(ownProducts.length) }} 个</dd></div>
             </dl>
+            <app-entity-property-list [items]="propertyItems"></app-entity-property-list>
             <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>
@@ -288,6 +294,42 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   private readonly destroy$ = new Subject<void>();
 
   readonly fallbackImage = 'assets/images/placeholder.svg';
+  readonly backRoute = '/domestic/competitors';
+
+  get propertyItems(): EntityPropertyItem[] {
+    if (!this.product || !this.dataset) return [];
+    return [
+      { label: '商品ID', value: this.product.productId },
+      { label: '型号', value: this.product.model },
+      { label: '评论样本', value: `${this.reviews.length} 条` },
+      { label: '更新时间', value: this.dataset.generatedAt },
+      {
+        label: '负责人',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+      {
+        label: '验证窗口',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+    ];
+  }
+
+  get crumbs(): PageHeaderCrumb[] {
+    const current = this.product?.title?.trim()
+      || this.product?.productId
+      || this.route.snapshot.paramMap.get('productId')
+      || '未命名';
+    return [
+      { label: '洞察中心' },
+      { label: '竞品证据', link: '/domestic/competitors' },
+      { label: current },
+    ];
+  }
+
   readonly aiReviewSystemPrompt = [
     '你是一名专业的国内电商 VOC 与竞品研究分析师。严格依据提供的商品档案、评论样本和关联数据作答,不得补造价格、销量、市场份额或历史趋势。',
     '评分星级和评论正文必须分开判断。“4-5星占比”只能描述评分分组,不得改写成“正向评论率”或“正文情绪正向率”;如果评分与正文矛盾,必须明确标记为数据质量风险。',

+ 20 - 2
src/modules/domestic-voc/competitor-relations.component.ts

@@ -17,6 +17,7 @@ import { ContentCardComponent } from '../shared/components/content-card/content-
 import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
 import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
 import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../shared/components/page-toolbar/page-toolbar.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';
@@ -71,10 +72,17 @@ const COMPETITOR_VOICE_PATTERNS: Record<string, RegExp> = {
 @Component({
   selector: 'app-domestic-competitor-relations',
   standalone: true,
-  imports: [CommonModule, FormsModule, PageHeaderComponent, SummaryMetricCardComponent, CrudTableWorkbenchComponent, LoadingSpinnerComponent, ProductIdentityComponent, ContentCardComponent, EmptyStateComponent],
+  imports: [CommonModule, FormsModule, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, CrudTableWorkbenchComponent, LoadingSpinnerComponent, ProductIdentityComponent, ContentCardComponent, EmptyStateComponent],
   template: `
     <section class="domestic-page">
-      <app-page-header title="竞品关系" eyebrow="Competitor Mapping" description="管理德玛仕商品与国内平台竞品的映射,并明确区分已配置关系和待采集指标。" icon="⇄"></app-page-header>
+      <app-page-header
+        title="竞品关系"
+        eyebrow="Competitor Mapping"
+        description="管理德玛仕商品与国内平台竞品的映射,并明确区分已配置关系和待采集指标。"
+        icon="⇄"
+        [crumbs]="[{ label: '洞察中心' }, { label: '竞品关系', link: '/domestic/competitors' }]">
+      </app-page-header>
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
       @if (dataset) {
         <div class="metric-grid">
@@ -279,6 +287,16 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
 
   dataset?: DomesticDataset;
   filteredRows: DomesticCompetitorRelationRow[] = [];
+
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    const range = this.dataset?.source.dateRange;
+    return [
+      { label: '周期', value: range ? `${range.start} 至 ${range.end}` : '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
   searchQuery = '';
   mappingCoverage = 0;
   page = 1;

+ 27 - 191
src/modules/domestic-voc/framework/domestic-action-workbench.component.html

@@ -5,6 +5,11 @@
     description="统一管理 VOC 洞察、反馈原声和经营规则形成的行动项。"
     icon="✓"
     density="compact"
+    [crumbs]="[
+      { label: '行动中心', link: '/action-suggestion' },
+      { label: '行动工作台', link: '/action-suggestion/kanban' },
+      { label: section === 'experience' ? '体验优化' : section === 'iteration' ? '产品迭代' : section === 'strategy' ? '长期策略' : '全部行动' }
+    ]"
     [badges]="[{ label: 'ActionItem API', tone: 'green' }, { label: currentTypeLabel, tone: 'blue' }]"
     [meta]="[
       { label: '行动项', value: actions.length },
@@ -22,12 +27,17 @@
         (click)="loadActions(true)">
         <lucide-icon [class.spinning]="refreshing" [img]="icons.refresh" [size]="17" [strokeWidth]="1.9"></lucide-icon>
       </button>
+    </div>
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields">
+    <div pageToolbarActions class="action-header-actions">
       <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-page-toolbar>
 
   <app-board-tabs
     class="framework-tabs action-scope-tabs"
@@ -139,27 +149,18 @@
         <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>
+      <app-error-state
+        title="行动中心暂未加载"
+        [description]="error"
+        (retry)="loadActions()">
+      </app-error-state>
     } @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>
+      <app-empty-state
+        [title]="hasActiveFilters ? '当前筛选没有行动项' : '当前视图暂无行动项'"
+        [description]="hasActiveFilters ? '清除筛选条件后查看其他行动。' : '从已确认洞察创建行动,或直接建立执行任务。'"
+        [actionText]="hasActiveFilters ? '清除筛选' : '新建行动'"
+        (action)="onEmptyAction()">
+      </app-empty-state>
     } @else if (viewMode === 'table') {
       <div class="action-table-wrap">
         <table class="action-table">
@@ -177,12 +178,12 @@
           </thead>
           <tbody>
             @for (action of filteredActions; track action.id) {
-              <tr [class.is-overdue]="isOverdue(action)">
+              <tr class="is-clickable" [class.is-overdue]="isOverdue(action)" (click)="openDetail(action)">
                 <td class="action-main-cell">
-                  <button type="button" class="action-title-button" (click)="openDetail(action)">
+                  <div class="action-title-button">
                     <strong>{{ action.title }}</strong>
                     <span>{{ action.description || '暂无补充说明' }}</span>
-                  </button>
+                  </div>
                 </td>
                 <td>
                   <span class="action-type-label">{{ actionTypeLabel(action.actionType) }}</span>
@@ -196,9 +197,9 @@
                 </td>
                 <td><span class="validation-cell">{{ action.validationMetric || '待定义' }}</span></td>
                 <td>
-                  <button type="button" class="row-open-button" aria-label="查看行动详情" title="查看详情" (click)="openDetail(action)">
+                  <span class="row-open-button" aria-hidden="true">
                     <lucide-icon [img]="icons.chevron" [size]="17" [strokeWidth]="1.8"></lucide-icon>
-                  </button>
+                  </span>
                 </td>
               </tr>
             }
@@ -240,171 +241,6 @@
     }
   </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"

+ 28 - 170
src/modules/domestic-voc/framework/domestic-action-workbench.component.ts

@@ -1,45 +1,35 @@
 import { CommonModule } from '@angular/common';
-import { HttpErrorResponse } from '@angular/common/http';
-import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
 import {
   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 { 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 { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 
 type ActionSection = 'kanban' | 'experience' | 'iteration' | 'strategy';
 type ActionViewMode = 'table' | 'board';
@@ -64,13 +54,6 @@ interface ActionLane {
   statuses: ActionStatus[];
 }
 
-interface InsightSummary {
-  title: string;
-  finding: string;
-  recommendation: string;
-  confidence: string;
-}
-
 @Component({
   selector: 'app-domestic-action-workbench',
   standalone: true,
@@ -80,17 +63,17 @@ interface InsightSummary {
     LucideAngularModule,
     ActionCreatePanelComponent,
     BoardTabsComponent,
+    EmptyStateComponent,
+    ErrorStateComponent,
     LoadingSpinnerComponent,
     PageHeaderComponent,
+    PageToolbarComponent,
   ],
   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[] = [
@@ -123,25 +106,14 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
   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';
@@ -156,19 +128,13 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
   refreshing = false;
   error = '';
   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 router: Router,
     private readonly platform: SaasPlatformService,
   ) {}
 
@@ -185,17 +151,6 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     this.destroy$.complete();
   }
 
-  @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
@@ -244,27 +199,6 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     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'),
-    };
-  }
-
   get currentTypeLabel(): string {
     return this.typeFilter === 'all' ? '全部类型' : this.actionTypeLabel(this.typeFilter);
   }
@@ -273,6 +207,16 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     return this.typeFilter === 'all' ? 'general' : this.typeFilter;
   }
 
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    return [
+      { label: '当前视图', value: this.currentTypeLabel },
+      { label: '行动项', value: String(this.actions.length) },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   get hasActiveFilters(): boolean {
     return Boolean(
       this.searchTerm.trim()
@@ -289,23 +233,15 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     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 }) => {
+      next: ({ actions, 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.loadWarning = context.warning;
         this.loading = false;
         this.refreshing = false;
       },
@@ -333,22 +269,14 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
   }
 
   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();
+    void this.router.navigate(['/action-suggestion/kanban', action.id], {
+      queryParams: this.section === 'kanban' ? {} : { from: this.section },
     });
   }
 
-  closeDetail(): void {
-    if (this.updating) return;
-    this.selectedAction = null;
-    this.updateError = '';
-    window.setTimeout(() => this.detailReturnFocus?.focus());
+  onEmptyAction(): void {
+    if (this.hasActiveFilters) this.resetFilters();
+    else this.openCreatePanel();
   }
 
   openCreatePanel(): void {
@@ -365,31 +293,6 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     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');
   }
@@ -426,7 +329,7 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
   }
 
   ownerLabel(userId: string | null): string {
-    if (!userId) return '待分配';
+    if (!userId) return '';
     if (userId === this.context?.principal.userId) return `${this.context.principal.displayName}(我)`;
     return userId;
   }
@@ -437,17 +340,10 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
   }
 
   date(value: string | null): string {
-    if (!value) return '未设置';
+    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));
-  }
-
   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];
@@ -466,15 +362,6 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
     return ({ kanban: 'all', experience: 'experience', iteration: 'product', strategy: 'strategy' } as const)[section];
   }
 
-  private isRecord(value: unknown): value is Record<string, unknown> {
-    return typeof value === 'object' && value !== null && !Array.isArray(value);
-  }
-
-  private recordString(record: Record<string, unknown>, key: string): string {
-    const value = record[key];
-    return typeof value === 'string' ? value : '';
-  }
-
   private collectPages<T>(request: (cursor: string) => Observable<CursorPage<T>>): Observable<T[]> {
     return request('').pipe(
       expand((page) => page.nextCursor ? request(page.nextCursor) : EMPTY),
@@ -482,33 +369,4 @@ export class DomesticActionWorkbenchComponent implements OnInit, OnDestroy {
       reduce((items, pageItems) => [...items, ...pageItems], [] as T[]),
     );
   }
-
-  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 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 '行动项更新失败,请稍后重试。';
-  }
 }

+ 26 - 1
src/modules/domestic-voc/framework/domestic-framework.shared.scss

@@ -159,6 +159,16 @@
   text-decoration: underline;
 }
 
+.data-table tbody tr.is-clickable,
+.rank-row.is-clickable {
+  cursor: pointer;
+}
+
+.data-table tbody tr.is-clickable:hover,
+.rank-row.is-clickable:hover {
+  background: #f4f4f5;
+}
+
 .rank-list,
 .info-list {
   display: grid;
@@ -175,6 +185,17 @@
   border-bottom: 1px solid #eef2f7;
 }
 
+button.rank-row {
+  width: 100%;
+  margin: 0;
+  padding: 0 0 10px;
+  border: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+}
+
 .rank-row:last-child,
 .info-row:last-child {
   padding-bottom: 0;
@@ -665,7 +686,11 @@
       transition: background 120ms ease;
 
       &:hover {
-        background: #f8fbff;
+        background: #f4f4f5;
+      }
+
+      &.is-clickable {
+        cursor: pointer;
       }
 
       &.is-overdue {

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

@@ -9,12 +9,21 @@
       eyebrow="Domestic Monitoring"
       [description]="description"
       icon="◉"
+      [crumbs]="[
+        { label: '效果验证', link: '/validation' },
+        { label: '趋势验证', link: '/domestic/voc-trend' },
+        { label: title }
+      ]"
+      backLabel="返回工作台"
+      backRoute="/domestic/overview"
       [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]="reviewScope.ownCount"
       [confidence]="reviewScope.ownCount ? 'medium' : 'low'">
     </app-page-header>
 
+    <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
     <app-board-tabs class="framework-tabs" [tabs]="tabs" [activeKey]="section" density="compact"></app-board-tabs>
 
     @if (section === 'trend') {
@@ -51,12 +60,12 @@
           <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>
+              <tr class="is-clickable" (click)="openCompetitor(review.productId)">
                 <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>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', review.productId]" (click)="$event.stopPropagation()">竞品详情</a></td>
               </tr>
             } @empty {
               <tr><td colspan="5"><app-empty-state size="sm" variant="table" title="暂无有效负向证据"></app-empty-state></td></tr>
@@ -96,12 +105,12 @@
           <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>
+              <tr class="is-clickable" (click)="openCompetitor(review.productId)">
                 <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>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', review.productId]" (click)="$event.stopPropagation()">竞品详情</a></td>
               </tr>
             } @empty {
               <tr><td colspan="5"><app-empty-state size="sm" variant="table" title="暂无有效负向证据"></app-empty-state></td></tr>
@@ -138,7 +147,14 @@
           <thead><tr><th>类目</th><th>商品数</th><th>成交金额</th><th>成交件数</th><th>转化率</th><th>退款金额占比</th></tr></thead>
           <tbody>
             @for (item of categories; track item.category) {
-              <tr><td><strong>{{ item.category }}</strong></td><td>{{ number(item.productCount) }}</td><td>{{ currency(item.gmv) }}</td><td>{{ number(item.soldUnits) }}</td><td>{{ percent(item.conversionRate) }}</td><td>{{ percent(item.refundRate) }}</td></tr>
+              <tr class="is-clickable" (click)="openCategoryProducts(item.category)">
+                <td><strong>{{ item.category }}</strong></td>
+                <td>{{ number(item.productCount) }}</td>
+                <td>{{ currency(item.gmv) }}</td>
+                <td>{{ number(item.soldUnits) }}</td>
+                <td>{{ percent(item.conversionRate) }}</td>
+                <td>{{ percent(item.refundRate) }}</td>
+              </tr>
             }
           </tbody>
         </table>

+ 26 - 1
src/modules/domestic-voc/framework/domestic-monitoring-workbench.component.ts

@@ -1,6 +1,6 @@
 import { CommonModule } from '@angular/common';
 import { Component, OnDestroy, OnInit } from '@angular/core';
-import { ActivatedRoute, RouterLink } from '@angular/router';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import type { EChartsOption } from 'echarts';
 import { NgxEchartsModule } from 'ngx-echarts';
 import { Subject, combineLatest, takeUntil } from 'rxjs';
@@ -19,6 +19,7 @@ import { EmptyStateComponent } from '../../shared/components/empty-state/empty-s
 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 { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 import { ReviewDataUtils, SentimentDistribution } from '../../shared/services/review-data-utils';
 
@@ -39,6 +40,7 @@ type MonitoringSection = 'trend' | 'emotion' | 'store';
     ErrorStateComponent,
     LoadingSpinnerComponent,
     PageHeaderComponent,
+    PageToolbarComponent,
     SummaryMetricCardComponent,
   ],
   templateUrl: './domestic-monitoring-workbench.component.html',
@@ -71,6 +73,7 @@ export class DomesticMonitoringWorkbenchComponent implements OnInit, OnDestroy {
 
   constructor(
     private readonly route: ActivatedRoute,
+    private readonly router: Router,
     private readonly datasetService: DomesticDatasetService,
     private readonly analytics: DomesticAnalyticsAdapterService,
   ) {}
@@ -109,6 +112,28 @@ export class DomesticMonitoringWorkbenchComponent implements OnInit, OnDestroy {
     return 'VOC 数据趋势';
   }
 
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    const range = this.dataset?.source.dateRange;
+    return [
+      { label: '周期', value: range ? `${range.start} 至 ${range.end}` : '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
+  openCompetitor(productId: string): void {
+    const id = productId?.trim();
+    if (!id) return;
+    void this.router.navigate(['/domestic/competitors', id]);
+  }
+
+  openCategoryProducts(category: string): void {
+    const value = category?.trim();
+    if (!value) return;
+    void this.router.navigate(['/voc-insight/products'], { queryParams: { category: value } });
+  }
+
   get description(): string {
     if (this.section === 'emotion') return '保留原情绪分析框架,并严格依据真实评论样本决定是否生成情绪结论。';
     if (this.section === 'store') return '汇总店铺经营健康度、商品规模、类目结构与退款表现。';

+ 28 - 7
src/modules/domestic-voc/framework/domestic-product-development.component.html

@@ -9,10 +9,16 @@
       eyebrow="Product Development"
       [description]="description"
       icon="△"
+      [crumbs]="[
+        { label: '新品开发', link: '/product-development/trend' },
+        { label: title }
+      ]"
       [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-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
     <app-board-tabs class="framework-tabs" [tabs]="tabs" [activeKey]="section" density="compact"></app-board-tabs>
 
     @if (section === 'definition' || section === 'lifecycle') {
@@ -34,7 +40,10 @@
         <app-content-card title="类目机会观察" subtitle="按当前成交金额排序" tone="green">
           <div class="rank-list">
             @for (item of categories.slice(0, 6); track item.category) {
-              <div class="rank-row"><div class="rank-copy"><strong>{{ item.category }}</strong><span>{{ item.productCount }} 个商品 · 转化 {{ percent(item.conversionRate) }}</span></div><span class="rank-value">{{ currency(item.gmv) }}</span></div>
+              <button type="button" class="rank-row is-clickable" (click)="openCategoryProducts(item.category)">
+                <div class="rank-copy"><strong>{{ item.category }}</strong><span>{{ item.productCount }} 个商品 · 转化 {{ percent(item.conversionRate) }}</span></div>
+                <span class="rank-value">{{ currency(item.gmv) }}</span>
+              </button>
             }
           </div>
         </app-content-card>
@@ -43,7 +52,13 @@
       <app-data-table-shell title="成交商品表现" subtitle="用于识别当前周期的重点商品,不代表新品生命周期" [count]="topProducts.length">
         <table class="data-table"><thead><tr><th class="product-cell">商品</th><th>类目</th><th>成交金额</th><th>成交件数</th><th>转化率</th></tr></thead><tbody>
           @for (product of topProducts; track product.productId) {
-            <tr><td class="product-cell"><app-product-identity [title]="product.title" [asin]="product.productId" [productSku]="product.model" [showImage]="false" size="xs" [detailRoute]="['/voc-insight/detail']" [detailQueryParams]="{ productId: product.productId }"></app-product-identity></td><td>{{ product.category2 || product.category1 }}</td><td>{{ currency(product.summary.gmv) }}</td><td>{{ number(product.summary.soldUnits) }}</td><td>{{ percent(product.summary.conversionRate) }}</td></tr>
+            <tr class="is-clickable" (click)="openOwnProduct(product.productId)">
+              <td class="product-cell"><app-product-identity [title]="product.title" [asin]="product.productId" [productSku]="product.model" [showImage]="false" size="xs"></app-product-identity></td>
+              <td>{{ product.category2 || product.category1 }}</td>
+              <td>{{ currency(product.summary.gmv) }}</td>
+              <td>{{ number(product.summary.soldUnits) }}</td>
+              <td>{{ percent(product.summary.conversionRate) }}</td>
+            </tr>
           }
         </tbody></table>
       </app-data-table-shell>
@@ -86,12 +101,12 @@
           <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>
+              <tr class="is-clickable" (click)="openCompetitor(review.productId)">
                 <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>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', review.productId]" (click)="$event.stopPropagation()">竞品详情</a></td>
               </tr>
             }
           </tbody>
@@ -109,7 +124,13 @@
       <app-data-table-shell title="竞品对标清单" subtitle="保留原竞品对标框架,当前展示已核验的商品关系" [count]="benchmarkGroups.length">
         <table class="data-table"><thead><tr><th>本品型号</th><th>本品商品ID</th><th>对标类目</th><th>竞品品牌</th><th>竞品数</th></tr></thead><tbody>
           @for (group of benchmarkGroups; track group.ownProductId) {
-            <tr><td><strong>{{ group.model || '-' }}</strong></td><td>{{ group.ownProductId }}</td><td>{{ group.category }}</td><td>{{ relationBrands(group) }}</td><td>{{ group.competitors.length }}</td></tr>
+            <tr class="is-clickable" (click)="openOwnProduct(group.ownProductId)">
+              <td><strong>{{ group.model || '—' }}</strong></td>
+              <td>{{ group.ownProductId }}</td>
+              <td>{{ group.category }}</td>
+              <td>{{ relationBrands(group) }}</td>
+              <td>{{ group.competitors.length }}</td>
+            </tr>
           }
         </tbody></table>
       </app-data-table-shell>
@@ -146,13 +167,13 @@
           <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>
+              <tr class="is-clickable" (click)="openCompetitor(competitor.productId)">
                 <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>
+                <td><a class="table-action-link" [routerLink]="['/domestic/competitors', competitor.productId]" (click)="$event.stopPropagation()">竞品详情</a></td>
               </tr>
             } @empty {
               <tr><td colspan="6"><app-empty-state size="sm" title="当前商品暂无竞品关系" description="可在竞品关系页按品牌和品类继续补充对标商品。"></app-empty-state></td></tr>

+ 32 - 1
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, RouterLink } from '@angular/router';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import type { EChartsOption } from 'echarts';
 import { NgxEchartsModule } from 'ngx-echarts';
 import { Subject, combineLatest, takeUntil } from 'rxjs';
@@ -27,6 +27,7 @@ import { EmptyStateComponent } from '../../shared/components/empty-state/empty-s
 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 { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.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';
@@ -49,6 +50,7 @@ type ProductDevelopmentSection = 'trend' | 'requirement' | 'benchmark' | 'defini
     ErrorStateComponent,
     LoadingSpinnerComponent,
     PageHeaderComponent,
+    PageToolbarComponent,
     ProductIdentityComponent,
     SummaryMetricCardComponent,
     VocProductPickerComponent,
@@ -91,6 +93,7 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
 
   constructor(
     private readonly route: ActivatedRoute,
+    private readonly router: Router,
     private readonly datasetService: DomesticDatasetService,
     private readonly analytics: DomesticAnalyticsAdapterService,
     private readonly stateService: VocInsightStateService,
@@ -139,6 +142,34 @@ export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
     return labels[this.section];
   }
 
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    const range = this.dataset?.source.dateRange;
+    return [
+      { label: '周期', value: range ? `${range.start} 至 ${range.end}` : '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
+  openOwnProduct(productId: string): void {
+    const id = productId?.trim();
+    if (!id) return;
+    void this.router.navigate(['/voc-insight/detail'], { queryParams: { productId: id } });
+  }
+
+  openCompetitor(productId: string): void {
+    const id = productId?.trim();
+    if (!id) return;
+    void this.router.navigate(['/domestic/competitors', id]);
+  }
+
+  openCategoryProducts(category: string): void {
+    const value = category?.trim();
+    if (!value) return;
+    void this.router.navigate(['/voc-insight/products'], { queryParams: { category: value } });
+  }
+
   get description(): string {
     const labels: Record<ProductDevelopmentSection, string> = {
       trend: '从真实成交、类目和商品表现中识别业务趋势,不将短周期经营波动包装成市场长期结论。',

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

@@ -9,10 +9,17 @@
       eyebrow="Return Analysis"
       [description]="description"
       icon="↩"
+      [crumbs]="[
+        { label: '效果验证', link: '/validation' },
+        { label: '退款验证', link: '/return-analysis/overview' },
+        { label: title }
+      ]"
       [badges]="[{ label: '退款汇总已接入', tone: 'green' }, { label: '原因明细待接入', tone: 'amber' }]"
       [meta]="[{ label: '案例', value: dataset.caseName }, { label: '数据周期', value: dataset.source.dateRange.start + ' 至 ' + dataset.source.dateRange.end }]">
     </app-page-header>
 
+    <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
     <app-board-tabs class="framework-tabs" [tabs]="tabs" [activeKey]="section" density="compact"></app-board-tabs>
 
     <div class="metric-grid">
@@ -61,8 +68,8 @@
         <thead><tr><th class="product-cell">商品</th><th>类目</th><th>成交金额</th><th>退款金额</th><th>退款金额占比</th></tr></thead>
         <tbody>
           @for (product of riskProducts; track product.productId) {
-            <tr>
-              <td class="product-cell"><app-product-identity [title]="product.title" [asin]="product.productId" [productSku]="product.model" [showImage]="false" size="xs" [detailRoute]="['/voc-insight/detail']" [detailQueryParams]="{ productId: product.productId }"></app-product-identity></td>
+            <tr class="is-clickable" (click)="openProduct(product)">
+              <td class="product-cell"><app-product-identity [title]="product.title" [asin]="product.productId" [productSku]="product.model" [showImage]="false" size="xs"></app-product-identity></td>
               <td>{{ product.category2 || product.category1 }}</td><td>{{ currency(product.summary.gmv) }}</td><td>{{ currency(product.summary.refundAmount) }}</td><td><strong>{{ percent(product.summary.refundToGmvRate) }}</strong></td>
             </tr>
           }

+ 20 - 1
src/modules/domestic-voc/framework/domestic-return-analysis.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, Router } from '@angular/router';
 import type { EChartsOption } from 'echarts';
 import { NgxEchartsModule } from 'ngx-echarts';
 import { Subject, combineLatest, takeUntil } from 'rxjs';
@@ -18,6 +18,7 @@ import { EmptyStateComponent } from '../../shared/components/empty-state/empty-s
 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 { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { ProductIdentityComponent } from '../../shared/components/product-identity/product-identity.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 
@@ -37,6 +38,7 @@ type ReturnSection = 'overview' | 'attribution' | 'correlation';
     ErrorStateComponent,
     LoadingSpinnerComponent,
     PageHeaderComponent,
+    PageToolbarComponent,
     ProductIdentityComponent,
     SummaryMetricCardComponent,
   ],
@@ -62,6 +64,7 @@ export class DomesticReturnAnalysisComponent implements OnInit, OnDestroy {
 
   constructor(
     private readonly route: ActivatedRoute,
+    private readonly router: Router,
     private readonly datasetService: DomesticDatasetService,
     private readonly analytics: DomesticAnalyticsAdapterService,
   ) {}
@@ -97,6 +100,22 @@ export class DomesticReturnAnalysisComponent implements OnInit, OnDestroy {
     return '退款整体概况';
   }
 
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    const range = this.dataset?.source.dateRange;
+    return [
+      { label: '周期', value: range ? `${range.start} 至 ${range.end}` : '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
+  openProduct(product: DomesticProduct): void {
+    const productId = product.productId?.trim();
+    if (!productId) return;
+    void this.router.navigate(['/voc-insight/detail'], { queryParams: { productId } });
+  }
+
   get description(): string {
     if (this.section === 'attribution') return '区分已接通的商品退款指标与尚未接入的订单级退款原因,避免用汇总数据替代归因证据。';
     if (this.section === 'correlation') return '保留原关联分析框架,等待订单、原因、规格和客户维度形成可验证的关联样本。';

+ 153 - 160
src/modules/domestic-voc/operating-overview.component.scss

@@ -11,81 +11,23 @@
   letter-spacing: 0;
 }
 
-.overview-head {
-  display: flex;
-  gap: 20px;
-  align-items: flex-end;
-  justify-content: space-between;
+app-page-toolbar {
+  display: block;
   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;
+  color: tokens.$color-text-subtle;
   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,
@@ -94,9 +36,9 @@
   min-height: 32px;
   padding: 0 10px;
   border: 1px solid transparent;
-  border-radius: 4px;
+  border-radius: tokens.$radius-sm;
   background: transparent;
-  color: #475467;
+  color: tokens.$color-text-secondary;
   font: inherit;
   font-size: 12px;
   font-weight: 700;
@@ -111,31 +53,32 @@
 .panel-head button:focus-visible,
 .table-toolbar-actions button:hover,
 .table-toolbar-actions button:focus-visible {
-  border-color: #cbd5e1;
-  background: #f8fafc;
-  color: #1d4ed8;
+  border-color: tokens.$color-border;
+  background: tokens.$color-accent;
+  color: tokens.$color-text;
   outline: none;
 }
 
 .command-actions .is-primary {
-  background: #1d4ed8;
-  color: #fff;
+  background: tokens.$color-primary;
+  border-color: tokens.$color-primary;
+  color: tokens.$color-primary-foreground;
 }
 
 .command-actions .is-primary:hover,
 .command-actions .is-primary:focus-visible {
-  border-color: #1e40af;
-  background: #1e40af;
-  color: #fff;
+  border-color: tokens.$color-primary-hover;
+  background: tokens.$color-primary-hover;
+  color: tokens.$color-primary-foreground;
 }
 
 .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;
+  border: 1px solid tokens.$color-border;
+  border-radius: tokens.$radius-md;
+  background: tokens.$color-surface;
 }
 
 .status-lead {
@@ -145,23 +88,23 @@
   align-items: center;
   min-width: 0;
   padding: 10px 14px;
-  border-right: 1px solid #e6e9ef;
+  border-right: 1px solid tokens.$color-border;
 }
 
 .status-dot {
   width: 8px;
   height: 8px;
   border-radius: 50%;
-  background: #64748b;
+  background: tokens.$color-text-muted;
 }
 
-.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.tone-green .status-dot { background: tokens.$color-green; }
+.status-lead.tone-amber .status-dot { background: tokens.$color-amber; }
+.status-lead.tone-red .status-dot { background: tokens.$color-red; }
+.status-lead.tone-blue .status-dot { background: tokens.$color-blue; }
 
 .status-lead strong {
-  color: #172033;
+  color: tokens.$color-text;
   font-size: 13px;
   white-space: nowrap;
 }
@@ -170,16 +113,23 @@
   min-width: 0;
   margin: 0;
   overflow: hidden;
-  color: #596579;
+  color: tokens.$color-text-muted;
   font-size: 11px;
   line-height: 1.4;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
 
+.status-reserved {
+  grid-column: 2 / -1;
+  color: tokens.$color-text-muted;
+  font-size: 10px;
+  font-weight: 600;
+}
+
 .status-facts {
   display: grid;
-  grid-template-columns: repeat(4, minmax(0, 1fr));
+  grid-template-columns: repeat(5, minmax(0, 1fr));
   min-width: 0;
   margin: 0;
 }
@@ -244,14 +194,42 @@
 .kpi-cell:hover,
 .kpi-cell:focus-visible {
   z-index: 1;
-  background: #f8fafc;
-  outline: 2px solid #93c5fd;
-  outline-offset: -2px;
+  background: tokens.$color-accent;
+  outline: none;
 }
 
-.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-cell:focus-visible {
+  outline: 3px solid rgba(23, 23, 23, 0.2);
+  outline-offset: -3px;
+}
+
+.kpi-cell.is-emphasis:hover,
+.kpi-cell.is-emphasis:focus-visible {
+  background: tokens.$color-accent;
+}
+
+.kpi-cell.tone-critical { box-shadow: inset 0 3px tokens.$color-red; }
+.kpi-cell.tone-caution { box-shadow: inset 0 3px tokens.$color-amber; }
+.kpi-cell.tone-primary { box-shadow: inset 0 3px tokens.$color-text; }
+
+.kpi-cell.is-emphasis {
+  z-index: 1;
+  background: tokens.$color-surface;
+  box-shadow: inset 0 0 0 1px tokens.$color-primary;
+}
+
+.kpi-cell.is-emphasis.tone-critical {
+  box-shadow: inset 0 3px tokens.$color-red, inset 0 0 0 1px tokens.$color-primary;
+}
+
+.kpi-cell.is-emphasis.tone-caution {
+  box-shadow: inset 0 3px tokens.$color-amber, inset 0 0 0 1px tokens.$color-primary;
+}
+
+.kpi-cell.is-emphasis > strong {
+  font-size: 26px;
+  font-weight: 700;
+}
 
 // 指标身份色:只作用于顶部色条和迷你趋势线,不与下方同比的好坏状态色混用。
 .kpi-cell.metric-gmv { box-shadow: inset 0 3px #2563eb; }
@@ -263,7 +241,7 @@
 .kpi-cell.metric-refund.tone-critical { box-shadow: inset 0 3px #b91c1c; }
 
 .kpi-label {
-  color: #667085;
+  color: tokens.$color-text-muted;
   font-size: 11px;
   font-weight: 700;
 }
@@ -272,7 +250,7 @@
   align-self: end;
   margin-top: 7px;
   overflow: hidden;
-  color: #172033;
+  color: tokens.$color-text;
   font-size: 22px;
   font-variant-numeric: tabular-nums;
   line-height: 1.05;
@@ -300,12 +278,21 @@
 }
 
 .kpi-trend b {
-  color: #475467;
+  color: tokens.$color-text-secondary;
   font-variant-numeric: tabular-nums;
 }
 
-.trend-good .kpi-trend b { color: #15803d; }
-.trend-bad .kpi-trend b { color: #b91c1c; }
+.trend-good .kpi-trend b { color: tokens.$color-green; }
+.trend-bad .kpi-trend b { color: tokens.$color-red; }
+
+.kpi-reserved {
+  position: relative;
+  z-index: 1;
+  margin-top: 4px;
+  color: tokens.$color-text-subtle;
+  font-size: 9px;
+  white-space: nowrap;
+}
 
 .kpi-spark {
   position: absolute;
@@ -313,8 +300,7 @@
   bottom: 6px;
   width: 72px;
   height: 26px;
-  opacity: 0.42;
-  color: #64748b;
+  opacity: 0.55;
   pointer-events: none;
 }
 
@@ -330,18 +316,6 @@
 .metric-refund .kpi-spark { color: #b45309; }
 .metric-refund.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;
@@ -380,8 +354,18 @@
 }
 
 .panel-head h2 {
+  margin: 0;
+  color: tokens.$color-text;
   font-size: 14px;
   line-height: 1.25;
+  letter-spacing: 0;
+}
+
+.panel-head p {
+  margin: 4px 0 0;
+  color: tokens.$color-text-muted;
+  font-size: 12px;
+  line-height: 1.45;
 }
 
 .panel-head > span {
@@ -401,10 +385,10 @@
 
 .signal-row {
   display: grid;
-  grid-template-columns: 28px minmax(0, 1fr) auto;
+  grid-template-columns: 28px minmax(0, 1fr) auto auto auto;
   gap: 10px;
   align-items: center;
-  min-height: 83px;
+  min-height: 72px;
   padding: 10px 12px;
   border: 0;
   border-bottom: 1px solid #edf0f4;
@@ -422,10 +406,31 @@
 
 .signal-row:hover,
 .signal-row:focus-visible {
-  background: #f8fafc;
+  background: tokens.$color-accent;
   outline: none;
 }
 
+.signal-row:hover .signal-go,
+.signal-row:focus-visible .signal-go {
+  color: tokens.$color-text;
+}
+
+.signal-go {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  color: tokens.$color-text-subtle;
+  font-size: 10px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.signal-go i {
+  font-size: 16px;
+  font-style: normal;
+  line-height: 1;
+}
+
 .signal-rank {
   display: grid;
   width: 26px;
@@ -473,6 +478,12 @@
   font-size: 10px;
 }
 
+.signal-reserved {
+  color: #a3a3a3;
+  font-size: 11px;
+  white-space: nowrap;
+}
+
 .signal-metric {
   color: #172033;
   font-size: 14px;
@@ -867,26 +878,25 @@
 .risk-tag.is-critical { background: #fef2f2; color: #b91c1c; }
 
 @media (max-width: 1180px) {
-  .overview-head {
-    align-items: flex-start;
-    flex-direction: column;
+  .status-band {
+    grid-template-columns: 1fr;
   }
 
-  .overview-command-bar {
-    width: 100%;
+  .status-lead {
+    border-right: 0;
+    border-bottom: 1px solid tokens.$color-border;
   }
 
-  .command-actions {
-    margin-left: auto;
+  .status-facts {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
   }
 
-  .status-band {
-    grid-template-columns: 1fr;
+  .status-facts > div:nth-child(3) {
+    border-right: 0;
   }
 
-  .status-lead {
-    border-right: 0;
-    border-bottom: 1px solid #e6e9ef;
+  .status-facts > div:nth-child(-n + 3) {
+    border-bottom: 1px solid #eef1f5;
   }
 
   .kpi-strip {
@@ -914,6 +924,10 @@
     border-right: 1px solid #edf0f4;
   }
 
+  .signal-go span {
+    display: none;
+  }
+
   .voc-grid {
     grid-template-columns: 1fr 1fr;
   }
@@ -931,45 +945,12 @@
     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 {
+  .status-lead p,
+  .status-reserved {
     grid-column: 1 / -1;
     overflow: visible;
     white-space: normal;
@@ -979,11 +960,11 @@
     grid-template-columns: 1fr 1fr;
   }
 
-  .status-facts > div:nth-child(2) {
+  .status-facts > div:nth-child(2n) {
     border-right: 0;
   }
 
-  .status-facts > div:nth-child(-n + 2) {
+  .status-facts > div:nth-child(-n + 4) {
     border-bottom: 1px solid #eef1f5;
   }
 
@@ -1010,6 +991,10 @@
     font-size: 19px;
   }
 
+  .kpi-cell.is-emphasis > strong {
+    font-size: 22px;
+  }
+
   .trend-chart {
     height: 404px;
   }
@@ -1022,6 +1007,10 @@
     border-right: 0;
   }
 
+  .signal-go span {
+    display: inline;
+  }
+
   .flow-row {
     grid-template-columns: 88px minmax(0, 1fr);
   }
@@ -1068,6 +1057,10 @@
     font-size: 17px;
   }
 
+  .kpi-cell.is-emphasis > strong {
+    font-size: 20px;
+  }
+
   .kpi-trend {
     overflow: hidden;
     text-overflow: ellipsis;

+ 60 - 30
src/modules/domestic-voc/operating-overview.component.ts

@@ -9,7 +9,10 @@ import { DomesticAnalyticsService, DomesticCategorySummary } from '../../app/cor
 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 { PageHeaderComponent, PageHeaderCrumb } from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../shared/components/page-toolbar/page-toolbar.component';
 import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
+import { SparklineComponent, SparklineTone } from '../shared/components/sparkline/sparkline.component';
 import { VocInsightStateService } from '../shared/services/voc-insight-state.service';
 
 interface OverviewKpi {
@@ -25,6 +28,7 @@ interface OverviewKpi {
   sparkAvailable: boolean;
   sparkPoints: string;
   sparkArea: string;
+  emphasis: boolean;
 }
 
 interface OverviewSignal {
@@ -97,59 +101,55 @@ interface OverviewConclusion {
 @Component({
   selector: 'app-domestic-operating-overview',
   standalone: true,
-  imports: [CommonModule, NgxEchartsModule, DataTableShellComponent, LoadingSpinnerComponent, ProductIdentityComponent],
+  imports: [CommonModule, NgxEchartsModule, DataTableShellComponent, LoadingSpinnerComponent, PageHeaderComponent, PageToolbarComponent, ProductIdentityComponent, SparklineComponent],
   changeDetection: ChangeDetectionStrategy.OnPush,
   template: `
     <section class="domestic-page">
       @if (vm$ | async; as vm) {
-        <header class="overview-head">
-          <div class="overview-head-copy">
-            <h1>经营概览</h1>
-            <p>从规模、流量、转化、退款到 VOC 证据,定位需要优先处理的类目与商品。</p>
+        <app-page-header
+          title="经营概览"
+          eyebrow="工作台"
+          description="从规模、流量、转化、退款到 VOC 证据,定位需要优先处理的类目与商品。"
+          density="compact"
+          [crumbs]="overviewCrumbs">
+        </app-page-header>
+
+        <app-page-toolbar [fields]="vm.toolbarFields">
+          <div pageToolbarActions 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 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>
+        </app-page-toolbar>
 
         <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>
+            <span class="status-reserved" title="有数据后再出现">下次复盘 {{ reservedPlaceholder }}</span>
           </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>
+            <div><dt>数据新鲜度 SLA</dt><dd title="有数据后再出现">{{ reservedPlaceholder }}</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]="['metric-' + card.key, 'tone-' + card.tone, 'trend-' + card.trendState]" [title]="card.helpText" (click)="runKpiAction(card.key)">
+            <button type="button" class="kpi-cell" [ngClass]="['metric-' + card.key, 'tone-' + card.tone, 'trend-' + card.trendState, card.emphasis ? 'is-emphasis' : '']" [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>
+              <span class="kpi-reserved">样本 {{ reservedPlaceholder }} · 置信度 {{ reservedPlaceholder }}</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>
+                <span class="kpi-spark">
+                  <app-sparkline [points]="card.sparkPoints" [tone]="kpiSparkTone(card)" [height]="24"></app-sparkline>
+                </span>
               }
             </button>
           }
@@ -171,10 +171,12 @@ interface OverviewConclusion {
             </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)">
+                <button type="button" class="signal-row" [ngClass]="'severity-' + signal.severity" [attr.aria-label]="signal.title + ',' + signalGoLabel(signal)" (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>
+                  <span class="signal-reserved">负责人 {{ reservedPlaceholder }} · 到期 {{ reservedPlaceholder }}</span>
+                  <span class="signal-go"><span>{{ signalGoLabel(signal) }}</span><i aria-hidden="true">›</i></span>
                 </button>
               }
             </div>
@@ -289,6 +291,11 @@ export class DomesticOperatingOverviewComponent {
 
   private readonly REFUND_WARN = 0.12;
   private readonly REFUND_CAUTION = 0.08;
+  readonly reservedPlaceholder = '—';
+  readonly overviewCrumbs: PageHeaderCrumb[] = [
+    { label: '工作台' },
+    { label: '经营概览' },
+  ];
 
   readonly vm$ = this.analytics.overview$.pipe(map((overview) => {
     const ownProducts = overview.dataset.products.filter((product) => product.role === 'own');
@@ -313,6 +320,16 @@ export class DomesticOperatingOverviewComponent {
     }));
     const ratingRows = this.buildRatingRows(reviewScope.competitor);
     const windows = comparableWindows(overview.dataset.dailyTotals);
+    const comparisonLabel = windows.previous.length && windows.current.length
+      ? `后 ${windows.current.length} 日日均 vs 前 ${windows.previous.length} 日日均`
+      : '当前周期';
+    const toolbarFields: PageToolbarField[] = [
+      { label: '周期', value: `${overview.dataset.source.dateRange.start} 至 ${overview.dataset.source.dateRange.end}` },
+      { label: '比较', value: comparisonLabel },
+      { label: '店铺', value: this.reservedPlaceholder },
+      { label: '类目', value: this.reservedPlaceholder },
+      { label: '负责人', value: this.reservedPlaceholder },
+    ];
     return {
       ...overview,
       topProducts,
@@ -328,10 +345,9 @@ export class DomesticOperatingOverviewComponent {
       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} 日日均`
-        : '当前周期',
+      comparisonLabel,
       generatedAtLabel: this.formatDateTime(overview.dataset.generatedAt),
+      toolbarFields,
       kpis: this.buildKpis(overview.dataset, overview.totals),
       trendOptions: this.buildTrendOptions(overview.dataset.dailyTotals),
       flowRows: this.buildFlowRows(overview.totals),
@@ -438,6 +454,19 @@ export class DomesticOperatingOverviewComponent {
     }
   }
 
+  signalGoLabel(signal: OverviewSignal): string {
+    if (signal.action === 'product') return '进入商品';
+    if (signal.action === 'refund') return '进入退款';
+    if (signal.action === 'category') return '进入类目';
+    return '进入竞品';
+  }
+
+  kpiSparkTone(card: OverviewKpi): SparklineTone {
+    if (card.trendState === 'good') return 'good';
+    if (card.trendState === 'bad') return 'bad';
+    return 'default';
+  }
+
   refundRiskClass(value: number): string {
     if (value > this.REFUND_WARN) return 'is-critical';
     if (value >= this.REFUND_CAUTION) return 'is-caution';
@@ -497,6 +526,7 @@ export class DomesticOperatingOverviewComponent {
         sparkAvailable: spark !== null,
         sparkPoints: spark ? spark.points : '',
         sparkArea: spark ? spark.area : '',
+        emphasis: cfg.key === 'refund' && (trendState === 'bad' || cfg.tone === 'critical' || cfg.tone === 'caution'),
       };
     };
 

+ 60 - 48
src/modules/feedback-inbox/feedback-inbox.component.html

@@ -1,14 +1,21 @@
 <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">
+  <app-page-header
+    [title]="pageTitle"
+    [description]="pageDescription"
+    [backLabel]="requestedReviewId ? '返回反馈中心' : ''"
+    [backRoute]="requestedReviewId ? inboxBackRoute : null"
+    [crumbs]="crumbs"
+    [sampleSize]="headerSampleSize"
+    [confidence]="headerConfidence"
+    [lastUpdated]="lastUpdatedLabel"
+    density="compact"
+  >
+    <div pageHeaderActions class="page-actions">
+      @if (requestedReviewId && selectedItem) {
+        <button type="button" class="header-text-button" (click)="togglePageOnly()">
+          {{ pageOnly ? '显示队列' : '仅此页' }}
+        </button>
+      }
       @if (dataset) {
         <span class="dataset-status">
           <lucide-icon name="Database" [size]="14"></lucide-icon>
@@ -25,39 +32,36 @@
         <lucide-icon name="RefreshCw" [size]="17" [class.is-spinning]="loading"></lucide-icon>
       </button>
     </div>
-  </header>
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields">
+    <div pageToolbarActions class="page-actions">
+      @if (requestedReviewId && selectedItem) {
+        <button type="button" class="header-text-button" (click)="togglePageOnly()">
+          {{ pageOnly ? '显示队列' : '仅此页' }}
+        </button>
+      }
+    </div>
+  </app-page-toolbar>
 
   @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>
+    <app-error-state
+      title="反馈数据暂时不可用"
+      [description]="errorMessage"
+      (retry)="retry()"
+    ></app-error-state>
   } @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>
+    <app-loading-spinner
+      text="正在整理反馈队列"
+      subtext="正在读取评价、商品与主题信号。"
+    ></app-loading-spinner>
   } @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>
+    <app-empty-state
+      title="当前没有可审阅的反馈"
+      description="数据集已加载,但没有包含有效评论正文。收件箱不会使用商品指标推断客户反馈。"
+      actionText="刷新数据"
+      (action)="refresh()"
+    ></app-empty-state>
   } @else {
     @if (errorMessage) {
       <div class="inline-alert" role="alert">
@@ -69,6 +73,7 @@
 
     <section
       class="inbox-workbench"
+      [class.is-page-only]="pageOnly"
       [attr.data-mobile-view]="mobileView"
       [attr.aria-busy]="loading">
       <aside class="queue-panel" aria-label="反馈队列">
@@ -126,16 +131,19 @@
               [attr.aria-pressed]="item.id === selectedId"
               (click)="selectItem(item)">
               <span class="queue-item-topline">
+                <span class="unread-slot" title="已读状态有数据后再出现" aria-hidden="true"></span>
                 <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-product">{{ item.product?.title || item.review.productId }}</span>
               <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.priorityLabel }}</span>
                 <span>{{ item.sampleCount }} 条同类样本</span>
                 <span>{{ item.confidence.label }}</span>
               </span>
@@ -225,6 +233,15 @@
               <p>{{ actionRecommendation(item) }}</p>
             </section>
           </div>
+        } @else if (missingRequestedItem) {
+          <div class="panel-empty">
+            <app-empty-state
+              title="未找到该条反馈"
+              description="当前数据集中没有这条评价,收件箱不会编造评论内容。"
+              actionText="返回反馈中心"
+              (action)="goInbox()"
+            ></app-empty-state>
+          </div>
         } @else {
           <div class="panel-empty">
             <lucide-icon name="MessageSquare" [size]="22"></lucide-icon>
@@ -255,7 +272,7 @@
                 (error)="useFallback($event)" />
               <div>
                 <strong>{{ item.product?.title || '商品信息待补' }}</strong>
-                <span>{{ item.product?.brand || '-' }} · {{ item.product?.model || item.review.productId }}</span>
+                <span>{{ item.product?.brand || '' }} · {{ item.product?.model || item.review.productId }}</span>
               </div>
             </div>
             <dl class="context-list">
@@ -267,13 +284,8 @@
           </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>
+            <span class="section-title">属性</span>
+            <app-entity-property-list [items]="selectedPropertyItems"></app-entity-property-list>
           </section>
 
           <section class="context-section action-context">
@@ -291,7 +303,7 @@
 
           <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>
+            <strong>{{ lastUpdatedLabel || '—' }}</strong>
           </footer>
         }
       </aside>

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

@@ -16,6 +16,29 @@ select {
   padding: 24px;
 }
 
+.inbox-page > app-loading-spinner,
+.inbox-page > app-error-state,
+.inbox-page > app-empty-state {
+  display: block;
+  min-height: min(520px, calc(100dvh - 180px));
+}
+
+.header-text-button {
+  min-height: 32px;
+  padding: 0 10px;
+  border: 1px solid #dfe4e8;
+  border-radius: 6px;
+  color: #3f5666;
+  background: #ffffff;
+  font-size: 12px;
+  font-weight: 650;
+}
+
+.header-text-button:hover {
+  border-color: #9eacb6;
+  background: #f8fafb;
+}
+
 .page-heading {
   display: flex;
   align-items: center;
@@ -127,6 +150,14 @@ select {
   box-shadow: 0 4px 18px rgba(24, 33, 43, 0.05);
 }
 
+.inbox-workbench.is-page-only {
+  grid-template-columns: minmax(390px, 1.36fr) minmax(270px, 0.8fr);
+}
+
+.inbox-workbench.is-page-only .queue-panel {
+  display: none;
+}
+
 .queue-panel,
 .evidence-panel,
 .context-panel {
@@ -376,6 +407,17 @@ select {
   white-space: nowrap;
 }
 
+.queue-product {
+  display: block;
+  overflow: hidden;
+  margin-top: 4px;
+  color: #7b858f;
+  font-size: 11px;
+  line-height: 1.35;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .queue-copy {
   display: -webkit-box;
   min-height: 36px;
@@ -476,8 +518,21 @@ select {
 }
 
 .evidence-heading {
+  position: sticky;
+  top: 0;
+  z-index: 2;
   padding: 18px 22px 16px;
   border-bottom: 1px solid #e8ecef;
+  background: #ffffff;
+}
+
+.unread-slot {
+  width: 7px;
+  height: 7px;
+  border: 1px solid #d4d4d4;
+  border-radius: 999px;
+  background: transparent;
+  flex: 0 0 auto;
 }
 
 .evidence-heading-row {
@@ -679,6 +734,11 @@ select {
   border-bottom: 1px solid #e8ecef;
 }
 
+.topic-context app-entity-property-list {
+  display: block;
+  margin-top: 12px;
+}
+
 .product-summary {
   display: grid;
   grid-template-columns: 46px minmax(0, 1fr);
@@ -993,6 +1053,10 @@ select:focus-visible {
     display: inline-flex;
   }
 
+  .header-text-button {
+    display: none;
+  }
+
   .evidence-body {
     overflow: visible;
   }

+ 236 - 16
src/modules/feedback-inbox/feedback-inbox.component.ts

@@ -32,7 +32,7 @@ import {
   Tag,
   X,
 } from 'lucide-angular';
-import { Subject, Subscription, takeUntil } from 'rxjs';
+import { Subject, Subscription, combineLatest, 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';
@@ -49,6 +49,18 @@ import {
   FeedbackSourceRole,
 } from './feedback-inbox.models';
 import { ActionCreatePanelComponent } from '../shared/components/action-create-panel/action-create-panel.component';
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
+import {
+  EntityPropertyItem,
+  EntityPropertyListComponent,
+} from '../shared/components/entity-property-list/entity-property-list.component';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
+import {
+  PageHeaderComponent,
+  PageHeaderCrumb,
+} from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../shared/components/page-toolbar/page-toolbar.component';
 
 type SourceFilter = 'all' | FeedbackSourceRole;
 type SentimentFilter = 'all' | FeedbackSentiment;
@@ -71,6 +83,12 @@ const TOPIC_COPY: Record<string, { label: string; description: string }> = {
     FormsModule,
     LucideAngularModule,
     ActionCreatePanelComponent,
+    PageHeaderComponent,
+    PageToolbarComponent,
+    EntityPropertyListComponent,
+    EmptyStateComponent,
+    ErrorStateComponent,
+    LoadingSpinnerComponent,
   ],
   providers: [
     {
@@ -108,7 +126,7 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
   private readonly router = inject(Router);
   private readonly destroy$ = new Subject<void>();
   private datasetSubscription?: Subscription;
-  private requestedReviewId = '';
+  requestedReviewId = '';
 
   @Output() readonly createAction = new EventEmitter<FeedbackActionDraft>();
 
@@ -138,13 +156,41 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
   mobileView: MobileView = 'queue';
   loading = true;
   errorMessage = '';
+  missingRequestedItem = false;
+  pageOnly = false;
+  inboxBackRoute: string | any[] | null = ['/feedback-inbox'];
   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') || '';
+    combineLatest([this.route.paramMap, this.route.queryParamMap])
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(([params, query]) => {
+        const pathId = (params.get('reviewId') || '').trim();
+        const queryId = (query.get('reviewId') || '').trim();
+        const nextSearch = query.get('q');
+        if (nextSearch != null) this.searchTerm = nextSearch;
+
+        if (!pathId && queryId) {
+          this.requestedReviewId = queryId;
+          this.syncInboxBackRoute();
+          this.applyRequestedSelection();
+          this.cdr.markForCheck();
+          void this.router.navigate(['/feedback-inbox', queryId], {
+            queryParams: { reviewId: null },
+            queryParamsHandling: 'merge',
+            replaceUrl: true,
+          });
+          return;
+        }
+
+        this.requestedReviewId = pathId || queryId;
+        this.syncInboxBackRoute();
+        this.applyRequestedSelection();
+        this.cdr.markForCheck();
+      });
     this.subscribeToDataset();
   }
 
@@ -205,6 +251,105 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
     return item.product?.relationCount ?? 0;
   }
 
+  get pageTitle(): string {
+    if (!this.requestedReviewId) return '反馈收件箱';
+    if (this.missingRequestedItem) return '未找到该条反馈';
+    return this.selectedItem?.topicLabel || this.selectedItem?.product?.title || '反馈详情';
+  }
+
+  get pageDescription(): string {
+    if (this.missingRequestedItem) {
+      return '当前数据集中没有这条评价,收件箱不会编造评论内容。';
+    }
+    if (this.requestedReviewId) return '';
+    return '集中审阅真实评价证据,并将已确认的问题转入行动流程。';
+  }
+
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    return [
+      { label: '周期', value: this.lastUpdatedLabel || '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
+  get crumbs(): PageHeaderCrumb[] {
+    if (!this.requestedReviewId) return [];
+    const current =
+      this.selectedItem?.topicLabel ||
+      this.selectedItem?.product?.title ||
+      (this.missingRequestedItem ? '未找到该条反馈' : '反馈详情');
+    return [
+      { label: '反馈中心', link: this.inboxBackRoute || '/feedback-inbox' },
+      { label: current },
+    ];
+  }
+
+  get headerSampleSize(): string | number | null {
+    return this.requestedReviewId && this.selectedItem ? this.selectedItem.sampleCount : null;
+  }
+
+  get headerConfidence(): 'high' | 'medium' | 'low' | null {
+    if (!this.requestedReviewId || !this.selectedItem) return null;
+    const level = this.selectedItem.confidence.level;
+    if (level === 'strong') return 'high';
+    if (level === 'moderate') return 'medium';
+    return 'low';
+  }
+
+  get lastUpdatedLabel(): string {
+    if (!this.dataset?.generatedAt) return '';
+    const parsed = new Date(this.dataset.generatedAt);
+    if (Number.isNaN(parsed.getTime())) return '';
+    return parsed.toLocaleString('zh-CN', {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+    });
+  }
+
+  get selectedPropertyItems(): EntityPropertyItem[] {
+    const item = this.selectedItem;
+    if (!item) return [];
+    const actionCreated = this.isActionCreated(item);
+    return [
+      { label: '评价 ID', value: item.review.reviewId },
+      { label: '商品', value: item.product?.title || item.review.productId },
+      { label: '主题', value: item.topicLabel },
+      { label: '同类样本', value: item.sampleCount },
+      { label: '置信度', value: item.confidence.label },
+      { label: '来源', value: item.sourceLabel },
+      { label: '优先级', value: item.priorityLabel },
+      {
+        label: '关联分析',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+      {
+        label: '已建行动',
+        value: actionCreated ? '本页已建草稿' : null,
+        reserved: true,
+        hint: actionCreated ? undefined : '有数据后再出现',
+      },
+      {
+        label: '负责人',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+      {
+        label: '已读',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+    ];
+  }
+
   refresh(): void {
     if (this.loading) return;
     this.loading = true;
@@ -221,21 +366,41 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
 
   selectItem(item: FeedbackInboxItem): void {
     this.selectedId = item.id;
+    this.missingRequestedItem = false;
     this.mobileView = 'detail';
-    void this.router.navigate([], {
-      relativeTo: this.route,
-      queryParams: { reviewId: item.review.reviewId },
-      queryParamsHandling: 'merge',
-      replaceUrl: true,
+    const currentId = (this.route.snapshot.paramMap.get('reviewId') || '').trim();
+    if (currentId === item.review.reviewId) return;
+    void this.router.navigate(['/feedback-inbox', item.review.reviewId], {
+      queryParamsHandling: 'preserve',
     });
   }
 
   showQueue(): void {
+    this.pageOnly = false;
     this.mobileView = 'queue';
   }
 
+  goInbox(): void {
+    this.pageOnly = false;
+    this.mobileView = 'queue';
+    void this.router.navigate(['/feedback-inbox'], {
+      queryParams: { reviewId: null },
+      queryParamsHandling: 'merge',
+    });
+  }
+
+  togglePageOnly(): void {
+    this.pageOnly = !this.pageOnly;
+    if (this.pageOnly) this.mobileView = 'detail';
+  }
+
   onFiltersChanged(): void {
     this.visibleLimit = 60;
+    this.persistSearchQuery();
+    if (this.requestedReviewId) {
+      this.ensureSelectedVisible();
+      return;
+    }
     const visible = this.filteredItems;
     if (!visible.some((item) => item.id === this.selectedId)) this.selectedId = visible[0]?.id ?? '';
   }
@@ -245,7 +410,9 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
     this.sourceFilter = 'all';
     this.sentimentFilter = 'all';
     this.visibleLimit = 60;
-    this.selectedId = this.items[0]?.id ?? '';
+    this.persistSearchQuery();
+    if (!this.requestedReviewId) this.selectedId = this.items[0]?.id ?? '';
+    else this.ensureSelectedVisible();
   }
 
   loadMore(): void {
@@ -307,11 +474,11 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
     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 || '-';
+    return product?.category3 || product?.category2 || product?.category1 || '';
   }
 
   actionRecommendation(item?: FeedbackInboxItem): string {
@@ -334,6 +501,63 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
     return item.id;
   }
 
+  private findRequestedItem(reviewId: string): FeedbackInboxItem | undefined {
+    return this.items.find((item) => item.review.reviewId === reviewId || item.id === reviewId);
+  }
+
+  private applyRequestedSelection(): void {
+    this.missingRequestedItem = false;
+    if (!this.dataset) return;
+    if (!this.items.length) {
+      this.selectedId = '';
+      if (this.requestedReviewId) this.missingRequestedItem = true;
+      return;
+    }
+    if (this.requestedReviewId) {
+      const requestedItem = this.findRequestedItem(this.requestedReviewId);
+      if (requestedItem) {
+        this.selectedId = requestedItem.id;
+        this.mobileView = 'detail';
+        this.ensureSelectedVisible();
+        return;
+      }
+      this.selectedId = '';
+      this.missingRequestedItem = true;
+      return;
+    }
+    if (!this.items.some((item) => item.id === this.selectedId)) {
+      this.selectedId = this.items[0]?.id ?? '';
+    }
+  }
+
+  private ensureSelectedVisible(): void {
+    if (!this.selectedId) return;
+    const index = this.filteredItems.findIndex((item) => item.id === this.selectedId);
+    if (index < 0) return;
+    if (index >= this.visibleLimit) {
+      this.visibleLimit = Math.ceil((index + 1) / 60) * 60;
+    }
+  }
+
+  private syncInboxBackRoute(): void {
+    this.inboxBackRoute = this.router.createUrlTree(['/feedback-inbox'], {
+      queryParams: { reviewId: null },
+      queryParamsHandling: 'merge',
+    }) as any;
+  }
+
+  private persistSearchQuery(): void {
+    const q = this.searchTerm.trim();
+    const current = this.route.snapshot.queryParamMap.get('q') || '';
+    if (q === current) return;
+    void this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: { q: q || null },
+      queryParamsHandling: 'merge',
+      replaceUrl: true,
+    });
+  }
+
   private subscribeToDataset(): void {
     this.loading = true;
     this.errorMessage = '';
@@ -343,12 +567,8 @@ export class FeedbackInboxComponent implements OnInit, OnDestroy {
         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.applyRequestedSelection();
           this.cdr.markForCheck();
         },
         error: () => {

+ 11 - 7
src/modules/knowledge-center/knowledge-center.component.html

@@ -5,11 +5,15 @@
     icon="▤"
     [sampleSize]="ownCount + competitorCount"
     [lastUpdated]="dataset?.generatedAt ? formatDate(dataset!.generatedAt) : ''"
-    density="compact">
-    <button pageHeaderActions type="button" class="header-button" [disabled]="loading" (click)="load()" title="刷新知识库">
+    density="compact"
+    [crumbs]="crumbs">
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields">
+    <button pageToolbarActions type="button" class="header-button" [disabled]="loading" (click)="load()" title="刷新知识库">
       <span [class.spinning]="loading">↻</span> 刷新
     </button>
-  </app-page-header>
+  </app-page-toolbar>
 
   @if (error) { <div class="status-banner error" role="alert">{{ error }}</div> }
   @if (notice) { <div class="status-banner success" role="status">{{ notice }}</div> }
@@ -124,8 +128,8 @@
       </tr></thead>
       <tbody>
         @for (product of pagedProducts; track product.productKey) {
-          <tr [class.selected]="isSelected(product)">
-            <td class="selection-cell">
+          <tr class="is-clickable" [class.selected]="isSelected(product)" (click)="openProduct(product)">
+            <td class="selection-cell" (click)="$event.stopPropagation()">
               <input type="checkbox" [checked]="isSelected(product)" (change)="toggleSelection(product, $any($event.target).checked)" [attr.aria-label]="'选择商品 ' + product.productId" />
             </td>
             @if (isColumnVisible('product')) {
@@ -170,11 +174,11 @@
                 } @else { <span class="unmaintained">待维护</span> }
               </td>
             }
-            @if (isColumnVisible('owner')) { <td>{{ item(product)?.ownerUserId || '-' }}</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">
+              <td class="actions" (click)="$event.stopPropagation()">
                 <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>

+ 1 - 0
src/modules/knowledge-center/knowledge-center.component.scss

@@ -137,6 +137,7 @@ button, input, textarea { font: inherit; }
 .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.is-clickable { cursor: pointer; }
 .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; }

+ 29 - 1
src/modules/knowledge-center/knowledge-center.component.ts

@@ -1,5 +1,6 @@
 import { CommonModule } from '@angular/common';
 import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
 import { FormsModule } from '@angular/forms';
 import { forkJoin, Subject, take, takeUntil } from 'rxjs';
 import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
@@ -10,7 +11,8 @@ import {
   CrudTableFilter,
   CrudTableWorkbenchComponent,
 } from '../shared/components/crud-table-workbench/crud-table-workbench.component';
-import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../shared/components/page-toolbar/page-toolbar.component';
 import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
 import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
 import {
@@ -36,6 +38,7 @@ interface KnowledgeMetric {
     CommonModule,
     FormsModule,
     PageHeaderComponent,
+    PageToolbarComponent,
     SummaryMetricCardComponent,
     CrudTableWorkbenchComponent,
     ProductIdentityComponent,
@@ -88,6 +91,7 @@ export class KnowledgeCenterComponent implements OnInit, OnDestroy {
   constructor(
     private readonly datasetService: DomesticDatasetService,
     private readonly knowledgeService: ProductKnowledgeService,
+    private readonly router: Router,
   ) {}
 
   ngOnInit(): void {
@@ -177,12 +181,31 @@ export class KnowledgeCenterComponent implements OnInit, OnDestroy {
       : 0;
   }
 
+  readonly crumbs: PageHeaderCrumb[] = [{ label: '知识库', link: '/knowledge-center' }];
+
+  get toolbarFields(): PageToolbarField[] {
+    return [
+      { label: '周期', value: this.toolbarPeriod },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   get dateRangeLabel(): string {
     const range = this.dataset?.source.dateRange;
     if (!range?.start || !range.end) return '暂无周期信息';
     return `${range.start.replaceAll('-', '.')} - ${range.end.replaceAll('-', '.')}`;
   }
 
+  private get toolbarPeriod(): string {
+    const range = this.dataset?.source.dateRange;
+    if (range?.start && range.end) {
+      return `${range.start.replaceAll('-', '.')} - ${range.end.replaceAll('-', '.')}`;
+    }
+    return this.dataset?.generatedAt ? this.formatDate(this.dataset.generatedAt) : '';
+  }
+
   get platformLabel(): string {
     return this.dataset?.platform === 'jd' ? '京东' : (this.dataset?.platform || '-');
   }
@@ -490,6 +513,11 @@ export class KnowledgeCenterComponent implements OnInit, OnDestroy {
     return product.role === 'own' ? { productId: product.productId } : null;
   }
 
+  openProduct(product: DomesticProduct): void {
+    const query = this.productQuery(product);
+    void this.router.navigate(this.productRoute(product), query ? { queryParams: query } : {});
+  }
+
   formatNumber(value: number): string {
     return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
   }

+ 42 - 7
src/modules/monitoring/pages/category-voc/category-voc.component.ts

@@ -1,6 +1,7 @@
 import { ChangeDetectorRef, Component, NgZone, OnInit, OnDestroy } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
 import { Subject, Subscription } from 'rxjs';
 import { takeUntil } from 'rxjs/operators';
 import { DomesticMonitoringViewService } from '../../../../app/core/services/domestic-monitoring-view.service';
@@ -8,7 +9,8 @@ import { DomesticDatasetService } from '../../../../app/core/services/domestic-d
 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';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
 import { BoardTabItem, BoardTabsComponent } from '../../../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
@@ -43,16 +45,19 @@ interface TableGroup {
 @Component({
   selector: 'app-category-voc',
   standalone: true,
-  imports: [CommonModule, FormsModule, FilterBarComponent, PageHeaderComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, DataTableShellComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, MsqDropdownComponent, NeuButtonComponent, PriorityAccordionComponent],
+  imports: [CommonModule, FormsModule, FilterBarComponent, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, DataTableShellComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, MsqDropdownComponent, NeuButtonComponent, PriorityAccordionComponent],
   template: `
     <div class="category-voc-container">
       <app-page-header
         title="类目 VOC"
         eyebrow="Monitoring"
         description="各品类 VOC 表现对比 · 销量 vs 差评率四象限 · 识别问题/机会品类"
-        icon="📦">
+        icon="📦"
+        [crumbs]="crumbs">
       </app-page-header>
 
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
       @if (loading) {
         <app-loading-spinner text="加载品类数据中..." size="sm"></app-loading-spinner>
       }
@@ -353,7 +358,8 @@ interface TableGroup {
                 </div>
               }
               <div class="diagnosis-actions">
-                <app-neu-button size="sm" color="primary" (btnClick)="openSelectedSubCategory()">查看子品类/品牌</app-neu-button>
+                <app-neu-button size="sm" color="primary" (btnClick)="openCategoryProducts(cat)">进入商品洞察</app-neu-button>
+                <app-neu-button size="sm" (btnClick)="openSelectedSubCategory()">查看子品类/品牌</app-neu-button>
                 <app-neu-button size="sm" (btnClick)="setDimension('category')">返回品类视图</app-neu-button>
               </div>
             } @else {
@@ -506,6 +512,15 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
   loadError = false;
   currentFilters: any = { timeRange: '30days' };
   designerFilterOptions: string[] = [];
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '监测', link: '/domestic/voc-overview' },
+    { label: '类目 VOC' },
+  ];
+  readonly toolbarFields: PageToolbarField[] = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
 
   // ─── 数据 ───
   categories: CategoryVocDensityItem[] = [];
@@ -548,12 +563,24 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
     { y: 340, label: '1' }
   ];
 
+  private requestedCategory = '';
+
   constructor(
     private monitoringService: DomesticMonitoringViewService,
     private datasetService: DomesticDatasetService,
     private ngZone: NgZone,
-    private cdr: ChangeDetectorRef
-  ) {}
+    private cdr: ChangeDetectorRef,
+    private router: Router,
+    private route: ActivatedRoute,
+  ) {
+    this.requestedCategory = this.route.snapshot.queryParamMap.get('category')?.trim() || '';
+  }
+
+  openCategoryProducts(cat: CategoryVocDensityItem): void {
+    const category = cat.category?.trim();
+    if (!category) return;
+    void this.router.navigate(['/voc-insight/products'], { queryParams: { category } });
+  }
 
   get dataset$() {
     return this.datasetService.dataset$;
@@ -634,7 +661,15 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
     if (this.selectedParentCategory && !items.some(item => item.category === this.selectedParentCategory)) {
       this.selectedParentCategory = '';
     }
-    if (this.activeCategory) {
+    if (this.requestedCategory) {
+      const requested = this.requestedCategory.toLocaleLowerCase('zh-CN');
+      const match = items.find((item) => {
+        const name = (item.category || '').trim().toLocaleLowerCase('zh-CN');
+        return name === requested || name.includes(requested) || requested.includes(name);
+      });
+      if (match) this.selectCategory(match);
+      this.requestedCategory = '';
+    } else if (this.activeCategory) {
       this.activeCategory = items.find(item => item.category === this.activeCategory?.category) || null;
     }
     this.totalProducts = items.reduce((s, c) => s + c.productCount, 0);

+ 20 - 2
src/modules/monitoring/pages/risk-alert/risk-alert.component.ts

@@ -7,7 +7,8 @@ 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 { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../../../shared/components/page-toolbar/page-toolbar.component';
 import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
 import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
 import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
@@ -21,7 +22,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
 @Component({
   selector: 'app-risk-alert',
   standalone: true,
-  imports: [CommonModule, FormsModule, RouterModule, MsqSearchBoxComponent, PageHeaderComponent, BoardTabsComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, NeuButtonComponent, ProductIdentityComponent, SummaryMetricCardComponent],
+  imports: [CommonModule, FormsModule, RouterModule, MsqSearchBoxComponent, PageHeaderComponent, PageToolbarComponent, BoardTabsComponent, EmptyStateComponent, ErrorStateComponent, LoadingSpinnerComponent, NeuButtonComponent, ProductIdentityComponent, SummaryMetricCardComponent],
   template: `
     <div class="risk-alert-container">
       <app-page-header
@@ -29,6 +30,7 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
         eyebrow="Monitoring"
         description="复用原预警工作台,按国内经营数据定位退款异常,并保留本品 VOC 风险入口。"
         icon="🛡️"
+        [crumbs]="crumbs"
         [badges]="[{ label: '经营风险已接通', tone: 'green' }, { label: '本品 VOC 风险待接入', tone: 'amber' }]"
         [meta]="riskHeaderMeta">
         <div pageHeaderActions class="header-right">
@@ -39,6 +41,8 @@ type RiskAlertTabKey = 'products' | 'scenarios' | 'spikes';
         </div>
       </app-page-header>
 
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
       @if (loading) {
         <app-loading-spinner text="加载预警数据中..." size="sm"></app-loading-spinner>
       }
@@ -467,11 +471,25 @@ export class RiskAlertComponent implements OnInit, OnDestroy {
     ];
   }
 
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '监测', link: '/domestic/voc-overview' },
+    { label: '风险预警' },
+  ];
+
   get riskPeriodLabel(): string {
     const item = this.riskItems[0];
     return item?.analysisStart && item?.analysisEnd ? `${item.analysisStart} 至 ${item.analysisEnd}` : '加载中';
   }
 
+  get toolbarFields(): PageToolbarField[] {
+    return [
+      { label: '周期', value: this.riskPeriodLabel || '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   setActiveTab(key: string): void {
     if (key === 'products' || key === 'scenarios' || key === 'spikes') {
       this.activeTab = key;

+ 20 - 2
src/modules/monitoring/pages/voc-overview/voc-overview.component.ts

@@ -6,7 +6,8 @@ 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 { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
 import { BoardTabItem, BoardTabsComponent } from '../../../shared/components/board-tabs/board-tabs.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
@@ -23,7 +24,7 @@ const CONFIDENCE_OK_MIN = 30;
 @Component({
   selector: 'app-voc-overview',
   standalone: true,
-  imports: [CommonModule, RouterModule, PageHeaderComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, EmptyStateComponent, EntryCardComponent, EntryCardGridComponent, ErrorStateComponent, LoadingSpinnerComponent],
+  imports: [CommonModule, RouterModule, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, BoardTabsComponent, ContentCardComponent, EmptyStateComponent, EntryCardComponent, EntryCardGridComponent, ErrorStateComponent, LoadingSpinnerComponent],
   template: `
     <div class="voc-overview-container">
       <app-page-header
@@ -31,9 +32,12 @@ const CONFIDENCE_OK_MIN = 30;
         eyebrow="Monitoring"
         description="沿用原版监测总览结构,分开呈现本品 VOC 就绪度、竞品参照主题与经营风险。"
         icon="◉"
+        [crumbs]="crumbs"
         [badges]="[{ label: '本品 VOC 待接入', tone: 'amber' }, { label: '竞品参照已接通', tone: 'purple' }, { label: '经营风险已接通', tone: 'green' }]">
       </app-page-header>
 
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
       @if (loading) {
         <app-loading-spinner text="加载数据中..." size="sm"></app-loading-spinner>
       }
@@ -551,6 +555,11 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
     return this.vocCount >= this.minSample ? '样本充足' : '样本不足,仅展示观察值';
   }
 
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '监测', link: '/domestic/voc-overview' },
+    { label: 'VOC 趋势总览' },
+  ];
+
   /** 当前筛选周期的人类可读标签 */
   get periodLabel(): string {
     const r = this.currentFilters?.timeRange;
@@ -567,6 +576,15 @@ export class VocOverviewComponent implements OnInit, OnDestroy {
     }
   }
 
+  get toolbarFields(): PageToolbarField[] {
+    return [
+      { label: '周期', value: this.periodLabel || '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   get positiveCount(): number {
     return this.healthScore
       ? Math.round(this.healthScore.totalReviews * this.healthScore.positiveRate / 100)

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

@@ -66,6 +66,8 @@
   justify-content: space-between;
   gap: 16px;
   min-width: 0;
+  padding-bottom: 14px;
+  border-bottom: 1px solid t.$color-border;
 }
 
 .content-card-copy {

+ 49 - 34
src/modules/shared/components/empty-state/empty-state.component.ts

@@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common';
       [ngClass]="['empty-' + size, 'variant-' + variant]"
       [attr.role]="'status'"
     >
-      <div class="empty-icon">{{ resolvedIcon }}</div>
+      <div class="empty-icon" aria-hidden="true"><span>{{ resolvedIcon }}</span></div>
       <h3 class="empty-title">{{ resolvedTitle }}</h3>
       <p class="empty-desc" *ngIf="resolvedDescription">{{ resolvedDescription }}</p>
       <div class="empty-actions" *ngIf="actionText || secondaryActionText">
@@ -41,21 +41,37 @@ import { CommonModule } from '@angular/common';
     }
 
     .empty-icon {
-      font-size: 48px;
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 48px;
+      height: 48px;
       margin-bottom: 16px;
-      opacity: 0.7;
+      border-radius: 50%;
+      background: #f4f4f5;
+      border: 1px solid #e5e5e5;
+      color: #737373;
+      font-size: 20px;
+      line-height: 1;
+      opacity: 1;
+
+      span {
+        font-size: 18px;
+        line-height: 1;
+        filter: grayscale(0.2);
+      }
     }
 
     .empty-title {
       font-size: 15px;
       font-weight: 600;
-      color: #343A40;
+      color: #171717;
       margin: 0 0 8px;
     }
 
     .empty-desc {
       font-size: 13px;
-      color: #868E96;
+      color: #737373;
       margin: 0 0 20px;
       max-width: 320px;
       line-height: 1.5;
@@ -69,79 +85,78 @@ import { CommonModule } from '@angular/common';
     }
 
     .empty-action {
-      padding: 8px 20px;
+      padding: 8px 16px;
+      min-height: 36px;
       border: 1px solid transparent;
-      border-radius: 6px;
+      border-radius: 8px;
       font-size: 13px;
       font-weight: 600;
       cursor: pointer;
       transition: background 0.15s, border-color 0.15s, color 0.15s;
 
       &.primary {
-        background: #343A40;
-        color: #fff;
+        background: #171717;
+        color: #fafafa;
 
-        &:hover { background: #1a1a2e; }
+        &:hover { background: #262626; }
       }
 
       &.secondary {
         background: #fff;
-        color: #343A40;
-        border-color: #E9ECEF;
+        color: #171717;
+        border-color: #e5e5e5;
 
-        &:hover { border-color: #343A40; }
+        &:hover { border-color: #d4d4d4; background: #f4f4f5; }
       }
     }
 
-    /* Size variants */
     .empty-sm {
       padding: 24px 16px;
-      .empty-icon { font-size: 32px; margin-bottom: 10px; }
+      .empty-icon { width: 36px; height: 36px; margin-bottom: 10px; }
+      .empty-icon span { font-size: 14px; }
       .empty-title { font-size: 13px; }
       .empty-desc { font-size: 12px; }
     }
 
     .empty-lg {
       padding: 64px 32px;
-      .empty-icon { font-size: 64px; margin-bottom: 20px; }
+      .empty-icon { width: 56px; height: 56px; margin-bottom: 16px; }
+      .empty-icon span { font-size: 22px; }
       .empty-title { font-size: 18px; }
       .empty-desc { font-size: 14px; max-width: 400px; }
     }
 
-    /* Variant: card(带白底卡片) */
     .variant-card {
       background: #fff;
-      border: 1px solid #EEF1F6;
-      border-radius: 16px;
-      box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
+      border: 1px solid #e5e5e5;
+      border-radius: 10px;
+      box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
     }
 
-    /* Variant: table(嵌入表格内的紧凑空态) */
     .variant-table {
       padding: 24px 16px;
-      background: #FAFBFC;
+      background: #fafafa;
       border-radius: 8px;
-      .empty-icon { font-size: 28px; margin-bottom: 8px; opacity: 0.6; }
+      .empty-icon { width: 32px; height: 32px; margin-bottom: 8px; }
+      .empty-icon span { font-size: 13px; }
       .empty-title { font-size: 13px; }
       .empty-desc { font-size: 12px; max-width: 260px; }
     }
 
-    /* Variant: product-required(需先选择产品) */
     .variant-product-required {
-      background: linear-gradient(135deg, rgba(37, 99, 235, 0.05), #fff);
-      border: 1px dashed #BFDBFE;
-      border-radius: 16px;
-      .empty-icon { color: #2563EB; }
-      .empty-title { color: #1E40AF; }
+      background: #fafafa;
+      border: 1px dashed #e5e5e5;
+      border-radius: 10px;
+      .empty-icon { background: #f4f4f5; border-color: #e5e5e5; color: #525252; }
+      .empty-title { color: #171717; }
     }
 
-    /* Variant: low-sample(样本量不足警告) */
     .variant-low-sample {
       background: linear-gradient(135deg, rgba(217, 119, 6, 0.06), #fff);
-      border: 1px dashed #FDE68A;
-      border-radius: 16px;
-      .empty-icon { color: #D97706; }
-      .empty-title { color: #92400E; }
+      border: 1px dashed #fde68a;
+      border-radius: 10px;
+      .empty-icon { background: #fffbeb; border-color: #fde68a; color: #d97706; }
+      .empty-title { color: #92400e; }
     }
   `]
 })

+ 61 - 0
src/modules/shared/components/entity-property-list/entity-property-list.component.scss

@@ -0,0 +1,61 @@
+@use '../../styles/tokens' as t;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.entity-property-list {
+  margin: 0;
+}
+
+.entity-property {
+  display: grid;
+  grid-template-columns: minmax(72px, 38%) minmax(0, 1fr);
+  gap: 4px 12px;
+  padding: 10px 0;
+  border-bottom: 1px solid t.$color-border;
+
+  &:last-child {
+    border-bottom: none;
+    padding-bottom: 0;
+  }
+
+  &:first-child {
+    padding-top: 0;
+  }
+}
+
+dt {
+  color: t.$color-text-muted;
+  font-size: 12px;
+  font-weight: 500;
+  line-height: 1.45;
+}
+
+dd {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+  margin: 0;
+}
+
+.entity-property-value {
+  color: t.$color-text;
+  font-size: 13px;
+  font-weight: 600;
+  line-height: 1.45;
+  word-break: break-word;
+
+  &.is-empty {
+    color: t.$color-text-subtle;
+    font-weight: 500;
+  }
+}
+
+.entity-property-hint {
+  color: t.$color-text-muted;
+  font-size: 11px;
+  line-height: 1.4;
+}

+ 57 - 0
src/modules/shared/components/entity-property-list/entity-property-list.component.ts

@@ -0,0 +1,57 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+export interface EntityPropertyItem {
+  label: string;
+  value?: string | number | null;
+  hint?: string;
+  reserved?: boolean;
+}
+
+@Component({
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  selector: 'app-entity-property-list',
+  standalone: true,
+  imports: [CommonModule],
+  template: `
+    <dl class="entity-property-list">
+      @for (item of items; track $index) {
+        <div class="entity-property">
+          <dt>{{ item.label }}</dt>
+          <dd>
+            <span class="entity-property-value" [class.is-empty]="isMissing(item)">{{ displayValue(item) }}</span>
+            @if (item.hint) {
+              <span class="entity-property-hint">{{ item.hint }}</span>
+            }
+          </dd>
+        </div>
+      }
+    </dl>
+  `,
+  styleUrls: ['./entity-property-list.component.scss']
+})
+export class EntityPropertyListComponent {
+  @Input() items: EntityPropertyItem[] = [];
+
+  displayValue(item: EntityPropertyItem): string {
+    return this.isMissing(item) ? '—' : String(item.value);
+  }
+
+  isMissing(item: EntityPropertyItem): boolean {
+    return this.isBlank(item.value) || (!!item.reserved && this.isBlank(item.value));
+  }
+
+  private isBlank(value: string | number | null | undefined): boolean {
+    if (value === 0) {
+      return false;
+    }
+    if (value == null) {
+      return true;
+    }
+    if (typeof value === 'string') {
+      const trimmed = value.trim();
+      return !trimmed || trimmed === '—';
+    }
+    return typeof value === 'number' && Number.isNaN(value);
+  }
+}

+ 5 - 4
src/modules/shared/components/entry-card/entry-card.component.scss

@@ -26,15 +26,16 @@ $tones: (
   padding: 18px 20px;
   background: t.$color-surface;
   border: 1px solid t.$color-border-soft;
-  border-radius: t.$radius-xl;
-  box-shadow: t.$shadow-card;
+  border-radius: t.$radius-card;
+  box-shadow: t.$shadow-card-flat;
   cursor: pointer;
   text-align: left;
   transition: transform t.$transition-fast, box-shadow t.$transition-fast, border-color t.$transition-fast;
 
   &:hover:not(.disabled) {
-    transform: translateY(-2px);
-    box-shadow: t.$shadow-float;
+    transform: translateY(-1px);
+    background: t.$color-accent;
+    box-shadow: t.$shadow-elev-1;
     border-color: t.$color-border;
   }
 

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

@@ -15,7 +15,7 @@
   </div>
 
   <!-- 全局搜索 -->
-  <div class="header-search" [class.focused]="searchFocused" [class.has-results]="searchResults.length > 0">
+  <div class="header-search command-pill" [class.focused]="searchFocused" [class.has-results]="searchResults.length > 0">
     <lucide-icon class="search-icon" [img]="icons.search" [size]="17" [strokeWidth]="1.8"></lucide-icon>
     <input
       #searchInput

+ 149 - 293
src/modules/shared/components/header/header.component.scss

@@ -1,31 +1,23 @@
-// ═══════════════════════════════════════
-// 拟态深色 — 顶部导航栏
-// ═══════════════════════════════════════
-
-$g-bg:    #F8F9FA;
-$g-light: #F1F3F5;
-$g-mid:   #E9ECEF;
-$g-text:  #868E96;
-$g-dark:  #343A40;
-$g-black: #1E1E20;
-
-// 深色拟态阴影色
-$h-surface: #2a2a2d;
-$h-light:   rgba(255,255,255,0.06);
-$h-dark:    rgba(0,0,0,0.4);
+$g-bg: #fafafa;
+$g-light: #f4f4f5;
+$g-mid: #e5e5e5;
+$g-text: #737373;
+$g-dark: #171717;
+$g-black: #171717;
+$h-surface: #ffffff;
+$h-light: rgba(255, 255, 255, 0.9);
+$h-dark: rgba(15, 23, 42, 0.04);
 
 .app-header {
   height: 56px;
-  background: $g-black;
-  background-image: linear-gradient(145deg, rgba(255,255,255,0.04), rgba(0,0,0,0.1));
+  background: $g-bg;
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 0 20px;
-  color: $g-light;
-  box-shadow:
-    0 2px 6px rgba(0,0,0,0.25),
-    inset 0 1px 0 rgba(255,255,255,0.05);
+  padding: 0 16px;
+  color: #334155;
+  border-bottom: 1px solid $g-mid;
+  box-shadow: none;
   position: relative;
   z-index: 1001;
 }
@@ -33,45 +25,48 @@ $h-dark:    rgba(0,0,0,0.4);
 .header-left {
   display: flex;
   align-items: center;
-  gap: 16px;
+  gap: 12px;
+  min-width: 216px;
 }
 
 .brand-title {
   margin: 0;
-  font-size: 17px;
+  font-size: 15px;
   font-weight: 600;
-  letter-spacing: 0.3px;
-  color: $g-light;
+  letter-spacing: 0;
+  color: $g-dark;
 }
 
 .collapse-toggle {
-  background: $h-surface;
-  border: none;
+  background: #ffffff;
+  border: 1px solid $g-mid;
   color: $g-text;
   cursor: pointer;
-  width: 28px;
-  height: 28px;
-  border-radius: 6px;
+  width: 34px;
+  height: 34px;
+  border-radius: 8px;
   display: flex;
   align-items: center;
   justify-content: center;
-  box-shadow:
-    2px 2px 4px $h-dark,
-    -2px -2px 4px $h-light;
-  transition: box-shadow 0.25s ease, color 0.25s ease, transform 0.15s ease;
+  box-shadow: none;
+  transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease;
 
   &:hover {
-    box-shadow:
-      1px 1px 3px $h-dark,
-      -1px -1px 3px $h-light;
-    color: $g-light;
+    border-color: #d1d5db;
+    background: $g-light;
+    color: $g-dark;
+    box-shadow: none;
   }
 
   &:active {
-    box-shadow:
-      inset 2px 2px 4px $h-dark,
-      inset -2px -2px 4px $h-light;
-    transform: scale(0.96);
+    background: #f1f5f9;
+    box-shadow: none;
+    transform: none;
+  }
+
+  &:focus-visible {
+    outline: none;
+    box-shadow: 0 0 0 3px rgba(23, 23, 23, 0.2);
   }
 
   .toggle-icon {
@@ -88,45 +83,37 @@ $h-dark:    rgba(0,0,0,0.4);
   }
 }
 
-// ─── 全局搜索 ───
-
 .header-search {
   flex: 1;
-  max-width: 520px;
-  margin: 0 32px;
+  max-width: 560px;
+  margin: 0 clamp(16px, 4vw, 56px);
   position: relative;
   display: flex;
   align-items: center;
-  background: rgba(0,0,0,0.18);
+  background: #f4f4f5;
   border-radius: 10px;
-  padding: 0 14px;
-  height: 38px;
-  border: 1px solid rgba(255,255,255,0.06);
-  box-shadow:
-    inset 2px 2px 6px rgba(0,0,0,0.4),
-    inset -2px -2px 6px rgba(255,255,255,0.03),
-    0 1px 0 rgba(255,255,255,0.04);
-  transition: box-shadow 0.3s ease, background 0.3s ease, border-color 0.3s ease;
+  padding: 0 12px;
+  height: 36px;
+  border: 1px solid $g-mid;
+  box-shadow: none;
+  transition: box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease;
 
   &.focused {
-    background: rgba(0,0,0,0.25);
-    border-color: rgba(99,102,241,0.3);
-    box-shadow:
-      inset 2px 2px 8px rgba(0,0,0,0.5),
-      inset -2px -2px 8px rgba(255,255,255,0.04),
-      0 0 0 3px rgba(99,102,241,0.12);
+    background: #ffffff;
+    border-color: #e5e5e5;
+    box-shadow: 0 0 0 3px rgba(23, 23, 23, 0.2);
   }
 
   .search-icon {
     display: inline-flex;
     align-items: center;
     justify-content: center;
-    margin-right: 10px;
-    opacity: 0.5;
+    margin-right: 8px;
+    opacity: 1;
     width: 16px;
     height: 16px;
     color: $g-text;
-    transition: opacity 0.25s ease;
+    transition: color 0.16s ease;
 
     ::ng-deep svg {
       width: 16px;
@@ -135,8 +122,7 @@ $h-dark:    rgba(0,0,0,0.4);
   }
 
   &.focused .search-icon {
-    opacity: 0.8;
-    color: $g-light;
+    color: #171717;
   }
 
   .search-input {
@@ -144,22 +130,32 @@ $h-dark:    rgba(0,0,0,0.4);
     background: none;
     border: none;
     outline: none;
-    color: $g-light;
+    color: $g-dark;
     font-size: 13px;
     height: 100%;
 
-    &::placeholder { color: $g-text; }
+    &::placeholder {
+      color: #94a3b8;
+    }
   }
 
   .search-clear {
+    display: inline-grid;
+    width: 28px;
+    height: 28px;
+    place-items: center;
     background: none;
     border: none;
     color: $g-text;
-    font-size: 16px;
     cursor: pointer;
-    padding: 0 4px;
+    padding: 0;
+    border-radius: 6px;
     line-height: 1;
-    &:hover { color: $g-light; }
+
+    &:hover {
+      background: #e2e8f0;
+      color: $g-dark;
+    }
   }
 
   .search-loading {
@@ -181,7 +177,7 @@ $h-dark:    rgba(0,0,0,0.4);
 
 @keyframes spin {
   from { transform: rotate(0deg); }
-  to   { transform: rotate(360deg); }
+  to { transform: rotate(360deg); }
 }
 
 .search-dropdown {
@@ -189,14 +185,14 @@ $h-dark:    rgba(0,0,0,0.4);
   top: calc(100% + 8px);
   left: 0;
   right: 0;
-  background: #fff;
+  background: #ffffff;
   border-radius: 12px;
-  box-shadow: 0 8px 32px rgba(0,0,0,0.18);
+  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
   border: 1px solid $g-mid;
   max-height: 480px;
   overflow-y: auto;
   z-index: 2000;
-  animation: fadeIn 0.2s ease;
+  animation: fadeIn 0.16s ease;
 
   .search-results-header {
     padding: 12px 16px 8px;
@@ -226,13 +222,14 @@ $h-dark:    rgba(0,0,0,0.4);
       font-size: 11px;
       color: $g-dark;
       cursor: pointer;
-      transition: all 0.2s;
+      transition: all 0.16s;
+
+      &:hover { background: #eef2f7; }
 
-      &:hover { background: $g-mid; }
       &.active {
-        background: $g-dark;
-        color: #fff;
-        border-color: $g-dark;
+        background: #f4f4f5;
+        color: #171717;
+        border-color: #e5e5e5;
       }
     }
   }
@@ -249,7 +246,10 @@ $h-dark:    rgba(0,0,0,0.4);
     cursor: pointer;
     transition: background 0.15s;
 
-    &:hover { background: $g-light; }
+    &:hover,
+    &.active {
+      background: #f8fafc;
+    }
 
     .result-left {
       display: flex;
@@ -264,11 +264,11 @@ $h-dark:    rgba(0,0,0,0.4);
         width: 8px;
         height: 8px;
         border-radius: 50%;
-        &.critical { background: #DC2626; }
-        &.high     { background: #F59E0B; }
-        &.medium   { background: #3B82F6; }
-        &.low      { background: #10B981; }
-        &.info     { background: #9CA3AF; }
+        &.critical { background: #dc2626; }
+        &.high { background: #f59e0b; }
+        &.medium { background: #3b82f6; }
+        &.low { background: #10b981; }
+        &.info { background: #9ca3af; }
       }
     }
 
@@ -315,11 +315,11 @@ $h-dark:    rgba(0,0,0,0.4);
           padding: 1px 6px;
           border-radius: 4px;
           font-weight: 600;
-          &.critical { background: #FEE2E2; color: #DC2626; }
-          &.high     { background: #FEF3C7; color: #D97706; }
-          &.medium   { background: #DBEAFE; color: #2563EB; }
-          &.low      { background: #D1FAE5; color: #059669; }
-          &.info     { background: $g-light; color: $g-text; }
+          &.critical { background: #fee2e2; color: #dc2626; }
+          &.high { background: #fef3c7; color: #d97706; }
+          &.medium { background: #dbeafe; color: #2563eb; }
+          &.low { background: #d1fae5; color: #059669; }
+          &.info { background: $g-light; color: $g-text; }
         }
 
         .result-metric {
@@ -327,7 +327,7 @@ $h-dark:    rgba(0,0,0,0.4);
           color: $g-text;
           padding: 1px 6px;
           border-radius: 4px;
-          background: #F3F4F6;
+          background: #f3f4f6;
         }
       }
     }
@@ -366,7 +366,6 @@ $h-dark:    rgba(0,0,0,0.4);
     }
   }
 
-  // 搜索历史
   .search-history-header {
     display: flex;
     justify-content: space-between;
@@ -403,6 +402,7 @@ $h-dark:    rgba(0,0,0,0.4);
     color: $g-dark;
 
     &:hover { background: $g-light; }
+
     .history-icon {
       display: inline-flex;
       align-items: center;
@@ -416,15 +416,10 @@ $h-dark:    rgba(0,0,0,0.4);
         height: 14px;
       }
     }
-    .history-text { flex: 1; }
-  }
 
-  // 活跃结果高亮
-  .search-result-item.active {
-    background: $g-light;
+    .history-text { flex: 1; }
   }
 
-  // 底部快捷键提示
   .search-footer {
     padding: 8px 16px;
     border-top: 1px solid $g-mid;
@@ -441,24 +436,37 @@ $h-dark:    rgba(0,0,0,0.4);
 .header-right {
   display: flex;
   align-items: center;
+  justify-content: flex-end;
+  min-width: 216px;
 }
 
 .user-profile {
   position: relative;
   cursor: pointer;
+  border: 1px solid $g-mid;
+  border-radius: 8px;
+  background: #ffffff;
+  box-shadow: none;
+
+  &:hover,
+  &.active {
+    border-color: #d1d5db;
+    background: $g-light;
+    box-shadow: none;
+  }
 
   .avatar-circle {
     width: 32px;
     height: 32px;
-    border-radius: 50%;
-    background: $h-surface;
+    border-radius: 7px;
+    background: transparent;
+    border: none;
     display: flex;
     align-items: center;
     justify-content: center;
-    box-shadow:
-      2px 2px 4px $h-dark,
-      -2px -2px 4px $h-light;
-    transition: box-shadow 0.25s ease, transform 0.15s ease;
+    box-shadow: none;
+    color: #475569;
+    transition: color 0.16s ease;
 
     .icon {
       font-size: 16px;
@@ -469,7 +477,7 @@ $h-dark:    rgba(0,0,0,0.4);
         justify-content: center;
         width: 18px;
         height: 18px;
-        color: $g-light;
+        color: #475569;
 
         ::ng-deep svg {
           width: 18px;
@@ -479,17 +487,13 @@ $h-dark:    rgba(0,0,0,0.4);
     }
 
     &:hover {
-      box-shadow:
-        1px 1px 3px $h-dark,
-        -1px -1px 3px $h-light;
-      transform: translateY(-1px);
+      box-shadow: none;
+      transform: none;
     }
   }
 
   &.active .avatar-circle {
-    box-shadow:
-      inset 2px 2px 4px $h-dark,
-      inset -2px -2px 4px $h-light;
+    box-shadow: none;
   }
 }
 
@@ -498,13 +502,13 @@ $h-dark:    rgba(0,0,0,0.4);
   top: 100%;
   right: 0;
   margin-top: 8px;
-  background: #fff;
-  border-radius: 8px;
-  box-shadow: 0 4px 16px rgba(0,0,0,0.1);
+  background: #ffffff;
+  border-radius: 10px;
+  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
   border: 1px solid $g-mid;
   min-width: 150px;
   padding: 4px 0;
-  animation: fadeIn 0.25s ease;
+  animation: fadeIn 0.16s ease;
 
   .dropdown-item {
     padding: 9px 16px;
@@ -513,7 +517,7 @@ $h-dark:    rgba(0,0,0,0.4);
     gap: 8px;
     color: $g-dark;
     font-size: 13px;
-    transition: background 0.25s ease;
+    transition: background 0.16s ease;
     cursor: pointer;
 
     .icon {
@@ -536,22 +540,24 @@ $h-dark:    rgba(0,0,0,0.4);
     &:hover { background: $g-light; }
 
     &.danger {
-      color: #D32F2F;
-      &:hover { background: #FFF5F5; }
+      color: #dc2626;
+      &:hover { background: #fef2f2; }
     }
   }
 }
 
 @keyframes fadeIn {
   from { opacity: 0; transform: translateY(-6px); }
-  to   { opacity: 1; transform: translateY(0); }
+  to { opacity: 1; transform: translateY(0); }
 }
 
-// ─── Modal ───
 .modal-overlay {
   position: fixed;
-  top: 0; left: 0; right: 0; bottom: 0;
-  background: rgba(28, 28, 30, 0.5);
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(15, 23, 42, 0.4);
   display: flex;
   align-items: center;
   justify-content: center;
@@ -560,10 +566,10 @@ $h-dark:    rgba(0,0,0,0.4);
 }
 
 .modal {
-  background: #fff;
+  background: #ffffff;
   width: 400px;
   border-radius: 12px;
-  box-shadow: 0 8px 32px rgba(0,0,0,0.12);
+  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
   border: 1px solid $g-mid;
   display: flex;
   flex-direction: column;
@@ -607,14 +613,15 @@ $h-dark:    rgba(0,0,0,0.4);
         height: 36px;
         padding: 4px 12px;
         border: 1px solid $g-mid;
-        border-radius: 6px;
+        border-radius: 8px;
         box-sizing: border-box;
         font-size: 13px;
-        transition: border-color 0.3s ease;
+        transition: border-color 0.16s ease, box-shadow 0.16s ease;
 
         &:focus {
-          border-color: $g-dark;
+          border-color: #e5e5e5;
           outline: none;
+          box-shadow: 0 0 0 3px rgba(23, 23, 23, 0.2);
         }
       }
     }
@@ -633,180 +640,44 @@ $h-dark:    rgba(0,0,0,0.4);
 .btn {
   height: 34px;
   padding: 0 18px;
-  border-radius: 6px;
+  border-radius: 8px;
   border: 1px solid transparent;
   cursor: pointer;
   font-size: 13px;
   font-weight: 500;
-  transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease, transform 0.2s ease;
+  transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease;
 
   &.btn-primary {
-    background: $g-dark;
-    color: #fff;
+    background: #171717;
+    color: #fafafa;
 
     &:hover {
-      background: $g-black;
-      transform: translateY(-1px);
+      background: #262626;
     }
   }
 
   &.btn-secondary {
-    background: #fff;
+    background: #ffffff;
     border-color: $g-mid;
     color: $g-dark;
 
     &:hover {
-      border-color: $g-dark;
-      transform: translateY(-1px);
-    }
-  }
-}
-
-/* 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;
+      border-color: #d1d5db;
+      background: $g-light;
     }
   }
-
-  .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;
+    gap: 8px;
   }
 
   .header-left {
     min-width: 0;
+    flex: 0 0 auto;
+    gap: 0;
   }
 
   .brand-title {
@@ -816,22 +687,6 @@ $h-dark:    rgba(0,0,0,0.4);
     white-space: nowrap;
   }
 
-  .header-search {
-    margin: 0 8px;
-  }
-}
-
-@media (max-width: 720px) {
-  .app-header {
-    padding: 0 10px;
-    gap: 8px;
-  }
-
-  .header-left {
-    flex: 0 0 auto;
-    gap: 0;
-  }
-
   .brand-container {
     display: none;
   }
@@ -843,6 +698,7 @@ $h-dark:    rgba(0,0,0,0.4);
   }
 
   .header-right {
+    min-width: 0;
     flex: 0 0 auto;
   }
 

+ 54 - 46
src/modules/shared/components/navigation/navigation.component.scss

@@ -12,9 +12,9 @@
   width: 232px;
   flex-direction: column;
   overflow: hidden;
-  border-right: 1px solid #e2e8f0;
+  border-right: 1px solid #e5e5e5;
   background: #ffffff;
-  color: #334155;
+  color: #525252;
   transition: width 180ms ease, transform 180ms ease;
 
   &.collapsed {
@@ -39,6 +39,15 @@
     .nav-actions {
       flex-direction: column;
     }
+
+    .nav-section-label {
+      display: none;
+    }
+
+    .nav-item-wrapper.has-section {
+      margin-top: 8px;
+      padding-top: 0;
+    }
   }
 }
 
@@ -51,7 +60,7 @@
   overflow-y: auto;
   padding: 12px 8px 18px;
   scrollbar-width: thin;
-  scrollbar-color: #cbd5e1 transparent;
+  scrollbar-color: #d4d4d4 transparent;
 }
 
 .nav-item-wrapper {
@@ -59,11 +68,24 @@
   flex-direction: column;
 }
 
-.nav-item-wrapper:nth-child(3),
-.nav-item-wrapper:nth-child(6) {
+.nav-item-wrapper.has-section {
   margin-top: 10px;
-  padding-top: 10px;
-  border-top: 1px solid #eef2f6;
+  padding-top: 4px;
+}
+
+.nav-item-wrapper:first-child.has-section {
+  margin-top: 0;
+  padding-top: 0;
+}
+
+.nav-section-label {
+  padding: 8px 10px 6px;
+  color: #737373;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  line-height: 1;
+  text-transform: none;
 }
 
 .nav-item {
@@ -76,9 +98,9 @@
   padding: 0 10px;
   overflow: hidden;
   border: 1px solid transparent;
-  border-radius: 6px;
+  border-radius: 8px;
   background: transparent;
-  color: #475569;
+  color: #525252;
   cursor: pointer;
   font: inherit;
   text-align: left;
@@ -87,31 +109,20 @@
   transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
 
   &:hover {
-    background: #f8fafc;
-    color: #0f172a;
+    background: #f4f4f5;
+    color: #171717;
   }
 
   &:focus-visible {
-    outline: 2px solid rgba(37, 99, 235, 0.35);
-    outline-offset: 1px;
+    outline: none;
+    box-shadow: 0 0 0 3px rgba(23, 23, 23, 0.2);
   }
 
   &.active {
-    border-color: #dbeafe;
-    background: #eff6ff;
-    color: #1d4ed8;
-    font-weight: 700;
-
-    &::before {
-      position: absolute;
-      top: 9px;
-      bottom: 9px;
-      left: 0;
-      width: 3px;
-      border-radius: 0 3px 3px 0;
-      background: #2563eb;
-      content: '';
-    }
+    border-color: transparent;
+    background: #ececec;
+    color: #171717;
+    font-weight: 600;
   }
 }
 
@@ -135,7 +146,7 @@
   width: 14px;
   height: 14px;
   flex: 0 0 auto;
-  color: #94a3b8;
+  color: #a3a3a3;
 }
 
 .sub-menu {
@@ -143,22 +154,19 @@
   gap: 1px;
   margin: 2px 0 6px 17px;
   padding-left: 10px;
-  border-left: 1px solid #e2e8f0;
+  border-left: 1px solid #e5e5e5;
 
   .nav-item {
     min-height: 34px;
     padding: 0 9px;
-    color: #64748b;
+    color: #737373;
   }
 
   .nav-item.active {
     border-color: transparent;
-    background: #f1f5f9;
-    color: #1e40af;
-
-    &::before {
-      display: none;
-    }
+    background: #ececec;
+    color: #171717;
+    font-weight: 600;
   }
 
   .nav-icon {
@@ -175,7 +183,7 @@
 .sidebar-footer {
   flex: 0 0 auto;
   padding: 10px 12px;
-  border-top: 1px solid #eef2f6;
+  border-top: 1px solid #e5e5e5;
   background: #ffffff;
 }
 
@@ -190,21 +198,21 @@
   width: 36px;
   height: 36px;
   place-items: center;
-  border: 1px solid #e2e8f0;
-  border-radius: 6px;
+  border: 1px solid #e5e5e5;
+  border-radius: 8px;
   background: #ffffff;
-  color: #64748b;
+  color: #737373;
   cursor: pointer;
 
   &:hover {
-    border-color: #cbd5e1;
-    background: #f8fafc;
-    color: #0f172a;
+    border-color: #d4d4d4;
+    background: #f4f4f5;
+    color: #171717;
   }
 
   &:focus-visible {
-    outline: 2px solid rgba(37, 99, 235, 0.35);
-    outline-offset: 1px;
+    outline: none;
+    box-shadow: 0 0 0 3px rgba(23, 23, 23, 0.2);
   }
 }
 

+ 7 - 1
src/modules/shared/components/navigation/navigation.component.ts

@@ -38,6 +38,7 @@ interface NavItem {
   requiresAuth?: boolean;
   children?: NavItem[];
   expanded?: boolean;
+  section?: string;
 }
 
 @Component({
@@ -49,7 +50,8 @@ interface NavItem {
       <div class="sidebar-menu">
         <ng-container *ngFor="let item of navItems">
           <!-- Parent Item -->
-          <div class="nav-item-wrapper" [class.has-children]="!!item.children">
+          <div class="nav-item-wrapper" [class.has-children]="!!item.children" [class.has-section]="!!item.section">
+            <div class="nav-section-label" *ngIf="item.section && !isCollapsed">{{ item.section }}</div>
             <button
               *ngIf="item.children; else leafItem"
               type="button"
@@ -128,6 +130,7 @@ export class NavigationComponent implements OnDestroy {
       displayName: '工作台',
       icon: LayoutDashboard,
       requiresAuth: false,
+      section: '工作',
     },
     {
       path: '/feedback-inbox',
@@ -141,6 +144,7 @@ export class NavigationComponent implements OnDestroy {
       icon: Radar,
       requiresAuth: false,
       expanded: true,
+      section: '洞察',
       children: [
         { path: '/voc-insight/ai', displayName: 'AI 洞察', icon: BrainCircuit, requiresAuth: false },
         { path: '/voc-insight/topics', displayName: '主题探索', icon: MessageSquareText, requiresAuth: false },
@@ -157,6 +161,7 @@ export class NavigationComponent implements OnDestroy {
       icon: Kanban,
       requiresAuth: false,
       expanded: false,
+      section: '行动',
       children: [
         { path: '/action-suggestion/kanban', displayName: '行动工作台', icon: ListChecks, requiresAuth: false },
         { path: '/domestic/risk-alerts', displayName: '风险预警', icon: ShieldAlert, requiresAuth: false },
@@ -180,6 +185,7 @@ export class NavigationComponent implements OnDestroy {
       icon: Database,
       requiresAuth: false,
       expanded: false,
+      section: '数据',
       children: [
         { path: '/data-center', displayName: '数据中台', icon: Database, requiresAuth: false },
         { path: '/knowledge-center', displayName: '商品知识库', icon: BookOpen, requiresAuth: false },

+ 60 - 62
src/modules/shared/components/neu-button/neu-button.component.scss

@@ -1,7 +1,4 @@
-// ═══════════════════════════════════════
-// Neumorphic / Liquid Glass — 按钮组件样式
-// ═══════════════════════════════════════
-@use '../../styles/neumorphic' as neu;
+@use '../../styles/tokens' as t;
 
 :host { display: inline-block; }
 
@@ -10,118 +7,119 @@
   align-items: center;
   justify-content: center;
   gap: 6px;
-  border: none;
+  min-height: 36px;
+  border: 1px solid t.$color-border;
   cursor: pointer;
   font-family: inherit;
-  color: neu.$text;
-  @include neu.raised;
+  font-weight: 600;
+  color: t.$color-text-secondary;
+  background: t.$color-surface;
+  box-shadow: none;
+  border-radius: t.$radius-md;
+  transition: background t.$transition-fast, border-color t.$transition-fast, color t.$transition-fast, box-shadow t.$transition-fast;
+
+  &:focus-visible {
+    @include t.focus-ring;
+  }
 
-  // ─── 尺寸 ───
   &.neu-sm {
+    min-height: 32px;
     padding: 6px 12px;
     font-size: 12px;
     border-radius: 8px;
-    @include neu.raised(8px, 4px, 2px);
   }
 
   &.neu-md {
-    padding: 9px 18px;
+    min-height: 36px;
+    padding: 8px 14px;
     font-size: 13px;
-    border-radius: neu.$radius;
   }
 
   &.neu-lg {
-    padding: 12px 24px;
+    min-height: 40px;
+    padding: 10px 18px;
     font-size: 15px;
-    border-radius: 12px;
-    @include neu.raised(12px, 8px, 4px);
+    border-radius: t.$radius-card;
   }
 
-  // ─── 变体: icon ───
   &.neu-icon {
     width: 36px;
     height: 36px;
+    min-height: 36px;
     padding: 0;
-    border-radius: neu.$radius;
+    border-radius: t.$radius-md;
 
-    &.neu-sm { width: 28px; height: 28px; border-radius: 8px; }
-    &.neu-lg { width: 44px; height: 44px; border-radius: 12px; }
+    &.neu-sm { width: 28px; height: 28px; min-height: 28px; border-radius: 8px; }
+    &.neu-lg { width: 44px; height: 44px; min-height: 44px; border-radius: t.$radius-card; }
   }
 
-  // ─── 变体: card(更大圆角、更柔和阴影)───
   &.neu-card {
     padding: 16px 20px;
-    @include neu.raised(14px, 10px, 5px);
     text-align: left;
     flex-direction: column;
     align-items: flex-start;
+    border-radius: t.$radius-card;
   }
 
-  // ─── 颜色: primary(蓝色调玻璃)───
-  &.neu-primary {
-    background: linear-gradient(145deg, #5b9bd5, #3a7bc8);
-    color: #fff;
-    box-shadow:
-      3px 3px 6px rgba(#2a5a94, 0.4),
-      -3px -3px 6px rgba(#7ec0ff, 0.25),
-      inset 0 1px 1px rgba(#fff, 0.2);
-    font-weight: 500;
-
-    &.neu-sm {
-      box-shadow:
-        2px 2px 4px rgba(#2a5a94, 0.4),
-        -2px -2px 4px rgba(#7ec0ff, 0.25),
-        inset 0 1px 1px rgba(#fff, 0.2);
-    }
+  &.neu-raised:not(.neu-primary),
+  &.neu-icon:not(.neu-primary) {
+    background: t.$color-surface;
+    border-color: t.$color-border;
+    color: t.$color-text;
+  }
+
+  &.neu-primary,
+  &.neu-raised.neu-primary {
+    background: t.$color-primary;
+    border-color: t.$color-primary;
+    color: t.$color-primary-foreground;
+    box-shadow: none;
 
     &:hover:not(.neu-disabled) {
-      background: linear-gradient(145deg, #6aabe5, #4a8bd8);
-      box-shadow:
-        2px 2px 4px rgba(#2a5a94, 0.5),
-        -2px -2px 4px rgba(#7ec0ff, 0.3),
-        inset 0 1px 1px rgba(#fff, 0.25);
-      color: #fff;
+      background: t.$color-primary-hover;
+      border-color: t.$color-primary-hover;
+      color: t.$color-primary-foreground;
+      box-shadow: none;
     }
 
     &:active:not(.neu-disabled) {
-      background: linear-gradient(145deg, #3570b5, #4a85c8);
-      box-shadow:
-        inset 2px 2px 5px rgba(#1e4a7a, 0.5),
-        inset -2px -2px 5px rgba(#7ec0ff, 0.15);
-      transform: scale(0.97);
-      color: rgba(#fff, 0.9);
+      background: t.$color-primary-hover;
+      box-shadow: none;
+      transform: none;
+      color: t.$color-primary-foreground;
     }
 
     &.neu-disabled {
-      background: linear-gradient(145deg, #9cbdd9, #8aaec8);
-      box-shadow:
-        1px 1px 3px rgba(#2a5a94, 0.2),
-        -1px -1px 3px rgba(#7ec0ff, 0.15);
+      background: #a3a3a3;
+      border-color: #a3a3a3;
+      color: #fafafa;
+      box-shadow: none;
     }
   }
 
-  // ─── 交互态(default 颜色)───
   &:not(.neu-primary):hover:not(.neu-disabled) {
-    @include neu.raised-hover;
-    color: neu.$active;
+    background: t.$color-accent;
+    border-color: t.$color-border;
+    color: t.$color-text;
+    box-shadow: none;
   }
 
   &:not(.neu-primary):active:not(.neu-disabled) {
-    @include neu.pressed;
-    transform: scale(0.97);
+    transform: none;
+    box-shadow: none;
   }
 
-  // ─── 选中态(持久凹陷)───
   &.neu-active {
-    @include neu.pressed;
-    color: neu.$active;
+    background: t.$color-accent;
+    border-color: t.$color-border;
+    color: t.$color-text;
     font-weight: 600;
+    box-shadow: none;
   }
 
-  // ─── 禁用态 ───
   &.neu-disabled {
     opacity: 0.5;
     cursor: not-allowed;
-    @include neu.raised(neu.$radius, 3px, 1px);
+    box-shadow: none;
   }
 }

+ 46 - 0
src/modules/shared/components/page-breadcrumb/page-breadcrumb.component.scss

@@ -0,0 +1,46 @@
+@use '../../styles/tokens' as t;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.page-breadcrumb {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  color: t.$color-text-subtle;
+  font-size: 12px;
+  line-height: 1.4;
+}
+
+.crumb-link {
+  color: t.$color-blue;
+  text-decoration: none;
+  transition: color t.$transition-fast, text-decoration-color t.$transition-fast;
+
+  &:hover {
+    color: t.$color-blue;
+    text-decoration: underline;
+  }
+}
+
+.crumb-text,
+.crumb-current {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.crumb-current {
+  color: t.$color-text-secondary;
+  font-weight: 600;
+}
+
+.crumb-sep {
+  color: t.$color-text-subtle;
+  font-weight: 400;
+}

+ 35 - 0
src/modules/shared/components/page-breadcrumb/page-breadcrumb.component.ts

@@ -0,0 +1,35 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { RouterModule } from '@angular/router';
+
+export interface PageBreadcrumbItem {
+  label: string;
+  link?: string | any[];
+}
+
+@Component({
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  selector: 'app-page-breadcrumb',
+  standalone: true,
+  imports: [CommonModule, RouterModule],
+  template: `
+    <nav class="page-breadcrumb" aria-label="面包屑">
+      @for (item of items; track $index; let last = $last) {
+        @if (!last && item.link) {
+          <a class="crumb-link" [routerLink]="item.link" queryParamsHandling="preserve">{{ item.label }}</a>
+        } @else if (!last) {
+          <span class="crumb-text">{{ item.label }}</span>
+        } @else {
+          <span class="crumb-current">{{ item.label }}</span>
+        }
+        @if (!last) {
+          <span class="crumb-sep" aria-hidden="true">/</span>
+        }
+      }
+    </nav>
+  `,
+  styleUrls: ['./page-breadcrumb.component.scss'],
+})
+export class PageBreadcrumbComponent {
+  @Input() items: PageBreadcrumbItem[] = [];
+}

+ 50 - 15
src/modules/shared/components/page-header/page-header.component.scss

@@ -10,6 +10,7 @@
   display: flex;
   align-items: flex-start;
   justify-content: space-between;
+  flex-wrap: wrap;
   gap: 10px;
 
   &.variant-neu,
@@ -37,6 +38,10 @@
     grid-template-columns: 1fr auto auto;
     align-items: flex-start;
 
+    .header-nav-row {
+      grid-column: 1 / -1;
+    }
+
     .header-aside {
       grid-column: 3;
     }
@@ -45,7 +50,7 @@
   &.layout-hero {
     flex-direction: column;
     padding: 28px 30px;
-    background: linear-gradient(135deg, rgba(t.$color-blue, 0.06), t.$color-surface 60%);
+    background: t.$color-accent;
 
     h1 {
       font-size: 28px;
@@ -60,6 +65,35 @@
   }
 }
 
+.header-nav-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 10px 16px;
+  width: 100%;
+  min-width: 0;
+}
+
+.header-back {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  color: t.$color-text-muted;
+  font-size: 12px;
+  font-weight: 600;
+  text-decoration: none;
+  transition: color t.$transition-fast;
+
+  &::before {
+    content: '←';
+    font-weight: 500;
+  }
+
+  &:hover {
+    color: t.$color-blue;
+  }
+}
+
 .header-main {
   display: flex;
   align-items: flex-start;
@@ -71,14 +105,15 @@
   display: inline-flex;
   align-items: center;
   justify-content: center;
-  width: 48px;
-  height: 48px;
+  width: 40px;
+  height: 40px;
   flex: 0 0 auto;
-  border-radius: 16px;
-  background: t.$color-surface-muted;
-  box-shadow: t.$shadow-neu-inset;
-  color: t.$color-primary;
-  font-size: 22px;
+  border-radius: 10px;
+  background: t.$color-accent;
+  border: 1px solid t.$color-border;
+  box-shadow: none;
+  color: t.$color-text;
+  font-size: 18px;
 }
 
 .header-copy {
@@ -160,8 +195,8 @@
 h1 {
   margin: 0;
   color: t.$color-text;
-  font-size: 24px;
-  font-weight: 800;
+  font-size: 22px;
+  font-weight: 700;
   line-height: 1.25;
 }
 
@@ -243,14 +278,14 @@ p {
   }
 
   .header-icon {
-    width: 40px;
-    height: 40px;
-    border-radius: 14px;
-    font-size: 18px;
+    width: 36px;
+    height: 36px;
+    border-radius: 10px;
+    font-size: 16px;
   }
 
   h1 {
-    font-size: 22px;
+    font-size: 20px;
   }
 
   p {

+ 18 - 1
src/modules/shared/components/page-header/page-header.component.ts

@@ -1,5 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
+import { RouterModule } from '@angular/router';
+import { PageBreadcrumbComponent, PageBreadcrumbItem } from '../page-breadcrumb/page-breadcrumb.component';
 
 export interface PageHeaderBadge {
   label: string;
@@ -11,17 +13,29 @@ export interface PageHeaderMetaItem {
   value: string | number;
 }
 
+export type PageHeaderCrumb = PageBreadcrumbItem;
+
 @Component({
   changeDetection: ChangeDetectionStrategy.OnPush,
   selector: 'app-page-header',
   standalone: true,
-  imports: [CommonModule],
+  imports: [CommonModule, RouterModule, PageBreadcrumbComponent],
   template: `
     <header
       class="shared-page-header"
       [ngClass]="['layout-' + layout, 'variant-' + variant]"
       [class.compact]="density === 'compact' || layout === 'compact'"
     >
+      @if (crumbs.length || (backLabel && backRoute)) {
+        <div class="header-nav-row">
+          @if (backLabel && backRoute) {
+            <a class="header-back" [routerLink]="backRoute" queryParamsHandling="preserve">{{ backLabel }}</a>
+          }
+          @if (crumbs.length) {
+            <app-page-breadcrumb [items]="crumbs"></app-page-breadcrumb>
+          }
+        </div>
+      }
       <div class="header-main">
         @if (icon) {
           <span class="header-icon" aria-hidden="true">{{ icon }}</span>
@@ -77,6 +91,9 @@ export class PageHeaderComponent {
   @Input() lastUpdated = '';
   @Input() sampleSize: string | number | null = null;
   @Input() confidence: 'high' | 'medium' | 'low' | null = null;
+  @Input() crumbs: PageHeaderCrumb[] = [];
+  @Input() backLabel = '';
+  @Input() backRoute: string | any[] | null = null;
 
   get confidenceLabel(): string {
     switch (this.confidence) {

+ 87 - 0
src/modules/shared/components/page-toolbar/page-toolbar.component.scss

@@ -0,0 +1,87 @@
+@use '../../styles/tokens' as t;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.page-toolbar {
+  display: flex;
+  align-items: stretch;
+  min-width: 0;
+  min-height: 44px;
+  overflow: hidden;
+  background: t.$color-surface;
+  border: 1px solid t.$color-border;
+  border-radius: t.$radius-card;
+}
+
+.page-toolbar-fields {
+  display: flex;
+  flex: 1;
+  min-width: 0;
+}
+
+.page-toolbar-field {
+  display: grid;
+  align-content: center;
+  min-width: 140px;
+  padding: 6px 14px;
+  border-right: 1px solid t.$color-border;
+}
+
+.page-toolbar-label {
+  color: t.$color-text-muted;
+  font-size: 10px;
+  font-weight: 700;
+  line-height: 1.2;
+}
+
+.page-toolbar-value {
+  margin-top: 3px;
+  color: t.$color-text;
+  font-size: 12px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  white-space: nowrap;
+}
+
+.page-toolbar-actions {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 4px;
+  margin-left: auto;
+  padding: 6px;
+}
+
+.page-toolbar-fields:has(+ .page-toolbar-actions:empty) .page-toolbar-field:last-child {
+  border-right: none;
+}
+
+.page-toolbar-actions:empty {
+  display: none;
+}
+
+@media (max-width: 720px) {
+  .page-toolbar {
+    flex-wrap: wrap;
+  }
+
+  .page-toolbar-fields {
+    flex-wrap: wrap;
+    width: 100%;
+  }
+
+  .page-toolbar-field {
+    flex: 1 1 40%;
+    min-width: 0;
+  }
+
+  .page-toolbar-actions {
+    width: 100%;
+    margin-left: 0;
+    border-top: 1px solid t.$color-border;
+    justify-content: flex-end;
+  }
+}

+ 41 - 0
src/modules/shared/components/page-toolbar/page-toolbar.component.ts

@@ -0,0 +1,41 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+export interface PageToolbarField {
+  label: string;
+  value: string;
+}
+
+@Component({
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  selector: 'app-page-toolbar',
+  standalone: true,
+  imports: [CommonModule],
+  template: `
+    <div class="page-toolbar" role="group">
+      <div class="page-toolbar-fields">
+        @for (field of fields; track $index) {
+          <div class="page-toolbar-field">
+            <span class="page-toolbar-label">{{ field.label }}</span>
+            <strong class="page-toolbar-value">{{ displayValue(field.value) }}</strong>
+          </div>
+        }
+      </div>
+      <div class="page-toolbar-actions">
+        <ng-content select="[pageToolbarActions]"></ng-content>
+      </div>
+    </div>
+  `,
+  styleUrls: ['./page-toolbar.component.scss']
+})
+export class PageToolbarComponent {
+  @Input() fields: PageToolbarField[] = [];
+
+  displayValue(value: string | null | undefined): string {
+    if (value == null) {
+      return '—';
+    }
+    const trimmed = String(value).trim();
+    return !trimmed || trimmed === '—' ? '—' : trimmed;
+  }
+}

+ 29 - 0
src/modules/shared/components/sparkline/sparkline.component.scss

@@ -0,0 +1,29 @@
+@use '../../styles/tokens' as t;
+
+:host {
+  display: block;
+  width: 100%;
+  line-height: 0;
+}
+
+.sparkline-svg {
+  display: block;
+  width: 100%;
+
+  polyline {
+    fill: none;
+    stroke: t.$color-primary;
+    stroke-width: 1.5;
+    stroke-linecap: round;
+    stroke-linejoin: round;
+    vector-effect: non-scaling-stroke;
+  }
+
+  &.tone-good polyline {
+    stroke: t.$color-green;
+  }
+
+  &.tone-bad polyline {
+    stroke: t.$color-red;
+  }
+}

+ 101 - 0
src/modules/shared/components/sparkline/sparkline.component.ts

@@ -0,0 +1,101 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+export type SparklineTone = 'default' | 'good' | 'bad';
+
+@Component({
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  selector: 'app-sparkline',
+  standalone: true,
+  imports: [CommonModule],
+  template: `
+    <svg
+      class="sparkline-svg"
+      [ngClass]="'tone-' + tone"
+      [attr.viewBox]="viewBox"
+      preserveAspectRatio="none"
+      [attr.height]="resolvedHeight"
+      [style.height.px]="resolvedHeight"
+      aria-hidden="true"
+      focusable="false"
+    >
+      @if (polylinePoints) {
+        <polyline [attr.points]="polylinePoints"></polyline>
+      }
+    </svg>
+  `,
+  styleUrls: ['./sparkline.component.scss']
+})
+export class SparklineComponent {
+  @Input() points: string | number[] = '';
+  @Input() tone: SparklineTone = 'default';
+  @Input() height = 28;
+
+  get resolvedHeight(): number {
+    const height = Number(this.height);
+    return Number.isFinite(height) && height > 0 ? height : 28;
+  }
+
+  get polylinePoints(): string {
+    if (typeof this.points === 'string') {
+      return this.points.trim();
+    }
+    return this.fromNumbers(Array.isArray(this.points) ? this.points : []);
+  }
+
+  get viewBox(): string {
+    if (typeof this.points === 'string') {
+      const pairs = this.parsePairs(this.points);
+      if (!pairs.length) {
+        return `0 0 100 ${this.resolvedHeight}`;
+      }
+      const maxX = Math.max(0, ...pairs.map((pair) => pair[0]));
+      const maxY = Math.max(0, ...pairs.map((pair) => pair[1]));
+      if (maxX <= 100 && maxY <= 30) {
+        return '0 0 100 30';
+      }
+      return `0 0 ${Math.max(maxX, 1)} ${Math.max(maxY, 1)}`;
+    }
+    return `0 0 100 ${this.resolvedHeight}`;
+  }
+
+  private fromNumbers(values: number[]): string {
+    const nums = values.map((value) => Number(value)).filter((value) => Number.isFinite(value));
+    if (!nums.length) {
+      return '';
+    }
+
+    const width = 100;
+    const height = this.resolvedHeight;
+    const padding = 2;
+    const innerWidth = Math.max(width - padding * 2, 1);
+    const innerHeight = Math.max(height - padding * 2, 1);
+    const min = Math.min(...nums);
+    const max = Math.max(...nums);
+    const span = max - min;
+    const yFor = (value: number) => (span === 0 ? height / 2 : padding + innerHeight * (1 - (value - min) / span));
+
+    if (nums.length === 1) {
+      const y = yFor(nums[0]).toFixed(1);
+      return `${padding},${y} ${width - padding},${y}`;
+    }
+
+    const step = innerWidth / (nums.length - 1);
+    return nums
+      .map((value, index) => `${(padding + index * step).toFixed(1)},${yFor(value).toFixed(1)}`)
+      .join(' ');
+  }
+
+  private parsePairs(points: string): Array<[number, number]> {
+    const parts = points
+      .trim()
+      .split(/[\s,]+/)
+      .map((part) => Number(part))
+      .filter((part) => Number.isFinite(part));
+    const pairs: Array<[number, number]> = [];
+    for (let i = 0; i + 1 < parts.length; i += 2) {
+      pairs.push([parts[i], parts[i + 1]]);
+    }
+    return pairs;
+  }
+}

+ 15 - 2
src/modules/shared/components/summary-metric-card/summary-metric-card.component.scss

@@ -94,9 +94,22 @@
   &.clickable {
     cursor: pointer;
 
+    &::after {
+      content: '›';
+      position: absolute;
+      top: 16px;
+      right: 14px;
+      color: #94a3b8;
+      font-size: 18px;
+      line-height: 1;
+      font-weight: 500;
+    }
+
     &:hover {
-      transform: translateY(-2px);
-      box-shadow: t.$shadow-float;
+      transform: translateY(-1px);
+      background: t.$color-accent;
+      border-color: t.$color-border;
+      box-shadow: t.$shadow-elev-1;
     }
 
     &:focus-visible {

+ 30 - 35
src/modules/shared/styles/_board-tabs.scss

@@ -1,75 +1,70 @@
-// ═══════════════════════════════════════
-// Board Tabs — 拟物质感子页面导航栏
-// 与左侧导航栏风格统一,使用 Neumorphic 凸起/凹陷按钮效果
-// 用法: @use '../../shared/styles/board-tabs';
-// ═══════════════════════════════════════
-@use 'neumorphic' as neu;
+@use 'tokens' as t;
 
-// ─── Tab Bar Container ───
 .board-tabs-bar {
   display: flex;
   align-items: center;
-  gap: 6px;
-  padding: 10px 24px;
-  background: neu.$bg;
+  gap: 2px;
+  padding: 0;
+  background: transparent;
   flex-shrink: 0;
-  border-bottom: none;
+  border-bottom: 1px solid t.$color-border;
 }
 
-// ─── Tab Button(拟物凸起按钮)───
 .board-tab {
   display: flex;
   align-items: center;
   gap: 6px;
-  padding: 9px 18px;
+  padding: 10px 14px;
   font-size: 13px;
   font-weight: 500;
-  color: neu.$text;
+  color: t.$color-text-muted;
   text-decoration: none;
   white-space: nowrap;
   cursor: pointer;
   position: relative;
   border: none;
+  background: transparent;
+  box-shadow: none;
+  border-radius: 0;
+  transition: color t.$transition-fast, background t.$transition-fast;
 
-  // 凸起态
-  @include neu.raised(neu.$radius, 5px, 2px);
-
-  // 底部选中指示条
   &::after {
     content: '';
     position: absolute;
-    bottom: 0;
-    left: 50%;
-    transform: translateX(-50%) scaleX(0);
-    width: 60%;
+    bottom: -1px;
+    left: 12px;
+    right: 12px;
     height: 2px;
-    background: neu.$active;
+    background: t.$color-primary;
     border-radius: 2px 2px 0 0;
-    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    transform: scaleX(0);
+    transform-origin: center;
+    transition: transform t.$duration-page t.$ease-out;
   }
 
   &:hover {
-    @include neu.raised-hover(3px, 1px);
-    color: neu.$active;
+    color: t.$color-text;
+    background: transparent;
+    box-shadow: none;
   }
 
   &:active {
-    @include neu.pressed;
-    transform: scale(0.97);
+    transform: none;
+    box-shadow: none;
   }
 
   &.active {
-    @include neu.pressed;
-    color: neu.$active;
+    color: t.$color-primary;
     font-weight: 600;
+    background: transparent;
+    box-shadow: none;
 
     &::after {
-      transform: translateX(-50%) scaleX(1);
+      transform: scaleX(1);
     }
   }
 }
 
-// ─── Icon & Label ───
 .tab-icon {
   font-size: 14px;
   line-height: 1;
@@ -81,7 +76,7 @@
     width: 18px;
     height: 18px;
     opacity: 0.7;
-    transition: opacity 0.25s ease;
+    transition: opacity 0.2s ease;
 
     ::ng-deep svg {
       width: 18px;
@@ -101,10 +96,10 @@
 
 .tab-sub {
   font-size: 10px;
-  color: rgba(neu.$text, 0.55);
+  color: t.$color-text-subtle;
   font-weight: 400;
 
   .board-tab.active & {
-    color: rgba(neu.$active, 0.7);
+    color: rgba(t.$color-primary, 0.72);
   }
 }

+ 20 - 10
src/modules/shared/styles/_button.scss

@@ -1,37 +1,47 @@
 @use 'tokens' as t;
 
-@mixin soft-button($variant: default, $radius: t.$radius-lg) {
+@mixin soft-button($variant: default, $radius: t.$radius-md) {
   display: inline-flex;
   align-items: center;
   justify-content: center;
   gap: 6px;
   min-height: 34px;
   padding: 8px 14px;
-  border: 0;
+  border: 1px solid transparent;
   border-radius: $radius;
   cursor: pointer;
   font: inherit;
   font-size: 13px;
   font-weight: 600;
-  transition: transform t.$transition-fast, box-shadow t.$transition-fast, background t.$transition-fast, color t.$transition-fast;
+  box-shadow: none;
+  transition: transform t.$transition-fast, box-shadow t.$transition-fast, background t.$transition-fast, color t.$transition-fast, border-color t.$transition-fast;
 
   @if $variant == primary {
-    background: linear-gradient(145deg, t.$color-primary, t.$color-primary-hover);
-    color: #fff;
-    box-shadow: 4px 4px 10px rgba(17, 24, 39, 0.18), -4px -4px 10px rgba(255, 255, 255, 0.78);
+    background: t.$color-primary;
+    color: t.$color-primary-foreground;
+    border-color: t.$color-primary;
   } @else {
-    background: linear-gradient(145deg, #ffffff, #f1f5f9);
+    background: t.$color-surface;
     color: t.$color-text-secondary;
-    box-shadow: t.$shadow-neu-soft;
+    border-color: t.$color-border;
   }
 
   &:hover:not(:disabled) {
     transform: translateY(-1px);
+
+    @if $variant == primary {
+      background: t.$color-primary-hover;
+      border-color: t.$color-primary-hover;
+    } @else {
+      border-color: t.$color-border;
+      color: t.$color-text;
+      background: t.$color-accent;
+    }
   }
 
   &:active:not(:disabled) {
     transform: translateY(0);
-    box-shadow: t.$shadow-neu-inset;
+    box-shadow: none;
   }
 
   &:focus-visible {
@@ -45,7 +55,7 @@
 }
 
 @mixin icon-button($size: 34px) {
-  @include soft-button(default, t.$radius-lg);
+  @include soft-button(default, t.$radius-md);
   width: $size;
   height: $size;
   min-height: $size;

+ 7 - 5
src/modules/shared/styles/_card.scss

@@ -52,11 +52,12 @@
   border-radius: t.$radius-card;
   box-shadow: t.$shadow-card-flat;
   cursor: pointer;
-  transition: transform t.$transition-fast, box-shadow t.$transition-fast, border-color t.$transition-fast;
+  transition: transform t.$transition-fast, box-shadow t.$transition-fast, border-color t.$transition-fast, background t.$transition-fast;
 
   &:hover {
-    transform: translateY(-2px);
-    box-shadow: t.$shadow-float;
+    transform: translateY(-1px);
+    background: t.$color-accent;
+    box-shadow: t.$shadow-elev-1;
     border-color: t.$color-border;
   }
 
@@ -102,8 +103,9 @@
 
   &:hover {
     transform: translateY(-1px);
-    border-color: rgba($accent, 0.2);
-    box-shadow: t.$shadow-float;
+    background: t.$color-accent;
+    border-color: t.$color-border;
+    box-shadow: t.$shadow-elev-1;
   }
 }
 

+ 6 - 5
src/modules/shared/styles/_state.scss

@@ -1,7 +1,7 @@
 @use 'tokens' as t;
 
 @mixin state-shell($padding: 28px) {
-  @include t.soft-neu-surface(t.$radius-xl);
+  @include t.soft-neu-surface(t.$radius-card);
   display: flex;
   flex-direction: column;
   align-items: center;
@@ -19,10 +19,11 @@
   width: $size;
   height: $size;
   border-radius: 50%;
-  background: t.$color-surface-muted;
-  box-shadow: t.$shadow-neu-inset;
-  color: t.$color-text-secondary;
-  font-size: calc($size * 0.42);
+  background: t.$color-accent;
+  border: 1px solid t.$color-border;
+  box-shadow: none;
+  color: t.$color-text-muted;
+  font-size: calc($size * 0.38);
 }
 
 @mixin skeleton-line($height: 12px, $radius: 999px) {

+ 30 - 27
src/modules/shared/styles/_tokens.scss

@@ -1,15 +1,17 @@
-$color-bg: #f6f8fb !default;
-$color-bg-soft: #f1f5f9 !default;
+$color-bg: #fafafa !default;
+$color-bg-soft: #f4f4f5 !default;
 $color-surface: #ffffff !default;
-$color-surface-muted: #f8fafc !default;
-$color-border: #e5e7eb !default;
-$color-border-soft: #edf1f7 !default;
-$color-text: #111827 !default;
-$color-text-secondary: #4b5563 !default;
-$color-text-muted: #6b7280 !default;
-$color-text-subtle: #9ca3af !default;
-$color-primary: #1f2937 !default;
-$color-primary-hover: #111827 !default;
+$color-surface-muted: #f4f4f5 !default;
+$color-accent: #f4f4f5 !default;
+$color-border: #e5e5e5 !default;
+$color-border-soft: #e5e5e5 !default;
+$color-text: #171717 !default;
+$color-text-secondary: #525252 !default;
+$color-text-muted: #737373 !default;
+$color-text-subtle: #a3a3a3 !default;
+$color-primary: #171717 !default;
+$color-primary-hover: #262626 !default;
+$color-primary-foreground: #fafafa !default;
 $color-blue: #2563eb !default;
 $color-green: #059669 !default;
 $color-amber: #d97706 !default;
@@ -17,21 +19,22 @@ $color-red: #dc2626 !default;
 $color-purple: #7c3aed !default;
 $radius-sm: 6px !default;
 $radius-md: 8px !default;
-$radius-lg: 12px !default;
+$radius-lg: 10px !default;
 $radius-xl: 16px !default;
 $radius-2xl: 20px !default;
-// 排版规范:卡片圆角(与 $radius-md 同值,语义化 token)
-$radius-card: 8px !default;
+$radius-card: 10px !default;
 $shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04) !default;
-$shadow-float: 0 16px 40px rgba(15, 23, 42, 0.14) !default;
+$shadow-float: 0 8px 24px rgba(15, 23, 42, 0.08) !default;
 $shadow-neu: 8px 8px 18px rgba(163, 177, 198, 0.22), -8px -8px 18px rgba(255, 255, 255, 0.88) !default;
 $shadow-neu-soft: 5px 5px 13px rgba(163, 177, 198, 0.18), -5px -5px 13px rgba(255, 255, 255, 0.78) !default;
 $shadow-neu-inset: inset 4px 4px 10px rgba(163, 177, 198, 0.18), inset -4px -4px 10px rgba(255, 255, 255, 0.88) !default;
-// 排版规范:卡片平实浅阴影(替代 neumorphic 立体阴影)
-$shadow-card-flat: 0 1px 3px rgba(0, 0, 0, 0.1) !default;
+$shadow-card-flat: 0 1px 2px rgba(15, 23, 42, 0.04) !default;
+$shadow-elev-1: 0 1px 2px rgba(15, 23, 42, 0.04) !default;
+$shadow-elev-2: 0 4px 14px rgba(15, 23, 42, 0.06) !default;
 $transition-fast: 160ms ease !default;
+$ease-out: cubic-bezier(0.16, 1, 0.3, 1) !default;
+$duration-page: 180ms !default;
 $font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif !default;
-// 排版规范:指标数值字重
 $font-weight-metric: 600 !default;
 
 @function tone-color($tone) {
@@ -58,21 +61,21 @@ $font-weight-metric: 600 !default;
   @return $color-text-secondary;
 }
 
-@mixin focus-ring($color: $color-blue) {
+@mixin focus-ring($color: $color-text) {
   outline: none;
-  box-shadow: 0 0 0 3px rgba($color, 0.18);
+  box-shadow: 0 0 0 3px rgba(23, 23, 23, 0.2);
 }
 
-@mixin soft-neu-surface($radius: $radius-xl, $shadow: $shadow-neu-soft) {
-  background: linear-gradient(145deg, #ffffff, #f6f8fc);
-  border: 1px solid rgba(#ffffff, 0.78);
+@mixin soft-neu-surface($radius: $radius-card, $shadow: $shadow-card-flat) {
+  background: $color-surface;
+  border: 1px solid $color-border;
   border-radius: $radius;
-  box-shadow: $shadow;
+  box-shadow: $shadow-card-flat;
 }
 
-@mixin soft-neu-inset($radius: $radius-lg) {
+@mixin soft-neu-inset($radius: $radius-md) {
   background: $color-surface-muted;
-  border: 1px solid rgba($color-border, 0.86);
+  border: 1px solid $color-border;
   border-radius: $radius;
-  box-shadow: $shadow-neu-inset;
+  box-shadow: none;
 }

+ 4 - 1
src/modules/system/pages/ai-prompt-management/ai-prompt-management.component.html

@@ -2,13 +2,16 @@
   <app-page-header
     title="AI提示词管理"
     eyebrow="System / AI Prompt"
-    description="只管理当前国内电商版本已接通的 AI 分析、多轮追问和数据边界配置;未迁移的跨境模块不进入生效范围。">
+    description="只管理当前国内电商版本已接通的 AI 分析、多轮追问和数据边界配置;未迁移的跨境模块不进入生效范围。"
+    [crumbs]="crumbs">
     <div pageHeaderActions class="header-actions">
       <button class="btn secondary" (click)="load()" [disabled]="loading">刷新</button>
       <button class="btn primary" (click)="save()" [disabled]="!draft || saving">{{ saving ? '保存中...' : '保存配置' }}</button>
     </div>
   </app-page-header>
 
+  <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
+
   <section class="output-style-panel model-policy-panel">
     <div class="output-style-copy">
       <span class="eyebrow">Model Policy</span>

+ 12 - 2
src/modules/system/pages/ai-prompt-management/ai-prompt-management.component.ts

@@ -2,7 +2,8 @@ import { CommonModule } from '@angular/common';
 import { Component, OnDestroy, OnInit } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { Subscription } from 'rxjs';
-import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../../../shared/components/page-toolbar/page-toolbar.component';
 import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
 import { MarkdownPipe } from '../../../shared/pipes/markdown.pipe';
 import { AiVisualReportComponent } from '../../../shared/components/ai-visual-report/ai-visual-report.component';
@@ -14,11 +15,20 @@ import { AiOutputStyleKey, AiOutputStyleTargetKey, AiPromptConfigService, AiProm
 @Component({
   selector: 'app-ai-prompt-management',
   standalone: true,
-  imports: [CommonModule, FormsModule, PageHeaderComponent, MsqDropdownComponent, MarkdownPipe, AiVisualReportComponent],
+  imports: [CommonModule, FormsModule, PageHeaderComponent, PageToolbarComponent, MsqDropdownComponent, MarkdownPipe, AiVisualReportComponent],
   templateUrl: './ai-prompt-management.component.html',
   styleUrls: ['./ai-prompt-management.component.scss']
 })
 export class AiPromptManagementComponent implements OnInit, OnDestroy {
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '系统' },
+    { label: 'AI 提示词' },
+  ];
+  readonly toolbarFields: PageToolbarField[] = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
   prompts: AiPromptDefinition[] = [];
   selectedKey = '';
   draft: AiPromptDefinition | null = null;

+ 9 - 1
src/modules/system/pages/api-management/api-management.component.html

@@ -3,11 +3,19 @@
     title="API 管理"
     eyebrow="System / API"
     description="查看国内 VOC 本地后端的 AI 网关配置与实时连通状态。"
-    icon="◇">
+    icon="◇"
+    [crumbs]="crumbs">
     <app-neu-button pageHeaderActions size="sm" (btnClick)="loadStatus()" [disabled]="loading">刷新状态</app-neu-button>
     <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="openPromptSettings()">AI 提示词</app-neu-button>
   </app-page-header>
 
+  <app-page-toolbar [fields]="toolbarFields">
+    <div pageToolbarActions>
+      <app-neu-button size="sm" (btnClick)="loadStatus()" [disabled]="loading">刷新状态</app-neu-button>
+      <app-neu-button size="sm" color="primary" (btnClick)="openPromptSettings()">AI 提示词</app-neu-button>
+    </div>
+  </app-page-toolbar>
+
   <div class="api-metric-grid">
     <app-summary-metric-card
       label="AI 服务"

+ 12 - 2
src/modules/system/pages/api-management/api-management.component.ts

@@ -5,14 +5,15 @@ import { finalize } from 'rxjs';
 import { AiGatewayService, AiGatewayStatus, AiGatewayTestResult } from '../../../../app/core/services/ai-gateway.service';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
 import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
-import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
 import { AiPromptConfigService } from '../../../shared/services/ai-prompt-config.service';
 
 @Component({
   selector: 'app-api-management',
   standalone: true,
-  imports: [CommonModule, PageHeaderComponent, ContentCardComponent, NeuButtonComponent, SummaryMetricCardComponent],
+  imports: [CommonModule, PageHeaderComponent, PageToolbarComponent, ContentCardComponent, NeuButtonComponent, SummaryMetricCardComponent],
   templateUrl: './api-management.component.html',
   styleUrls: ['./api-management.component.scss'],
 })
@@ -26,6 +27,15 @@ export class ApiManagementComponent implements OnInit {
   loading = false;
   testing = false;
   error = '';
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '系统' },
+    { label: 'API 管理' },
+  ];
+  readonly toolbarFields: PageToolbarField[] = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
 
   ngOnInit(): void {
     this.loadStatus();

+ 7 - 2
src/modules/system/pages/system-overview/system-overview.component.ts

@@ -1,7 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { Component } from '@angular/core';
 import { Router } from '@angular/router';
-import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../../shared/components/page-header/page-header.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
 
 interface SystemEntry {
@@ -30,7 +30,8 @@ interface SystemCategory {
         title="系统管理"
         eyebrow="System"
         description="集中管理组织结构、系统参数与第三方集成,按类别进入对应配置页。"
-        icon="⚙">
+        icon="⚙"
+        [crumbs]="crumbs">
       </app-page-header>
 
       <!-- 3 类入口卡 -->
@@ -150,6 +151,10 @@ interface SystemCategory {
   `]
 })
 export class SystemOverviewComponent {
+  readonly crumbs: PageHeaderCrumb[] = [
+    { label: '系统' },
+    { label: '系统管理' },
+  ];
   readonly categories: SystemCategory[] = [
     {
       key: 'organization',

+ 24 - 39
src/modules/validation-center/validation-center.component.html

@@ -1,22 +1,15 @@
 <main class="validation-center">
-  <header class="command-bar">
-    <div class="command-title">
-      <div class="title-line">
-        <lucide-icon [img]="icons.clipboard" [size]="21"></lucide-icon>
-        <h1>效果验证中心</h1>
-      </div>
-      <div class="context-line">
-        <span>工作空间 {{ workspaceId }}</span>
-        <span>平台 {{ platform }}</span>
-        <span>经营周期 {{ snapshotPeriod }}</span>
-        <span>只读准备度</span>
-      </div>
-    </div>
-    <button class="secondary-button" type="button" [disabled]="loading" (click)="refresh()">
+  <app-page-header
+    title="效果验证中心"
+    [crumbs]="crumbs">
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields">
+    <button pageToolbarActions class="secondary-button" type="button" [disabled]="loading" (click)="refresh()">
       <lucide-icon [img]="icons.refresh" [size]="15" [class.spinning]="loading"></lucide-icon>
       刷新
     </button>
-  </header>
+  </app-page-toolbar>
 
   <section class="boundary-band">
     <div class="boundary-main">
@@ -103,19 +96,17 @@
         <p>读取行动项、来源分析和国内电商经营快照。</p>
       </div>
     } @else if (errorMessage) {
-      <div class="state-panel error">
-        <lucide-icon [img]="icons.alert" [size]="24"></lucide-icon>
-        <strong>验证数据加载失败</strong>
-        <p>{{ errorMessage }}</p>
-        <button class="secondary-button compact" type="button" (click)="refresh()">重新加载</button>
-      </div>
+      <app-error-state
+        title="验证数据加载失败"
+        [description]="errorMessage"
+        (retry)="refresh()">
+      </app-error-state>
     } @else if (!items.length) {
       <div class="empty-layout">
-        <div class="state-panel">
-          <lucide-icon [img]="icons.clipboard" [size]="26"></lucide-icon>
-          <strong>暂无可验证行动</strong>
-          <p>当前工作空间的行动接口返回 0 条记录。行动创建后会按真实 validationMetric 和来源证据进入准备度队列。</p>
-        </div>
+        <app-empty-state
+          title="暂无可验证行动"
+          description="当前工作空间的行动接口返回 0 条记录。行动创建后会按真实 validationMetric 和来源证据进入准备度队列。">
+        </app-empty-state>
         <div class="readiness-guide">
           <h2>进入正式验证需要的字段</h2>
           <div class="guide-grid">
@@ -127,11 +118,10 @@
         </div>
       </div>
     } @else if (!filteredItems.length) {
-      <div class="state-panel">
-        <lucide-icon [img]="icons.search" [size]="24"></lucide-icon>
-        <strong>没有匹配结果</strong>
-        <p>调整验证阶段或搜索条件。</p>
-      </div>
+      <app-empty-state
+        title="没有匹配结果"
+        description="调整验证阶段或搜索条件。">
+      </app-empty-state>
     } @else {
       <div class="table-scroll">
         <table>
@@ -149,7 +139,7 @@
           </thead>
           <tbody>
             @for (item of filteredItems; track item.id) {
-              <tr (dblclick)="openDetail(item)">
+              <tr class="is-clickable" (click)="openDetail(item)">
                 <td class="action-cell">
                   <strong>{{ item.action.title }}</strong>
                   <span>{{ item.action.productKey || '工作空间级行动' }}</span>
@@ -164,7 +154,8 @@
                 <td class="missing-fields">{{ item.missingLabels.length ? item.missingLabels.join('、') : '无' }}</td>
                 <td>{{ formatDateTime(item.action.updatedAt) }}</td>
                 <td class="row-action-cell">
-                  <button type="button" aria-label="查看验证详情" title="查看详情" (click)="openDetail(item)">
+                  <button type="button" class="row-open" aria-label="进入验证详情" title="进入验证详情" (click)="openDetail(item); $event.stopPropagation()">
+                    <span>进入</span>
                     <lucide-icon [img]="icons.arrow" [size]="16"></lucide-icon>
                   </button>
                 </td>
@@ -176,9 +167,3 @@
     }
   </section>
 </main>
-
-@if (selectedItem; as detail) {
-  <div class="detail-backdrop" (mousedown)="$event.target === $event.currentTarget && closeDetail()">
-    <app-validation-detail [item]="detail" (dismissed)="closeDetail()"></app-validation-detail>
-  </div>
-}

+ 7 - 1
src/modules/validation-center/validation-center.component.scss

@@ -366,6 +366,10 @@ td {
   line-height: 1.4;
 }
 
+tbody tr.is-clickable {
+  cursor: pointer;
+}
+
 tbody tr:hover {
   background: #fcfcfd;
 }
@@ -487,10 +491,12 @@ td small {
 
 .row-action-cell button {
   display: inline-flex;
-  width: 30px;
+  min-width: 30px;
   height: 30px;
   align-items: center;
   justify-content: center;
+  gap: 4px;
+  padding: 0 8px;
   border: 1px solid #d0d5dd;
   border-radius: 5px;
   background: #fff;

+ 18 - 9
src/modules/validation-center/validation-center.component.ts

@@ -22,12 +22,16 @@ import {
   Target,
   X,
 } from 'lucide-angular';
+import { Router } from '@angular/router';
 import { catchError, forkJoin, map, of, skip, take } from 'rxjs';
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../shared/components/page-header/page-header.component';
+import { PageToolbarComponent, PageToolbarField } from '../shared/components/page-toolbar/page-toolbar.component';
 import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
 import { DomesticDataset } from '../../app/core/models/domestic.models';
 import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
 import { SaasActionItem, SaasAnalysisRun, SaasPlatformService } from '../../app/core/services/saas-platform.service';
-import { ValidationDetailComponent } from './validation-detail.component';
 import {
   VALIDATION_STAGE_META,
   ValidationStage,
@@ -49,7 +53,7 @@ interface ValidationLoadResult {
 @Component({
   selector: 'app-validation-center',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule, ValidationDetailComponent],
+  imports: [CommonModule, FormsModule, LucideAngularModule, PageHeaderComponent, PageToolbarComponent, EmptyStateComponent, ErrorStateComponent],
   templateUrl: './validation-center.component.html',
   styleUrl: './validation-center.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -57,11 +61,13 @@ interface ValidationLoadResult {
 export class ValidationCenterComponent implements OnInit {
   private readonly platformService = inject(SaasPlatformService);
   private readonly datasetService = inject(DomesticDatasetService);
+  private readonly router = inject(Router);
   private readonly cdr = inject(ChangeDetectorRef);
   private readonly destroyRef = inject(DestroyRef);
 
   readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
   readonly platform = RUNTIME_CONFIG.domesticPlatform.toUpperCase();
+  readonly crumbs: PageHeaderCrumb[] = [{ label: '验证中心', link: '/validation' }];
   readonly icons = {
     activity: Activity,
     alert: AlertCircle,
@@ -92,7 +98,6 @@ export class ValidationCenterComponent implements OnInit {
   analyses: SaasAnalysisRun[] = [];
   dataset?: DomesticDataset;
   warnings: string[] = [];
-  selectedItem: ValidationViewItem | null = null;
   stageFilter: StageFilter = 'all';
   searchTerm = '';
   loading = true;
@@ -141,6 +146,15 @@ export class ValidationCenterComponent implements OnInit {
     return range?.start && range?.end ? `${range.start} 至 ${range.end}` : '快照周期未知';
   }
 
+  get toolbarFields(): PageToolbarField[] {
+    return [
+      { label: '周期', value: this.snapshotPeriod },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   stageCount(stage: StageFilter): number {
     return stage === 'all' ? this.items.length : this.items.filter((item) => item.stage === stage).length;
   }
@@ -154,11 +168,7 @@ export class ValidationCenterComponent implements OnInit {
   }
 
   openDetail(item: ValidationViewItem): void {
-    this.selectedItem = item;
-  }
-
-  closeDetail(): void {
-    this.selectedItem = null;
+    void this.router.navigate(['/validation', item.id], { queryParamsHandling: 'preserve' });
   }
 
   refresh(): void {
@@ -195,7 +205,6 @@ export class ValidationCenterComponent implements OnInit {
     this.loading = true;
     this.errorMessage = '';
     this.warnings = [];
-    this.closeDetail();
     const datasetSource$ = refreshDataset && this.dataset
       ? this.datasetService.dataset$.pipe(skip(1))
       : this.datasetService.dataset$;

+ 19 - 0
src/modules/validation-center/validation-center.data.ts

@@ -0,0 +1,19 @@
+import { EMPTY, Observable, expand, map, of, takeLast } from 'rxjs';
+import { CursorPage } from '../../app/core/services/saas-platform.service';
+
+export function findCursorPageItem<T extends { id: string }>(
+  fetch: (cursor: string) => Observable<CursorPage<T>>,
+  id: string,
+): Observable<T | null> {
+  const target = id.trim();
+  if (!target) return of(null);
+  return fetch('').pipe(
+    expand((page) => (
+      page.items.some((item) => item.id === target) || !page.nextCursor
+        ? EMPTY
+        : fetch(page.nextCursor)
+    )),
+    takeLast(1),
+    map((page) => page.items.find((item) => item.id === target) ?? null),
+  );
+}

+ 2 - 0
src/modules/validation-center/validation-center.models.ts

@@ -17,6 +17,8 @@ export interface PersistedValidationFields {
   currentValue?: number | null;
   conclusion?: string | null;
   verifiedAt?: string | null;
+  slaHours?: number | null;
+  assignee?: string | null;
 }
 
 export type ValidationSourceAction = SaasActionItem & PersistedValidationFields;

+ 12 - 5
src/modules/validation-center/validation-detail.component.html

@@ -1,13 +1,20 @@
-<aside class="detail-shell" role="dialog" aria-modal="true" aria-labelledby="validation-detail-title">
+<section
+  class="detail-shell"
+  [attr.role]="isDrawer ? 'dialog' : null"
+  [attr.aria-modal]="isDrawer ? true : null"
+  aria-labelledby="validation-detail-title"
+>
   <header class="detail-header">
     <div class="detail-title">
       <span class="stage-badge" [attr.data-stage]="item.stage">{{ item.stageLabel }}</span>
       <h2 id="validation-detail-title">{{ item.action.title }}</h2>
       <p>{{ item.action.description || '行动项未提供补充说明。' }}</p>
     </div>
-    <button class="icon-button" type="button" aria-label="关闭验证详情" title="关闭" (click)="close()">
-      <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
-    </button>
+    @if (isDrawer) {
+      <button class="icon-button" type="button" aria-label="关闭验证详情" title="关闭" (click)="close()">
+        <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
+      </button>
+    }
   </header>
 
   <div class="detail-scroll">
@@ -141,4 +148,4 @@
       }
     </section>
   </div>
-</aside>
+</section>

+ 8 - 0
src/modules/validation-center/validation-detail.component.scss

@@ -13,6 +13,14 @@
   box-shadow: -12px 0 32px rgb(16 24 40 / 14%);
 }
 
+:host.is-page .detail-shell {
+  width: 100%;
+  max-height: none;
+  border: 1px solid #e5e5e5;
+  border-radius: 10px;
+  box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+}
+
 .detail-header {
   display: flex;
   flex: 0 0 auto;

+ 8 - 0
src/modules/validation-center/validation-detail.component.ts

@@ -24,11 +24,19 @@ import { ValidationMetricDirection, ValidationViewItem } from './validation-cent
   templateUrl: './validation-detail.component.html',
   styleUrl: './validation-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
+  host: {
+    '[class.is-page]': 'variant === "page"',
+  },
 })
 export class ValidationDetailComponent {
   @Input({ required: true }) item!: ValidationViewItem;
+  @Input() variant: 'drawer' | 'page' = 'drawer';
   @Output() readonly dismissed = new EventEmitter<void>();
 
+  get isDrawer(): boolean {
+    return this.variant !== 'page';
+  }
+
   readonly icons = {
     activity: Activity,
     arrowDown: ArrowDown,

+ 36 - 0
src/modules/validation-center/validation-page.component.html

@@ -0,0 +1,36 @@
+<main class="entity-page">
+  <app-page-header
+    [title]="pageTitle"
+    backLabel="返回验证中心"
+    [backRoute]="backRoute"
+    [crumbs]="crumbs"
+    [badges]="badges"
+    [meta]="headerMeta"
+  ></app-page-header>
+
+  @if (loading) {
+    <app-loading-spinner text="正在加载验证详情" subtext="读取行动、来源分析与经营快照。"></app-loading-spinner>
+  } @else if (errorMessage) {
+    <app-error-state
+      title="验证数据加载失败"
+      [description]="errorMessage"
+      [showBack]="true"
+      (retry)="reload()"
+      (back)="goList()"
+    ></app-error-state>
+  } @else if (!item) {
+    <app-empty-state
+      title="未找到该验证行动"
+      description="刷新后仍可能因记录不存在或尚未同步而无法打开。"
+      actionText="返回验证中心"
+      (action)="goList()"
+    ></app-empty-state>
+  } @else {
+    <div class="entity-layout">
+      <app-validation-detail [item]="item" variant="page"></app-validation-detail>
+      <app-content-card title="属性" density="compact" headingLevel="h3">
+        <app-entity-property-list [items]="propertyItems"></app-entity-property-list>
+      </app-content-card>
+    </div>
+  }
+</main>

+ 25 - 0
src/modules/validation-center/validation-page.component.scss

@@ -0,0 +1,25 @@
+@use '../shared/styles/tokens' as t;
+
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.entity-page {
+  padding: 8px 20px 48px;
+  color: t.$color-text;
+}
+
+.entity-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(240px, 280px);
+  gap: 20px;
+  align-items: start;
+  margin-top: 16px;
+}
+
+@media (max-width: 960px) {
+  .entity-layout {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}

+ 210 - 0
src/modules/validation-center/validation-page.component.ts

@@ -0,0 +1,210 @@
+import { CommonModule } from '@angular/common';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit, inject } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, Router } from '@angular/router';
+import { catchError, forkJoin, map, of, switchMap, take } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import { SaasPlatformService } from '../../app/core/services/saas-platform.service';
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
+import { EntityPropertyItem, EntityPropertyListComponent } from '../shared/components/entity-property-list/entity-property-list.component';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
+import { PageHeaderBadge, PageHeaderComponent, PageHeaderCrumb, PageHeaderMetaItem } from '../shared/components/page-header/page-header.component';
+import { findCursorPageItem } from './validation-center.data';
+import { ValidationViewItem, buildValidationViewItem } from './validation-center.models';
+import { ValidationDetailComponent } from './validation-detail.component';
+
+@Component({
+  selector: 'app-validation-page',
+  standalone: true,
+  imports: [
+    CommonModule,
+    PageHeaderComponent,
+    ValidationDetailComponent,
+    EntityPropertyListComponent,
+    ContentCardComponent,
+    EmptyStateComponent,
+    ErrorStateComponent,
+    LoadingSpinnerComponent,
+  ],
+  templateUrl: './validation-page.component.html',
+  styleUrl: './validation-page.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ValidationPageComponent implements OnInit {
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly platformService = inject(SaasPlatformService);
+  private readonly datasetService = inject(DomesticDatasetService);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroyRef = inject(DestroyRef);
+
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  readonly backRoute = ['/validation'];
+
+  entityId = '';
+  item: ValidationViewItem | null = null;
+  loading = true;
+  errorMessage = '';
+  private requestVersion = 0;
+
+  ngOnInit(): void {
+    this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+      this.entityId = params.get('id')?.trim() ?? '';
+      this.load();
+    });
+  }
+
+  get pageTitle(): string {
+    return this.item?.action.title || '验证详情';
+  }
+
+  get crumbs(): PageHeaderCrumb[] {
+    return [
+      { label: '验证中心', link: '/validation' },
+      { label: this.item?.action.title || '详情' },
+    ];
+  }
+
+  get badges(): PageHeaderBadge[] {
+    if (!this.item) return [];
+    const toneMap: Record<ValidationViewItem['stage'], NonNullable<PageHeaderBadge['tone']>> = {
+      baseline_pending: 'amber',
+      observing: 'blue',
+      decision_ready: 'purple',
+      verified: 'green',
+    };
+    const tone = toneMap[this.item.stage];
+    return [{ label: this.item.stageLabel, tone }];
+  }
+
+  get headerMeta(): PageHeaderMetaItem[] {
+    if (!this.item) return [];
+    return [
+      { label: '阶段', value: this.item.stageLabel },
+      {
+        label: '指标',
+        value: this.item.metric.available ? this.item.metric.formattedValue : this.item.metric.unavailableReason,
+      },
+    ];
+  }
+
+  get propertyItems(): EntityPropertyItem[] {
+    const item = this.item;
+    if (!item) return [];
+    const assignee = item.action.assigneeUserId?.trim() || item.action.assignee?.trim() || null;
+    return [
+      { label: '验证阶段', value: item.stageLabel },
+      { label: '行动状态', value: this.actionStatusLabel(item.action.status) },
+      {
+        label: '指标',
+        value: item.metric.available ? item.metric.formattedValue : item.metric.unavailableReason,
+      },
+      { label: '证据', value: item.evidenceIds.length ? `${item.evidenceIds.length} 条` : null },
+      { label: '来源分析', value: item.analysis?.id ?? null },
+      {
+        label: '负责人',
+        value: assignee,
+        reserved: true,
+        hint: assignee ? undefined : '有数据后再出现',
+      },
+      {
+        label: '到期',
+        value: this.formatOptionalDate(item.action.dueAt),
+        reserved: true,
+        hint: item.action.dueAt ? undefined : '有数据后再出现',
+      },
+      {
+        label: 'SLA',
+        value: Number.isFinite(item.action.slaHours) ? item.action.slaHours : null,
+        reserved: true,
+        hint: Number.isFinite(item.action.slaHours) ? undefined : '有数据后再出现',
+      },
+    ];
+  }
+
+  reload(): void {
+    this.load();
+  }
+
+  goList(): void {
+    void this.router.navigate(this.backRoute, { queryParamsHandling: 'preserve' });
+  }
+
+  private load(): void {
+    const version = ++this.requestVersion;
+    this.loading = true;
+    this.errorMessage = '';
+    this.item = null;
+    this.cdr.markForCheck();
+
+    if (!this.entityId) {
+      this.loading = false;
+      this.cdr.markForCheck();
+      return;
+    }
+
+    findCursorPageItem((cursor) => this.platformService.actions(this.workspaceId, '', cursor, 100), this.entityId).pipe(
+      switchMap((action) => {
+        if (!action) return of({ item: null, error: '' });
+        const analysisId = action.sourceAnalysisId?.trim() ?? '';
+        return forkJoin({
+          analyses: analysisId
+            ? findCursorPageItem((cursor) => this.platformService.analyses(this.workspaceId, '', cursor, 100), analysisId).pipe(
+              map((analysis) => analysis ? [analysis] : []),
+            )
+            : of([]),
+          dataset: this.datasetService.dataset$.pipe(take(1)),
+        }).pipe(
+          map(({ analyses, dataset }) => ({
+            item: buildValidationViewItem(action, analyses, dataset),
+            error: '',
+          })),
+        );
+      }),
+      catchError((error: unknown) => of({ item: null, error: this.errorText(error) })),
+      takeUntilDestroyed(this.destroyRef),
+    ).subscribe(({ item, error }) => {
+      if (version !== this.requestVersion) return;
+      this.loading = false;
+      this.item = item;
+      this.errorMessage = error;
+      this.cdr.markForCheck();
+    });
+  }
+
+  private actionStatusLabel(status: ValidationViewItem['action']['status']): string {
+    return {
+      open: '待处理',
+      planned: '已计划',
+      in_progress: '进行中',
+      blocked: '阻塞',
+      completed: '已完成',
+      cancelled: '已取消',
+    }[status];
+  }
+
+  private formatOptionalDate(value: string | null | undefined): string | null {
+    if (!value?.trim()) return null;
+    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',
+    }).format(date);
+  }
+
+  private errorText(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 '服务不可达';
+      return `HTTP ${error.status}`;
+    }
+    return error instanceof Error ? error.message : '验证详情加载失败';
+  }
+}

+ 10 - 2
src/modules/voc-insight/pages/feature-satisfaction.component.ts

@@ -7,6 +7,7 @@ import { combineLatest, Subject, takeUntil } from 'rxjs';
 import { AiChatModalComponent } from '../../shared/components/ai-chat-modal/ai-chat-modal.component';
 import { VocInsightRailComponent } from '../shared/components/voc-insight-rail/voc-insight-rail.component';
 import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
@@ -25,7 +26,7 @@ type FeaturePerformanceGroup = { category: string; rank: number; avgSatisfaction
 @Component({
   selector: 'app-voc-feature-satisfaction',
   standalone: true,
-  imports: [CommonModule, FormsModule, NgxEchartsModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, SummaryMetricCardComponent, AlertCardComponent, ContentCardComponent, MsqDropdownComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent],
+  imports: [CommonModule, FormsModule, NgxEchartsModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, AlertCardComponent, ContentCardComponent, MsqDropdownComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent],
   template: `
     <div class="voc-page-shell">
       <app-voc-insight-rail
@@ -45,9 +46,11 @@ type FeaturePerformanceGroup = { category: string; rank: number; avgSatisfaction
         icon="◎"
         [badges]="[{ label: '本品范围', tone: 'blue' }, { label: data?.summary?.reviewSampleCount ? '本品评论已接入' : '本品评论待接入', tone: data?.summary?.reviewSampleCount ? 'green' : 'amber' }]"
         [sampleSize]="data?.summary?.reviewSampleCount || 0"
-        [confidence]="data?.summary?.reviewSampleCount ? 'medium' : 'low'">
+        [confidence]="data?.summary?.reviewSampleCount ? 'medium' : 'low'"
+        [crumbs]="[{ label: '洞察中心' }, { label: '功能满足度', link: '/voc-insight/feature' }]">
         <button pageHeaderActions class="action-btn primary" (click)="openAiChat()" [disabled]="!data?.summary?.reviewSampleCount">✦ AI 功能分析</button>
       </app-page-header>
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
       <ng-container *ngIf="!loading && !error && data">
         <!-- 概览 -->
@@ -555,6 +558,11 @@ type FeaturePerformanceGroup = { category: string; rank: number; avgSatisfaction
 })
 export class VocFeatureSatisfactionComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
+  readonly toolbarFields = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
   private filteredConflictsCache: {
     data: FeatureSatisfactionData | null;
     filter: string;

+ 10 - 2
src/modules/voc-insight/pages/pain-point-insight.component.ts

@@ -4,6 +4,7 @@ import { combineLatest, Subject, Subscription, takeUntil } from 'rxjs';
 import { AiChatModalComponent } from '../../shared/components/ai-chat-modal/ai-chat-modal.component';
 import { VocInsightRailComponent } from '../shared/components/voc-insight-rail/voc-insight-rail.component';
 import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
@@ -26,7 +27,7 @@ type PainPointTabKey = 'overview' | 'explicit' | 'implicit' | 'weight' | 'correl
 @Component({
   selector: 'app-voc-pain-point-insight',
   standalone: true,
-  imports: [CommonModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, DataTableShellComponent, AlertCardComponent, SplitPanelComponent, MarkdownPipe, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent],
+  imports: [CommonModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, ContentCardComponent, DataTableShellComponent, AlertCardComponent, SplitPanelComponent, MarkdownPipe, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent],
   template: `
     <div class="voc-page-shell">
       <app-voc-insight-rail
@@ -46,9 +47,11 @@ type PainPointTabKey = 'overview' | 'explicit' | 'implicit' | 'weight' | 'correl
         icon="◇"
         [badges]="[{ label: '本品范围', tone: 'blue' }, { label: data?.summary?.reviewSampleCount ? '本品评论已接入' : '本品评论待接入', tone: data?.summary?.reviewSampleCount ? 'green' : 'amber' }]"
         [sampleSize]="data?.summary?.reviewSampleCount || 0"
-        [confidence]="data?.summary?.reviewSampleCount ? 'medium' : 'low'">
+        [confidence]="data?.summary?.reviewSampleCount ? 'medium' : 'low'"
+        [crumbs]="[{ label: '洞察中心' }, { label: '痛点深度洞察', link: '/voc-insight/pain-point' }]">
         <button pageHeaderActions class="action-btn primary" (click)="openAiChat()" [disabled]="!data?.summary?.reviewSampleCount">✦ AI 痛点分析</button>
       </app-page-header>
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
       <ng-container *ngIf="!loading && !error && data">
         <div class="summary-grid">
@@ -789,6 +792,11 @@ type PainPointTabKey = 'overview' | 'explicit' | 'implicit' | 'weight' | 'correl
 })
 export class VocPainPointInsightComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
+  readonly toolbarFields = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
   private aiSub: Subscription | null = null;
   private pageTabsCacheKey = '';
   private pageTabsCache: BoardTabItem[] = [];

+ 10 - 2
src/modules/voc-insight/pages/scenario-dashboard.component.ts

@@ -4,6 +4,7 @@ import { combineLatest, Subject, takeUntil } from 'rxjs';
 import { AiChatModalComponent } from '../../shared/components/ai-chat-modal/ai-chat-modal.component';
 import { VocInsightRailComponent } from '../shared/components/voc-insight-rail/voc-insight-rail.component';
 import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
@@ -32,7 +33,7 @@ interface ScenarioEvidenceRow {
 @Component({
   selector: 'app-voc-scenario-dashboard',
   standalone: true,
-  imports: [CommonModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, SummaryMetricCardComponent, AlertCardComponent, ContentCardComponent, DataTableShellComponent, SplitPanelComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent],
+  imports: [CommonModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, AlertCardComponent, ContentCardComponent, DataTableShellComponent, SplitPanelComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent],
   template: `
     <div class="voc-page-shell">
       <app-voc-insight-rail
@@ -52,9 +53,11 @@ interface ScenarioEvidenceRow {
         icon="◌"
         [badges]="[{ label: '本品范围', tone: 'blue' }, { label: data?.summary?.reviewSampleCount ? '本品评论已接入' : '本品评论待接入', tone: data?.summary?.reviewSampleCount ? 'green' : 'amber' }]"
         [sampleSize]="data?.summary?.reviewSampleCount || 0"
-        [confidence]="data?.summary?.reviewSampleCount ? 'medium' : 'low'">
+        [confidence]="data?.summary?.reviewSampleCount ? 'medium' : 'low'"
+        [crumbs]="[{ label: '洞察中心' }, { label: '使用场景', link: '/voc-insight/scenario' }]">
         <button pageHeaderActions class="action-btn primary" (click)="openAiChat()" [disabled]="!data?.summary?.reviewSampleCount">✦ AI 场景洞察</button>
       </app-page-header>
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
       <ng-container *ngIf="!loading && !error && data">
         <!-- 概览 -->
@@ -796,6 +799,11 @@ interface ScenarioEvidenceRow {
 })
 export class VocScenarioDashboardComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
+  readonly toolbarFields = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
   private pageTabsCacheKey = '';
   private pageTabsCache: BoardTabItem[] = [];
   private sortedBySatisfactionCacheData: ScenarioDashboardData | null = null;

+ 10 - 2
src/modules/voc-insight/pages/voice-classification.component.ts

@@ -6,6 +6,7 @@ import { BoardTabItem } from '../../shared/components/board-tabs/board-tabs.comp
 import { AiChatModalComponent } from '../../shared/components/ai-chat-modal/ai-chat-modal.component';
 import { VocInsightRailComponent } from '../shared/components/voc-insight-rail/voc-insight-rail.component';
 import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
 import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
 import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
@@ -39,7 +40,7 @@ type ReviewAnnotationDraft = {
 @Component({
   selector: 'app-voc-voice-classification',
   standalone: true,
-  imports: [CommonModule, FormsModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, SummaryMetricCardComponent, ReviewCardComponent, ReviewDetailModalComponent, AllReviewsModalComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent, ContentCardComponent, ProductIdentityComponent, MsqDropdownComponent],
+  imports: [CommonModule, FormsModule, AiChatModalComponent, VocInsightRailComponent, PageHeaderComponent, PageToolbarComponent, SummaryMetricCardComponent, ReviewCardComponent, ReviewDetailModalComponent, AllReviewsModalComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent, ContentCardComponent, ProductIdentityComponent, MsqDropdownComponent],
   template: `
     <div class="voc-page-shell">
       <app-voc-insight-rail
@@ -59,9 +60,11 @@ type ReviewAnnotationDraft = {
         icon="◍"
         [badges]="[{ label: '本品范围', tone: 'blue' }, { label: data?.summary?.totalReviews ? '本品评论已接入' : '本品评论待接入', tone: data?.summary?.totalReviews ? 'green' : 'amber' }]"
         [sampleSize]="data?.summary?.totalReviews || 0"
-        [confidence]="data?.summary?.totalReviews ? 'medium' : 'low'">
+        [confidence]="data?.summary?.totalReviews ? 'medium' : 'low'"
+        [crumbs]="[{ label: '洞察中心' }, { label: '用户声音分类', link: '/voc-insight/voice' }]">
         <button pageHeaderActions class="action-btn primary" (click)="openAiChat()" [disabled]="!data?.summary?.totalReviews">✦ AI 声音洞察</button>
       </app-page-header>
+      <app-page-toolbar [fields]="toolbarFields"></app-page-toolbar>
 
       <app-loading-spinner *ngIf="loading" text="正在加载用户声音数据..."></app-loading-spinner>
       <app-error-state *ngIf="!loading && error" [title]="error" [showRetry]="true" (retry)="loadData()"></app-error-state>
@@ -1002,6 +1005,11 @@ type ReviewAnnotationDraft = {
 })
 export class VocVoiceClassificationComponent implements OnInit, OnDestroy {
   private destroy$ = new Subject<void>();
+  readonly toolbarFields = [
+    { label: '店铺', value: '—' },
+    { label: '类目', value: '—' },
+    { label: '负责人', value: '—' },
+  ];
   loading = false;
   error = '';
   data: VoiceClassificationData | null = null;

+ 19 - 1
src/modules/voc-insight/topic-explorer/topic-detail.component.html

@@ -1,5 +1,16 @@
+<app-page-header
+  *ngIf="!isEmbedded"
+  [title]="headerTitle"
+  [description]="topic?.description || ''"
+  backLabel="返回主题探索"
+  [backRoute]="backRoute"
+  [crumbs]="crumbs">
+</app-page-header>
+
 <section class="topic-detail" *ngIf="topic as item; else detailState">
-  <header class="detail-header">
+  <div class="entity-layout" [class.is-embedded]="isEmbedded">
+    <div class="entity-main">
+  <header class="detail-header" *ngIf="isEmbedded">
     <div>
       <div class="detail-title-row">
         <h2>{{ item.label }}</h2>
@@ -76,6 +87,13 @@
     </div>
     <ng-template #noEvidence><div class="inline-empty">当前主题没有可跳转的原始评价证据。</div></ng-template>
   </section>
+    </div>
+
+    <aside class="topic-properties">
+      <h3>属性</h3>
+      <app-entity-property-list [items]="propertyItems"></app-entity-property-list>
+    </aside>
+  </div>
 </section>
 
 <ng-template #detailState>

+ 29 - 0
src/modules/voc-insight/topic-explorer/topic-detail.component.scss

@@ -14,6 +14,35 @@ button {
   background: #ffffff;
 }
 
+.entity-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(220px, 260px);
+  gap: 20px;
+  align-items: start;
+}
+
+.entity-layout.is-embedded {
+  grid-template-columns: minmax(0, 1fr);
+}
+
+.topic-properties {
+  padding: 16px 18px;
+  border: 1px solid #e4e7ec;
+  border-radius: 10px;
+  background: #fff;
+}
+
+.topic-properties h3 {
+  margin: 0 0 12px;
+  font-size: 14px;
+}
+
+@media (max-width: 960px) {
+  .entity-layout {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+
 .detail-header,
 .detail-title-row,
 .section-heading,

+ 50 - 6
src/modules/voc-insight/topic-explorer/topic-detail.component.ts

@@ -22,6 +22,8 @@ import {
   X,
 } from 'lucide-angular';
 import { Subject, takeUntil } from 'rxjs';
+import { EntityPropertyItem, EntityPropertyListComponent } from '../../shared/components/entity-property-list/entity-property-list.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../shared/components/page-header/page-header.component';
 import { TopicExplorerDataService } from './topic-explorer-data.service';
 import {
   TopicEvidence,
@@ -33,7 +35,7 @@ import {
 @Component({
   selector: 'app-topic-detail',
   standalone: true,
-  imports: [CommonModule, LucideAngularModule],
+  imports: [CommonModule, LucideAngularModule, PageHeaderComponent, EntityPropertyListComponent],
   providers: [
     TopicExplorerDataService,
     {
@@ -71,12 +73,54 @@ export class TopicDetailComponent implements OnInit, OnDestroy {
 
   loading = true;
   errorMessage = '';
+  topicId = '';
+  readonly backRoute = '/voc-insight/topics';
+
+  get isEmbedded(): boolean {
+    return this.suppliedByParent;
+  }
+
+  get headerTitle(): string {
+    return this.topic?.label?.trim() || this.topicId || '未命名';
+  }
+
+  get crumbs(): PageHeaderCrumb[] {
+    return [
+      { label: '洞察中心' },
+      { label: '主题探索', link: '/voc-insight/topics' },
+      { label: this.headerTitle },
+    ];
+  }
+
+  get propertyItems(): EntityPropertyItem[] {
+    const topic = this.topic;
+    if (!topic) return [];
+    return [
+      { label: '证据', value: topic.evidenceCount ? `${topic.evidenceCount} 条` : null },
+      { label: '影响商品', value: topic.productCount ? `${topic.productCount} 个` : null },
+      { label: '置信度', value: topic.confidenceLabel },
+      { label: '可用性', value: topic.availabilityLabel },
+      { label: '关联商品', value: topic.products[0]?.title ?? null },
+      {
+        label: '负责人',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+      {
+        label: '验证窗口',
+        value: null,
+        reserved: true,
+        hint: '有数据后再出现',
+      },
+    ];
+  }
 
   ngOnInit(): void {
     if (this.suppliedByParent) return;
-    const topicId = this.route.snapshot.paramMap.get('id') || this.route.snapshot.queryParamMap.get('topic') || '';
+    this.topicId = this.route.snapshot.paramMap.get('id') || this.route.snapshot.queryParamMap.get('topic') || '';
     const source = this.parseSource(this.route.snapshot.queryParamMap.get('source'));
-    if (!topicId) {
+    if (!this.topicId) {
       this.loading = false;
       return;
     }
@@ -84,7 +128,7 @@ export class TopicDetailComponent implements OnInit, OnDestroy {
       .pipe(takeUntil(this.destroy$))
       .subscribe({
         next: (data) => {
-          this.currentTopic = this.dataService.buildSnapshot(data, source).topics.find((topic) => topic.id === topicId) ?? null;
+          this.currentTopic = this.dataService.buildSnapshot(data, source).topics.find((topic) => topic.id === this.topicId) ?? null;
           this.loading = false;
           this.cdr.markForCheck();
         },
@@ -103,8 +147,8 @@ export class TopicDetailComponent implements OnInit, OnDestroy {
 
   openEvidence(evidence: TopicEvidence): void {
     if (!this.topic) return;
-    void this.router.navigate(['/feedback-inbox'], {
-      queryParams: { reviewId: evidence.reviewId, q: this.topic.label },
+    void this.router.navigate(['/feedback-inbox', evidence.reviewId], {
+      queryParams: { q: this.topic.label },
     });
   }
 

+ 18 - 28
src/modules/voc-insight/topic-explorer/topic-explorer.component.html

@@ -1,25 +1,19 @@
 <main class="topic-page">
-  <header class="page-heading">
-    <div class="heading-copy">
-      <div class="title-line">
-        <span class="title-icon"><lucide-icon name="tag" [size]="19"></lucide-icon></span>
-        <div>
-          <h1>主题探索</h1>
-          <p>在同一工作台查看痛点、功能、场景和情绪,并下钻到原始评价证据。</p>
-        </div>
-      </div>
-    </div>
-    <div class="heading-actions" *ngIf="snapshot as data">
-      <div class="dataset-time">
-        <span>{{ data.platform | uppercase }} · {{ data.caseName }}</span>
-        <small>数据生成 {{ dateTimeLabel(data.generatedAt) }}</small>
-      </div>
+  <app-page-header
+    title="主题探索"
+    eyebrow="洞察中心"
+    description="在同一工作台查看痛点、功能、场景和情绪,并进入主题独立页查看证据。"
+    [crumbs]="crumbs">
+  </app-page-header>
+
+  <app-page-toolbar [fields]="toolbarFields">
+    <div pageToolbarActions>
       <button class="icon-button" type="button" title="刷新主题数据" aria-label="刷新主题数据"
         [disabled]="refreshing" (click)="refresh()">
         <lucide-icon name="refresh-cw" [size]="16" [class.is-spinning]="refreshing"></lucide-icon>
       </button>
     </div>
-  </header>
+  </app-page-toolbar>
 
   <section class="state-panel loading-state" *ngIf="loading">
     <span class="spinner"></span>
@@ -127,7 +121,7 @@
       </div>
     </section>
 
-    <section class="topic-workbench" [class.has-detail]="selectedTopic">
+    <section class="topic-workbench">
       <div class="topic-list-panel">
         <div class="table-scroll" *ngIf="visibleTopics.length; else emptyTopics">
           <table class="topic-table">
@@ -142,7 +136,11 @@
               </tr>
             </thead>
             <tbody>
-              <tr *ngFor="let topic of visibleTopics; trackBy: trackTopic" [class.selected]="topic.id === selectedTopicId">
+              <tr
+                *ngFor="let topic of visibleTopics; trackBy: trackTopic"
+                class="is-clickable"
+                [class.selected]="topic.id === selectedTopicId"
+                (click)="selectTopic(topic)">
                 <td class="topic-cell">
                   <button type="button" class="topic-link" (click)="selectTopic(topic)">
                     <span class="topic-name">{{ topic.label }}</span>
@@ -179,7 +177,8 @@
                     title="进入反馈收件箱审阅首条证据" (click)="openEvidence(topic, $event)">
                     <lucide-icon name="arrow-up-right" [size]="15"></lucide-icon><span>审阅</span>
                   </button>
-                  <button class="row-open" type="button" title="查看主题详情" aria-label="查看主题详情" (click)="selectTopic(topic)">
+                  <button class="row-open" type="button" title="进入主题详情" aria-label="进入主题详情" (click)="selectTopic(topic); $event.stopPropagation()">
+                    <span>进入</span>
                     <lucide-icon name="chevron-right" [size]="16"></lucide-icon>
                   </button>
                 </td>
@@ -203,15 +202,6 @@
         </footer>
       </div>
 
-      <aside class="detail-panel" *ngIf="selectedTopic as topic">
-        <app-topic-detail [topic]="topic" (close)="closeDetail()"></app-topic-detail>
-      </aside>
-
-      <aside class="selection-placeholder" *ngIf="!selectedTopic">
-        <span><lucide-icon name="package" [size]="20"></lucide-icon></span>
-        <strong>选择一个主题查看详情</strong>
-        <p>详情包含证据趋势、影响商品和可跳转的原始评价。</p>
-      </aside>
     </section>
 
     <footer class="data-boundary">

+ 5 - 5
src/modules/voc-insight/topic-explorer/topic-explorer.component.scss

@@ -478,13 +478,13 @@ button:disabled {
   transition: background 140ms ease;
 }
 
-.topic-table tbody tr:hover,
-.topic-table tbody tr.selected {
-  background: #f8fbff;
+.topic-table tbody tr.is-clickable {
+  cursor: pointer;
 }
 
-.topic-table tbody tr.selected td:first-child {
-  box-shadow: inset 3px 0 #2e90fa;
+.topic-table tbody tr:hover,
+.topic-table tbody tr.selected {
+  background: #f4f4f5;
 }
 
 .topic-cell {

+ 27 - 6
src/modules/voc-insight/topic-explorer/topic-explorer.component.ts

@@ -26,7 +26,8 @@ import {
   X,
 } from 'lucide-angular';
 import { Subject, takeUntil } from 'rxjs';
-import { TopicDetailComponent } from './topic-detail.component';
+import { PageHeaderComponent, PageHeaderCrumb } from '../../shared/components/page-header/page-header.component';
+import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
 import { TopicExplorerDataService } from './topic-explorer-data.service';
 import {
   TopicAvailability,
@@ -50,7 +51,7 @@ interface ViewOption {
 @Component({
   selector: 'app-topic-explorer',
   standalone: true,
-  imports: [CommonModule, FormsModule, LucideAngularModule, TopicDetailComponent],
+  imports: [CommonModule, FormsModule, LucideAngularModule, PageHeaderComponent, PageToolbarComponent],
   providers: [
     TopicExplorerDataService,
     {
@@ -159,6 +160,23 @@ export class TopicExplorerComponent implements OnInit, OnDestroy {
     return this.snapshot?.topics.find((topic) => topic.id === this.selectedTopicId) ?? null;
   }
 
+  get crumbs(): PageHeaderCrumb[] {
+    return [
+      { label: '洞察中心' },
+      { label: '主题探索', link: '/voc-insight/topics' },
+    ];
+  }
+
+  get toolbarFields(): Array<{ label: string; value: string }> {
+    const data = this.snapshot;
+    return [
+      { label: '周期', value: data ? `${data.periodStart} 至 ${data.periodEnd}` : '—' },
+      { label: '店铺', value: '—' },
+      { label: '类目', value: '—' },
+      { label: '负责人', value: '—' },
+    ];
+  }
+
   get currentView(): ViewOption {
     return this.views.find((view) => view.id === this.activeView) ?? this.views[0];
   }
@@ -214,8 +232,11 @@ export class TopicExplorerComponent implements OnInit, OnDestroy {
   }
 
   selectTopic(topic: UnifiedVocTopic): void {
-    this.selectedTopicId = topic.id;
-    this.syncUrl();
+    void this.router.navigate(['/voc-insight/topics', topic.id], {
+      queryParams: {
+        source: this.sourceScope === 'all' ? null : this.sourceScope,
+      },
+    });
   }
 
   closeDetail(): void {
@@ -227,8 +248,8 @@ export class TopicExplorerComponent implements OnInit, OnDestroy {
     event?.stopPropagation();
     const evidence = topic.evidence[0];
     if (!evidence) return;
-    void this.router.navigate(['/feedback-inbox'], {
-      queryParams: { reviewId: evidence.reviewId, q: topic.label },
+    void this.router.navigate(['/feedback-inbox', evidence.reviewId], {
+      queryParams: { q: topic.label },
     });
   }
 

+ 5 - 0
src/modules/voc-insight/voc-insight-detail/domestic-voc-detail.component.html

@@ -29,6 +29,9 @@
       eyebrow="Product VOC Detail"
       description="在原商品详情框架内统一查看经营指标、评论证据、改进建议与趋势。"
       icon="🔬"
+      backLabel="返回商品列表"
+      [backRoute]="backRoute"
+      [crumbs]="crumbs"
       [badges]="[{ label: dataset.platform, tone: 'blue' }, { label: reviews.length ? '评论已接入' : '评论待接入', tone: reviews.length ? 'green' : 'amber' }]"
       [meta]="[{ label: '商品ID', value: product.productId }, { label: '型号', value: product.model || '-' }]"
       [lastUpdated]="date(dataset.generatedAt)"
@@ -64,6 +67,8 @@
           <div class="rail-meta-row"><span>竞品关系</span><strong>{{ number(product.relationCount) }} 条</strong></div>
         </div>
 
+        <app-entity-property-list [items]="propertyItems"></app-entity-property-list>
+
         <div class="rail-actions">
           <app-neu-button size="sm" color="primary" (btnClick)="setActiveTab('reviews')">查看评论</app-neu-button>
           <app-neu-button size="sm" (btnClick)="goCompetitors()">竞品关系</app-neu-button>

Некоторые файлы не были показаны из-за большого количества измененных файлов