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

feat: migrate real VOC framework for domestic ecommerce

gangvy 2 месяцев назад
Родитель
Сommit
5874a2ecd5
100 измененных файлов с 24108 добавлено и 2668 удалено
  1. 29 0
      .hintrc
  2. 86 194
      README.md
  3. 56 0
      TASKS.md
  4. 170 0
      angular.json
  5. 0 16
      apps/web/.editorconfig
  6. 0 101
      apps/web/angular.json
  7. 0 40
      apps/web/package.json
  8. 0 50
      apps/web/src/app/app.component.html
  9. 0 219
      apps/web/src/app/app.component.scss
  10. 0 20
      apps/web/src/app/app.component.spec.ts
  11. 0 43
      apps/web/src/app/app.component.ts
  12. 0 9
      apps/web/src/app/app.config.ts
  13. 0 25
      apps/web/src/app/app.routes.ts
  14. 0 20
      apps/web/src/app/core/adapters/product-identity.adapter.spec.ts
  15. 0 17
      apps/web/src/app/core/adapters/product-identity.adapter.ts
  16. 0 105
      apps/web/src/app/core/models/domain.models.ts
  17. 0 47
      apps/web/src/app/core/services/dataset-store.service.ts
  18. 0 93
      apps/web/src/app/features/dashboard/dashboard.component.html
  19. 0 117
      apps/web/src/app/features/dashboard/dashboard.component.scss
  20. 0 58
      apps/web/src/app/features/dashboard/dashboard.component.ts
  21. 0 57
      apps/web/src/app/features/data-status/data-status.component.html
  22. 0 53
      apps/web/src/app/features/data-status/data-status.component.scss
  23. 0 33
      apps/web/src/app/features/data-status/data-status.component.ts
  24. 0 80
      apps/web/src/app/features/products/products.component.html
  25. 0 76
      apps/web/src/app/features/products/products.component.scss
  26. 0 62
      apps/web/src/app/features/products/products.component.ts
  27. 0 60
      apps/web/src/app/features/voc/voc.component.html
  28. 0 38
      apps/web/src/app/features/voc/voc.component.scss
  29. 0 45
      apps/web/src/app/features/voc/voc.component.ts
  30. 0 0
      apps/web/src/assets/.gitkeep
  31. BIN
      apps/web/src/favicon.ico
  32. 0 13
      apps/web/src/index.html
  33. 0 173
      apps/web/src/styles.scss
  34. 0 14
      apps/web/tsconfig.spec.json
  35. 1035 776
      package-lock.json
  36. 76 12
      package.json
  37. 8 0
      proxy.conf.json
  38. 48 0
      src/app/app.component.scss
  39. 59 0
      src/app/app.component.ts
  40. 16 0
      src/app/app.config.ts
  41. 47 0
      src/app/app.routes.ts
  42. 22 0
      src/app/core/config/runtime-config.ts
  43. 40 0
      src/app/core/guards/auth.guard.ts
  44. 287 0
      src/app/core/models/api.models.ts
  45. 78 0
      src/app/core/models/domestic.models.ts
  46. 1 0
      src/app/core/models/index.ts
  47. 97 0
      src/app/core/services/amazon-raw-api.service.ts
  48. 187 0
      src/app/core/services/api-connectivity.service.ts
  49. 14 0
      src/app/core/services/api.config.ts
  50. 42 0
      src/app/core/services/category-api.service.ts
  51. 584 0
      src/app/core/services/data-store.service.ts
  52. 271 0
      src/app/core/services/data-sync.service.ts
  53. 58 0
      src/app/core/services/domestic-dataset.service.ts
  54. 22 0
      src/app/core/services/domestic-ecommerce-gateway.service.ts
  55. 212 0
      src/app/core/services/http-api.service.ts
  56. 19 0
      src/app/core/services/index.ts
  57. 43 0
      src/app/core/services/keyword-api.service.ts
  58. 20 0
      src/app/core/services/layout.service.ts
  59. 31 0
      src/app/core/services/listing-api.service.ts
  60. 75 0
      src/app/core/services/order-api.service.ts
  61. 67 0
      src/app/core/services/product-api.service.ts
  62. 17 0
      src/app/core/services/seller-api.service.ts
  63. 340 0
      src/app/core/services/sorftime-api.service.ts
  64. 354 0
      src/app/core/services/tiktok-api.service.ts
  65. 71 0
      src/app/echarts.ts
  66. 24 0
      src/app/parse.ts
  67. 0 0
      src/assets/data/demashi-summary.json
  68. 5 0
      src/assets/images/placeholder.svg
  69. BIN
      src/assets/images/鍒锋柊.png
  70. 1 0
      src/favicon.ico
  71. 220 0
      src/index.html
  72. 2 2
      src/main.ts
  73. 11 0
      src/modules/action-suggestion/action-suggestion-shell.component.ts
  74. 898 0
      src/modules/action-suggestion/action-suggestion.component.html
  75. 1785 0
      src/modules/action-suggestion/action-suggestion.component.scss
  76. 1334 0
      src/modules/action-suggestion/action-suggestion.component.ts
  77. 619 0
      src/modules/action-suggestion/pages/experience-optimization/experience-optimization.component.ts
  78. 502 0
      src/modules/action-suggestion/pages/product-iteration/product-iteration.component.ts
  79. 572 0
      src/modules/action-suggestion/pages/strategy-adjustment/strategy-adjustment.component.ts
  80. 122 0
      src/modules/dashboard/DASHBOARD_OVERVIEW.md
  81. 552 0
      src/modules/dashboard/dashboard.component.html
  82. 2080 0
      src/modules/dashboard/dashboard.component.scss
  83. 1495 0
      src/modules/dashboard/dashboard.component.ts
  84. 329 0
      src/modules/dashboard/pages/designer-performance/designer-performance.component.html
  85. 529 0
      src/modules/dashboard/pages/designer-performance/designer-performance.component.scss
  86. 543 0
      src/modules/dashboard/pages/designer-performance/designer-performance.component.ts
  87. 151 0
      src/modules/dashboard/pages/order-list/order-list.component.html
  88. 182 0
      src/modules/dashboard/pages/order-list/order-list.component.scss
  89. 186 0
      src/modules/dashboard/pages/order-list/order-list.component.ts
  90. 109 0
      src/modules/domestic-voc/data-status.component.ts
  91. 311 0
      src/modules/knowledge-center/competitor-backfill-dialog/competitor-backfill-dialog.component.ts
  92. 1593 0
      src/modules/knowledge-center/competitor-detail/competitor-detail.component.scss
  93. 1771 0
      src/modules/knowledge-center/competitor-detail/competitor-detail.component.ts
  94. 322 0
      src/modules/knowledge-center/competitor-detail/product-feature-analysis/product-feature-analysis.component.ts
  95. 406 0
      src/modules/knowledge-center/competitor-detail/product-specs/product-specs.component.ts
  96. 629 0
      src/modules/knowledge-center/competitor-detail/product-variation-history/product-variation-history.component.ts
  97. 441 0
      src/modules/knowledge-center/competitor-detail/product-variations/product-variations.component.ts
  98. 777 0
      src/modules/knowledge-center/export-reviews-dialog/export-reviews-dialog.component.ts
  99. 492 0
      src/modules/knowledge-center/fetch-count-dialog/fetch-count-dialog.component.ts
  100. 533 0
      src/modules/knowledge-center/influencer-detail/influencer-detail.component.scss

+ 29 - 0
.hintrc

@@ -0,0 +1,29 @@
+{
+  "extends": [
+    "development"
+  ],
+  "hints": {
+    "compat-api/css": [
+      "default",
+      {
+        "ignore": [
+          "position: sticky",
+          "backdrop-filter",
+          "user-select"
+        ]
+      }
+    ],
+    "axe/forms": [
+      "default",
+      {
+        "select-name": "off"
+      }
+    ],
+    "axe/text-alternatives": [
+      "default",
+      {
+        "image-alt": "off"
+      }
+    ]
+  }
+}

+ 86 - 194
README.md

@@ -1,247 +1,139 @@
 # Saas VOC
 
-国内电商 VOC(Voice of Customer)通用模板。项目以德玛仕为首个案例,在不影响现有跨境电商系统和线上数据库的前提下,复用现有 Angular VOC 页面、Parse 数据访问模式,以及 Fmode 封装的 JustOneAPI 电商数据中转能力
+基于现有 `msq-voc-web` 前端框架打包出的国内电商 VOC 通用模板,首个案例为德玛仕京东商品。项目保留原系统的 Angular App Shell、顶部搜索、侧边导航、共享组件、VOC 页面结构和 ECharts 能力,只替换最小闭环所需的数据入口与国内商品语义
 
-> 当前阶段:独立前端 MVP 已可运行,使用德玛仕 Excel 规范化结果完成商品、竞品、经营概览、VOC 空状态和数据质量闭环;独立数据库与 JustOneAPI 评论采集待接入。
->
-> 更新时间:2026-07-22
+> 当前状态(2026-07-22):真实框架迁入和本地德玛仕数据闭环已完成;独立数据库、JustOneAPI 评论采集和独立环境部署尚未完成。
 
-## 项目目标
+## 迁入基线
 
-在 2026-07-25 前优先接通一个最小、可部署、可复用的国内电商 VOC 闭环:
+- 来源仓库:`E:\workspace\msq-voc-web`
+- 固定来源提交:`9326c20`
+- 迁入方式:从固定 Git 提交提取,不复制来源仓库未提交的本地修改
+- 目标仓库:`https://git.fmode.cn/18307996893/Saas-voc.git`
+- 目标仓库不会连接或写入原跨境系统线上数据库
 
-1. 导入德玛仕商品经营数据和竞品映射。
-2. 通过 Fmode `voc-e-commerce` 中转接口获取国内电商商品和评论数据。
-3. 将商品、评论和竞品关系写入全新的独立数据库。
-4. 复用现有 VOC 页面,展示商品列表、情绪分布、负面问题和竞品对比。
+当前启用路由:
 
-## 隔离原则
+- `/voc-insight/products`:德玛仕商品列表、搜索、排序和商品选择
+- `/voc-insight/voice`:用户声音分类;无评论时展示真实空状态
+- `/voc-insight/pain-point`:痛点深度洞察
+- `/voc-insight/feature`:功能满足度
+- `/voc-insight/scenario`:使用场景
+- `/data-status`:导入结果、待接依赖和数据质量问题
 
-- 不连接、不写入现有跨境电商线上库。
-- 新建独立 PostgreSQL 数据库和独立 Parse 应用配置。
-- 现有 `msq-voc-web`、`moshengqi-server` 仅作为代码和数据模型参考。
-- JustOneAPI 上游密钥只保留在 Fmode 服务端,本项目只使用 Fmode API Key。
-- Fmode API Key、Parse master key、数据库连接串不得进入浏览器代码或 Git。
+`/login` 保留为独立 Parse 应用接入后的入口,但当前路由未启用 `AuthGuard`。
 
-## 总体架构
+## 最小闭环
 
-```text
-Saas VOC Web
-  |-- 读取新 Parse 应用中的商品、评论和分析结果
-  |-- 调用本项目服务端任务,不直接持有任何私钥
-  |
-Saas VOC Server / Worker
-  |-- 使用 FMODE_API_KEY
-  |-- 调用 /api/voc-e-commerce/{platform}/{upstream-path}
-  |-- 清洗并写入新 Parse 数据库
-  |
-Fmode voc-e-commerce
-  |-- 鉴权、计费、缓存、重试
-  |-- 隐藏 JustOneAPI token
-  |
-JustOneAPI
-```
-
-## 最小复用策略
-
-现有前端大量代码把 `asin` 当作“商品唯一字符串”,而不是执行 ASIN 格式校验。为了在截止日前降低改动量,首版不做全项目全局重命名。
-
-采用兼容层方案:
-
-- 新模型的标准字段使用 `platform` 和 `productId`。
-- 唯一商品键使用 `platform:productId`,不能只使用商品 ID。
-- 国内平台商品 ID 一律按字符串保存,避免长数字精度丢失。
-- 复用旧组件时,由适配器临时提供 `asin = productId` 兼容字段。
-- 新增代码只使用 `productId`,后续再逐步移除 `asin` 兼容字段。
-
-这样可以复用商品选择器、VOC 图表、评论情绪统计和部分 Parse 查询封装,同时避免让新数据库继续以 Amazon 为核心语义。
-
-## 新数据库
-
-计划使用独立 Parse 应用和独立 PostgreSQL 数据库。实际连接信息只通过部署环境配置,不写入本仓库。
+当前可以完成:
 
-首版最小数据类:
+1. 加载德玛仕 Excel 的规范化结果。
+2. 展示 2,817 个有经营数据的商品。
+3. 按商品 ID、型号或名称搜索并选择商品。
+4. 在原 VOC 页面中保持商品上下文。
+5. 对当前真实的 0 条评论展示空状态,不伪造情绪、痛点或分析结果。
+6. 查看竞品关系、导入统计和数据质量问题。
 
-### Product
+德玛仕数据摘要:
 
-- `platform`: `jd`、`taobao`、`tmall`、`pdd`、`douyin`、`1688` 等
-- `productId`: 平台商品 ID
-- `productKey`: `${platform}:${productId}`
-- `asin`: 临时兼容字段,值与 `productId` 相同
-- `role`: `own` 或 `competitor`
-- `brand`、`title`、`model`
-- `category1`、`category2`、`category3`
-- `source`: `excel` 或 `justone`
-- `rawData`: 必要时保留原始响应
+- 日指标记录:9,717 条
+- 经营商品:2,817 个
+- 竞品关系:40 条
+- 唯一竞品商品:37 个
+- 评论正文:0 条
+- 指标日期:2026-07-14 至 2026-07-20
 
-### ProductMetricDaily
+源文件:
 
-- `productKey`、`date`
-- 成交金额、成交件数、订单数、客户数
-- 曝光、点击、访客、浏览、加购
-- 转化率、退款金额、退款件数
-- 唯一键:`productKey:date`
-
-### ProductRelation
+```text
+E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟品收集0721.xlsx
+```
 
-- `ownProductKey`
-- `competitorProductKey`
-- `competitorBrand`
-- `category`
-- 唯一键:`ownProductKey:competitorProductKey`
+规范化结果位于 `src/assets/data/demashi-summary.json`。
 
-### ProductReview
+## 复用策略
 
-- `platform`、`productId`、`productKey`
-- `reviewId`、`rating`、`title`、`content`
-- `reviewDate`、`helpful`、`verified`
-- `role`: `own` 或 `competitor`
-- `source`: `justone` 或 `file`
-- 唯一键优先使用 `platform:productId:reviewId`,无 reviewId 时使用内容哈希
+国内商品的标准身份是 `platform + productId`,唯一键为 `platform:productId`。原框架大量组件使用 `asin` 表示商品唯一字符串,为控制首版改动量,适配层暂时提供 `asin = productId`:
 
-### ImportBatch
+- 页面统一显示“商品ID”,不显示 ASIN。
+- 国内平台商品 ID 始终按字符串保存。
+- 新增领域模型使用 `platform`、`productId` 和 `productKey`。
+- `asin` 只作为复用原组件的兼容字段,后续再逐步移除。
 
-- `fileName`、`fileHash`、`type`
-- `status`、`total`、`success`、`failed`
-- `errorSummary`、`startedAt`、`finishedAt`
+## 环境隔离
 
-## Fmode 电商中转
+浏览器默认只访问同源地址:
 
-后端参考模块:
+- Parse:`/parse`
+- 国内电商中转:`/api/voc-e-commerce`
+- 德玛仕本地数据:`/assets/data/demashi-summary.json`
 
-`E:\workspace\server\future-server\fmode-server\modules\voc-e-commerce`
+运行时配置定义在 `src/app/core/config/runtime-config.ts`,部署环境可以在 Angular 脚本加载前注入 `window.__SAAS_VOC_CONFIG__` 覆盖同源路径。
 
-已确认能力
+禁止将以下内容写入浏览器代码或 Git
 
-- 路由已挂载到 `/api/voc-e-commerce`。
-- 支持 `GET /health`、`GET /test` 和任意上游路径透传。
-- 平台由路径第一段识别,例如 `jd/...`、`taobao/...`、`1688/...`。
-- 支持 GET、POST、PUT、PATCH 参数转发。
-- 上游 POST 类接口使用 `application/x-www-form-urlencoded`。
-- 支持超时、重试、响应缓存和失败结果不计费。
-- Fmode `sk-` Key 可通过 `Authorization: Bearer ...` 或 `x-api-key` 传入。
-- 缓存命中和上游成功请求都会按当前规则计费。
-- 返回外层统一为 `{ code: 200, data: upstreamResponse }`。
+- Fmode API Key
+- JustOneAPI token
+- Parse master key
+- PostgreSQL 连接串
 
-服务端调用示意
+Fmode Key 只能由服务端或 Worker 持有。前端通过同源服务端调用:
 
-```bash
-curl "${FMODE_SERVER_URL}/api/voc-e-commerce/jd/search-item-list/v1?keyword=demo&page=1" \
-  -H "x-api-key: ${FMODE_API_KEY}"
+```text
+/api/voc-e-commerce/{platform}/{upstream-path}
 ```
 
-注意:`FMODE_API_KEY` 只能存在于服务端或本地开发环境,不能放入 Angular 环境文件或静态资源。
-
-## 德玛仕案例数据
-
-当前输入文件:
-
-`E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟品收集0721.xlsx`
-
-已完成只读分析:
+参考中转模块:
 
-- `德玛仕产品基础数据`:9,717 条日数据,时间范围 2026-07-14 至 2026-07-20。
-- 2,817 个有经营数据的唯一 SKU。
-- 2 个一级类目、10 个二级类目、49 个三级类目。
-- 37 个经营指标字段,包含成交、流量、加购、下单和退款。
-- `竟对品牌及编码`:28 个自有 SKU 映射、37 个唯一竞品 SKU、10 个类目。
-- 自有 SKU `100204398739` 在经营数据中没有对应记录,需要作为缺失数据标记。
-- 两个自有 SKU 尚未填写竞品,不能进入竞品对比。
-- 文件没有评论正文,不能单独形成真实 VOC 分析结果。
-
-## 首版页面
-
-当前只保留最容易接通的四个工作台页面:
-
-1. 经营概览:7 日经营指标、成交趋势、类目贡献和成交商品。
-2. 商品与竞品:类目筛选、SKU 搜索、分页、经营指标和竞品映射。
-3. VOC 分析:评论样本量、评分/情绪空状态,以及自有商品与竞品采集状态。
-4. 数据状态:规范化结果、数据链路、外部依赖和质量问题。
-
-首版不伪造评论、评分、情绪或问题标签。评论接入后,情绪可先按评分计算:4 至 5 星为正向,3 星为中性,1 至 2 星为负向。
+```text
+E:\workspace\server\future-server\fmode-server\modules\voc-e-commerce
+```
 
 ## 本地运行
 
-建议使用 Node.js 20 LTS。当前 Angular 17 工作区声明支持 `>=18.13 <23`。
+建议使用 Node.js 20 LTS。当前工作区声明支持 Node.js `>=18.13 <23`。
 
 ```bash
 npm ci
 npm run verify:data
 npm run build
 npm test
-npm run dev
+npm start
 ```
 
-开发地址默认为 `http://localhost:4200/`。如果端口已占用,可在 `apps/web` 工作区传入其他端口
+默认开发地址为 `http://localhost:4200/`。本地 `/api/voc-e-commerce` 通过 `proxy.conf.json` 转发到 `http://127.0.0.1:3000`,API Key 仍应只配置在该服务端进程中。
 
-规范化数据位于 `apps/web/src/assets/data/demashi-summary.json`。需要从原始 Excel 重新生成时执行
+从 Excel 重新生成规范化数据:
 
 ```bash
 npm run import:demashi -- "E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟品收集0721.xlsx"
 ```
 
-## 当前进度
-
-- [x] 新 Git 仓库初始化
-- [x] 现有跨境前端、后端和数据库模型盘点
-- [x] 德玛仕 Excel 结构和数据量核对
-- [x] `voc-e-commerce` 路由、鉴权、计费、缓存和重试逻辑核对
-- [x] 本机 Fmode API Key 存在性和格式核对,未输出密钥
-- [x] 确定新数据库隔离方案
-- [x] 确定 `productId` 标准字段和 `asin` 临时兼容方案
-- [x] 建立 Angular 17 独立前端与共享领域模型
-- [x] 实现德玛仕 Excel 可重复规范化和结果校验
-- [x] 接通经营概览、商品与竞品、VOC 空状态、数据状态页面
-- [x] 完成生产构建、3 项单测、桌面与移动端浏览器验收
-- [ ] 创建新 PostgreSQL 数据库和 Parse 应用
-- [ ] 验证 JustOneAPI 京东商品详情与评论接口路径和返回字段
-- [ ] 将规范化商品、指标、关系和评论持久化到新 Parse 应用
-- [ ] 完成独立环境部署验收
-
-## 已知问题
-
-### 生产 API 联调受阻
-
-2026-07-22 使用本机 Fmode API Key 联调时,`server.fmode.cn:443` TCP 可达,但 TLS 握手被连接端重置。业务搜索请求没有成功发出,也没有消耗 API 额度。
-
-在该问题修复前,只能确认模块代码、路由挂载和自动测试覆盖,不能把 JustOneAPI 的实际字段契约标记为已验证。
-
-### 上游凭证需要环境化
-
-参考模块当前存在上游 token 默认值。正式复用前必须删除源码默认凭证,只允许使用 `VOC_E_COMMERCE_TOKEN` 或 `JUSTONE_TOKEN` 环境变量,并轮换已经进入源码历史的旧 token。
-
-### 评论数据尚缺
-
-德玛仕 Excel 只有商品经营数据和竞品编码。真实 VOC 演示还需要:
-
-- 修复 Fmode 生产接口后从 JustOneAPI 采集评论;或
-- 提供一份评论 CSV/XLSX,至少包含平台、商品 ID、评分、正文和评论时间。
-
-### 前端依赖基线
-
-当前保持 Angular 17,以便与现有跨境前端兼容并控制首版改动量。`npm audit --omit=dev` 仍报告 8 项 Angular 17 高危公告,官方修复需要跨大版本升级;正式公网发布前应升级到受支持的 Angular 版本并完成回归。开发和 CI 使用 Node.js 20 LTS,不使用当前 Angular 17 未声明支持的 Node.js 24。
+## 当前验证
 
-## 截止日前任务顺序
+- `npm run build`:通过
+- `npm test`:3 项 Jasmine 测试通过
+- `npm run verify:data`:通过
+- 桌面端:商品搜索、商品选择、VOC 路由和空状态通过
+- 移动端:App Shell 抽屉、VOC rail 和数据状态页通过
+- 运行时请求:当前激活闭环只请求同源德玛仕 JSON,没有访问旧 Parse 或旧跨境域名
+- `npm audit --omit=dev`:17 项原框架依赖公告(1 critical、11 high、5 moderate),正式公网部署前必须升级处理
 
-1. 创建新数据库和 Parse 应用,验证完全隔离。
-2. 修复或确认 Fmode 生产域名 TLS 入口。
-3. 用一个德玛仕京东 SKU 验证搜索、详情、评论三个上游接口。
-4. 接通评论采集或评论文件导入。
-5. 将现有规范化结果幂等写入新 Parse 应用。
-6. 把前端数据源从本地 JSON 切换到新 Parse 应用。
-7. 完成独立环境部署和端到端验收。
+## 尚未完成
 
-## 暂不包含
+- 创建独立 PostgreSQL 数据库和独立 Parse 应用
+- 验证 JustOneAPI 京东商品详情、评论接口和真实字段契约
+- 将商品、日指标、竞品关系和评论幂等写入新 Parse 应用
+- 将前端主数据源从静态 JSON 切换为新 Parse 应用
+- 部署独立环境并完成端到端验收
+- 升级原框架的 Angular 17、`xlsx`、ECharts、Parse 依赖基线并处理安全公告
 
-- Amazon、Sorftime、SP-API 和跨境订单链路
-- 京东、淘宝、天猫等平台的定时全量采集
-- 多租户计费和复杂权限后台
-- AI 自动报告、行动建议、研发闭环
-- 社媒、退货、订单、库存和广告分析
-- 实时预警、消息推送和复杂导出
+完整任务顺序见 [TASKS.md](TASKS.md)。
 
-## 参考代码
+## 明确不包含
 
-- 跨境 VOC 前端:`E:\workspace\msq-voc-web`
-- 跨境系统后端:`E:\workspace\server\moshengqi-server`
-- 国内电商数据中转:`E:\workspace\server\future-server\fmode-server\modules\voc-e-commerce`
+- 原 Amazon、Sorftime、SP-API 和跨境订单链路
+- 直接复用原跨境线上数据库
+- 浏览器直接持有 Fmode 或 JustOneAPI 密钥
+- 在没有评论正文时生成虚假 VOC 分析
+- 首版多租户计费、复杂权限、定时全量采集和 AI 自动报告

+ 56 - 0
TASKS.md

@@ -0,0 +1,56 @@
+# Saas VOC 任务清单
+
+更新时间:2026-07-22
+
+## 已完成
+
+- [x] 初始化独立 `Saas-voc` Git 仓库
+- [x] 固定原框架来源提交 `9326c20`
+- [x] 将原 Angular App Shell、导航、共享组件和 VOC 页面迁入仓库根目录
+- [x] 保留 Parse、`fmode-ng`、ECharts 和原 VOC 页面依赖
+- [x] 建立 `platform + productId` 标准身份和 `asin` 兼容层
+- [x] 实现德玛仕 Excel 规范化脚本和数据校验脚本
+- [x] 接入 2,817 个真实商品、9,717 条日指标和 40 条竞品关系
+- [x] 接通商品搜索、商品选择、VOC 页面商品上下文和真实零评论空状态
+- [x] 增加数据接入状态页
+- [x] 将 Parse 和电商中转默认地址改为同源路径
+- [x] 移除旧跨境线上域名和旧 Parse 二次登录逻辑
+- [x] 完成桌面与移动端响应式验收
+- [x] 完成构建、单测和数据完整性校验
+
+## 周六前 P0
+
+- [ ] 创建全新的 PostgreSQL 数据库
+- [ ] 创建独立 Parse 应用,配置独立 `appId`、server URL 和 master key
+- [ ] 在服务端配置 Fmode API Key,不把 Key 下发到浏览器
+- [ ] 恢复或确认 Fmode 生产 TLS 入口可用
+- [ ] 用德玛仕京东商品 `11266507445` 验证商品详情接口
+- [ ] 用同一商品验证评论列表接口和分页字段
+- [ ] 固化 JustOneAPI 响应到国内领域模型的字段映射
+- [ ] 实现商品、日指标、竞品关系和评论的幂等导入
+- [ ] 将前端读取切到独立 Parse 应用,并保留明确的本地案例模式
+- [ ] 部署独立前后端环境并跑一次端到端验收
+
+## P0 验收标准
+
+- [ ] 新环境与原跨境数据库完全隔离
+- [ ] 输入一个国内平台商品 ID 后能获取并保存商品与评论
+- [ ] 选择商品后 VOC 页面展示真实评论统计,或显示可解释的真实空状态
+- [ ] 失败请求不写入脏数据,不伪造分析结果
+- [ ] 浏览器和 Git 中不存在 Fmode、JustOneAPI、Parse master key 或数据库密码
+- [ ] `npm run build`、`npm test`、`npm run verify:data` 全部通过
+
+## 后续 P1
+
+- [ ] 升级到受支持的 Angular 版本并完成全量回归
+- [ ] 处理 `npm audit` 中原框架遗留的 78 项公告;其中生产依赖 17 项(1 critical、11 high、5 moderate)
+- [ ] 按实际国内功能边界裁剪未挂路由的跨境模块
+- [ ] 将 `asin` 兼容字段逐步替换为 `productId`
+- [ ] 增加评论采集任务状态、失败重试和导入批次页面
+- [ ] 为京东以外的平台增加独立字段适配器和契约测试
+
+## 当前外部阻塞
+
+- Fmode 生产入口 TLS 握手在本机测试时被连接端重置,尚未完成真实 JustOneAPI 字段验证。
+- 德玛仕 Excel 不包含评论正文,当前评论样本数为 0。
+- 独立 PostgreSQL / Parse 的连接信息尚未提供或创建。

+ 170 - 0
angular.json

@@ -0,0 +1,170 @@
+{
+  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+  "version": 1,
+  "newProjectRoot": "projects",
+  "projects": {
+    "ai-voc-frontend": {
+      "projectType": "application",
+      "schematics": {
+        "@schematics/angular:component": {
+          "style": "scss",
+          "standalone": true
+        },
+        "@schematics/angular:directive": {
+          "standalone": true
+        },
+        "@schematics/angular:pipe": {
+          "standalone": true
+        }
+      },
+      "root": "",
+      "sourceRoot": "src",
+      "prefix": "app",
+      "architect": {
+        "build": {
+          "builder": "@angular-devkit/build-angular:browser",
+          "options": {
+            "outputPath": "dist",
+            "index": "src/index.html",
+            "main": "src/main.ts",
+            "polyfills": [
+              "zone.js"
+            ],
+            "tsConfig": "tsconfig.app.json",
+            "inlineStyleLanguage": "scss",
+            "assets": [
+              "src/favicon.ico",
+              "src/assets"
+            ],
+            "styles": [
+              "src/styles.scss"
+            ],
+            "scripts": [],
+            "allowedCommonJsDependencies": [
+              "echarts",
+              "date-fns",
+              "bowser",
+              "camelcase",
+              "decamelize",
+              "p-retry",
+              "crypto-js",
+              "recorder-core",
+              "dingtalk-jsapi",
+              "spark-md5",
+              "semver",
+              "querystring",
+              "file-saver"
+            ]
+          },
+          "configurations": {
+            "production": {
+              "budgets": [
+                {
+                  "type": "initial",
+                  "maximumWarning": "20mb",
+                  "maximumError": "25mb"
+                },
+                {
+                  "type": "anyComponentStyle",
+                  "maximumWarning": "20mb",
+                  "maximumError": "25mb"
+                },
+                {
+                  "type": "allScript",
+                  "maximumWarning": "20mb",
+                  "maximumError": "25mb"
+                },
+                {
+                  "type": "all",
+                  "maximumWarning": "20mb",
+                  "maximumError": "25mb"
+                }
+              ],
+              "outputHashing": "all",
+              "optimization": true,
+              "sourceMap": false,
+              "namedChunks": false,
+              "extractLicenses": true,
+              "vendorChunk": false,
+              "buildOptimizer": true
+            },
+            "development": {
+              "buildOptimizer": false,
+              "optimization": false,
+              "vendorChunk": true,
+              "extractLicenses": false,
+              "sourceMap": true,
+              "namedChunks": true
+            }
+          },
+          "defaultConfiguration": "production"
+        },
+        "serve": {
+          "builder": "@angular-devkit/build-angular:dev-server",
+          "options": {
+            "proxyConfig": "proxy.conf.json",
+            "headers": {
+              "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
+              "Pragma": "no-cache",
+              "Expires": "0"
+            }
+          },
+          "configurations": {
+            "production": {
+              "buildTarget": "ai-voc-frontend:build:production"
+            },
+            "development": {
+              "buildTarget": "ai-voc-frontend:build:development"
+            }
+          },
+          "defaultConfiguration": "development"
+        },
+        "extract-i18n": {
+          "builder": "@angular-devkit/build-angular:extract-i18n",
+          "options": {
+            "buildTarget": "ai-voc-frontend:build"
+          }
+        },
+        "test": {
+          "builder": "@angular-devkit/build-angular:karma",
+          "options": {
+            "polyfills": [
+              "zone.js",
+              "zone.js/testing"
+            ],
+            "tsConfig": "tsconfig.spec.json",
+            "inlineStyleLanguage": "scss",
+            "assets": [
+              "src/favicon.ico",
+              "src/assets"
+            ],
+            "styles": [
+              "src/styles.scss"
+            ],
+            "scripts": []
+          }
+        },
+        "lint": {
+          "builder": "@angular-eslint/builder:lint",
+          "options": {
+            "lintFilePatterns": [
+              "src/**/*.ts",
+              "src/**/*.html"
+            ]
+          }
+        }
+      }
+    }
+  },
+  "cli": {
+    "analytics": false,
+    "cache": {
+      "enabled": true,
+      "path": ".angular/cache",
+      "environment": "all"
+    },
+    "schematicCollections": [
+      "@angular-eslint/schematics"
+    ]
+  }
+}

+ 0 - 16
apps/web/.editorconfig

@@ -1,16 +0,0 @@
-# Editor configuration, see https://editorconfig.org
-root = true
-
-[*]
-charset = utf-8
-indent_style = space
-indent_size = 2
-insert_final_newline = true
-trim_trailing_whitespace = true
-
-[*.ts]
-quote_type = single
-
-[*.md]
-max_line_length = off
-trim_trailing_whitespace = false

+ 0 - 101
apps/web/angular.json

@@ -1,101 +0,0 @@
-{
-  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
-  "version": 1,
-  "newProjectRoot": "projects",
-  "projects": {
-    "web": {
-      "projectType": "application",
-      "schematics": {
-        "@schematics/angular:component": {
-          "style": "scss"
-        }
-      },
-      "root": "",
-      "sourceRoot": "src",
-      "prefix": "app",
-      "architect": {
-        "build": {
-          "builder": "@angular-devkit/build-angular:application",
-          "options": {
-            "outputPath": "dist/web",
-            "index": "src/index.html",
-            "browser": "src/main.ts",
-            "polyfills": [
-              "zone.js"
-            ],
-            "tsConfig": "tsconfig.app.json",
-            "inlineStyleLanguage": "scss",
-            "assets": [
-              "src/favicon.ico",
-              "src/assets"
-            ],
-            "styles": [
-              "src/styles.scss"
-            ],
-            "scripts": []
-          },
-          "configurations": {
-            "production": {
-              "budgets": [
-                {
-                  "type": "initial",
-                  "maximumWarning": "500kb",
-                  "maximumError": "1mb"
-                },
-                {
-                  "type": "anyComponentStyle",
-                  "maximumWarning": "12kb",
-                  "maximumError": "20kb"
-                }
-              ],
-              "outputHashing": "all"
-            },
-            "development": {
-              "optimization": false,
-              "extractLicenses": false,
-              "sourceMap": true
-            }
-          },
-          "defaultConfiguration": "production"
-        },
-        "serve": {
-          "builder": "@angular-devkit/build-angular:dev-server",
-          "configurations": {
-            "production": {
-              "buildTarget": "web:build:production"
-            },
-            "development": {
-              "buildTarget": "web:build:development"
-            }
-          },
-          "defaultConfiguration": "development"
-        },
-        "extract-i18n": {
-          "builder": "@angular-devkit/build-angular:extract-i18n",
-          "options": {
-            "buildTarget": "web:build"
-          }
-        },
-        "test": {
-          "builder": "@angular-devkit/build-angular:karma",
-          "options": {
-            "polyfills": [
-              "zone.js",
-              "zone.js/testing"
-            ],
-            "tsConfig": "tsconfig.spec.json",
-            "inlineStyleLanguage": "scss",
-            "assets": [
-              "src/favicon.ico",
-              "src/assets"
-            ],
-            "styles": [
-              "src/styles.scss"
-            ],
-            "scripts": []
-          }
-        }
-      }
-    }
-  }
-}

+ 0 - 40
apps/web/package.json

@@ -1,40 +0,0 @@
-{
-  "name": "@saas-voc/web",
-  "version": "0.1.0",
-  "scripts": {
-    "ng": "ng",
-    "start": "ng serve",
-    "build": "ng build",
-    "watch": "ng build --watch --configuration development",
-    "test": "ng test",
-    "test:ci": "ng test --watch=false --browsers=ChromeHeadless"
-  },
-  "private": true,
-  "dependencies": {
-    "@angular/animations": "^17.3.0",
-    "@angular/common": "^17.3.0",
-    "@angular/compiler": "^17.3.0",
-    "@angular/core": "^17.3.0",
-    "@angular/forms": "^17.3.0",
-    "@angular/platform-browser": "^17.3.0",
-    "@angular/platform-browser-dynamic": "^17.3.0",
-    "@angular/router": "^17.3.0",
-    "lucide-angular": "0.468.0",
-    "rxjs": "~7.8.0",
-    "tslib": "^2.3.0",
-    "zone.js": "~0.14.3"
-  },
-  "devDependencies": {
-    "@angular-devkit/build-angular": "^17.3.17",
-    "@angular/cli": "^17.3.17",
-    "@angular/compiler-cli": "^17.3.0",
-    "@types/jasmine": "~5.1.0",
-    "jasmine-core": "~5.1.0",
-    "karma": "~6.4.0",
-    "karma-chrome-launcher": "~3.2.0",
-    "karma-coverage": "~2.2.0",
-    "karma-jasmine": "~5.1.0",
-    "karma-jasmine-html-reporter": "~2.1.0",
-    "typescript": "~5.4.2"
-  }
-}

+ 0 - 50
apps/web/src/app/app.component.html

@@ -1,50 +0,0 @@
-<div class="app-shell">
-  <header class="topbar">
-    <button class="icon-button menu-button" type="button" (click)="toggleMenu()" aria-label="打开导航">
-      <lucide-icon [img]="MenuIcon" [size]="20"></lucide-icon>
-    </button>
-    <a class="brand" routerLink="/" (click)="closeMenu()">
-      <span class="brand-mark">VOC</span>
-      <span class="brand-name">Saas VOC</span>
-    </a>
-    <div class="topbar-context">
-      <span class="case-name">德玛仕案例</span>
-      <span class="connection-status" [class.is-error]="store.error()">
-        <span class="status-dot"></span>
-        {{ store.error() ? '数据异常' : (store.loading() ? '数据加载中' : '本地数据已就绪') }}
-      </span>
-    </div>
-  </header>
-
-  <div class="shell-body">
-    <button class="nav-backdrop" type="button" [class.visible]="menuOpen()" (click)="closeMenu()" aria-label="关闭导航"></button>
-    <aside class="sidebar" [class.open]="menuOpen()">
-      <nav aria-label="主导航">
-        @for (item of navItems; track item.route) {
-          <a
-            class="nav-item"
-            [routerLink]="item.route"
-            routerLinkActive="active"
-            [routerLinkActiveOptions]="{ exact: item.exact }"
-            (click)="closeMenu()"
-          >
-            <lucide-icon [img]="item.icon" [size]="18"></lucide-icon>
-            <span>{{ item.label }}</span>
-          </a>
-        }
-      </nav>
-
-      <div class="sidebar-footer">
-        <div class="source-label">数据源</div>
-        <div class="source-value">
-          <lucide-icon [img]="DatabaseIcon" [size]="16"></lucide-icon>
-          <span>京东 / Excel</span>
-        </div>
-      </div>
-    </aside>
-
-    <main class="main-content">
-      <router-outlet></router-outlet>
-    </main>
-  </div>
-</div>

+ 0 - 219
apps/web/src/app/app.component.scss

@@ -1,219 +0,0 @@
-:host {
-  display: block;
-  min-height: 100vh;
-}
-
-.app-shell {
-  min-height: 100vh;
-  background: var(--color-page);
-}
-
-.topbar {
-  position: fixed;
-  inset: 0 0 auto 0;
-  z-index: 30;
-  display: flex;
-  align-items: center;
-  height: 56px;
-  padding: 0 20px;
-  color: #fff;
-  background: #171a1f;
-  border-bottom: 1px solid #2c3037;
-}
-
-.brand {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  width: 200px;
-}
-
-.brand-mark {
-  display: grid;
-  place-items: center;
-  width: 34px;
-  height: 28px;
-  color: #111827;
-  background: #d9f99d;
-  border-radius: 4px;
-  font-size: 11px;
-  font-weight: 800;
-}
-
-.brand-name {
-  font-size: 15px;
-  font-weight: 650;
-}
-
-.topbar-context {
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  gap: 18px;
-  min-width: 0;
-  margin-left: auto;
-}
-
-.case-name {
-  color: #e5e7eb;
-  font-size: 13px;
-}
-
-.connection-status {
-  display: flex;
-  align-items: center;
-  gap: 7px;
-  min-width: 112px;
-  color: #cbd5e1;
-  font-size: 12px;
-}
-
-.status-dot {
-  width: 7px;
-  height: 7px;
-  background: #84cc16;
-  border-radius: 50%;
-}
-
-.connection-status.is-error .status-dot {
-  background: #ef4444;
-}
-
-.menu-button {
-  display: none;
-  margin-right: 10px;
-  color: #fff;
-}
-
-.icon-button {
-  align-items: center;
-  justify-content: center;
-  width: 36px;
-  height: 36px;
-  border-radius: 6px;
-}
-
-.icon-button:hover {
-  background: #2c3037;
-}
-
-.shell-body {
-  min-height: 100vh;
-  padding-top: 56px;
-}
-
-.sidebar {
-  position: fixed;
-  inset: 56px auto 0 0;
-  z-index: 20;
-  display: flex;
-  flex-direction: column;
-  width: 220px;
-  padding: 18px 12px;
-  background: #fff;
-  border-right: 1px solid var(--color-border);
-}
-
-nav {
-  display: grid;
-  gap: 4px;
-}
-
-.nav-item {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  min-height: 42px;
-  padding: 0 12px;
-  color: #667085;
-  border-radius: 6px;
-  font-size: 13px;
-  font-weight: 550;
-}
-
-.nav-item:hover {
-  color: #1f2937;
-  background: #f4f6f8;
-}
-
-.nav-item.active {
-  color: #17210a;
-  background: #eef7dc;
-}
-
-.sidebar-footer {
-  margin-top: auto;
-  padding: 14px 12px 4px;
-  border-top: 1px solid var(--color-border);
-}
-
-.source-label {
-  margin-bottom: 8px;
-  color: #98a2b3;
-  font-size: 11px;
-}
-
-.source-value {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  color: #475467;
-  font-size: 12px;
-}
-
-.main-content {
-  min-height: calc(100vh - 56px);
-  margin-left: 220px;
-}
-
-.nav-backdrop {
-  display: none;
-}
-
-@media (max-width: 760px) {
-  .topbar {
-    padding: 0 12px;
-  }
-
-  .menu-button {
-    display: flex;
-  }
-
-  .brand {
-    width: auto;
-  }
-
-  .brand-name,
-  .case-name {
-    display: none;
-  }
-
-  .sidebar {
-    transform: translateX(-100%);
-    transition: transform 180ms ease;
-  }
-
-  .sidebar.open {
-    transform: translateX(0);
-  }
-
-  .nav-backdrop {
-    position: fixed;
-    inset: 56px 0 0;
-    z-index: 15;
-    display: block;
-    visibility: hidden;
-    background: rgba(17, 24, 39, 0.4);
-    opacity: 0;
-    transition: opacity 180ms ease;
-  }
-
-  .nav-backdrop.visible {
-    visibility: visible;
-    opacity: 1;
-  }
-
-  .main-content {
-    margin-left: 0;
-  }
-}

+ 0 - 20
apps/web/src/app/app.component.spec.ts

@@ -1,20 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-import { provideHttpClient } from '@angular/common/http';
-import { provideHttpClientTesting } from '@angular/common/http/testing';
-import { provideRouter } from '@angular/router';
-import { AppComponent } from './app.component';
-
-describe('AppComponent', () => {
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      imports: [AppComponent],
-      providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
-    }).compileComponents();
-  });
-
-  it('creates the application shell', () => {
-    const fixture = TestBed.createComponent(AppComponent);
-    const app = fixture.componentInstance;
-    expect(app).toBeTruthy();
-  });
-});

+ 0 - 43
apps/web/src/app/app.component.ts

@@ -1,43 +0,0 @@
-import { Component, inject, signal } from '@angular/core';
-import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
-import {
-  BarChart3,
-  Boxes,
-  Database,
-  LayoutDashboard,
-  LucideAngularModule,
-  Menu,
-} from 'lucide-angular';
-import { DatasetStore } from './core/services/dataset-store.service';
-
-@Component({
-  selector: 'app-root',
-  standalone: true,
-  imports: [RouterOutlet, RouterLink, RouterLinkActive, LucideAngularModule],
-  templateUrl: './app.component.html',
-  styleUrl: './app.component.scss',
-})
-export class AppComponent {
-  readonly store = inject(DatasetStore);
-  readonly menuOpen = signal(false);
-  readonly MenuIcon = Menu;
-  readonly DatabaseIcon = Database;
-  readonly navItems = [
-    { label: '经营概览', route: '/', icon: LayoutDashboard, exact: true },
-    { label: '商品与竞品', route: '/products', icon: Boxes, exact: false },
-    { label: 'VOC 分析', route: '/voc', icon: BarChart3, exact: false },
-    { label: '数据状态', route: '/data', icon: Database, exact: false },
-  ];
-
-  constructor() {
-    this.store.load();
-  }
-
-  toggleMenu(): void {
-    this.menuOpen.update((value) => !value);
-  }
-
-  closeMenu(): void {
-    this.menuOpen.set(false);
-  }
-}

+ 0 - 9
apps/web/src/app/app.config.ts

@@ -1,9 +0,0 @@
-import { ApplicationConfig } from '@angular/core';
-import { provideHttpClient } from '@angular/common/http';
-import { provideRouter } from '@angular/router';
-
-import { routes } from './app.routes';
-
-export const appConfig: ApplicationConfig = {
-  providers: [provideHttpClient(), provideRouter(routes)]
-};

+ 0 - 25
apps/web/src/app/app.routes.ts

@@ -1,25 +0,0 @@
-import { Routes } from '@angular/router';
-
-export const routes: Routes = [
-  {
-    path: '',
-    title: '经营概览',
-    loadComponent: () => import('./features/dashboard/dashboard.component').then((m) => m.DashboardComponent),
-  },
-  {
-    path: 'products',
-    title: '商品与竞品',
-    loadComponent: () => import('./features/products/products.component').then((m) => m.ProductsComponent),
-  },
-  {
-    path: 'voc',
-    title: 'VOC 分析',
-    loadComponent: () => import('./features/voc/voc.component').then((m) => m.VocComponent),
-  },
-  {
-    path: 'data',
-    title: '数据状态',
-    loadComponent: () => import('./features/data-status/data-status.component').then((m) => m.DataStatusComponent),
-  },
-  { path: '**', redirectTo: '' },
-];

+ 0 - 20
apps/web/src/app/core/adapters/product-identity.adapter.spec.ts

@@ -1,20 +0,0 @@
-import { buildProductKey, withAsinCompatibility } from './product-identity.adapter';
-import { ProductRecord } from '../models/domain.models';
-
-describe('product identity adapter', () => {
-  it('builds a platform-scoped product key', () => {
-    expect(buildProductKey('jd', '11266507445')).toBe('jd:11266507445');
-  });
-
-  it('keeps asin as a temporary compatibility alias', () => {
-    const product = {
-      platform: 'jd',
-      productId: '11266507445',
-      productKey: '',
-      asin: '',
-    } as ProductRecord;
-    const adapted = withAsinCompatibility(product);
-    expect(adapted.productKey).toBe('jd:11266507445');
-    expect(adapted.asin).toBe('11266507445');
-  });
-});

+ 0 - 17
apps/web/src/app/core/adapters/product-identity.adapter.ts

@@ -1,17 +0,0 @@
-import { Platform, ProductRecord } from '../models/domain.models';
-
-export function buildProductKey(platform: Platform, productId: string | number): string {
-  const normalizedId = String(productId ?? '').trim();
-  if (!normalizedId) throw new Error('productId is required');
-  return `${platform}:${normalizedId}`;
-}
-
-export function withAsinCompatibility(product: ProductRecord): ProductRecord {
-  const productId = String(product.productId || product.asin || '').trim();
-  return {
-    ...product,
-    productId,
-    productKey: product.productKey || buildProductKey(product.platform, productId),
-    asin: productId,
-  };
-}

+ 0 - 105
apps/web/src/app/core/models/domain.models.ts

@@ -1,105 +0,0 @@
-export type Platform = 'jd' | 'taobao' | 'tmall' | 'pdd' | 'douyin' | '1688';
-export type ProductRole = 'own' | 'competitor';
-
-export interface MetricSummary {
-  gmv: number;
-  soldUnits: number;
-  transactionOrders: number;
-  transactionCustomers: number;
-  impressions: number;
-  clicks: number;
-  views: number;
-  visitors: number;
-  cartUnits: number;
-  orderAmount: number;
-  orderUnits: number;
-  orderCount: number;
-  refundAmount: number;
-  refundUnits: number;
-  refundOrders: number;
-  conversionRate: number;
-  clickThroughRate: number;
-  averageUnitPrice: number;
-  refundToGmvRate: number;
-}
-
-export interface DailyMetric extends MetricSummary {
-  date: string;
-}
-
-export interface ProductRecord {
-  platform: Platform;
-  productId: string;
-  productKey: string;
-  asin: string;
-  role: ProductRole;
-  brand: string;
-  title: string;
-  model: string;
-  category1: string;
-  category2: string;
-  category3: string;
-  source: 'excel' | 'justone';
-  relationCount: number;
-  summary: MetricSummary;
-  trend: DailyMetric[];
-}
-
-export interface ProductRelation {
-  relationKey: string;
-  ownProductKey: string;
-  ownProductId: string;
-  competitorProductKey: string;
-  competitorProductId: string;
-  competitorBrand: string;
-  category: string;
-}
-
-export interface MappingGroup {
-  ownProductId: string;
-  ownProductKey: string;
-  model: string;
-  category: string;
-  competitors: ProductRelation[];
-}
-
-export interface QualityItem {
-  ownProductId: string;
-  ownProductKey?: string;
-  model?: string;
-  category?: string;
-  slot?: number;
-  brand?: string;
-}
-
-export interface DemashiDataset {
-  schemaVersion: number;
-  generatedAt: string;
-  caseName: string;
-  platform: Platform;
-  source: {
-    sourceFile: string;
-    sourceHash: string;
-    sheets: string[];
-    dateRange: { start: string; end: string };
-  };
-  summary: {
-    metricRows: number;
-    metricProducts: number;
-    mappingRows: number;
-    relations: number;
-    uniqueCompetitorProducts: number;
-    category2Count: number;
-    category3Count: number;
-    reviewCount: number;
-  };
-  dailyTotals: DailyMetric[];
-  products: ProductRecord[];
-  mappingGroups: MappingGroup[];
-  relations: ProductRelation[];
-  quality: {
-    orphanMappings: QualityItem[];
-    mappingsWithoutCompetitor: QualityItem[];
-    brandWithoutProductId: QualityItem[];
-  };
-}

+ 0 - 47
apps/web/src/app/core/services/dataset-store.service.ts

@@ -1,47 +0,0 @@
-import { HttpClient } from '@angular/common/http';
-import { Injectable, computed, inject, signal } from '@angular/core';
-import { finalize, map } from 'rxjs/operators';
-import { withAsinCompatibility } from '../adapters/product-identity.adapter';
-import { DemashiDataset, MappingGroup, ProductRecord } from '../models/domain.models';
-
-@Injectable({ providedIn: 'root' })
-export class DatasetStore {
-  private readonly http = inject(HttpClient);
-  private readonly datasetState = signal<DemashiDataset | null>(null);
-  private readonly loadingState = signal(false);
-  private readonly errorState = signal('');
-  private loaded = false;
-
-  readonly dataset = this.datasetState.asReadonly();
-  readonly loading = this.loadingState.asReadonly();
-  readonly error = this.errorState.asReadonly();
-  readonly products = computed(() => this.datasetState()?.products ?? []);
-  readonly mappingGroups = computed(() => this.datasetState()?.mappingGroups ?? []);
-
-  load(): void {
-    if (this.loaded || this.loadingState()) return;
-    this.loadingState.set(true);
-    this.errorState.set('');
-    this.http.get<DemashiDataset>('assets/data/demashi-summary.json').pipe(
-      map((dataset) => ({
-        ...dataset,
-        products: dataset.products.map(withAsinCompatibility),
-      })),
-      finalize(() => this.loadingState.set(false)),
-    ).subscribe({
-      next: (dataset) => {
-        this.datasetState.set(dataset);
-        this.loaded = true;
-      },
-      error: () => this.errorState.set('案例数据加载失败'),
-    });
-  }
-
-  getProduct(productKey: string): ProductRecord | undefined {
-    return this.products().find((product) => product.productKey === productKey);
-  }
-
-  getMapping(productKey: string): MappingGroup | undefined {
-    return this.mappingGroups().find((group) => group.ownProductKey === productKey);
-  }
-}

+ 0 - 93
apps/web/src/app/features/dashboard/dashboard.component.html

@@ -1,93 +0,0 @@
-<section class="page">
-  @if (store.loading()) {
-    <div class="loading-state">正在加载德玛仕案例数据...</div>
-  } @else if (store.error()) {
-    <div class="error-state">{{ store.error() }}</div>
-  } @else {
-    @if (dataset(); as data) {
-    <header class="page-header">
-      <div>
-        <h1 class="page-title">经营概览</h1>
-        <p class="page-subtitle">{{ data.source.dateRange.start }} 至 {{ data.source.dateRange.end }}</p>
-      </div>
-      <a class="text-link" routerLink="/data">查看数据状态</a>
-    </header>
-
-    <div class="kpi-grid">
-      <article class="kpi-item">
-        <div class="kpi-icon green"><lucide-icon [img]="TrendingUpIcon" [size]="19"></lucide-icon></div>
-        <div><span class="kpi-label">7 日成交金额</span><strong>{{ formatCurrency(totalGmv()) }}</strong></div>
-      </article>
-      <article class="kpi-item">
-        <div class="kpi-icon blue"><lucide-icon [img]="PackageIcon" [size]="19"></lucide-icon></div>
-        <div><span class="kpi-label">成交订单</span><strong>{{ formatNumber(totalOrders()) }}</strong></div>
-      </article>
-      <article class="kpi-item">
-        <div class="kpi-icon amber"><lucide-icon [img]="CompareIcon" [size]="19"></lucide-icon></div>
-        <div><span class="kpi-label">竞品映射覆盖</span><strong>{{ formatPercent(mappingCoverage()) }}</strong></div>
-      </article>
-      <article class="kpi-item">
-        <div class="kpi-icon red"><lucide-icon [img]="RefundIcon" [size]="19"></lucide-icon></div>
-        <div><span class="kpi-label">取消及售后退款</span><strong>{{ formatCurrency(totalRefund()) }}</strong></div>
-      </article>
-    </div>
-
-    <div class="analysis-grid">
-      <section class="panel trend-panel">
-        <div class="panel-header">
-          <h2 class="panel-title">成交趋势</h2>
-          <span class="muted">{{ formatNumber(data.summary.metricRows) }} 条日数据</span>
-        </div>
-        <div class="bar-chart" role="img" aria-label="7 日成交金额趋势">
-          @for (item of data.dailyTotals; track item.date) {
-            <div class="bar-column" [title]="item.date + ' ' + formatCurrency(item.gmv)">
-              <div class="bar-value">{{ formatCurrency(item.gmv) }}</div>
-              <div class="bar-track"><div class="bar" [style.height.%]="item.gmv / maxDailyGmv() * 100"></div></div>
-              <span>{{ shortDate(item.date) }}</span>
-            </div>
-          }
-        </div>
-      </section>
-
-      <section class="panel category-panel">
-        <div class="panel-header">
-          <h2 class="panel-title">二级类目贡献</h2>
-          <span class="muted">Top 6</span>
-        </div>
-        <div class="category-list">
-          @for (item of categoryTotals(); track item.name) {
-            <div class="category-row">
-              <div class="category-meta"><span>{{ item.name }}</span><strong>{{ formatCurrency(item.gmv) }}</strong></div>
-              <div class="progress-track"><div class="progress-fill" [style.width.%]="item.gmv / maxCategoryGmv() * 100"></div></div>
-            </div>
-          }
-        </div>
-      </section>
-    </div>
-
-    <section class="panel top-products">
-      <div class="panel-header">
-        <h2 class="panel-title">成交商品</h2>
-        <a class="text-link" routerLink="/products">全部商品</a>
-      </div>
-      <div class="table-scroll">
-        <table>
-          <thead><tr><th>SKU</th><th>商品</th><th>三级类目</th><th class="number">成交金额</th><th class="number">成交单量</th><th class="number">退款金额</th></tr></thead>
-          <tbody>
-            @for (product of topProducts(); track product.productKey) {
-              <tr>
-                <td class="mono">{{ product.productId }}</td>
-                <td><span class="product-title" [title]="product.title">{{ product.title }}</span></td>
-                <td>{{ product.category3 }}</td>
-                <td class="number strong">{{ formatCurrency(product.summary.gmv) }}</td>
-                <td class="number">{{ formatNumber(product.summary.transactionOrders) }}</td>
-                <td class="number">{{ formatCurrency(product.summary.refundAmount) }}</td>
-              </tr>
-            }
-          </tbody>
-        </table>
-      </div>
-    </section>
-    }
-  }
-</section>

+ 0 - 117
apps/web/src/app/features/dashboard/dashboard.component.scss

@@ -1,117 +0,0 @@
-.text-link {
-  color: var(--color-accent);
-  font-size: 13px;
-  font-weight: 600;
-}
-
-.text-link:hover { text-decoration: underline; }
-
-.kpi-grid {
-  display: grid;
-  grid-template-columns: repeat(4, minmax(0, 1fr));
-  gap: 12px;
-  margin-bottom: 16px;
-}
-
-.kpi-item {
-  display: flex;
-  align-items: center;
-  gap: 14px;
-  min-width: 0;
-  min-height: 92px;
-  padding: 16px;
-  background: #fff;
-  border: 1px solid var(--color-border);
-  border-radius: 8px;
-  box-shadow: var(--shadow-sm);
-}
-
-.kpi-icon {
-  display: grid;
-  place-items: center;
-  flex: 0 0 38px;
-  width: 38px;
-  height: 38px;
-  border-radius: 6px;
-}
-
-.kpi-icon.green { color: #4d6b1c; background: #eef7dc; }
-.kpi-icon.blue { color: #1d4ed8; background: #eff6ff; }
-.kpi-icon.amber { color: #b45309; background: #fff7ed; }
-.kpi-icon.red { color: #b91c1c; background: #fef2f2; }
-
-.kpi-item strong,
-.kpi-label { display: block; }
-.kpi-label { margin-bottom: 4px; color: var(--color-muted); font-size: 12px; }
-.kpi-item strong { overflow: hidden; font-size: 20px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
-
-.analysis-grid {
-  display: grid;
-  grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.8fr);
-  gap: 16px;
-  margin-bottom: 16px;
-}
-
-.panel-header .muted { font-size: 12px; }
-
-.bar-chart {
-  display: grid;
-  grid-template-columns: repeat(7, minmax(54px, 1fr));
-  gap: 12px;
-  height: 250px;
-  padding: 26px 20px 18px;
-}
-
-.bar-column {
-  display: grid;
-  grid-template-rows: 22px 1fr 20px;
-  gap: 7px;
-  min-width: 0;
-  text-align: center;
-}
-
-.bar-value { overflow: hidden; color: var(--color-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
-.bar-track { position: relative; min-height: 0; background: #f2f4f7; border-radius: 4px 4px 2px 2px; }
-.bar { position: absolute; inset: auto 0 0; min-height: 3px; background: #6c8c35; border-radius: 4px 4px 2px 2px; }
-.bar-column span { color: var(--color-muted); font-size: 11px; }
-
-.category-list { display: grid; gap: 18px; padding: 20px; }
-.category-meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 7px; font-size: 12px; }
-.category-meta span { overflow: hidden; color: var(--color-muted); text-overflow: ellipsis; white-space: nowrap; }
-.category-meta strong { font-weight: 600; }
-.progress-track { height: 6px; overflow: hidden; background: #f2f4f7; border-radius: 3px; }
-.progress-fill { height: 100%; background: #2563eb; border-radius: 3px; }
-
-.table-scroll { overflow-x: auto; }
-table { width: 100%; table-layout: fixed; }
-th, td { height: 46px; padding: 0 16px; border-bottom: 1px solid #eef0f2; text-align: left; }
-th { color: var(--color-muted); background: #fafbfc; font-size: 11px; font-weight: 600; }
-td { color: #475467; font-size: 12px; }
-tbody tr:last-child td { border-bottom: 0; }
-tbody tr:hover { background: #fafcf7; }
-th:nth-child(1) { width: 132px; }
-th:nth-child(2) { width: 38%; }
-th:nth-child(3) { width: 20%; }
-.number { text-align: right; }
-.strong { color: var(--color-text); font-weight: 650; }
-.mono { font-family: "SFMono-Regular", Consolas, monospace; }
-.product-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
-@media (max-width: 1100px) {
-  .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-  .analysis-grid { grid-template-columns: 1fr; }
-}
-
-@media (max-width: 620px) {
-  .kpi-grid { grid-template-columns: 1fr; }
-  .kpi-item { min-height: 78px; }
-  .bar-chart {
-    grid-template-columns: repeat(7, minmax(0, 1fr));
-    gap: 6px;
-    height: 190px;
-    padding: 18px 12px 12px;
-  }
-  .bar-column { grid-template-rows: 1fr 20px; }
-  .bar-value { display: none; }
-  table { min-width: 820px; }
-}

+ 0 - 58
apps/web/src/app/features/dashboard/dashboard.component.ts

@@ -1,58 +0,0 @@
-import { Component, computed, inject } from '@angular/core';
-import { RouterLink } from '@angular/router';
-import { GitCompareArrows, LucideAngularModule, PackageCheck, RotateCcw, TrendingUp } from 'lucide-angular';
-import { DatasetStore } from '../../core/services/dataset-store.service';
-
-@Component({
-  selector: 'app-dashboard',
-  standalone: true,
-  imports: [RouterLink, LucideAngularModule],
-  templateUrl: './dashboard.component.html',
-  styleUrl: './dashboard.component.scss',
-})
-export class DashboardComponent {
-  readonly store = inject(DatasetStore);
-  readonly TrendingUpIcon = TrendingUp;
-  readonly PackageIcon = PackageCheck;
-  readonly CompareIcon = GitCompareArrows;
-  readonly RefundIcon = RotateCcw;
-  readonly dataset = this.store.dataset;
-  readonly topProducts = computed(() => this.store.products().slice(0, 6));
-  readonly totalGmv = computed(() => this.dataset()?.dailyTotals.reduce((sum, item) => sum + item.gmv, 0) ?? 0);
-  readonly totalOrders = computed(() => this.dataset()?.dailyTotals.reduce((sum, item) => sum + item.transactionOrders, 0) ?? 0);
-  readonly totalRefund = computed(() => this.dataset()?.dailyTotals.reduce((sum, item) => sum + item.refundAmount, 0) ?? 0);
-  readonly mappingCoverage = computed(() => {
-    const dataset = this.dataset();
-    if (!dataset?.summary.mappingRows) return 0;
-    const ready = dataset.mappingGroups.filter((group) => group.competitors.length > 0).length;
-    return ready / dataset.summary.mappingRows;
-  });
-  readonly maxDailyGmv = computed(() => Math.max(1, ...(this.dataset()?.dailyTotals.map((item) => item.gmv) ?? [1])));
-  readonly categoryTotals = computed(() => {
-    const totals = new Map<string, number>();
-    for (const product of this.store.products()) {
-      const name = product.category2 || '未分类';
-      totals.set(name, (totals.get(name) ?? 0) + product.summary.gmv);
-    }
-    return Array.from(totals, ([name, gmv]) => ({ name, gmv }))
-      .sort((left, right) => right.gmv - left.gmv)
-      .slice(0, 6);
-  });
-  readonly maxCategoryGmv = computed(() => Math.max(1, ...this.categoryTotals().map((item) => item.gmv)));
-
-  formatCurrency(value: number): string {
-    return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
-  }
-
-  formatNumber(value: number): string {
-    return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
-  }
-
-  formatPercent(value: number): string {
-    return new Intl.NumberFormat('zh-CN', { style: 'percent', maximumFractionDigits: 1 }).format(value || 0);
-  }
-
-  shortDate(value: string): string {
-    return value.slice(5);
-  }
-}

+ 0 - 57
apps/web/src/app/features/data-status/data-status.component.html

@@ -1,57 +0,0 @@
-<section class="page data-page">
-  <header class="page-header">
-    <div><h1 class="page-title">数据状态</h1><p class="page-subtitle">导入结果、质量问题与外部依赖</p></div>
-  </header>
-
-  @if (dataset(); as data) {
-    <section class="source-band panel">
-      <div class="source-icon"><lucide-icon [img]="SpreadsheetIcon" [size]="22"></lucide-icon></div>
-      <div class="source-main"><strong>{{ data.source.sourceFile }}</strong><span>{{ data.source.dateRange.start }} 至 {{ data.source.dateRange.end }}</span></div>
-      <div class="source-meta"><span>最近生成</span><strong>{{ formatDate(data.generatedAt) }}</strong></div>
-      <div class="ready"><lucide-icon [img]="CheckIcon" [size]="16"></lucide-icon><span>已完成规范化</span></div>
-    </section>
-
-    <div class="data-summary">
-      <div><span>日指标记录</span><strong>{{ data.summary.metricRows }}</strong></div>
-      <div><span>有经营数据商品</span><strong>{{ data.summary.metricProducts }}</strong></div>
-      <div><span>竞品关系</span><strong>{{ data.summary.relations }}</strong></div>
-      <div><span>可分析评论</span><strong>{{ data.summary.reviewCount }}</strong></div>
-    </div>
-
-    <div class="status-grid">
-      <section class="panel pipeline-panel">
-        <div class="panel-header"><h2 class="panel-title">数据链路</h2></div>
-        <ol class="pipeline-list">
-          <li class="done"><lucide-icon [img]="CheckIcon" [size]="17"></lucide-icon><div><strong>Excel 商品经营数据</strong><span>{{ data.summary.metricRows }} 条记录</span></div></li>
-          <li class="done"><lucide-icon [img]="CheckIcon" [size]="17"></lucide-icon><div><strong>商品与竞品关系</strong><span>{{ data.summary.mappingRows }} 组映射</span></div></li>
-          <li class="waiting"><lucide-icon [img]="ClockIcon" [size]="17"></lucide-icon><div><strong>独立 Parse 数据库</strong><span>待创建与部署</span></div></li>
-          <li class="waiting"><lucide-icon [img]="ClockIcon" [size]="17"></lucide-icon><div><strong>JustOneAPI 评论</strong><span>Fmode TLS 入口待恢复</span></div></li>
-        </ol>
-      </section>
-
-      <section class="panel dependency-panel">
-        <div class="panel-header"><h2 class="panel-title">外部依赖</h2></div>
-        <div class="dependency-list">
-          <div><lucide-icon [img]="DatabaseIcon" [size]="18"></lucide-icon><span><strong>PostgreSQL / Parse</strong><small>新库配置待就绪</small></span><b class="waiting-text">待处理</b></div>
-          <div><lucide-icon [img]="WarningIcon" [size]="18"></lucide-icon><span><strong>server.fmode.cn:443</strong><small>TLS 握手失败</small></span><b class="blocked-text">阻塞</b></div>
-        </div>
-      </section>
-    </div>
-
-    <section class="panel quality-panel">
-      <div class="panel-header"><h2 class="panel-title">数据质量问题</h2><span class="issue-count">{{ qualityIssues().length }} 项</span></div>
-      <div class="table-scroll">
-        <table>
-          <thead><tr><th>级别</th><th>类型</th><th>SKU</th><th>详情</th></tr></thead>
-          <tbody>
-            @for (issue of qualityIssues(); track issue.type + issue.productId) {
-              <tr><td><span class="level" [class.warning]="issue.level === '警告'">{{ issue.level }}</span></td><td>{{ issue.type }}</td><td class="mono">{{ issue.productId }}</td><td>{{ issue.detail }}</td></tr>
-            } @empty {
-              <tr><td class="empty-cell" colspan="4">未发现数据质量问题</td></tr>
-            }
-          </tbody>
-        </table>
-      </div>
-    </section>
-  }
-</section>

+ 0 - 53
apps/web/src/app/features/data-status/data-status.component.scss

@@ -1,53 +0,0 @@
-.source-band { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto auto; align-items: center; gap: 14px; min-height: 78px; margin-bottom: 14px; padding: 14px 18px; }
-.source-icon { display: grid; place-items: center; width: 40px; height: 40px; color: #4d6b1c; background: #eef7dc; border-radius: 6px; }
-.source-main, .source-main strong, .source-main span, .source-meta span, .source-meta strong { display: block; }
-.source-main { min-width: 0; }
-.source-main strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
-.source-main span, .source-meta span { margin-top: 3px; color: var(--color-muted); font-size: 10px; }
-.source-meta { min-width: 150px; padding-left: 18px; border-left: 1px solid var(--color-border); }
-.source-meta strong { margin-top: 3px; font-size: 11px; font-weight: 600; }
-.ready { display: flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 10px; color: #4d6b1c; background: #eef7dc; border-radius: 5px; font-size: 10px; }
-
-.data-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 14px; background: #fff; border: 1px solid var(--color-border); border-radius: 8px; box-shadow: var(--shadow-sm); }
-.data-summary div { padding: 14px 18px; border-right: 1px solid var(--color-border); }
-.data-summary div:last-child { border-right: 0; }
-.data-summary span, .data-summary strong { display: block; }
-.data-summary span { color: var(--color-muted); font-size: 10px; }
-.data-summary strong { margin-top: 4px; font-size: 19px; }
-
-.status-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, .75fr); gap: 14px; margin-bottom: 14px; }
-.pipeline-list { display: grid; margin: 0; padding: 10px 18px 16px; list-style: none; }
-.pipeline-list li { position: relative; display: grid; grid-template-columns: 28px 1fr; min-height: 58px; padding-top: 12px; }
-.pipeline-list li::after { position: absolute; top: 35px; bottom: -10px; left: 8px; width: 1px; background: var(--color-border); content: ""; }
-.pipeline-list li:last-child::after { display: none; }
-.pipeline-list lucide-icon { margin-top: 1px; }
-.pipeline-list .done lucide-icon { color: #65a30d; }
-.pipeline-list .waiting lucide-icon { color: #d97706; }
-.pipeline-list strong, .pipeline-list span { display: block; }
-.pipeline-list strong { font-size: 12px; }
-.pipeline-list span { margin-top: 3px; color: var(--color-muted); font-size: 10px; }
-
-.dependency-list { display: grid; padding: 10px 18px 18px; }
-.dependency-list > div { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; min-height: 58px; border-bottom: 1px solid #eef0f2; }
-.dependency-list > div:last-child { border-bottom: 0; }
-.dependency-list lucide-icon { color: var(--color-muted); }
-.dependency-list strong, .dependency-list small { display: block; }
-.dependency-list strong { font-size: 12px; }
-.dependency-list small { margin-top: 2px; color: var(--color-muted); font-size: 10px; }
-.dependency-list b { font-size: 10px; font-weight: 650; }
-.waiting-text { color: #b45309; }.blocked-text { color: #b91c1c; }
-
-.issue-count { color: var(--color-muted); font-size: 11px; }
-.table-scroll { overflow-x: auto; }
-table { width: 100%; }
-th, td { height: 44px; padding: 0 16px; border-bottom: 1px solid #eef0f2; text-align: left; }
-th { color: var(--color-muted); background: #fafbfc; font-size: 11px; font-weight: 600; }
-td { color: #475467; font-size: 12px; }
-tbody tr:last-child td { border-bottom: 0; }
-.mono { font-family: "SFMono-Regular", Consolas, monospace; }
-.level { display: inline-grid; place-items: center; min-width: 38px; height: 22px; color: #92400e; background: #fff7ed; border-radius: 4px; font-size: 10px; }
-.level.warning { color: #b91c1c; background: #fef2f2; }
-.empty-cell { height: 140px; text-align: center; }
-
-@media (max-width: 980px) { .status-grid { grid-template-columns: 1fr; } }
-@media (max-width: 700px) { .source-band { grid-template-columns: 42px 1fr; } .source-meta { grid-column: 2; padding-left: 0; border-left: 0; } .ready { grid-column: 2; justify-self: start; } .data-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .data-summary div:nth-child(2) { border-right: 0; } .data-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--color-border); } table { min-width: 680px; } }

+ 0 - 33
apps/web/src/app/features/data-status/data-status.component.ts

@@ -1,33 +0,0 @@
-import { Component, computed, inject } from '@angular/core';
-import { CheckCircle2, CircleAlert, Clock3, Database, FileSpreadsheet, LucideAngularModule } from 'lucide-angular';
-import { DatasetStore } from '../../core/services/dataset-store.service';
-
-@Component({
-  selector: 'app-data-status',
-  standalone: true,
-  imports: [LucideAngularModule],
-  templateUrl: './data-status.component.html',
-  styleUrl: './data-status.component.scss',
-})
-export class DataStatusComponent {
-  readonly store = inject(DatasetStore);
-  readonly SpreadsheetIcon = FileSpreadsheet;
-  readonly DatabaseIcon = Database;
-  readonly CheckIcon = CheckCircle2;
-  readonly WarningIcon = CircleAlert;
-  readonly ClockIcon = Clock3;
-  readonly dataset = this.store.dataset;
-  readonly qualityIssues = computed(() => {
-    const quality = this.dataset()?.quality;
-    if (!quality) return [];
-    return [
-      ...quality.orphanMappings.map((item) => ({ level: '警告', type: '经营数据缺失', productId: item.ownProductId, detail: `${item.model || '-'} 在经营数据中无匹配 SKU` })),
-      ...quality.mappingsWithoutCompetitor.map((item) => ({ level: '提示', type: '竞品映射缺失', productId: item.ownProductId, detail: `${item.model || '-'} 未填写竞品 SKU` })),
-      ...quality.brandWithoutProductId.map((item) => ({ level: '提示', type: '竞品 SKU 缺失', productId: item.ownProductId, detail: `${item.brand || '-'} 有品牌但无商品 ID` })),
-    ];
-  });
-
-  formatDate(value: string): string {
-    return value ? new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : '--';
-  }
-}

+ 0 - 80
apps/web/src/app/features/products/products.component.html

@@ -1,80 +0,0 @@
-<section class="page products-page">
-  <header class="page-header">
-    <div><h1 class="page-title">商品与竞品</h1><p class="page-subtitle">{{ formatNumber(filteredProducts().length) }} 个匹配商品</p></div>
-  </header>
-
-  <div class="filters panel">
-    <label class="search-control">
-      <lucide-icon [img]="SearchIcon" [size]="17"></lucide-icon>
-      <span class="sr-only">搜索商品</span>
-      <input type="search" [ngModel]="search()" (ngModelChange)="setSearch($event)" placeholder="搜索 SKU、型号或商品名称" />
-    </label>
-    <label class="select-control">
-      <span class="sr-only">选择三级类目</span>
-      <select [ngModel]="category()" (ngModelChange)="setCategory($event)">
-        <option value="">全部三级类目</option>
-        @for (item of categories(); track item) { <option [value]="item">{{ item }}</option> }
-      </select>
-    </label>
-  </div>
-
-  <div class="workspace-grid">
-    <section class="panel product-table-panel">
-      <div class="table-scroll">
-        <table>
-          <thead><tr><th>SKU</th><th>商品名称</th><th>类目</th><th class="number">成交金额</th><th class="number">竞品</th></tr></thead>
-          <tbody>
-            @for (product of visibleProducts(); track product.productKey) {
-              <tr [class.selected]="selectedProduct()?.productKey === product.productKey" (click)="select(product.productKey)">
-                <td class="mono">{{ product.productId }}</td>
-                <td><span class="product-title" [title]="product.title">{{ product.title }}</span></td>
-                <td>{{ product.category3 }}</td>
-                <td class="number strong">{{ formatCurrency(product.summary.gmv) }}</td>
-                <td class="number">{{ product.relationCount }}</td>
-              </tr>
-            } @empty {
-              <tr><td class="empty-cell" colspan="5">没有匹配商品</td></tr>
-            }
-          </tbody>
-        </table>
-      </div>
-      <footer class="pagination">
-        <span>第 {{ page() }} / {{ pageCount() }} 页</span>
-        <div>
-          <button class="icon-button" type="button" (click)="previousPage()" [disabled]="page() === 1" aria-label="上一页"><lucide-icon [img]="PreviousIcon" [size]="17"></lucide-icon></button>
-          <button class="icon-button" type="button" (click)="nextPage()" [disabled]="page() === pageCount()" aria-label="下一页"><lucide-icon [img]="NextIcon" [size]="17"></lucide-icon></button>
-        </div>
-      </footer>
-    </section>
-
-    <aside class="panel detail-panel">
-      @if (selectedProduct(); as product) {
-        <div class="detail-heading">
-          <span class="mono">{{ product.productId }}</span>
-          <h2>{{ product.model || product.category3 }}</h2>
-          <p [title]="product.title">{{ product.title }}</p>
-        </div>
-        <dl class="metrics-grid">
-          <div><dt>7 日成交</dt><dd>{{ formatCurrency(product.summary.gmv) }}</dd></div>
-          <div><dt>成交单量</dt><dd>{{ formatNumber(product.summary.transactionOrders) }}</dd></div>
-          <div><dt>成交转化</dt><dd>{{ formatPercent(product.summary.conversionRate) }}</dd></div>
-          <div><dt>退款金额</dt><dd>{{ formatCurrency(product.summary.refundAmount) }}</dd></div>
-        </dl>
-        <section class="competitor-section">
-          <h3>竞品映射</h3>
-          @if (selectedMapping()?.competitors?.length) {
-            <div class="competitor-list">
-              @for (competitor of selectedMapping()!.competitors; track competitor.relationKey) {
-                <div class="competitor-row"><div><strong>{{ competitor.competitorBrand || '未标注品牌' }}</strong><span>{{ competitor.competitorProductId }}</span></div><span>京东</span></div>
-              }
-            </div>
-          } @else {
-            <div class="compact-empty">尚未建立竞品映射</div>
-          }
-        </section>
-      } @else {
-        <div class="empty-state">选择一个商品</div>
-      }
-    </aside>
-  </div>
-</section>

+ 0 - 76
apps/web/src/app/features/products/products.component.scss

@@ -1,76 +0,0 @@
-.filters {
-  display: flex;
-  gap: 12px;
-  margin-bottom: 16px;
-  padding: 12px;
-}
-
-.search-control,
-.select-control {
-  display: flex;
-  align-items: center;
-  height: 38px;
-  color: var(--color-muted);
-  background: #fff;
-  border: 1px solid #d0d5dd;
-  border-radius: 6px;
-}
-
-.search-control { flex: 1; gap: 8px; max-width: 560px; padding: 0 12px; }
-.search-control:focus-within, .select-control:focus-within { border-color: #7f9d47; box-shadow: 0 0 0 3px #eef7dc; }
-input, select { width: 100%; height: 100%; border: 0; color: var(--color-text); background: transparent; outline: 0; font-size: 13px; }
-.select-control { width: 230px; padding: 0 10px; }
-
-.workspace-grid {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) 340px;
-  gap: 16px;
-  align-items: start;
-}
-
-.table-scroll { overflow-x: auto; }
-table { width: 100%; table-layout: fixed; }
-th, td { height: 45px; padding: 0 14px; border-bottom: 1px solid #eef0f2; text-align: left; }
-th { color: var(--color-muted); background: #fafbfc; font-size: 11px; font-weight: 600; }
-td { color: #475467; font-size: 12px; }
-tbody tr { cursor: pointer; }
-tbody tr:hover { background: #fafcf7; }
-tbody tr.selected { background: #eef7dc; }
-th:nth-child(1) { width: 128px; }
-th:nth-child(2) { width: 42%; }
-th:nth-child(3) { width: 22%; }
-th:nth-child(4) { width: 118px; }
-th:nth-child(5) { width: 64px; }
-.number { text-align: right; }
-.strong { color: var(--color-text); font-weight: 650; }
-.mono { font-family: "SFMono-Regular", Consolas, monospace; }
-.product-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.empty-cell { height: 180px; color: var(--color-muted); text-align: center; }
-
-.pagination { display: flex; align-items: center; justify-content: space-between; min-height: 50px; padding: 0 14px; color: var(--color-muted); font-size: 12px; }
-.pagination div { display: flex; gap: 6px; }
-.icon-button { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: 6px; }
-.icon-button:hover:not(:disabled) { background: #f4f6f8; }
-.icon-button:disabled { cursor: not-allowed; opacity: .4; }
-
-.detail-panel { position: sticky; top: 72px; overflow: hidden; }
-.detail-heading { padding: 20px; border-bottom: 1px solid var(--color-border); }
-.detail-heading .mono { color: var(--color-accent); font-size: 11px; }
-.detail-heading h2 { margin-top: 6px; font-size: 17px; }
-.detail-heading p { display: -webkit-box; margin-top: 8px; overflow: hidden; color: var(--color-muted); font-size: 12px; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
-.metrics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; padding: 18px 20px; border-bottom: 1px solid var(--color-border); }
-.metrics-grid div { min-width: 0; padding: 9px 0; }
-.metrics-grid dt { color: var(--color-muted); font-size: 11px; }
-.metrics-grid dd { margin: 4px 0 0; overflow: hidden; font-size: 14px; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
-.competitor-section { padding: 18px 20px 20px; }
-.competitor-section h3 { margin-bottom: 12px; font-size: 13px; }
-.competitor-list { display: grid; gap: 8px; }
-.competitor-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 48px; padding: 9px 10px; background: #f8fafc; border-radius: 6px; }
-.competitor-row div { min-width: 0; }
-.competitor-row strong, .competitor-row span { display: block; }
-.competitor-row strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
-.competitor-row div span, .competitor-row > span { margin-top: 2px; color: var(--color-muted); font-size: 10px; }
-.compact-empty { padding: 28px 10px; color: var(--color-muted); background: #f8fafc; border-radius: 6px; text-align: center; font-size: 12px; }
-
-@media (max-width: 1120px) { .workspace-grid { grid-template-columns: 1fr; } .detail-panel { position: static; } }
-@media (max-width: 680px) { .filters { flex-direction: column; } .search-control, .select-control { width: 100%; max-width: none; } table { min-width: 780px; } }

+ 0 - 62
apps/web/src/app/features/products/products.component.ts

@@ -1,62 +0,0 @@
-import { Component, computed, effect, inject, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { ChevronLeft, ChevronRight, LucideAngularModule, Search } from 'lucide-angular';
-import { ProductRecord } from '../../core/models/domain.models';
-import { DatasetStore } from '../../core/services/dataset-store.service';
-
-@Component({
-  selector: 'app-products',
-  standalone: true,
-  imports: [FormsModule, LucideAngularModule],
-  templateUrl: './products.component.html',
-  styleUrl: './products.component.scss',
-})
-export class ProductsComponent {
-  readonly store = inject(DatasetStore);
-  readonly SearchIcon = Search;
-  readonly PreviousIcon = ChevronLeft;
-  readonly NextIcon = ChevronRight;
-  readonly search = signal('');
-  readonly category = signal('');
-  readonly page = signal(1);
-  readonly pageSize = 20;
-  readonly selectedKey = signal('');
-  readonly categories = computed(() => Array.from(new Set(this.store.products().map((item) => item.category3).filter(Boolean))).sort());
-  readonly filteredProducts = computed(() => {
-    const keyword = this.search().trim().toLowerCase();
-    const category = this.category();
-    return this.store.products().filter((product) => {
-      const matchesCategory = !category || product.category3 === category;
-      const haystack = `${product.productId} ${product.title} ${product.model} ${product.category3}`.toLowerCase();
-      return matchesCategory && (!keyword || haystack.includes(keyword));
-    });
-  });
-  readonly pageCount = computed(() => Math.max(1, Math.ceil(this.filteredProducts().length / this.pageSize)));
-  readonly visibleProducts = computed(() => {
-    const start = (this.page() - 1) * this.pageSize;
-    return this.filteredProducts().slice(start, start + this.pageSize);
-  });
-  readonly selectedProduct = computed<ProductRecord | undefined>(() => {
-    const products = this.filteredProducts();
-    return products.find((item) => item.productKey === this.selectedKey()) ?? products[0];
-  });
-  readonly selectedMapping = computed(() => {
-    const product = this.selectedProduct();
-    return product ? this.store.getMapping(product.productKey) : undefined;
-  });
-
-  constructor() {
-    effect(() => {
-      if (this.page() > this.pageCount()) this.page.set(this.pageCount());
-    }, { allowSignalWrites: true });
-  }
-
-  setSearch(value: string): void { this.search.set(value); this.page.set(1); }
-  setCategory(value: string): void { this.category.set(value); this.page.set(1); }
-  select(productKey: string): void { this.selectedKey.set(productKey); }
-  previousPage(): void { this.page.update((value) => Math.max(1, value - 1)); }
-  nextPage(): void { this.page.update((value) => Math.min(this.pageCount(), value + 1)); }
-  formatCurrency(value: number): string { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0); }
-  formatNumber(value: number): string { return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0); }
-  formatPercent(value: number): string { return new Intl.NumberFormat('zh-CN', { style: 'percent', maximumFractionDigits: 1 }).format(value || 0); }
-}

+ 0 - 60
apps/web/src/app/features/voc/voc.component.html

@@ -1,60 +0,0 @@
-<section class="page voc-page">
-  <header class="page-header">
-    <div><h1 class="page-title">VOC 分析</h1><p class="page-subtitle">评论样本状态与商品对比</p></div>
-    <label class="product-select">
-      <span class="sr-only">选择商品</span>
-      <select [ngModel]="selectedProduct()?.productKey" (ngModelChange)="selectProduct($event)">
-        @for (product of store.products().slice(0, 200); track product.productKey) {
-          <option [value]="product.productKey">{{ product.productId }} · {{ product.model || product.category3 }}</option>
-        }
-      </select>
-    </label>
-  </header>
-
-  @if (selectedProduct(); as product) {
-    <div class="status-strip">
-      <article><span>可分析评论</span><strong>0</strong><small>最小样本 10</small></article>
-      <article><span>平均评分</span><strong>--</strong><small>尚无评分数据</small></article>
-      <article><span>负向评论</span><strong>--</strong><small>尚未形成分布</small></article>
-      <article><span>已映射竞品</span><strong>{{ mapping()?.competitors?.length || 0 }}</strong><small>{{ product.category3 }}</small></article>
-    </div>
-
-    <div class="voc-grid">
-      <section class="panel sentiment-panel">
-        <div class="panel-header"><h2 class="panel-title">情绪分布</h2><span class="state-text">等待评论数据</span></div>
-        <div class="sentiment-bars">
-          <div><span>正向</span><div class="track"><div class="fill positive"></div></div><strong>--</strong></div>
-          <div><span>中性</span><div class="track"><div class="fill neutral"></div></div><strong>--</strong></div>
-          <div><span>负向</span><div class="track"><div class="fill negative"></div></div><strong>--</strong></div>
-        </div>
-        <div class="source-empty">
-          <lucide-icon [img]="MessageIcon" [size]="28"></lucide-icon>
-          <strong>暂无评论样本</strong>
-          <span>{{ product.productId }} 尚未同步 JustOneAPI 评论</span>
-        </div>
-      </section>
-
-      <section class="panel issue-panel">
-        <div class="panel-header"><h2 class="panel-title">问题标签</h2><span class="state-text">0 个问题</span></div>
-        <div class="issue-empty">
-          <lucide-icon [img]="AlertIcon" [size]="24"></lucide-icon>
-          <span>样本量达到 10 条后生成问题标签</span>
-        </div>
-      </section>
-    </div>
-
-    <section class="panel comparison-panel">
-      <div class="panel-header"><h2 class="panel-title">自有商品与竞品评论状态</h2><lucide-icon class="header-icon" [img]="DatabaseIcon" [size]="18"></lucide-icon></div>
-      <div class="table-scroll">
-        <table>
-          <thead><tr><th>角色</th><th>品牌</th><th>商品 ID</th><th class="number">评论数</th><th>数据状态</th></tr></thead>
-          <tbody>
-            @for (row of comparisonRows(); track row.role + row.productId) {
-              <tr><td>{{ row.role }}</td><td class="strong">{{ row.brand }}</td><td class="mono">{{ row.productId }}</td><td class="number">{{ row.reviewCount }}</td><td><span class="pending-status">待采集</span></td></tr>
-            }
-          </tbody>
-        </table>
-      </div>
-    </section>
-  }
-</section>

+ 0 - 38
apps/web/src/app/features/voc/voc.component.scss

@@ -1,38 +0,0 @@
-.product-select { display: block; width: min(420px, 100%); height: 38px; }
-.product-select select { width: 100%; height: 100%; padding: 0 34px 0 12px; border: 1px solid #d0d5dd; border-radius: 6px; color: var(--color-text); background: #fff; outline: 0; font-size: 12px; }
-.product-select select:focus { border-color: #7f9d47; box-shadow: 0 0 0 3px #eef7dc; }
-
-.status-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 16px; overflow: hidden; background: #fff; border: 1px solid var(--color-border); border-radius: 8px; box-shadow: var(--shadow-sm); }
-.status-strip article { min-width: 0; padding: 16px 18px; border-right: 1px solid var(--color-border); }
-.status-strip article:last-child { border-right: 0; }
-.status-strip span, .status-strip strong, .status-strip small { display: block; }
-.status-strip span { color: var(--color-muted); font-size: 11px; }
-.status-strip strong { margin-top: 5px; font-size: 22px; }
-.status-strip small { margin-top: 3px; overflow: hidden; color: var(--color-subtle); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
-
-.voc-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(300px, .75fr); gap: 16px; margin-bottom: 16px; }
-.state-text { color: var(--color-subtle); font-size: 11px; }
-.sentiment-bars { display: grid; gap: 14px; padding: 20px 20px 6px; }
-.sentiment-bars > div { display: grid; grid-template-columns: 44px minmax(0, 1fr) 32px; align-items: center; gap: 10px; color: var(--color-muted); font-size: 11px; }
-.track { height: 7px; overflow: hidden; background: #eef0f2; border-radius: 4px; }
-.fill { width: 0; height: 100%; }
-.positive { background: #65a30d; }.neutral { background: #d97706; }.negative { background: #dc2626; }
-.sentiment-bars strong { text-align: right; }
-.source-empty { display: grid; place-items: center; min-height: 190px; padding: 26px; color: var(--color-subtle); text-align: center; }
-.source-empty strong { margin-top: 9px; color: #475467; font-size: 13px; }
-.source-empty span { margin-top: 4px; font-size: 11px; }
-.issue-empty { display: grid; place-items: center; min-height: 296px; padding: 28px; color: var(--color-subtle); text-align: center; }
-.issue-empty span { max-width: 180px; margin-top: 10px; font-size: 11px; }
-.header-icon { color: var(--color-subtle); }
-
-.table-scroll { overflow-x: auto; }
-table { width: 100%; }
-th, td { height: 45px; padding: 0 16px; border-bottom: 1px solid #eef0f2; text-align: left; }
-th { color: var(--color-muted); background: #fafbfc; font-size: 11px; font-weight: 600; }
-td { color: #475467; font-size: 12px; }
-tbody tr:last-child td { border-bottom: 0; }
-.number { text-align: right; }.strong { color: var(--color-text); font-weight: 650; }.mono { font-family: "SFMono-Regular", Consolas, monospace; }
-.pending-status { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; color: #92400e; background: #fff7ed; border-radius: 4px; font-size: 10px; }
-
-@media (max-width: 980px) { .voc-grid { grid-template-columns: 1fr; } .issue-empty { min-height: 160px; } }
-@media (max-width: 700px) { .status-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } .status-strip article:nth-child(2) { border-right: 0; } .status-strip article:nth-child(-n+2) { border-bottom: 1px solid var(--color-border); } table { min-width: 640px; } }

+ 0 - 45
apps/web/src/app/features/voc/voc.component.ts

@@ -1,45 +0,0 @@
-import { Component, computed, inject, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { CircleAlert, DatabaseZap, LucideAngularModule, MessageSquareText } from 'lucide-angular';
-import { ProductRecord } from '../../core/models/domain.models';
-import { DatasetStore } from '../../core/services/dataset-store.service';
-
-@Component({
-  selector: 'app-voc',
-  standalone: true,
-  imports: [FormsModule, LucideAngularModule],
-  templateUrl: './voc.component.html',
-  styleUrl: './voc.component.scss',
-})
-export class VocComponent {
-  readonly store = inject(DatasetStore);
-  readonly MessageIcon = MessageSquareText;
-  readonly AlertIcon = CircleAlert;
-  readonly DatabaseIcon = DatabaseZap;
-  readonly selectedKey = signal('');
-  readonly selectedProduct = computed<ProductRecord | undefined>(() => {
-    const products = this.store.products();
-    return products.find((item) => item.productKey === this.selectedKey()) ?? products[0];
-  });
-  readonly mapping = computed(() => {
-    const product = this.selectedProduct();
-    return product ? this.store.getMapping(product.productKey) : undefined;
-  });
-  readonly comparisonRows = computed(() => {
-    const product = this.selectedProduct();
-    if (!product) return [];
-    return [
-      { role: '自有商品', brand: product.brand, productId: product.productId, reviewCount: 0 },
-      ...(this.mapping()?.competitors ?? []).map((item) => ({
-        role: '竞品',
-        brand: item.competitorBrand || '未标注品牌',
-        productId: item.competitorProductId,
-        reviewCount: 0,
-      })),
-    ];
-  });
-
-  selectProduct(productKey: string): void {
-    this.selectedKey.set(productKey);
-  }
-}

+ 0 - 0
apps/web/src/assets/.gitkeep


BIN
apps/web/src/favicon.ico


+ 0 - 13
apps/web/src/index.html

@@ -1,13 +0,0 @@
-<!doctype html>
-<html lang="zh-CN">
-<head>
-  <meta charset="utf-8">
-  <title>Saas VOC | 国内电商用户之声</title>
-  <base href="/">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <link rel="icon" type="image/x-icon" href="favicon.ico">
-</head>
-<body>
-  <app-root></app-root>
-</body>
-</html>

+ 0 - 173
apps/web/src/styles.scss

@@ -1,173 +0,0 @@
-:root {
-  --color-page: #f5f7f9;
-  --color-surface: #ffffff;
-  --color-text: #1d2939;
-  --color-muted: #667085;
-  --color-subtle: #98a2b3;
-  --color-border: #e4e7ec;
-  --color-accent: #5e7b28;
-  --color-accent-soft: #eef7dc;
-  --color-blue: #2563eb;
-  --color-amber: #d97706;
-  --color-red: #dc2626;
-  --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05);
-  color-scheme: light;
-}
-
-* {
-  box-sizing: border-box;
-}
-
-html,
-body {
-  min-width: 320px;
-  min-height: 100%;
-  margin: 0;
-  color: var(--color-text);
-  background: var(--color-page);
-  font-family: Inter, "PingFang SC", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-  font-size: 14px;
-  line-height: 1.5;
-  letter-spacing: 0;
-}
-
-button,
-input,
-select {
-  font: inherit;
-  letter-spacing: 0;
-}
-
-button,
-a {
-  -webkit-tap-highlight-color: transparent;
-}
-
-button {
-  padding: 0;
-  border: 0;
-  color: inherit;
-  background: none;
-  cursor: pointer;
-}
-
-a {
-  color: inherit;
-  text-decoration: none;
-}
-
-h1,
-h2,
-h3,
-p {
-  margin: 0;
-}
-
-table {
-  border-collapse: collapse;
-}
-
-.page {
-  width: min(100%, 1600px);
-  margin: 0 auto;
-  padding: 24px;
-}
-
-.page-header {
-  display: flex;
-  align-items: flex-start;
-  justify-content: space-between;
-  gap: 20px;
-  margin-bottom: 20px;
-}
-
-.page-title {
-  font-size: 22px;
-  line-height: 1.3;
-  font-weight: 680;
-}
-
-.page-subtitle {
-  margin-top: 5px;
-  color: var(--color-muted);
-  font-size: 13px;
-}
-
-.panel {
-  background: var(--color-surface);
-  border: 1px solid var(--color-border);
-  border-radius: 8px;
-  box-shadow: var(--shadow-sm);
-}
-
-.panel-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 16px;
-  min-height: 52px;
-  padding: 0 18px;
-  border-bottom: 1px solid var(--color-border);
-}
-
-.panel-title {
-  font-size: 15px;
-  font-weight: 650;
-}
-
-.muted {
-  color: var(--color-muted);
-}
-
-.loading-state,
-.error-state,
-.empty-state {
-  display: grid;
-  place-items: center;
-  min-height: 220px;
-  padding: 32px;
-  color: var(--color-muted);
-  text-align: center;
-}
-
-.error-state {
-  color: var(--color-red);
-}
-
-.sr-only {
-  position: absolute;
-  width: 1px;
-  height: 1px;
-  padding: 0;
-  overflow: hidden;
-  clip: rect(0, 0, 0, 0);
-  white-space: nowrap;
-  border: 0;
-}
-
-@media (max-width: 760px) {
-  .page {
-    padding: 16px 12px 28px;
-  }
-
-  .page-header {
-    align-items: stretch;
-    flex-direction: column;
-    gap: 12px;
-  }
-
-  .page-title {
-    font-size: 19px;
-  }
-}
-
-@media (prefers-reduced-motion: reduce) {
-  *,
-  *::before,
-  *::after {
-    scroll-behavior: auto !important;
-    transition-duration: 0.01ms !important;
-    animation-duration: 0.01ms !important;
-    animation-iteration-count: 1 !important;
-  }
-}

+ 0 - 14
apps/web/tsconfig.spec.json

@@ -1,14 +0,0 @@
-/* To learn more about this file see: https://angular.io/config/tsconfig. */
-{
-  "extends": "./tsconfig.json",
-  "compilerOptions": {
-    "outDir": "./out-tsc/spec",
-    "types": [
-      "jasmine"
-    ]
-  },
-  "include": [
-    "src/**/*.spec.ts",
-    "src/**/*.d.ts"
-  ]
-}

Разница между файлами не показана из-за своего большого размера
+ 1035 - 776
package-lock.json


+ 76 - 12
package.json

@@ -1,21 +1,85 @@
 {
-  "name": "saas-voc",
-  "version": "0.1.0",
-  "private": true,
-  "workspaces": [
-    "apps/web"
-  ],
+  "name": "saas-voc-domestic",
+  "version": "0.2.0",
+  "description": "基于原 AI-VOC 框架的国内电商 VOC 通用模板",
   "scripts": {
-    "dev": "npm run start --workspace @saas-voc/web",
-    "build": "npm run build --workspace @saas-voc/web",
-    "test": "npm run test:ci --workspace @saas-voc/web",
+    "ng": "ng",
+    "start": "ng serve",
+    "build": "ng build",
+    "build:prod": "ng build",
+    "watch": "ng build --watch --configuration development",
+    "test": "ng test --watch=false --browsers=ChromeHeadless",
+    "lint": "ng lint",
+    "e2e": "ng e2e",
+    "serve:prod": "ng serve --configuration production",
+    "analyze": "ng build --stats-json && npx webpack-bundle-analyzer dist/stats.json",
     "import:demashi": "node tools/import-demashi.mjs",
     "verify:data": "node tools/verify-generated-data.mjs"
   },
+  "keywords": [
+    "angular",
+    "voc",
+    "ai",
+    "sentiment-analysis",
+    "dashboard",
+    "ecommerce",
+    "analytics"
+  ],
+  "author": "AI-VOC Team",
+  "license": "MIT",
+  "dependencies": {
+    "@angular/animations": "^17.0.0",
+    "@angular/common": "^17.0.0",
+    "@angular/compiler": "^17.0.0",
+    "@angular/core": "^17.0.0",
+    "@angular/forms": "^17.0.0",
+    "@angular/platform-browser": "^17.0.0",
+    "@angular/platform-browser-dynamic": "^17.0.0",
+    "@angular/router": "^17.0.0",
+    "@awesome-cordova-plugins/core": "^8.1.0",
+    "@awesome-cordova-plugins/diagnostic": "^8.1.0",
+    "@awesome-cordova-plugins/media-capture": "^8.1.0",
+    "@capacitor/camera": "^8.0.0",
+    "@ionic/angular": "^8.7.17",
+    "date-fns": "^2.30.0",
+    "echarts": "^5.6.0",
+    "file-saver": "^2.0.5",
+    "fmode-ng": "^0.0.243",
+    "jsencrypt": "^3.5.4",
+    "marked": "^17.0.4",
+    "ngx-echarts": "^17.2.0",
+    "parse": "^8.0.3",
+    "rxjs": "~7.8.0",
+    "tslib": "^2.3.0",
+    "xlsx": "^0.18.5",
+    "zone.js": "~0.14.0"
+  },
   "devDependencies": {
-    "xlsx": "^0.18.5"
+    "@angular-devkit/build-angular": "^17.0.0",
+    "@angular/cli": "^17.0.0",
+    "@angular/compiler-cli": "^17.0.0",
+    "@types/file-saver": "^2.0.7",
+    "@types/jasmine": "~5.1.0",
+    "@types/node": "^18.18.0",
+    "jasmine-core": "~5.1.0",
+    "karma": "~6.4.0",
+    "karma-chrome-launcher": "^3.2.0",
+    "karma-coverage": "~2.2.0",
+    "karma-jasmine": "~5.1.0",
+    "karma-jasmine-html-reporter": "~2.1.0",
+    "typescript": "^5.2.0",
+    "webpack-bundle-analyzer": "^4.9.1"
   },
   "engines": {
-    "node": ">=18.13 <23"
-  }
+    "node": ">=18.13 <23",
+    "npm": ">=9.0.0"
+  },
+  "browserslist": [
+    "last 2 Chrome versions",
+    "last 2 Firefox versions",
+    "last 2 Safari versions",
+    "last 2 Edge versions",
+    "iOS >= 12",
+    "Android >= 8"
+  ]
 }

+ 8 - 0
proxy.conf.json

@@ -0,0 +1,8 @@
+{
+  "/api/voc-e-commerce": {
+    "target": "http://127.0.0.1:3000",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
+  }
+}

+ 48 - 0
src/app/app.component.scss

@@ -0,0 +1,48 @@
+.app-layout {
+  min-height: 100vh;
+  background: #f5f7fa;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+  display: flex;
+  flex-direction: column;
+}
+
+.main-container {
+  display: flex;
+  flex: 1;
+  flex-direction: row;
+  overflow: hidden; /* Prevent double scrollbar */
+}
+
+.main-content {
+  flex: 1;
+  min-width: 0; /* Prevent flex overflow / sidebar overlap */
+  overflow-x: hidden;
+  overflow-y: auto;
+  height: calc(100vh - 56px); /* Subtract header height */
+  margin-left: 220px; /* Fixed sidebar width */
+  transition: margin-left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+
+  &.nav-collapsed {
+    margin-left: 64px;
+  }
+
+  &.full-width {
+    margin-left: 0;
+  }
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  padding: 0;
+}
+
+@media (max-width: 720px) {
+  .main-content,
+  .main-content.nav-collapsed {
+    margin-left: 0;
+  }
+}

+ 59 - 0
src/app/app.component.ts

@@ -0,0 +1,59 @@
+import { Component, HostListener } from '@angular/core';
+import { RouterOutlet, Router, NavigationEnd } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { NavigationComponent } from '../modules/shared/components/navigation/navigation.component';
+import { HeaderComponent } from '../modules/shared/components/header/header.component';
+import { ToastContainerComponent } from '../modules/shared/components/toast-container/toast-container.component';
+import { DialogContainerComponent } from '../modules/shared/components/dialog-container/dialog-container.component';
+import { LayoutService } from './core/services/layout.service';
+import { filter } from 'rxjs/operators';
+
+@Component({
+  selector: 'app-root',
+  standalone: true,
+  imports: [RouterOutlet, CommonModule, NavigationComponent, HeaderComponent, ToastContainerComponent, DialogContainerComponent],
+  template: `
+    <div class="app-layout">
+      <app-header *ngIf="showNavigation"></app-header>
+      <div class="main-container">
+        <app-navigation *ngIf="showNavigation"></app-navigation>
+        <main class="main-content" [class.full-width]="!showNavigation" [class.nav-collapsed]="sidebarCollapsed">
+          <router-outlet></router-outlet>
+        </main>
+      </div>
+    </div>
+    <app-toast-container></app-toast-container>
+    <app-dialog-container></app-dialog-container>
+  `,
+  styleUrls: ['./app.component.scss']
+})
+export class AppComponent {
+  title = '国内电商 VOC';
+  showNavigation = true;
+  sidebarCollapsed = false;
+  private readonly mobileBreakpoint = 720;
+  private mobileViewport = window.innerWidth <= this.mobileBreakpoint;
+
+  constructor(private router: Router, private layoutService: LayoutService) {
+    if (this.mobileViewport) {
+      this.layoutService.setCollapse(true);
+    }
+
+    this.router.events.pipe(
+      filter(event => event instanceof NavigationEnd)
+    ).subscribe((event: any) => {
+      // Hide navigation on login page
+      this.showNavigation = !event.url.includes('/login');
+    });
+    this.layoutService.isCollapsed$.subscribe(c => this.sidebarCollapsed = c);
+  }
+
+  @HostListener('window:resize')
+  onViewportResize(): void {
+    const mobileViewport = window.innerWidth <= this.mobileBreakpoint;
+    if (mobileViewport && !this.mobileViewport) {
+      this.layoutService.setCollapse(true);
+    }
+    this.mobileViewport = mobileViewport;
+  }
+}

+ 16 - 0
src/app/app.config.ts

@@ -0,0 +1,16 @@
+import { ApplicationConfig } from '@angular/core';
+import { provideRouter } from '@angular/router';
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
+import { provideEchartsCore } from 'ngx-echarts';
+
+import { routes } from './app.routes';
+
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideRouter(routes),
+    provideHttpClient(withInterceptorsFromDi()),
+    provideAnimationsAsync(),
+    provideEchartsCore({ echarts: () => import('./echarts').then((m) => m.default) }),
+  ]
+};

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

@@ -0,0 +1,47 @@
+import { Routes } from '@angular/router';
+
+export const routes: Routes = [
+  { path: '', redirectTo: '/voc-insight/products', pathMatch: 'full' },
+  {
+    path: 'login',
+    loadComponent: () => import('../modules/login/login.component').then((m) => m.LoginComponent),
+  },
+  {
+    path: 'voc-insight',
+    loadComponent: () => import('../modules/voc-insight/voc-insight-shell.component').then((m) => m.VocInsightShellComponent),
+    children: [
+      { path: '', redirectTo: 'products', pathMatch: 'full' },
+      {
+        path: 'products',
+        title: '国内商品 VOC',
+        loadComponent: () => import('../modules/voc-insight/voc-insight.component').then((m) => m.VocInsightComponent),
+      },
+      {
+        path: 'voice',
+        title: '用户声音分类',
+        loadComponent: () => import('../modules/voc-insight/pages/voice-classification.component').then((m) => m.VocVoiceClassificationComponent),
+      },
+      {
+        path: 'pain-point',
+        title: '痛点深度洞察',
+        loadComponent: () => import('../modules/voc-insight/pages/pain-point-insight.component').then((m) => m.VocPainPointInsightComponent),
+      },
+      {
+        path: 'feature',
+        title: '功能满足度',
+        loadComponent: () => import('../modules/voc-insight/pages/feature-satisfaction.component').then((m) => m.VocFeatureSatisfactionComponent),
+      },
+      {
+        path: 'scenario',
+        title: '使用场景',
+        loadComponent: () => import('../modules/voc-insight/pages/scenario-dashboard.component').then((m) => m.VocScenarioDashboardComponent),
+      },
+    ],
+  },
+  {
+    path: 'data-status',
+    title: '数据接入状态',
+    loadComponent: () => import('../modules/domestic-voc/data-status.component').then((m) => m.DomesticDataStatusComponent),
+  },
+  { path: '**', redirectTo: '/voc-insight/products' },
+];

+ 22 - 0
src/app/core/config/runtime-config.ts

@@ -0,0 +1,22 @@
+export interface SaasVocRuntimeConfig {
+  parseServerUrl: string;
+  parseAppId: string;
+  apiBaseUrl: string;
+  vocEcommercePath: string;
+}
+
+declare global {
+  interface Window {
+    __SAAS_VOC_CONFIG__?: Partial<SaasVocRuntimeConfig>;
+  }
+}
+
+const browserConfig = typeof window !== 'undefined' ? window.__SAAS_VOC_CONFIG__ ?? {} : {};
+const browserOrigin = typeof window !== 'undefined' ? window.location.origin : '';
+
+export const RUNTIME_CONFIG: Readonly<SaasVocRuntimeConfig> = Object.freeze({
+  parseServerUrl: browserConfig.parseServerUrl || `${browserOrigin}/parse`,
+  parseAppId: browserConfig.parseAppId || 'saas-voc-local',
+  apiBaseUrl: browserConfig.apiBaseUrl || '',
+  vocEcommercePath: browserConfig.vocEcommercePath || '/api/voc-e-commerce',
+});

+ 40 - 0
src/app/core/guards/auth.guard.ts

@@ -0,0 +1,40 @@
+import { Injectable } from '@angular/core';
+import { 
+  CanActivate, 
+  ActivatedRouteSnapshot, 
+  RouterStateSnapshot, 
+  UrlTree, 
+  Router 
+} from '@angular/router';
+import { Observable } from 'rxjs';
+import { MSQParse } from '../../parse';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class AuthGuard implements CanActivate {
+  constructor(private router: Router) {}
+
+  canActivate(
+    route: ActivatedRouteSnapshot,
+    state: RouterStateSnapshot
+  ): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
+    return this.checkLogin(state.url);
+  }
+
+  async checkLogin(url: string): Promise<boolean | UrlTree> {
+    try {
+      const currentUser = await MSQParse.User.current();
+      if (currentUser) {
+        return true;
+      }
+    } catch (e) {
+      console.error('Auth Check Error', e);
+    }
+
+    // Store the attempted URL for redirecting
+    return this.router.createUrlTree(['/login'], {
+      queryParams: { returnUrl: url }
+    });
+  }
+}

+ 287 - 0
src/app/core/models/api.models.ts

@@ -0,0 +1,287 @@
+/**
+ * 统一 API 数据模型
+ * 基于 docs/front/data-api.md 和 data-cleaning-api.md 定义
+ */
+
+// ==================== 通用类型 ====================
+
+/** 统一响应包装 */
+export interface ApiResponse<T> {
+    code: number;
+    message: string;
+    data: T;
+    timestamp?: string;
+}
+
+/** 统一分页响应 */
+export interface PaginatedResponse<T> {
+    items: T[];
+    total: number;
+    page: number;
+    pageSize: number;
+    hasMore: boolean;
+}
+
+/** 统一金额 */
+export interface Money {
+    amount: number;
+    currency: string;
+}
+
+/** 统一分页请求参数 */
+export interface PaginationParams {
+    page?: number;
+    pageSize?: number;
+}
+
+/** 统一时间范围请求参数 */
+export interface DateRangeParams {
+    startDate: string;
+    endDate: string;
+}
+
+// ==================== 商品相关 ====================
+
+/** 统一商品摘要 */
+export interface ProductSummary {
+    id: string;
+    asin: string;
+    title: string;
+    imageUrl: string;
+    price: Money | null;
+    rating: number | null;
+    reviewCount: number | null;
+    category: string;
+    platform: 'amazon' | 'sorftime';
+}
+
+/** 商品详情 */
+export interface ProductDetail {
+    id: string;
+    asin: string;
+    title: string;
+    description: string;
+    images: string[];
+    price: Money | null;
+    rating: number | null;
+    reviewCount: number | null;
+    category: string;
+    brand: string;
+    salesRank: number | null;
+    variations: ProductVariation[];
+}
+
+export interface ProductVariation {
+    asin: string;
+    title: string;
+    price: Money | null;
+    imageUrl: string;
+    attributes: Record<string, string>;
+}
+
+/** 商品销量数据 */
+export interface ProductSalesData {
+    periods: SalesPeriod[];
+}
+
+export interface SalesPeriod {
+    date: string;
+    unitCount: number;
+    orderCount: number;
+    revenue: Money;
+}
+
+/** 商品评论分析 */
+export interface ProductReviewsData {
+    summary: ReviewSummary;
+    topics: ReviewTopic[];
+    reviews: Review[];
+}
+
+export interface ReviewSummary {
+    total: number;
+    avgRating: number;
+    distribution: Record<number, number>;
+}
+
+export interface ReviewTopic {
+    name: string;
+    sentiment: string;
+    count: number;
+}
+
+export interface Review {
+    id: string;
+    rating: number;
+    title: string;
+    content: string;
+    createTime: string;
+    author: string;
+    verified: boolean;
+}
+
+// ==================== 关键词相关 ====================
+
+export interface KeywordItem {
+    keyword: string;
+    searchVolume: number;
+    trend: string;
+    competition: string;
+    cpc: Money;
+}
+
+export interface ReverseKeywordItem {
+    keyword: string;
+    rank: number;
+    searchVolume: number;
+    trend: string;
+}
+
+export interface KeywordTrendData {
+    periods: { date: string; searchVolume: number; resultCount: number }[];
+}
+
+export interface RankedProduct extends ProductSummary {
+    rank: number;
+}
+
+// ==================== 类目相关 ====================
+
+export interface CategoryNode {
+    id: string;
+    name: string;
+    parentId: string | null;
+    children: CategoryNode[];
+    productCount: number | null;
+}
+
+// ==================== 订单相关 ====================
+
+export interface OrderItem {
+    orderId: string;
+    orderDate: string;
+    status: string;
+    total: Money;
+    itemCount: number;
+    buyerName: string;
+    channel: string;
+}
+
+export interface OrderDetail {
+    orderId: string;
+    orderDate: string;
+    status: string;
+    total: Money;
+    items: OrderProductItem[];
+    shipping: ShippingInfo;
+    payment: PaymentInfo;
+}
+
+export interface OrderProductItem {
+    asin: string;
+    sku: string;
+    title: string;
+    quantity: number;
+    price: Money;
+    imageUrl: string;
+}
+
+export interface ShippingInfo {
+    name: string;
+    method: string;
+    status: string;
+    estimatedDelivery: string | null;
+}
+
+export interface PaymentInfo {
+    method: string;
+    total: Money;
+}
+
+export interface OrderMetricsData {
+    periods: { date: string; orderCount: number; unitCount: number; revenue: Money }[];
+}
+
+// ==================== Listing 相关 ====================
+
+export interface ListingItem {
+    sku: string;
+    asin: string;
+    title: string;
+    status: string;
+    price: Money | null;
+    imageUrl: string;
+    condition: string;
+    createdAt: string;
+}
+
+export interface ListingDetail {
+    sku: string;
+    asin: string;
+    title: string;
+    status: string;
+    price: Money | null;
+    imageUrl: string;
+    condition: string;
+    issues: ListingIssue[];
+    offers: ListingOffer[];
+    fulfillment: string;
+}
+
+export interface ListingIssue {
+    code: string;
+    message: string;
+    severity: string;
+}
+
+export interface ListingOffer {
+    offerType: string;
+    price: Money;
+    fulfillment: string;
+}
+
+// ==================== 卖家相关 ====================
+
+export interface SellerInfo {
+    sellerId: string;
+    storeName: string;
+    marketplaceId: string;
+}
+
+// ==================== TikTok 相关 ====================
+
+export interface TikTokUser {
+    userId: string;
+    uniqueId: string;
+    nickname: string;
+    avatar: string;
+    bio: string;
+    followerCount: number;
+    followingCount: number;
+    likeCount: number;
+    videoCount: number;
+}
+
+export interface TikTokVideo {
+    videoId: string;
+    description: string;
+    coverUrl: string;
+    videoUrl: string;
+    duration: number;
+    createTime: string;
+    stats: {
+        playCount: number;
+        likeCount: number;
+        commentCount: number;
+        shareCount: number;
+    };
+}
+
+export interface TikTokComment {
+    commentId: string;
+    text: string;
+    author: { userId: string; uniqueId: string; nickname: string; avatar: string };
+    likeCount: number;
+    createTime: string;
+    replies: TikTokComment[];
+}

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

@@ -0,0 +1,78 @@
+export interface DomesticMetricSummary {
+  gmv: number;
+  soldUnits: number;
+  transactionOrders: number;
+  transactionCustomers: number;
+  impressions: number;
+  clicks: number;
+  views: number;
+  visitors: number;
+  cartUnits: number;
+  orderAmount: number;
+  orderUnits: number;
+  orderCount: number;
+  refundAmount: number;
+  refundUnits: number;
+  refundOrders: number;
+  conversionRate: number;
+  clickThroughRate: number;
+  averageUnitPrice: number;
+  refundToGmvRate: number;
+}
+
+export interface DomesticProduct {
+  platform: string;
+  productId: string;
+  productKey: string;
+  asin: string;
+  role: 'own' | 'competitor';
+  brand: string;
+  title: string;
+  model: string;
+  category1: string;
+  category2: string;
+  category3: string;
+  source: string;
+  relationCount: number;
+  summary: DomesticMetricSummary;
+  trend: Array<{ date: string; gmv: number; soldUnits: number; transactionOrders: number }>;
+}
+
+export interface DomesticReview {
+  productId: string;
+  reviewId: string;
+  rating: number;
+  content: string;
+  reviewDate?: string;
+}
+
+export interface DomesticQuality {
+  orphanMappings: Array<{ ownProductId: string; model: string; category: string }>;
+  mappingsWithoutCompetitor: Array<{ ownProductId: string; model: string; category: string }>;
+  brandWithoutProductId: Array<{ ownProductId: string; slot: number; brand: string }>;
+}
+
+export interface DomesticDataset {
+  schemaVersion: number;
+  generatedAt: string;
+  caseName: string;
+  platform: string;
+  source: {
+    sourceFile: string;
+    sourceHash: string;
+    dateRange: { start: string; end: string };
+  };
+  summary: {
+    metricRows: number;
+    metricProducts: number;
+    mappingRows: number;
+    relations: number;
+    uniqueCompetitorProducts: number;
+    category2Count: number;
+    category3Count: number;
+    reviewCount: number;
+  };
+  products: DomesticProduct[];
+  reviews?: DomesticReview[];
+  quality: DomesticQuality;
+}

+ 1 - 0
src/app/core/models/index.ts

@@ -0,0 +1 @@
+export * from './api.models';

+ 97 - 0
src/app/core/services/amazon-raw-api.service.ts

@@ -0,0 +1,97 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpApiService } from './http-api.service';
+
+/**
+ * Amazon SP-API 原始接口服务
+ * 直接调用 /api/amazon/ 下的原始接口
+ * 用于清洗接口未覆盖的场景
+ */
+@Injectable({ providedIn: 'root' })
+export class AmazonRawApiService {
+    constructor(private api: HttpApiService) { }
+
+    /** 测试连通性 */
+    test(): Observable<any> {
+        return this.api.amazonGet('/test');
+    }
+
+    // ---- 订单 ----
+    getOrders(params: {
+        createdAfter?: string;
+        createdBefore?: string;
+        orderStatuses?: string;
+        marketplaceIds?: string;
+        maxResultsPerPage?: number;
+    }): Observable<any> {
+        return this.api.amazonGet('/orders', params);
+    }
+
+    getOrder(orderId: string): Observable<any> {
+        return this.api.amazonGet(`/orders/${orderId}`);
+    }
+
+    getOrderItems(orderId: string): Observable<any> {
+        return this.api.amazonGet(`/orders/${orderId}/items`);
+    }
+
+    // ---- 销售 ----
+    getOrderMetrics(params: {
+        marketplaceIds: string;
+        interval: string;
+        granularity: string;
+    }): Observable<any> {
+        return this.api.amazonGet('/sales/orderMetrics', params);
+    }
+
+    // ---- 商品目录 ----
+    searchCatalogItems(params: {
+        keywords?: string;
+        marketplaceIds: string;
+        includedData?: string;
+    }): Observable<any> {
+        return this.api.amazonGet('/catalog/items', params);
+    }
+
+    getCatalogItem(asin: string, params: {
+        marketplaceIds: string;
+        includedData?: string;
+    }): Observable<any> {
+        return this.api.amazonGet(`/catalog/items/${asin}`, params);
+    }
+
+    // ---- Listings ----
+    getListingsItems(sellerId: string, params?: any): Observable<any> {
+        return this.api.amazonGet(`/listings/items/${sellerId}`, params);
+    }
+
+    getListingsItem(sellerId: string, sku: string, params?: any): Observable<any> {
+        return this.api.amazonGet(`/listings/items/${sellerId}/${sku}`, params);
+    }
+
+    // ---- 客户反馈 ----
+    getReviewTopics(asin: string): Observable<any> {
+        return this.api.amazonGet(`/customerFeedback/items/${asin}/reviews/topics`);
+    }
+
+    getReviewTrends(asin: string): Observable<any> {
+        return this.api.amazonGet(`/customerFeedback/items/${asin}/reviews/trends`);
+    }
+
+    getBrowseNode(asin: string): Observable<any> {
+        return this.api.amazonGet(`/customerFeedback/items/${asin}/browseNode`);
+    }
+
+    // ---- 卖家 ----
+    getMarketplaceParticipations(): Observable<any> {
+        return this.api.amazonGet('/sellers/marketplaceParticipations');
+    }
+
+    // ---- 退货 ----
+    getReturns(params?: {
+        createdAfter?: string;
+        createdBefore?: string;
+    }): Observable<any> {
+        return this.api.amazonGet('/returns', params);
+    }
+}

+ 187 - 0
src/app/core/services/api-connectivity.service.ts

@@ -0,0 +1,187 @@
+import { Injectable } from '@angular/core';
+import { Observable, forkJoin, of } from 'rxjs';
+import { map, catchError, timeout } from 'rxjs/operators';
+import { HttpApiService } from './http-api.service';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+import { SorftimeApiService } from './sorftime-api.service';
+import { API_CONFIG } from './api.config';
+
+export interface ConnectivityResult {
+    service: string;
+    status: 'ok' | 'error' | 'timeout';
+    latency: number;
+    message: string;
+}
+
+/**
+ * API 连通性测试服务
+ * 用于验证各平台 API 的可用性
+ */
+@Injectable({ providedIn: 'root' })
+export class ApiConnectivityService {
+    constructor(
+        private api: HttpApiService,
+        private amazonRaw: AmazonRawApiService,
+        private sorftimeApi: SorftimeApiService
+    ) { }
+
+    /** 测试所有 API 连通性 */
+    testAll(): Observable<ConnectivityResult[]> {
+        return forkJoin([
+            this.testAmazon(),
+            this.testSorftime(),
+            this.testTikHub()
+        ]);
+    }
+
+    /** 测试所有 Sorftime 端点详细连通性 */
+    testSorftimeEndpoints(): Observable<ConnectivityResult[]> {
+        return forkJoin([
+            this.testSorftime(),
+            this.testSorftimeCategoryTree(),
+            this.testSorftimeProductQuery(),
+            this.testSorftimeKeywordQuery(),
+            this.testSorftimeProductReviews()
+        ]);
+    }
+
+    /** Sorftime — CategoryTree 端点 */
+    private testSorftimeCategoryTree(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.sorftimeApi.getCategoryTree().pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map((resp: any) => ({
+                service: 'Sorftime CategoryTree',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: `返回数据: ${Array.isArray(resp?.data) ? resp.data.length : '有'}条类目`
+            })),
+            catchError(err => of({
+                service: 'Sorftime CategoryTree',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+
+    /** Sorftime — ProductQuery 端点 */
+    private testSorftimeProductQuery(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.sorftimeApi.searchProducts('test', { pageSize: 1 }).pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map((resp: any) => ({
+                service: 'Sorftime ProductQuery',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: `搜索正常`
+            })),
+            catchError(err => of({
+                service: 'Sorftime ProductQuery',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+
+    /** Sorftime — KeywordQuery 端点 */
+    private testSorftimeKeywordQuery(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.sorftimeApi.searchKeywords('test').pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map((resp: any) => ({
+                service: 'Sorftime KeywordQuery',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: `关键词查询正常`
+            })),
+            catchError(err => of({
+                service: 'Sorftime KeywordQuery',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+
+    /** Sorftime — ProductReviewsQuery 端点 */
+    private testSorftimeProductReviews(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.sorftimeApi.getProductReviews('B08N5WRWNW').pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map((resp: any) => ({
+                service: 'Sorftime ProductReviews',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: `评论查询正常`
+            })),
+            catchError(err => of({
+                service: 'Sorftime ProductReviews',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+
+    /** 测试 Amazon SP-API */
+    testAmazon(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.amazonRaw.test().pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map(() => ({
+                service: 'Amazon SP-API',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: '连接正常'
+            })),
+            catchError(err => of({
+                service: 'Amazon SP-API',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+
+    /** 测试 Sorftime API */
+    testSorftime(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.api.rawGet<any>(`${API_CONFIG.BASE_URL}${API_CONFIG.SORFTIME_PREFIX}/test`).pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map(() => ({
+                service: 'Sorftime API',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: '连接正常'
+            })),
+            catchError(err => of({
+                service: 'Sorftime API',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+
+    /** 测试 TikHub API */
+    testTikHub(): Observable<ConnectivityResult> {
+        const start = Date.now();
+        return this.api.rawPost<any>(`${API_CONFIG.BASE_URL}${API_CONFIG.TIKHUB_PREFIX}/forward`, { path: '/api/v1/health/check', method: 'GET' }).pipe(
+            timeout(API_CONFIG.TIMEOUT),
+            map(() => ({
+                service: 'TikHub API',
+                status: 'ok' as const,
+                latency: Date.now() - start,
+                message: '连接正常'
+            })),
+            catchError(err => of({
+                service: 'TikHub API',
+                status: (err?.name === 'TimeoutError' ? 'timeout' : 'error') as 'timeout' | 'error',
+                latency: Date.now() - start,
+                message: err?.message || '连接失败'
+            }))
+        );
+    }
+}

+ 14 - 0
src/app/core/services/api.config.ts

@@ -0,0 +1,14 @@
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+
+export const API_CONFIG = {
+  BASE_URL: RUNTIME_CONFIG.apiBaseUrl,
+  VOC_ECOMMERCE_PREFIX: RUNTIME_CONFIG.vocEcommercePath,
+  AMAZON_PREFIX: '/api/disabled/amazon',
+  SORFTIME_PREFIX: '/api/disabled/sorftime',
+  TIKHUB_PREFIX: '/api/disabled/tikhub',
+  TIKHUB_PROXY_URL: '/api/disabled/tikhub',
+  TIKHUB_TOKEN: '',
+  CLEAN_PREFIX: '/api/v2',
+  DEFAULT_PAGE_SIZE: 20,
+  TIMEOUT: 30000,
+};

+ 42 - 0
src/app/core/services/category-api.service.ts

@@ -0,0 +1,42 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+import { HttpApiService } from './http-api.service';
+
+/**
+ * 类目 API 服务
+ * 对接 /api/amazon 和 /api/sorftime 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class CategoryApiService {
+    constructor(
+        private amazonRaw: AmazonRawApiService,
+        private api: HttpApiService
+    ) { }
+
+    /** 类目树 — 通过 Sorftime 获取 */
+    getTree(params?: {
+        platform?: 'amazon' | 'sorftime';
+        domain?: string;
+    }): Observable<any> {
+        return this.api.sorftimeForward('/categories/tree', {
+            domain: params?.domain
+        });
+    }
+
+    /** 类目热销产品 — 通过 Sorftime 获取 */
+    getTopProducts(categoryId: string, params?: {
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.api.sorftimeForward(`/categories/${categoryId}/top-products`, params);
+    }
+
+    /** 类目关键词 — 通过 Sorftime 获取 */
+    getKeywords(categoryId: string, params?: {
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.api.sorftimeForward(`/categories/${categoryId}/keywords`, params);
+    }
+}

+ 584 - 0
src/app/core/services/data-store.service.ts

@@ -0,0 +1,584 @@
+import { Injectable } from '@angular/core';
+import { Observable, from, of } from 'rxjs';
+import { map, catchError } from 'rxjs/operators';
+import { MSQParse } from '../../parse';
+import { PermissionService } from '../../../modules/shared/services/permission.service';
+
+interface DataStoreQueryOptions {
+    limit?: number;
+    skip?: number;
+    orderBy?: string;
+    descending?: boolean;
+    include?: string[];
+    select?: string[];
+    exclude?: string[];
+}
+
+/**
+ * 数据持久化服务
+ * 使用 Parse 将 API 数据保存至数据库
+ */
+@Injectable({ providedIn: 'root' })
+export class DataStoreService {
+
+    private readonly OWN_PRODUCT_CLASSES = new Set(['ProductDetail', 'AsinSkuMapping', 'Product']);
+    private readonly SHOP_SAFE_SELECT_KEYS = [
+        'name',
+        'shopName',
+        'storeName',
+        'platform',
+        'region',
+        'marketplaceId',
+        'domain',
+        'nodeIds',
+        'sync_status',
+        'last_sync_at',
+        'status',
+        'deleted',
+        'disable',
+        'createdAt',
+        'updatedAt'
+    ];
+    private readonly GLOBAL_SENSITIVE_EXCLUDE_KEYS = [
+        'credentials',
+        'SpApiConfig',
+        'accessToken',
+        'access_token',
+        'accessTokenExpiresAt',
+        'refreshToken',
+        'refresh_token',
+        'clientSecret',
+        'client_secret',
+        'secret',
+        'authorization',
+        'shop.config',
+        'shop.credentials',
+        'shop.SpApiConfig',
+        'shop.config.SpApiConfig',
+        'shop.accessToken',
+        'shop.access_token',
+        'shop.accessTokenExpiresAt',
+        'shop.refreshToken',
+        'shop.refresh_token',
+        'shop.clientSecret',
+        'shop.client_secret',
+        'shop.clientId',
+        'shop.client_id',
+        'shop.sellerID',
+        'shop.sellerId'
+    ];
+    private readonly SHOP_SENSITIVE_EXCLUDE_KEYS = [
+        'config',
+        'credentials',
+        'SpApiConfig',
+        'accessToken',
+        'access_token',
+        'accessTokenExpiresAt',
+        'refreshToken',
+        'refresh_token',
+        'clientSecret',
+        'client_secret',
+        'clientId',
+        'client_id',
+        'sellerID',
+        'sellerId',
+        'secret',
+        'authorization'
+    ];
+    private readonly SENSITIVE_KEY_NAMES = new Set([
+        'credentials',
+        'spapiconfig',
+        'accesstoken',
+        'access_token',
+        'accesstokenexpiresat',
+        'refreshtoken',
+        'refresh_token',
+        'clientsecret',
+        'client_secret',
+        'secret',
+        'authorization'
+    ]);
+
+    constructor(private permissionService: PermissionService) { }
+
+    /**
+     * 保存数据到 Parse 数据库
+     * @param className Parse 类名
+     * @param data 要保存的数据
+     * @param uniqueKey 唯一标识字段名(用于去重更新)
+     */
+    save(className: string, data: Record<string, any>, uniqueKey?: string): Observable<any> {
+        return from(this.saveToParseClass(className, data, uniqueKey)).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] 保存 ${className} 失败(已回退):`, err?.message || err);
+                return of(null);
+            })
+        );
+    }
+
+    /**
+     * 批量保存数据
+     */
+    saveBatch(className: string, items: Record<string, any>[], uniqueKey?: string): Observable<any[]> {
+        const promises = items.map(item => this.saveToParseClass(className, item, uniqueKey));
+        return from(Promise.all(promises)).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] 批量保存 ${className} 失败(已回退):`, err?.message || err);
+                return of([]);
+            })
+        );
+    }
+
+    /**
+     * 查询 Parse 数据
+     */
+    query(className: string, filters?: Record<string, any>, options?: {
+        limit?: number;
+        skip?: number;
+        orderBy?: string;
+        descending?: boolean;
+        include?: string[];
+        select?: string[];
+        exclude?: string[];
+    }): Observable<any[]> {
+        return from(this.queryParseClass(className, filters, options)).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] 查询 ${className} 失败(已回退):`, err?.message || err);
+                return of([]);
+            })
+        );
+    }
+
+    /**
+     * 调用 Parse Cloud Function
+     */
+    cloudFunction(params: { id: string;[key: string]: any }): Observable<any> {
+        return from(MSQParse.Cloud.function(params)).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] Cloud Function 调用失败(已回退):`, err?.message || err);
+                return of(null);
+            })
+        );
+    }
+
+    /**
+     * 查询 Parse 数据条数
+     */
+    count(className: string, filters?: Record<string, any>): Observable<number> {
+        return from(this.countParseClass(className, filters)).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] 计数 ${className} 失败(已回退):`, err?.message || err);
+                return of(0);
+            })
+        );
+    }
+
+    /**
+     * 查询指定日期之后创建的记录数
+     */
+    countSince(className: string, sinceDate: Date): Observable<number> {
+        return from((async () => {
+            const query = new MSQParse.Query(className);
+            query.greaterThanOrEqualTo('createdAt', sinceDate);
+            return query.count();
+        })()).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] 计数 ${className} (since) 失败(已回退):`, err?.message || err);
+                return of(0);
+            })
+        );
+    }
+
+    /**
+     * 删除 Parse 数据库中的记录
+     * @param className Parse 类名
+     * @param objectId 要删除的记录 objectId
+     */
+    delete(className: string, objectId: string): Observable<any> {
+        return from((async () => {
+            const query = new MSQParse.Query(className);
+            const obj = await query.get(objectId);
+            if (obj) return obj.destroy();
+            return null;
+        })()).pipe(
+            catchError(err => {
+                console.warn(`[DataStore] 删除 ${className}/${objectId} 失败:`, err?.message || err);
+                return of(null);
+            })
+        );
+    }
+
+    // ==================== 内部方法 ====================
+
+    private async saveToParseClass(
+        className: string,
+        data: Record<string, any>,
+        uniqueKey?: string
+    ): Promise<any> {
+        // 注意:在 fmode-ng 的 Parse 封装中,不推荐使用 Object.extend,
+        // 需要直接使用字符串类名构造 Query / Object,否则可能落到默认 ExtendedClass
+
+        // 如果有唯一键,先查询是否存在
+        if (uniqueKey && data[uniqueKey]) {
+            const query = new MSQParse.Query(className);
+            query.equalTo(uniqueKey, data[uniqueKey]);
+            const existing = await query.first();
+            if (existing) {
+                Object.entries(data).forEach(([key, value]) => {
+                    existing.set(key, value);
+                });
+                existing.set('updatedAt_custom', new Date().toISOString());
+                return existing.save();
+            }
+        }
+
+        // 创建新记录
+        const obj = new MSQParse.Object(className);
+        Object.entries(data).forEach(([key, value]) => {
+            obj.set(key, value);
+        });
+        obj.set('createdAt_custom', new Date().toISOString());
+        return obj.save();
+    }
+
+    private async queryParseClass(
+        className: string,
+        filters?: Record<string, any>,
+        options?: DataStoreQueryOptions
+    ): Promise<any[]> {
+        const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters);
+        if (permissionFilters === null) return [];
+
+        // 检查是否有大数组 containedIn 需要分批
+        const BATCH_THRESHOLD = 150;
+        if (permissionFilters) {
+            for (const [key, value] of Object.entries(permissionFilters)) {
+                if (Array.isArray(value) && value.length > BATCH_THRESHOLD) {
+                    return this.batchedContainedInQuery(className, key, value, permissionFilters, options);
+                }
+            }
+        }
+
+        // 直接使用类名字符串构造 Query,确保真正访问到对应的 Parse 表
+        const query = new MSQParse.Query(className);
+
+        this.applyQueryFilters(query, permissionFilters);
+        this.applyQueryProjection(query, className, options);
+
+        if (options?.limit) query.limit(options.limit);
+        if (options?.skip) query.skip(options.skip);
+        if (options?.orderBy) {
+            if (options.descending) {
+                query.descending(options.orderBy);
+            } else {
+                query.ascending(options.orderBy);
+            }
+        }
+
+        const results = await query.find();
+        return results.map((r: any) => this.toClientRecord(className, r));
+    }
+
+    /** 自动分批 containedIn 查询,避免 URL 过长导致 ERR_FAILED */
+    private async batchedContainedInQuery(
+        className: string,
+        arrayKey: string,
+        arrayValues: any[],
+        filters: Record<string, any>,
+        options?: DataStoreQueryOptions
+    ): Promise<any[]> {
+        const BATCH_SIZE = 150;
+        const perBatchLimit = options?.limit ? Math.min(options.limit, 1000) : 1000;
+        const allResults: any[] = [];
+
+        for (let i = 0; i < arrayValues.length; i += BATCH_SIZE) {
+            const batch = arrayValues.slice(i, i + BATCH_SIZE);
+            const query = new MSQParse.Query(className);
+
+            // 设置当前批次的 containedIn
+            (query as any).containedIn(arrayKey, batch);
+
+            this.applyQueryFilters(query, filters, arrayKey);
+            this.applyQueryProjection(query, className, options);
+
+            query.limit(perBatchLimit);
+            if (options?.orderBy) {
+                if (options.descending) {
+                    query.descending(options.orderBy);
+                } else {
+                    query.ascending(options.orderBy);
+                }
+            }
+
+            const results = await query.find();
+            allResults.push(...results.map((r: any) => this.toClientRecord(className, r)));
+        }
+
+        // 如果有全局 limit,截断
+        if (options?.limit && allResults.length > options.limit) {
+            return allResults.slice(0, options.limit);
+        }
+        return allResults;
+    }
+
+    private async countParseClass(
+        className: string,
+        filters?: Record<string, any>
+    ): Promise<number> {
+        const permissionFilters = await this.applyOwnProductPermissionFilters(className, filters);
+        if (permissionFilters === null) return 0;
+
+        const query = new MSQParse.Query(className);
+        this.applyQueryFilters(query, permissionFilters);
+        return query.count();
+    }
+
+    private applyQueryProjection(query: any, className: string, options?: DataStoreQueryOptions): void {
+        (options?.include || []).forEach(path => {
+            if (path) query.include(path);
+        });
+
+        const selectKeys = options?.select?.length
+            ? options.select
+            : (className === 'Shop' ? this.SHOP_SAFE_SELECT_KEYS : []);
+        if (selectKeys.length) query.select(selectKeys);
+
+        const excludeKeys = this.getSensitiveExcludeKeys(className, options?.exclude || []);
+        if (excludeKeys.length && typeof query.exclude === 'function') {
+            query.exclude(excludeKeys);
+        }
+    }
+
+    private getSensitiveExcludeKeys(className: string, extraExcludeKeys: string[]): string[] {
+        const keys = new Set<string>([
+            ...this.GLOBAL_SENSITIVE_EXCLUDE_KEYS,
+            ...extraExcludeKeys
+        ]);
+        if (className === 'Shop') {
+            this.SHOP_SENSITIVE_EXCLUDE_KEYS.forEach(key => keys.add(key));
+        }
+        return Array.from(keys);
+    }
+
+    private toClientRecord(className: string, row: any): any {
+        const raw = this.safeToPlainObject(row);
+        return this.sanitizeForClient(className, raw, [], new WeakSet<object>());
+    }
+
+    private safeToPlainObject(value: any): any {
+        if (!value || typeof value !== 'object') return value;
+        if (this.isParseObjectLike(value)) {
+            return this.parseObjectToShallowJson(value, true);
+        }
+        if (typeof value.toJSON === 'function') {
+            try {
+                return value.toJSON();
+            } catch {
+                return this.parseObjectToShallowJson(value, true);
+            }
+        }
+        return value;
+    }
+
+    private sanitizeForClient(className: string, value: any, path: string[] = [], seen: WeakSet<object> = new WeakSet<object>()): any {
+        if (Array.isArray(value)) {
+            return value.map(item => this.sanitizeForClient(className, item, path, seen));
+        }
+        if (!value || typeof value !== 'object') return value;
+        if (value instanceof Date) return value.toISOString();
+        if (path.length > 10) return this.circularReferenceToShallowJson(value);
+        if (this.isParseObjectLike(value)) {
+            return path.length === 0
+                ? this.sanitizeForClient(className, this.safeToPlainObject(value), path, seen)
+                : this.parseObjectToShallowJson(value);
+        }
+        if (seen.has(value)) return this.circularReferenceToShallowJson(value);
+        seen.add(value);
+
+        const sanitized: Record<string, any> = {};
+        Object.entries(value).forEach(([key, nestedValue]) => {
+            if (this.shouldDropSensitiveKey(className, key, path)) return;
+            sanitized[key] = this.sanitizeForClient(className, nestedValue, [...path, key], seen);
+        });
+        return sanitized;
+    }
+
+    private isParseObjectLike(value: any): boolean {
+        return !!value
+            && typeof value === 'object'
+            && (
+                typeof value.toPointer === 'function'
+                || value.__type === 'Pointer'
+                || value.__type === 'Object'
+                || (typeof value.className === 'string' && (value.attributes || value.id))
+            );
+    }
+
+    private parseObjectToShallowJson(value: any, includeDataFields = false): Record<string, any> {
+        const data = value?.data && typeof value.data === 'object'
+            ? value.data
+            : (value?.attributes && typeof value.attributes === 'object' ? value.attributes : value);
+        const objectId = value?.id || value?.objectId || data?.objectId || '';
+        const className = value?.className || data?.className || '';
+        const result: Record<string, any> = {};
+        if (className) result['className'] = className;
+        if (objectId) result['objectId'] = objectId;
+
+        if (includeDataFields && data && data !== value) {
+            Object.entries(data).forEach(([key, nested]) => {
+                if (nested !== undefined) result[key] = nested;
+            });
+        }
+
+        [
+            'name',
+            'shopName',
+            'storeName',
+            'platform',
+            'region',
+            'marketplaceId',
+            'domain',
+            'status',
+            'deleted',
+            'disable',
+            'createdAt',
+            'updatedAt'
+        ].forEach(key => {
+            const nested = value?.[key] ?? data?.[key];
+            if (nested === undefined || nested === null) return;
+            result[key] = nested instanceof Date ? nested.toISOString() : nested;
+        });
+
+        if (!includeDataFields && !result['__type'] && className && objectId) result['__type'] = 'Pointer';
+        return result;
+    }
+
+    private circularReferenceToShallowJson(value: any): Record<string, any> {
+        const objectId = value?.objectId || value?.id || '';
+        const className = value?.className || '';
+        const result: Record<string, any> = {};
+        if (className) result['className'] = className;
+        if (objectId) result['objectId'] = objectId;
+        if (className && objectId) result['__type'] = 'Pointer';
+        return result;
+    }
+
+    private shouldDropSensitiveKey(className: string, key: string, path: string[]): boolean {
+        const lowerKey = key.toLowerCase();
+        const normalizedKey = key.replace(/[_-]/g, '').toLowerCase();
+        const normalizedPath = path.map(part => part.replace(/[_-]/g, '').toLowerCase());
+        const isShopObject = className === 'Shop' || normalizedPath.includes('shop');
+        const isSensitiveContainer = normalizedPath.some(part => part === 'config' || part === 'credentials' || part === 'spapiconfig');
+
+        if (lowerKey.startsWith('_')) return true;
+        if (lowerKey === 'data' && normalizedPath.includes('shop')) return true;
+        if (lowerKey === 'config' && isShopObject) return true;
+        if (this.SENSITIVE_KEY_NAMES.has(lowerKey) || this.SENSITIVE_KEY_NAMES.has(normalizedKey)) {
+            return isShopObject || isSensitiveContainer;
+        }
+        return false;
+    }
+
+    private applyQueryFilters(query: any, filters?: Record<string, any> | null, skipKey?: string): void {
+        if (!filters) return;
+        Object.entries(filters).forEach(([key, value]) => {
+            if (key === skipKey || value === undefined || value === null) return;
+            if (Array.isArray(value)) {
+                if (value.length > 0) query.containedIn(key, value);
+                return;
+            }
+            if (this.isOperatorFilter(value)) {
+                Object.entries(value).forEach(([operator, raw]) => {
+                    const normalized = this.normalizeQueryValue(raw);
+                    switch (operator) {
+                        case '$ne':
+                        case '$neq':
+                            query.notEqualTo(key, normalized);
+                            break;
+                        case '$in':
+                            if (Array.isArray(normalized) && normalized.length > 0) query.containedIn(key, normalized);
+                            break;
+                        case '$nin':
+                            if (Array.isArray(normalized) && normalized.length > 0) query.notContainedIn(key, normalized);
+                            break;
+                        case '$gte':
+                            query.greaterThanOrEqualTo(key, normalized);
+                            break;
+                        case '$lte':
+                            query.lessThanOrEqualTo(key, normalized);
+                            break;
+                        case '$gt':
+                            query.greaterThan(key, normalized);
+                            break;
+                        case '$lt':
+                            query.lessThan(key, normalized);
+                            break;
+                        case '$exists':
+                            if (!!normalized) {
+                                query.exists(key);
+                            } else {
+                                query.doesNotExist(key);
+                            }
+                            break;
+                    }
+                });
+                return;
+            }
+            query.equalTo(key, this.normalizeQueryValue(value));
+        });
+    }
+
+    private isOperatorFilter(value: any): boolean {
+        return value && typeof value === 'object' && !Array.isArray(value)
+            && Object.keys(value).some(key => key.startsWith('$'));
+    }
+
+    private normalizeQueryValue(value: any): any {
+        if (Array.isArray(value)) return value.map(item => this.normalizeQueryValue(item));
+        if (value && typeof value === 'object' && value.__type === 'Date' && value.iso) {
+            return new Date(value.iso);
+        }
+        return value;
+    }
+
+    private async applyOwnProductPermissionFilters(
+        className: string,
+        filters?: Record<string, any>
+    ): Promise<Record<string, any> | null> {
+        if (!this.OWN_PRODUCT_CLASSES.has(className)) return filters || {};
+
+        const context = await this.permissionService.getOwnProductAccessContext();
+        if (!context.restricted) return filters || {};
+        if (!context.asins.size) return null;
+
+        return this.mergeAllowedAsins(filters || {}, Array.from(context.asins));
+    }
+
+    private mergeAllowedAsins(filters: Record<string, any>, allowedAsins: string[]): Record<string, any> | null {
+        const asinKey = ['asin', 'Asin', 'ASIN'].find(key => Object.prototype.hasOwnProperty.call(filters, key));
+        if (!asinKey) {
+            return { asin: allowedAsins, ...filters };
+        }
+
+        const rawValue = filters[asinKey];
+        const allowedSet = new Set(allowedAsins.map(value => this.normalizeAsin(value)).filter(Boolean));
+        let nextValue: any;
+
+        if (Array.isArray(rawValue)) {
+            nextValue = rawValue
+                .map(value => String(value || '').trim())
+                .filter(value => allowedSet.has(this.normalizeAsin(value)));
+            if (!nextValue.length) return null;
+        } else {
+            const normalized = this.normalizeAsin(rawValue);
+            if (!normalized || !allowedSet.has(normalized)) return null;
+            nextValue = rawValue;
+        }
+
+        return { ...filters, [asinKey]: nextValue };
+    }
+
+    private normalizeAsin(value: any): string {
+        return String(value || '').trim().toUpperCase();
+    }
+}

+ 271 - 0
src/app/core/services/data-sync.service.ts

@@ -0,0 +1,271 @@
+import { Injectable } from '@angular/core';
+import { Observable, forkJoin, of } from 'rxjs';
+import { map, catchError, switchMap, tap } from 'rxjs/operators';
+import { OrderApiService } from './order-api.service';
+import { ProductApiService } from './product-api.service';
+import { ListingApiService } from './listing-api.service';
+import { SellerApiService } from './seller-api.service';
+import { KeywordApiService } from './keyword-api.service';
+import { CategoryApiService } from './category-api.service';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+import { DataStoreService } from './data-store.service';
+
+export interface SyncResult {
+    module: string;
+    success: boolean;
+    count: number;
+    message: string;
+}
+
+/**
+ * 数据同步服务
+ * 从各平台 API 拉取数据并持久化到 Parse 数据库
+ */
+@Injectable({ providedIn: 'root' })
+export class DataSyncService {
+    constructor(
+        private orderApi: OrderApiService,
+        private productApi: ProductApiService,
+        private listingApi: ListingApiService,
+        private sellerApi: SellerApiService,
+        private keywordApi: KeywordApiService,
+        private categoryApi: CategoryApiService,
+        private amazonRaw: AmazonRawApiService,
+        private dataStore: DataStoreService
+    ) { }
+
+    /** 同步订单数据 */
+    syncOrders(startDate: string, endDate: string): Observable<SyncResult> {
+        return this.orderApi.getOrders({ startDate, endDate, pageSize: 100 }).pipe(
+            switchMap(data => {
+                if (data?.items?.length) {
+                    return this.dataStore.saveBatch('Order', data.items.map((o: any) => ({
+                        orderId: o.orderId,
+                        orderDate: o.orderDate,
+                        status: o.status,
+                        totalAmount: o.total?.amount || 0,
+                        currency: o.total?.currency || 'USD',
+                        itemCount: o.itemCount,
+                        buyerName: o.buyerName,
+                        channel: o.channel
+                    })), 'orderId').pipe(
+                        map(() => ({
+                            module: '订单', success: true,
+                            count: data.items.length,
+                            message: `成功同步 ${data.items.length} 条订单`
+                        }))
+                    );
+                }
+                return of({ module: '订单', success: true, count: 0, message: '无新订单数据' });
+            }),
+            catchError(err => of({
+                module: '订单', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    /** 同步订单指标数据 */
+    syncOrderMetrics(startDate: string, endDate: string): Observable<SyncResult> {
+        return this.orderApi.getOrderMetrics({ startDate, endDate, granularity: 'day' }).pipe(
+            switchMap(data => {
+                if (data?.periods?.length) {
+                    return this.dataStore.saveBatch('OrderMetrics', data.periods.map(p => ({
+                        date: p.date,
+                        orderCount: p.orderCount,
+                        unitCount: p.unitCount,
+                        revenue: p.revenue?.amount || 0,
+                        currency: p.revenue?.currency || 'USD'
+                    })), 'date').pipe(
+                        map(() => ({
+                            module: '订单指标', success: true,
+                            count: data.periods.length,
+                            message: `成功同步 ${data.periods.length} 条指标数据`
+                        }))
+                    );
+                }
+                return of({ module: '订单指标', success: true, count: 0, message: '无指标数据' });
+            }),
+            catchError(err => of({
+                module: '订单指标', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    /** 同步商品搜索数据 */
+    syncProducts(keyword: string): Observable<SyncResult> {
+        return this.productApi.search({ keyword, pageSize: 50 }).pipe(
+            switchMap(data => {
+                if (data?.items?.length) {
+                    return this.dataStore.saveBatch('Product', data.items.map((p: any) => ({
+                        asin: p.asin,
+                        title: p.title,
+                        imageUrl: p.imageUrl,
+                        priceAmount: p.price?.amount || 0,
+                        currency: p.price?.currency || 'USD',
+                        rating: p.rating,
+                        reviewCount: p.reviewCount,
+                        category: p.category,
+                        platform: p.platform
+                    })), 'asin').pipe(
+                        map(() => ({
+                            module: '商品', success: true,
+                            count: data.items.length,
+                            message: `成功同步 ${data.items.length} 条商品`
+                        }))
+                    );
+                }
+                return of({ module: '商品', success: true, count: 0, message: '无商品数据' });
+            }),
+            catchError(err => of({
+                module: '商品', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    /** 全量同步 */
+    syncAll(params: {
+        startDate: string;
+        endDate: string;
+        keyword?: string;
+        sellerId?: string;
+    }): Observable<SyncResult[]> {
+        const tasks: Observable<SyncResult>[] = [
+            this.syncOrders(params.startDate, params.endDate),
+            this.syncOrderMetrics(params.startDate, params.endDate),
+        ];
+        if (params.keyword) {
+            tasks.push(this.syncProducts(params.keyword));
+            tasks.push(this.syncKeywords(params.keyword));
+        }
+        if (params.sellerId) {
+            tasks.push(this.syncListings(params.sellerId));
+        }
+        tasks.push(this.syncCategories());
+        return forkJoin(tasks);
+    }
+
+    /** 同步关键词数据 */
+    syncKeywords(keyword: string): Observable<SyncResult> {
+        return this.keywordApi.search({ keyword, pageSize: 50 }).pipe(
+            switchMap(data => {
+                if (data?.items?.length) {
+                    return this.dataStore.saveBatch('Keyword', data.items.map((k: any) => ({
+                        keyword: k.keyword,
+                        searchVolume: k.searchVolume,
+                        trend: k.trend,
+                        competition: k.competition,
+                        cpcAmount: k.cpc?.amount || 0,
+                        currency: k.cpc?.currency || 'USD'
+                    })), 'keyword').pipe(
+                        map(() => ({
+                            module: '关键词', success: true,
+                            count: data.items.length,
+                            message: `成功同步 ${data.items.length} 条关键词`
+                        }))
+                    );
+                }
+                return of({ module: '关键词', success: true, count: 0, message: '无关键词数据' });
+            }),
+            catchError(err => of({
+                module: '关键词', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    /** 同步类目数据 */
+    syncCategories(): Observable<SyncResult> {
+        return this.categoryApi.getTree().pipe(
+            switchMap(data => {
+                if (data?.items?.length) {
+                    const flatCategories = this.flattenCategories(data.items);
+                    return this.dataStore.saveBatch('Category', flatCategories.map(c => ({
+                        categoryId: c.id,
+                        name: c.name,
+                        parentId: c.parentId,
+                        productCount: c.productCount
+                    })), 'categoryId').pipe(
+                        map(() => ({
+                            module: '类目', success: true,
+                            count: flatCategories.length,
+                            message: `成功同步 ${flatCategories.length} 个类目`
+                        }))
+                    );
+                }
+                return of({ module: '类目', success: true, count: 0, message: '无类目数据' });
+            }),
+            catchError(err => of({
+                module: '类目', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    /** 同步 Listing 数据 */
+    syncListings(sellerId: string): Observable<SyncResult> {
+        return this.listingApi.getListings({ sellerId, pageSize: 50 }).pipe(
+            switchMap(data => {
+                if (data?.items?.length) {
+                    return this.dataStore.saveBatch('Listing', data.items.map((l: any) => ({
+                        sku: l.sku, asin: l.asin, title: l.title,
+                        status: l.status, priceAmount: l.price?.amount || 0,
+                        currency: l.price?.currency || 'USD',
+                        imageUrl: l.imageUrl, condition: l.condition,
+                        createdAt: l.createdAt
+                    })), 'sku').pipe(
+                        map(() => ({
+                            module: 'Listing', success: true,
+                            count: data.items.length,
+                            message: `成功同步 ${data.items.length} 条 Listing`
+                        }))
+                    );
+                }
+                return of({ module: 'Listing', success: true, count: 0, message: '无 Listing 数据' });
+            }),
+            catchError(err => of({
+                module: 'Listing', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    /** 同步评论数据 */
+    syncReviews(asin: string): Observable<SyncResult> {
+        return this.productApi.getReviews(asin).pipe(
+            switchMap(data => {
+                if (data?.reviews?.length) {
+                    return this.dataStore.saveBatch('Review', data.reviews.map((r: any) => ({
+                        reviewId: r.id, asin, rating: r.rating,
+                        title: r.title, content: r.content,
+                        author: r.author, verified: r.verified,
+                        createTime: r.createTime
+                    })), 'reviewId').pipe(
+                        map(() => ({
+                            module: '评论', success: true,
+                            count: data.reviews.length,
+                            message: `成功同步 ${data.reviews.length} 条评论`
+                        }))
+                    );
+                }
+                return of({ module: '评论', success: true, count: 0, message: '无评论数据' });
+            }),
+            catchError(err => of({
+                module: '评论', success: false, count: 0,
+                message: `同步失败: ${err.message}`
+            }))
+        );
+    }
+
+    private flattenCategories(nodes: any[], result: any[] = []): any[] {
+        for (const node of nodes) {
+            result.push(node);
+            if (node.children?.length) {
+                this.flattenCategories(node.children, result);
+            }
+        }
+        return result;
+    }
+}

+ 58 - 0
src/app/core/services/domestic-dataset.service.ts

@@ -0,0 +1,58 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable, map, shareReplay } from 'rxjs';
+import { DomesticDataset, DomesticProduct, DomesticReview } from '../models/domestic.models';
+import { ProductInfo } from '../../../modules/shared/interfaces/voc-insight-interfaces';
+
+@Injectable({ providedIn: 'root' })
+export class DomesticDatasetService {
+  private readonly http = inject(HttpClient);
+
+  readonly dataset$: Observable<DomesticDataset> = this.http
+    .get<DomesticDataset>('assets/data/demashi-summary.json')
+    .pipe(shareReplay({ bufferSize: 1, refCount: false }));
+
+  readonly products$: Observable<ProductInfo[]> = this.dataset$.pipe(
+    map((dataset) => dataset.products.map((product) => this.toProductInfo(product, dataset))),
+    shareReplay({ bufferSize: 1, refCount: false }),
+  );
+
+  getReviews(productId: string): Observable<DomesticReview[]> {
+    return this.dataset$.pipe(
+      map((dataset) => (dataset.reviews ?? []).filter((review) => review.productId === productId)),
+    );
+  }
+
+  toProductInfo(product: DomesticProduct, dataset: DomesticDataset): ProductInfo {
+    return {
+      id: product.productKey,
+      title: product.title,
+      photo: 'assets/images/placeholder.svg',
+      price: product.summary.averageUnitPrice,
+      sales: product.summary.soldUnits,
+      asin: product.productId,
+      launchDate: new Date(dataset.source.dateRange.start),
+      category: product.category3 || product.category2 || product.category1,
+      store: '京东',
+      vocSummary: {
+        totalReviews: 0,
+        averageRating: 0,
+        positiveRate: 0,
+        negativeRate: 0,
+        neutralRate: 0,
+      },
+      competitorComparison: { rating: 0, price: 0, sales: 0 },
+      analysisStatus: {
+        dataCompleteness: dataset.summary.reviewCount > 0 ? 100 : 35,
+        aiProgress: 0,
+        lastUpdate: new Date(dataset.generatedAt),
+      },
+      productSku: product.model || product.productId,
+      sellerSku: product.productId,
+      site: dataset.platform,
+      userAccount: dataset.caseName,
+      itemStatus: 1,
+      salesDailyTrend: product.trend.map((item) => ({ date: item.date, value: item.soldUnits })),
+    };
+  }
+}

+ 22 - 0
src/app/core/services/domestic-ecommerce-gateway.service.ts

@@ -0,0 +1,22 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { API_CONFIG } from './api.config';
+
+@Injectable({ providedIn: 'root' })
+export class DomesticEcommerceGatewayService {
+  private readonly http = inject(HttpClient);
+
+  get<T>(platform: string, upstreamPath: string, query: Record<string, string | number> = {}): Observable<T> {
+    const safePlatform = encodeURIComponent(platform.trim());
+    const safePath = upstreamPath.split('/').filter(Boolean).map(encodeURIComponent).join('/');
+    const params = Object.entries(query).reduce(
+      (result, [key, value]) => result.set(key, String(value)),
+      new HttpParams(),
+    );
+    return this.http.get<T>(
+      `${API_CONFIG.BASE_URL}${API_CONFIG.VOC_ECOMMERCE_PREFIX}/${safePlatform}/${safePath}`,
+      { params },
+    );
+  }
+}

+ 212 - 0
src/app/core/services/http-api.service.ts

@@ -0,0 +1,212 @@
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
+import { Observable, throwError } from 'rxjs';
+import { map, catchError, retry } from 'rxjs/operators';
+import { API_CONFIG } from './api.config';
+import { ApiResponse, PaginatedResponse } from '../models/api.models';
+
+const SHOP_OBJECT_ID_KEY = 'msq_shop_objectId';
+
+/**
+ * 核心 HTTP 请求服务
+ * 封装统一的请求/响应处理、错误处理、重试逻辑
+ */
+@Injectable({
+    providedIn: 'root'
+})
+export class HttpApiService {
+    private shopObjectId: string | null = null;
+
+    constructor(private http: HttpClient) {
+        // 从 localStorage 恢复 shopObjectId
+        try {
+            this.shopObjectId = localStorage.getItem(SHOP_OBJECT_ID_KEY);
+        } catch (e) {
+            // localStorage 不可用时忽略
+        }
+    }
+
+    /** 设置当前店铺 ObjectId (Amazon 接口需要) */
+    setShopObjectId(id: string): void {
+        this.shopObjectId = id;
+        try {
+            localStorage.setItem(SHOP_OBJECT_ID_KEY, id);
+        } catch (e) {
+            // localStorage 不可用时忽略
+        }
+    }
+
+    getShopObjectId(): string | null {
+        return this.shopObjectId;
+    }
+
+    // ==================== 统一清洗接口 (v2) ====================
+
+    /** GET 请求 - 清洗接口 */
+    cleanGet<T>(path: string, params?: Record<string, any>): Observable<T> {
+        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.CLEAN_PREFIX}${path}`;
+        return this.http.get<ApiResponse<T>>(url, {
+            params: this.buildParams(params)
+        }).pipe(
+            retry(1),
+            map(res => this.extractData(res)),
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    // ==================== Amazon SP-API ====================
+
+    /** GET 请求 - Amazon 接口 */
+    amazonGet<T>(path: string, params?: Record<string, any>): Observable<T> {
+        if (!this.shopObjectId) {
+            return throwError(() => new Error('未配置 shop-objectId,请先在系统设置中配置店铺'));
+        }
+        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.AMAZON_PREFIX}${path}`;
+        return this.http.get<any>(url, {
+            params: this.buildParams(params),
+            headers: this.buildAmazonHeaders()
+        }).pipe(
+            retry(1),
+            map(res => res?.data ?? res),
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    /** POST 请求 - Amazon 接口 */
+    amazonPost<T>(path: string, body: any, params?: Record<string, any>): Observable<T> {
+        if (!this.shopObjectId) {
+            return throwError(() => new Error('未配置 shop-objectId,请先在系统设置中配置店铺'));
+        }
+        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.AMAZON_PREFIX}${path}`;
+        return this.http.post<any>(url, body, {
+            params: this.buildParams(params),
+            headers: this.buildAmazonHeaders()
+        }).pipe(
+            retry(1),
+            map(res => res?.data ?? res),
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    // ==================== Sorftime API ====================
+
+    /** POST 转发请求 - Sorftime 接口 */
+    sorftimeForward<T>(apiPath: string, body?: any): Observable<T> {
+        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.SORFTIME_PREFIX}/forward`;
+        const payload = {
+            path: apiPath,
+            method: 'POST',
+            body: body?.body ?? {},
+            query: body?.query ?? {}
+        };
+        return this.http.post<any>(url, payload).pipe(
+            map(res => {
+                // 兼容两类错误结构:
+                // 1) { success:false, message:"..." }
+                // 2) { Code:10, Message:"请求参数错误", Data: ... }
+                if (res?.success === false) {
+                    throw new Error(res?.message || 'Sorftime 上游接口返回失败');
+                }
+                if (typeof res?.Code === 'number' && res.Code !== 200 && res.Code !== 0) {
+                    throw new Error(res?.Message || `Sorftime API Error ${res.Code}`);
+                }
+                return res?.Data ?? res?.data ?? res;
+            }),
+            catchError(err => {
+                console.warn(`[Sorftime] ${apiPath} 调用失败 (上游API可能未就绪):`, err?.status || '', err?.error?.message || err?.message || '');
+                return throwError(() => err);
+            })
+        );
+    }
+
+    // ==================== TikHub API ====================
+
+    /** POST 转发请求 - TikHub 接口 (server-msq 转发) */
+    tikhubForward<T>(apiPath: string, body?: any): Observable<T> {
+        const url = `${API_CONFIG.BASE_URL}${API_CONFIG.TIKHUB_PREFIX}/forward`;
+        return this.http.post<any>(url, { path: apiPath, ...body }).pipe(
+            retry(1),
+            map(res => res?.data ?? res),
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    /** GET 请求 - TikHub thapi 代理 (直连,带 Bearer Token) */
+    tikhubGet<T>(apiPath: string, params?: Record<string, any>): Observable<T> {
+        const url = `${API_CONFIG.TIKHUB_PROXY_URL}${apiPath}`;
+        const headers = new HttpHeaders({
+            'Authorization': API_CONFIG.TIKHUB_TOKEN,
+            'Accept': 'application/json'
+        });
+        return this.http.get<any>(url, {
+            params: this.buildParams(params),
+            headers
+        }).pipe(
+            retry(1),
+            map(res => res?.data ?? res),
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    // ==================== 通用 GET/POST ====================
+
+    rawGet<T>(url: string, params?: Record<string, any>): Observable<T> {
+        return this.http.get<T>(url, { params: this.buildParams(params) }).pipe(
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    rawPost<T>(url: string, body: any): Observable<T> {
+        return this.http.post<T>(url, body).pipe(
+            catchError(err => this.handleError(err))
+        );
+    }
+
+    // ==================== 内部方法 ====================
+
+    private buildParams(params?: Record<string, any>): HttpParams {
+        let httpParams = new HttpParams();
+        if (params) {
+            Object.entries(params).forEach(([key, value]) => {
+                if (value !== undefined && value !== null && value !== '') {
+                    httpParams = httpParams.set(key, String(value));
+                }
+            });
+        }
+        return httpParams;
+    }
+
+    private buildAmazonHeaders(): HttpHeaders {
+        let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
+        if (this.shopObjectId) {
+            headers = headers.set('shop-objectId', this.shopObjectId);
+        }
+        return headers;
+    }
+
+    private extractData<T>(res: ApiResponse<T>): T {
+        if (res.code === 0) {
+            return res.data;
+        }
+        throw new Error(`API Error [${res.code}]: ${res.message}`);
+    }
+
+    private handleError(error: any): Observable<never> {
+        let message = '请求失败';
+        if (error?.status) {
+            switch (error.status) {
+                case 400: message = '请求参数错误'; break;
+                case 401: message = '认证失败,请检查店铺配置'; break;
+                case 403: message = '权限不足'; break;
+                case 429: message = '请求过于频繁,请稍后重试'; break;
+                case 500: message = '服务器内部错误'; break;
+                case 502: message = '上游服务调用失败'; break;
+                case 503: message = '服务暂不可用'; break;
+            }
+        } else if (error?.message) {
+            message = error.message;
+        }
+        console.error('[HttpApiService]', message, error);
+        return throwError(() => new Error(message));
+    }
+}

+ 19 - 0
src/app/core/services/index.ts

@@ -0,0 +1,19 @@
+// Core API Services
+export { HttpApiService } from './http-api.service';
+export { API_CONFIG } from './api.config';
+
+// Domain API Services
+export { ProductApiService } from './product-api.service';
+export { OrderApiService } from './order-api.service';
+export { KeywordApiService } from './keyword-api.service';
+export { CategoryApiService } from './category-api.service';
+export { ListingApiService } from './listing-api.service';
+export { SellerApiService } from './seller-api.service';
+export { TikTokApiService } from './tiktok-api.service';
+export { AmazonRawApiService } from './amazon-raw-api.service';
+export { SorftimeApiService } from './sorftime-api.service';
+
+// Data Services
+export { DataStoreService } from './data-store.service';
+export { DataSyncService } from './data-sync.service';
+export { ApiConnectivityService } from './api-connectivity.service';

+ 43 - 0
src/app/core/services/keyword-api.service.ts

@@ -0,0 +1,43 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpApiService } from './http-api.service';
+
+/**
+ * 关键词 API 服务
+ * 对接 /api/sorftime 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class KeywordApiService {
+    constructor(private api: HttpApiService) { }
+
+    /** 关键词查询 */
+    search(params: {
+        keyword: string;
+        matchMode?: 'broad' | 'exact' | 'phrase';
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.api.sorftimeForward('/keywords/search', params);
+    }
+
+    /** 关键词下的产品排名 */
+    getProducts(keyword: string, params?: {
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.api.sorftimeForward(`/keywords/${encodeURIComponent(keyword)}/products`, params);
+    }
+
+    /** ASIN 反查关键词 */
+    reverseByAsin(asin: string, params?: {
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.api.sorftimeForward(`/keywords/reverse/${asin}`, params);
+    }
+
+    /** 关键词趋势 */
+    getTrend(keyword: string): Observable<any> {
+        return this.api.sorftimeForward(`/keywords/${encodeURIComponent(keyword)}/trend`);
+    }
+}

+ 20 - 0
src/app/core/services/layout.service.ts

@@ -0,0 +1,20 @@
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class LayoutService {
+  private collapsedSubject = new BehaviorSubject<boolean>(false);
+  isCollapsed$ = this.collapsedSubject.asObservable();
+
+  constructor() {}
+
+  toggleCollapse() {
+    this.collapsedSubject.next(!this.collapsedSubject.value);
+  }
+
+  setCollapse(isCollapsed: boolean) {
+    this.collapsedSubject.next(isCollapsed);
+  }
+}

+ 31 - 0
src/app/core/services/listing-api.service.ts

@@ -0,0 +1,31 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+
+/**
+ * Listing API 服务
+ * 对接 /api/amazon 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class ListingApiService {
+    constructor(private amazonRaw: AmazonRawApiService) { }
+
+    /** Listing 列表 */
+    getListings(params: {
+        sellerId: string;
+        keywords?: string;
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.amazonRaw.getListingsItems(params.sellerId, {
+            keywords: params.keywords,
+            page: params.page,
+            pageSize: params.pageSize
+        });
+    }
+
+    /** Listing 详情 */
+    getDetail(sellerId: string, sku: string): Observable<any> {
+        return this.amazonRaw.getListingsItem(sellerId, sku);
+    }
+}

+ 75 - 0
src/app/core/services/order-api.service.ts

@@ -0,0 +1,75 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+import { OrderMetricsData } from '../models/api.models';
+
+/**
+ * 订单 API 服务
+ * 对接 /api/amazon 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class OrderApiService {
+    constructor(private amazonRaw: AmazonRawApiService) { }
+
+    /** 订单列表 */
+    getOrders(params: {
+        startDate: string;
+        endDate: string;
+        status?: string;
+        marketplaceIds?: string;
+        pageSize?: number;
+    }): Observable<any> {
+        return this.amazonRaw.getOrders({
+            createdAfter: params.startDate,
+            createdBefore: params.endDate,
+            orderStatuses: params.status,
+            marketplaceIds: params.marketplaceIds || 'ATVPDKIKX0DER',
+            maxResultsPerPage: params.pageSize
+        });
+    }
+
+    /** 订单详情 */
+    getOrderDetail(orderId: string): Observable<any> {
+        return this.amazonRaw.getOrder(orderId);
+    }
+
+    /** 订单统计指标 */
+    getOrderMetrics(params: {
+        startDate: string;
+        endDate: string;
+        granularity?: 'day' | 'week' | 'month';
+        marketplaceIds?: string;
+    }): Observable<OrderMetricsData> {
+        const interval = `${params.startDate}--${params.endDate}`;
+        const granularity = (params.granularity || 'day').charAt(0).toUpperCase()
+            + (params.granularity || 'day').slice(1);
+
+        return this.amazonRaw.getOrderMetrics({
+            marketplaceIds: params.marketplaceIds || 'ATVPDKIKX0DER',
+            interval,
+            granularity
+        }).pipe(
+            map((raw: any) => this.mapOrderMetrics(raw))
+        );
+    }
+
+    /** 将 Amazon SP-API orderMetrics 响应映射为统一格式 */
+    private mapOrderMetrics(raw: any): OrderMetricsData {
+        const payload = raw?.payload || raw;
+        if (!Array.isArray(payload)) {
+            return { periods: [] };
+        }
+        return {
+            periods: payload.map((item: any) => ({
+                date: item.interval?.split('--')?.[0] || '',
+                orderCount: item.orderCount || 0,
+                unitCount: item.unitCount || 0,
+                revenue: {
+                    amount: Number(item.totalSales?.amount || 0),
+                    currency: item.totalSales?.currencyCode || 'USD'
+                }
+            }))
+        };
+    }
+}

+ 67 - 0
src/app/core/services/product-api.service.ts

@@ -0,0 +1,67 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+import { HttpApiService } from './http-api.service';
+
+/**
+ * 商品 API 服务
+ * 对接 /api/amazon 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class ProductApiService {
+    constructor(
+        private amazonRaw: AmazonRawApiService,
+        private api: HttpApiService
+    ) { }
+
+    /** 搜索商品(Amazon Catalog) */
+    search(params: {
+        keyword: string;
+        platform?: 'amazon' | 'sorftime';
+        page?: number;
+        pageSize?: number;
+    }): Observable<any> {
+        if (params.platform === 'sorftime') {
+            return this.api.sorftimeForward('/products/search', {
+                keyword: params.keyword,
+                page: params.page,
+                pageSize: params.pageSize
+            });
+        }
+        return this.amazonRaw.searchCatalogItems({
+            keywords: params.keyword,
+            marketplaceIds: 'ATVPDKIKX0DER',
+            includedData: 'summaries,images'
+        });
+    }
+
+    /** 商品详情 */
+    getDetail(asin: string): Observable<any> {
+        return this.amazonRaw.getCatalogItem(asin, {
+            marketplaceIds: 'ATVPDKIKX0DER',
+            includedData: 'summaries,images,salesRanks'
+        });
+    }
+
+    /** 商品销量数据 — 通过 orderMetrics 间接获取 */
+    getSales(asin: string, params: {
+        startDate: string;
+        endDate: string;
+        granularity?: 'day' | 'week' | 'month';
+    }): Observable<any> {
+        const interval = `${params.startDate}--${params.endDate}`;
+        const granularity = (params.granularity || 'day').charAt(0).toUpperCase()
+            + (params.granularity || 'day').slice(1);
+        return this.amazonRaw.getOrderMetrics({
+            marketplaceIds: 'ATVPDKIKX0DER',
+            interval,
+            granularity
+        });
+    }
+
+    /** 商品评论分析 */
+    getReviews(asin: string): Observable<any> {
+        return this.amazonRaw.getReviewTopics(asin);
+    }
+}

+ 17 - 0
src/app/core/services/seller-api.service.ts

@@ -0,0 +1,17 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { AmazonRawApiService } from './amazon-raw-api.service';
+
+/**
+ * 卖家 API 服务
+ * 对接 /api/amazon 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class SellerApiService {
+    constructor(private amazonRaw: AmazonRawApiService) { }
+
+    /** 获取卖家参与的市场列表 */
+    getMarketplaces(): Observable<any> {
+        return this.amazonRaw.getMarketplaceParticipations();
+    }
+}

+ 340 - 0
src/app/core/services/sorftime-api.service.ts

@@ -0,0 +1,340 @@
+import { Injectable } from '@angular/core';
+import { Observable, forkJoin, of } from 'rxjs';
+import { map, catchError, switchMap } from 'rxjs/operators';
+import { HttpApiService } from './http-api.service';
+
+/**
+ * Sorftime 平台 API 服务
+ * 封装所有 Sorftime 数据查询接口,通过 /api/sorftime/forward 转发
+ * 认证已在后端内置,前端无需传入 Token
+ */
+@Injectable({ providedIn: 'root' })
+export class SorftimeApiService {
+    /** domain 数值映射,需与店铺管理里的 marketplace/domain 配置保持一致 */
+    private readonly DOMAIN_MAP: Record<string, number> = {
+        'amazon.com': 1,
+        'amazon.co.uk': 2,
+        'amazon.de': 3,
+        'amazon.fr': 4,
+        'amazon.in': 5,
+        'amazon.ca': 6,
+        'amazon.co.jp': 7,
+        'amazon.es': 8,
+        'amazon.it': 9,
+        'amazon.com.mx': 10,
+        'amazon.ae': 11,
+        'amazon.com.au': 12,
+        'amazon.com.br': 13,
+        'amazon.sa': 14,
+    };
+    private readonly MARKETPLACE_DOMAIN_MAP: Record<string, number> = {
+        ATVPDKIKX0DER: 1,
+        A1F83G8C2ARO7P: 2,
+        A1PA6795UKMFR9: 3,
+        A13V1IB3VIYZZH: 4,
+        A21TJRUUN4KGV: 5,
+        A2EUQ1WTGCTBG2: 6,
+        A1VC38T7YXB528: 7,
+        A1RKKUPIHCS9HS: 8,
+        APJ6JRA9NG5V4: 9,
+        A1AM78C64UM0Y8: 10,
+        A2VIGQ35RCS4UG: 11,
+        A39IBJ37TRP1C6: 12,
+        A2Q3Y263D00KWC: 13,
+        A17E79C6D8DWNP: 14,
+    };
+    private readonly DEFAULT_DOMAIN_ID = 1;
+
+    constructor(private api: HttpApiService) { }
+
+    /** 将 domain 字符串、数字字符串、站点 URL 或 marketplaceId 转为 Sorftime 数值 ID */
+    private getDomainId(domain?: string | number): number {
+        if (!domain) return this.DEFAULT_DOMAIN_ID;
+        if (typeof domain === 'number' && Number.isFinite(domain) && domain > 0) return domain;
+
+        const normalized = String(domain).trim();
+        if (/^\d+$/.test(normalized)) return Number(normalized);
+        if (this.MARKETPLACE_DOMAIN_MAP[normalized]) return this.MARKETPLACE_DOMAIN_MAP[normalized];
+
+        const host = normalized
+            .toLowerCase()
+            .replace(/^https?:\/\//, '')
+            .replace(/^www\./, '')
+            .split('/')[0];
+        return this.DOMAIN_MAP[host] ?? this.DEFAULT_DOMAIN_ID;
+    }
+
+    // ==================== 类目相关 ====================
+
+    /** 类目树 */
+    getCategoryTree(domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/CategoryTree', {
+            query: { domain: this.getDomainId(domain) },
+            body: {}
+        });
+    }
+
+    /** 类目详情 */
+    getCategoryDetail(categoryId: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/CategoryRequest', {
+            query: { domain: this.getDomainId(domain) },
+            body: { NodeId: categoryId }
+        });
+    }
+
+    /** 类目下的产品 */
+    getCategoryProducts(categoryId: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/CategoryProducts', {
+            query: { domain: this.getDomainId(domain) },
+            body: { NodeId: categoryId }
+        });
+    }
+
+    // ==================== 产品相关 ====================
+
+    /**
+     * 按关键词搜索竞品产品:用 /api/ProductQuery (QueryType=7) 按标题搜索
+     * 1 次请求可返回多个产品,若被验证码拦截则回退到批量 ProductRequest
+     */
+    searchProducts(keyword: string, params?: { page?: number; pageSize?: number; domain?: string }): Observable<any[]> {
+        const domain = params?.domain;
+        const page = params?.page || 1;
+        const kw = (keyword || '').trim() || 'swimsuit';
+
+        console.log(`[Sorftime] searchProducts via ProductQuery: keyword="${kw}", page=${page}`);
+
+        return this.searchProductsByQuery(kw, page, domain).pipe(
+            switchMap((products: any[]) => {
+                if (products.length > 0) {
+                    console.log(`[Sorftime] ProductQuery returned ${products.length} valid products`);
+                    return of(products);
+                }
+                // ProductQuery 无数据,回退到批量 ProductRequest
+                console.warn('[Sorftime] ProductQuery returned 0 valid, falling back to batch ProductRequest');
+                return this.getProductsByAsins(this.FALLBACK_ASINS, domain);
+            }),
+            catchError(err => {
+                console.warn('[Sorftime] ProductQuery failed:', err?.message, ', falling back to batch ProductRequest');
+                return this.getProductsByAsins(this.FALLBACK_ASINS, domain);
+            })
+        );
+    }
+
+    /**
+     * 通过 /api/ProductQuery 搜索产品(QueryType=7 按标题搜索)
+     * 单次请求可返回多个产品
+     */
+    searchProductsByQuery(keyword: string, page: number = 1, domain?: string): Observable<any[]> {
+        const domainId = this.getDomainId(domain);
+        return this.api.sorftimeForward<any>('/api/ProductQuery', {
+            query: { domain: domainId },
+            body: { Page: page, Query: '1', QueryType: '7', Pattern: keyword }
+        }).pipe(
+            map((resp: any) => {
+                // 检测验证码拦截
+                if (resp && resp.code === -10) {
+                    console.warn('[Sorftime] ProductQuery blocked by captcha');
+                    return [];
+                }
+                const products = this.extractProductList(resp);
+                return products.filter(p => this.isValidProduct(p));
+            }),
+            catchError(err => {
+                console.warn('[Sorftime] ProductQuery error:', err?.message);
+                return of([]);
+            })
+        );
+    }
+
+    /** 从 Sorftime 响应中提取产品数组(兼容多种响应结构) */
+    private extractProductList(resp: any): any[] {
+        if (!resp) return [];
+        if (Array.isArray(resp)) return resp;
+        // 尝试常见的嵌套结构
+        for (const key of ['Data', 'data', 'Products', 'products', 'Result', 'result', 'Items', 'items']) {
+            const val = resp[key];
+            if (Array.isArray(val) && val.length > 0) return val;
+        }
+        // 如果响应本身是一个产品对象(有 Asin 字段)
+        if (resp.Asin || resp.asin) return [resp];
+        // 遍历所有 key 找数组
+        for (const key of Object.keys(resp)) {
+            const val = resp[key];
+            if (Array.isArray(val) && val.length > 0 && val[0] && (val[0].Asin || val[0].asin || val[0].ASIN)) {
+                return val;
+            }
+        }
+        return [];
+    }
+
+    /** 按 ASIN 获取单个产品详情 */
+    getProductByAsin(asin: string, domain?: string): Observable<any> {
+        const domainId = this.getDomainId(domain);
+        return this.api.sorftimeForward<any>('/api/ProductRequest', {
+            query: { domain: domainId },
+            body: { ASIN: asin, Trend: 1, QueryTrendStartDt: '', QueryTrendEndDt: '' }
+        }).pipe(
+            map(result => result ? [result] : []),
+            catchError(() => of([]))
+        );
+    }
+
+    /** 批量按 ASIN 获取产品详情(并行请求) */
+    getProductsByAsins(asins: string[], domain?: string): Observable<any[]> {
+        const domainId = this.getDomainId(domain);
+        const requests = asins.map(asin =>
+            this.api.sorftimeForward<any>('/api/ProductRequest', {
+                query: { domain: domainId },
+                body: { ASIN: asin, Trend: 1, QueryTrendStartDt: '', QueryTrendEndDt: '' }
+            }).pipe(catchError(() => of(null)))
+        );
+        return forkJoin(requests).pipe(
+            map(results => results.filter(r => this.isValidProduct(r)))
+        );
+    }
+
+    /** 判断 Sorftime 返回的产品数据是否有效(非空壳) */
+    private isValidProduct(p: any): boolean {
+        if (!p || typeof p !== 'object') return false;
+        // 有效产品至少应有 Title 或 Price > 0
+        const hasTitle = !!(p.Title || p.title);
+        const hasPrice = Number(p.Price || p.SalesPrice || 0) > 0;
+        const hasRatings = Number(p.RatingsCount || 0) > 0;
+        return hasTitle || hasPrice || hasRatings;
+    }
+
+    /** 回退 ASIN(当 ProductQuery 也被封时使用) */
+    private readonly FALLBACK_ASINS: string[] = [
+        'B0DJHP1FCS', 'B0CSVRWJ1H'
+    ];
+
+    /** 产品详情 */
+    getProductDetail(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/ProductRequest', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin, Trend: 1, QueryTrendStartDt: '', QueryTrendEndDt: '' }
+        });
+    }
+
+    /** ASIN 销量查询 */
+    getAsinSalesVolume(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/AsinSalesVolume', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin }
+        });
+    }
+
+    /** 同类产品实时查询 */
+    getSimilarProducts(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/SimilarProductRealtimeRequest', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin }
+        });
+    }
+
+    /**
+     * 同类产品实时查询(图片模式)
+     * Sorftime 控制台实测该端点常要求 Image(base64 dataUrl),仅传 ASIN 会返回 Code:10
+     */
+    getSimilarProductsByImage(imageDataUrl: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/SimilarProductRealtimeRequest', {
+            query: { domain: this.getDomainId(domain) },
+            body: { Image: imageDataUrl }
+        });
+    }
+
+    /** 同类产品实时查询状态 */
+    getSimilarProductsStatus(taskId: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/SimilarProductRealtimeRequestStatusQuery', {
+            query: { domain: this.getDomainId(domain) },
+            body: { TaskId: taskId }
+        });
+    }
+
+    /** 同类产品实时查询结果(分页) */
+    getSimilarProductsCollection(taskId: string, page: number = 1, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/SimilarProductRealtimeRequestCollection', {
+            query: { domain: this.getDomainId(domain) },
+            body: { TaskId: taskId, Page: page }
+        });
+    }
+
+    /** 产品评论查询 */
+    getProductReviews(asin: string, options?: {
+        star?: string;
+        pageIndex?: number;
+        onlyPurchase?: number;
+        queryStartDt?: string;
+        domain?: string;
+    }): Observable<any> {
+        const body: any = { ASIN: asin };
+        if (options?.star) body.Star = options.star;
+        if (options?.pageIndex) body.PageIndex = options.pageIndex;
+        if (options?.onlyPurchase) body.OnlyPurchase = options.onlyPurchase;
+        if (options?.queryStartDt) body.QueryStartDt = options.queryStartDt;
+        return this.api.sorftimeForward('/api/ProductReviewsQuery', {
+            query: { domain: this.getDomainId(options?.domain) },
+            body
+        });
+    }
+
+    /** 产品监控数据 */
+    getMonitorData(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/MonitorQuery', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin }
+        });
+    }
+
+    // ==================== 关键词相关 ====================
+
+    /** 关键词查询 */
+    searchKeywords(keyword: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/KeywordQuery', {
+            query: { domain: this.getDomainId(domain) },
+            body: { keyword }
+        });
+    }
+
+    /** ASIN 反查关键词 */
+    getAsinKeywords(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/ASINRequestKeyword', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin }
+        });
+    }
+
+    /** 关键词产品排名 */
+    getKeywordProductRanking(keyword: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/KeywordProductRanking', {
+            query: { domain: this.getDomainId(domain) },
+            body: { keyword }
+        });
+    }
+
+    /** 关键词搜索结果趋势 */
+    getKeywordSearchTrend(keyword: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/KeywordSearchResultTrend', {
+            query: { domain: this.getDomainId(domain) },
+            body: { keyword }
+        });
+    }
+
+    // ==================== 变体与相似产品 ====================
+
+    /** 变体历史信息 */
+    getProductVariationHistory(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/ProductVariationHistory', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin }
+        });
+    }
+
+    /** 相似产品实时查询 */
+    getSimilarProductsRealtime(asin: string, domain?: string): Observable<any> {
+        return this.api.sorftimeForward('/api/SimilarProductRealtimeRequest', {
+            query: { domain: this.getDomainId(domain) },
+            body: { ASIN: asin }
+        });
+    }
+}

+ 354 - 0
src/app/core/services/tiktok-api.service.ts

@@ -0,0 +1,354 @@
+import { Injectable } from '@angular/core';
+import { Observable, map, catchError } from 'rxjs';
+import { HttpApiService } from './http-api.service';
+
+/**
+ * TikTok API 服务
+ * 对接 /api/tikhub 原始接口(v2 统一清洗接口尚未实现)
+ */
+@Injectable({ providedIn: 'root' })
+export class TikTokApiService {
+    constructor(private api: HttpApiService) { }
+
+    /** 用户资料 */
+    getUserProfile(uniqueId: string): Observable<any> {
+        return this.api.tikhubForward(`/tiktok/users/${uniqueId}`);
+    }
+
+    /** 视频详情 */
+    getVideo(videoId: string): Observable<any> {
+        return this.api.tikhubForward(`/tiktok/videos/${videoId}`);
+    }
+
+    /** 视频评论 */
+    getVideoComments(videoId: string, params?: {
+        cursor?: string;
+        count?: number;
+    }): Observable<any> {
+        return this.api.tikhubForward(`/tiktok/videos/${videoId}/comments`, params);
+    }
+
+    /** 用户粉丝 */
+    getUserFollowers(userId: string, params?: {
+        offset?: number;
+        count?: number;
+    }): Observable<any> {
+        return this.api.tikhubForward(`/tiktok/users/${userId}/followers`, params);
+    }
+
+    // ==================== 搜索相关 ====================
+
+    /** 搜索视频 — 通过 TikHub forward 调用 /api/v1/tiktok/app/v3/fetch_video_search_result */
+    searchVideos(keyword: string, params?: { count?: number; cursor?: number }): Observable<any> {
+        return this.api.tikhubForward('/api/v1/tiktok/app/v3/fetch_video_search_result', {
+            method: 'GET',
+            query: { keyword, count: params?.count || 10, ...(params?.cursor ? { cursor: params.cursor } : {}) }
+        }).pipe(
+            map((resp: any) => {
+                const items = resp?.data?.search_item_list || resp?.search_item_list || [];
+                return {
+                    items: items.map((item: any) => item?.aweme_info || item),
+                    hasMore: resp?.data?.has_more ?? false,
+                    cursor: resp?.data?.cursor ?? 0
+                };
+            })
+        );
+    }
+
+    /** 搜索视频并提取互动统计 */
+    searchVideoStats(keyword: string, count = 10): Observable<{
+        keyword: string;
+        totalViews: number;
+        totalLikes: number;
+        totalComments: number;
+        totalShares: number;
+        avgEngagement: number;
+        videoCount: number;
+        topVideos: any[];
+    }> {
+        return this.searchVideos(keyword, { count }).pipe(
+            map(result => {
+                const videos = result.items || [];
+                let totalViews = 0, totalLikes = 0, totalComments = 0, totalShares = 0;
+                const topVideos = videos.map((v: any) => {
+                    const stats = v.statistics || {};
+                    totalViews += stats.play_count || 0;
+                    totalLikes += stats.digg_count || 0;
+                    totalComments += stats.comment_count || 0;
+                    totalShares += stats.share_count || 0;
+                    const awemeId = v.aweme_id || v.id || '';
+                    const authorId = v.author?.unique_id || v.author?.uniqueId || '';
+                    const cover = v.video?.cover?.url_list?.[0]
+                        || v.video?.dynamic_cover?.url_list?.[0]
+                        || v.video?.origin_cover?.url_list?.[0]
+                        || v.video?.ai_dynamic_cover?.url_list?.[0]
+                        || '';
+                    const videoUrl = awemeId && authorId
+                        ? `https://www.tiktok.com/@${authorId}/video/${awemeId}`
+                        : (awemeId ? `https://www.tiktok.com/video/${awemeId}` : '');
+                    return {
+                        id: awemeId,
+                        desc: v.desc || '',
+                        views: stats.play_count || 0,
+                        likes: stats.digg_count || 0,
+                        comments: stats.comment_count || 0,
+                        shares: stats.share_count || 0,
+                        author: v.author?.nickname || '',
+                        authorId,
+                        authorAvatar: v.author?.avatar_thumb?.url_list?.[0] || v.author?.avatar_larger?.url_list?.[0] || '',
+                        cover,
+                        videoUrl,
+                        duration: v.video?.duration || v.duration || 0,
+                        platform: 'tiktok',
+                        hashtags: (v.cha_list || []).map((c: any) => c.cha_name || c.title || '').filter(Boolean)
+                    };
+                });
+                const avgEngagement = totalViews > 0
+                    ? Math.round((totalLikes + totalComments + totalShares) / totalViews * 10000) / 100
+                    : 0;
+                return { keyword, totalViews, totalLikes, totalComments, totalShares, avgEngagement, videoCount: videos.length, topVideos };
+            })
+        );
+    }
+
+    // ==================== thapi 代理接口 (直连 TikHub) ====================
+
+    /** 搜索视频 (thapi) */
+    searchVideosV2(keyword: string, params?: { count?: number; cursor?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_video_search_result', {
+            keyword, count: params?.count || 10, ...(params?.cursor ? { cursor: params.cursor } : {})
+        }).pipe(
+            map((resp: any) => {
+                // aweme_list 可能为空,实际数据在 search_item_list[].aweme_info
+                let items = resp?.aweme_list || [];
+                if (!items.length && resp?.search_item_list?.length) {
+                    items = resp.search_item_list
+                        .map((si: any) => si?.aweme_info)
+                        .filter(Boolean);
+                }
+                return {
+                    items,
+                    hasMore: !!(resp?.has_more),
+                    cursor: resp?.cursor ?? 0
+                };
+            })
+        );
+    }
+
+    /** 视频详情 (thapi) */
+    getVideoV2(awemeId: string): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_one_video', { aweme_id: awemeId });
+    }
+
+    /** 视频评论 (thapi) */
+    getVideoCommentsV2(awemeId: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_video_comments', {
+            aweme_id: awemeId, count: params?.count || 20, ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    /** 评论回复 (thapi) */
+    getCommentRepliesV2(awemeId: string, commentId: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_video_comment_replies', {
+            item_id: awemeId, comment_id: commentId, count: params?.count || 20,
+            ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    /** 话题详情 (thapi) */
+    getHashtagDetail(chId: string): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_hashtag_detail', { ch_id: chId });
+    }
+
+    /** 话题视频列表 (thapi) */
+    getHashtagVideos(chId: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_hashtag_video_list', {
+            ch_id: chId, count: params?.count || 20, ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    /** 搜索用户 — 优先 V3,失败回退已验证的 Web 接口 */
+    searchUsers(keyword: string, params?: { cursor?: number; count?: number; offset?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_user_search_result', {
+            keyword, count: params?.count || 10,
+            ...(params?.offset != null ? { offset: params.offset } : {}),
+            ...(params?.cursor ? { cursor: params.cursor } : {})
+        }).pipe(
+            catchError(() => {
+                console.warn('[TikTokApi] fetch_user_search_result V3 failed, falling back to web endpoint');
+                return this.api.tikhubGet('/v1/tiktok/web/fetch_search_user', {
+                    keyword, count: params?.count || 10,
+                    ...(params?.cursor ? { cursor: params.cursor } : {})
+                });
+            })
+        );
+    }
+
+    /** 用户详情 (thapi) */
+    getUserProfileV2(uniqueId: string): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/web/fetch_user_profile', { uniqueId });
+    }
+
+    /** 用户帖子 (thapi) */
+    getUserPostsV2(secUserId: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_user_post_videos', {
+            sec_user_id: secUserId, count: params?.count || 20, ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    // ==================== Instagram (thapi) ====================
+
+    /** Instagram 搜索 — V1 已验证可用,V2 超时不稳定 */
+    instagramSearch(query: string, params?: { pagination_token?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/instagram/v1/fetch_search', {
+            query, select: 'users'
+        }).pipe(
+            catchError(() => {
+                console.warn('[TikTokApi] instagram/v1/fetch_search failed, trying v2');
+                return this.api.tikhubGet('/v1/instagram/v2/general_search', {
+                    keyword: query,
+                    ...(params?.pagination_token ? { pagination_token: params.pagination_token } : {})
+                });
+            })
+        );
+    }
+
+    /** Instagram 用户详情 */
+    instagramUserInfo(username: string): Observable<any> {
+        return this.api.tikhubGet('/v1/instagram/v1/fetch_user_info_by_username_v3', { username });
+    }
+
+    /** Instagram 用户帖子 */
+    instagramUserPosts(userId: string, params?: { count?: number; end_cursor?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/instagram/v1/fetch_user_posts_v2', {
+            user_id: userId, count: params?.count || 12, ...(params?.end_cursor ? { end_cursor: params.end_cursor } : {})
+        });
+    }
+
+    // ==================== TikTok Shop (thapi) ====================
+
+    /** TikTok Shop 搜索商品 — 优先 App V3,失败回退 Web 接口 */
+    shopSearchProducts(keyword: string, params?: {
+        cursor?: number; count?: number; sort_type?: number;
+        customer_review_four_star?: boolean; have_discount?: boolean;
+        min_price?: string; max_price?: string; page?: number;
+    }): Observable<any> {
+        // 优先使用 App V3 接口
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_product_search', {
+            keyword,
+            cursor: params?.cursor ?? 0,
+            count: params?.count || 12,
+            sort_type: params?.sort_type || 1,
+            customer_review_four_star: params?.customer_review_four_star ?? false,
+            have_discount: params?.have_discount ?? false,
+            ...(params?.min_price ? { min_price: params.min_price } : {}),
+            ...(params?.max_price ? { max_price: params.max_price } : {})
+        }).pipe(
+            catchError(() => {
+                // V3 失败时回退到 Web 接口(注意: Web 接口参数名是 search_word)
+                console.warn('[TikTokApi] fetch_product_search V3 failed, falling back to web endpoint');
+                return this.api.tikhubGet('/v1/tiktok/shop/web/fetch_search_products_list', {
+                    search_word: keyword,
+                    page: params?.page || 1,
+                    count: params?.count || 12,
+                    ...(params?.sort_type ? { sort_by: params.sort_type } : {})
+                });
+            })
+        );
+    }
+
+    /** TikTok Shop 商品详情 — 文档接口: fetch_product_detail_v4 */
+    shopProductDetail(productId: string, region = 'US'): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_product_detail_v4', { product_id: productId, region });
+    }
+
+    /** TikTok Shop 商品评论 */
+    shopProductReviews(productId: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_product_review', {
+            product_id: productId, count: params?.count || 20, ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    /** TikTok Shop 热卖商品 — 文档接口: fetch_hot_selling_products_list */
+    shopHotSellingProducts(params?: { category_id?: string; count?: number; region?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/shop/web/fetch_hot_selling_products_list', {
+            count: params?.count || 20,
+            region: params?.region || 'US',
+            ...(params?.category_id ? { category_id: params.category_id } : {})
+        });
+    }
+
+    /** TikTok Shop 商品分类列表 */
+    shopProductCategories(): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/shop/web/fetch_products_category_list', {});
+    }
+
+    // ==================== TikTok Ads / Creative Center (thapi) ====================
+
+    /** 广告搜索 — 文档接口: search_ads, 返回 data.materials[] */
+    searchAds(keyword: string, params?: { page?: number; limit?: number; country_code?: string; order_by?: string; period?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/ads/search_ads', {
+            keyword, page: params?.page || 1, limit: params?.limit || 20,
+            period: params?.period || 180,
+            ...(params?.country_code ? { country_code: params.country_code } : {}),
+            ...(params?.order_by ? { order_by: params.order_by } : {})
+        });
+    }
+
+    /** 关键词洞察 — 文档接口: get_keyword_insights, 返回 data.data.keyword_list[] */
+    getKeywordInsights(keyword: string, params?: { country_code?: string; period?: number; order_by?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/ads/get_keyword_insights', {
+            keyword,
+            period: params?.period || 7,
+            order_by: params?.order_by || 'post',
+            order_type: 'desc',
+            ...(params?.country_code ? { country_code: params.country_code } : {})
+        });
+    }
+
+    /** 热门产品列表 */
+    getTopProducts(params?: { page?: number; limit?: number; country_code?: string; category_id?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/ads/get_top_products', {
+            page: params?.page || 1, limit: params?.limit || 20,
+            ...(params?.country_code ? { country_code: params.country_code } : {}),
+            ...(params?.category_id ? { category_id: params.category_id } : {})
+        });
+    }
+
+    /** 热门标签列表 */
+    getPopularHashtags(params?: { page?: number; limit?: number; country_code?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/ads/get_hashtag_list', {
+            page: params?.page || 1, limit: params?.limit || 20,
+            ...(params?.country_code ? { country_code: params.country_code } : {})
+        });
+    }
+
+    /** 搜索话题 (thapi) */
+    searchHashtags(keyword: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_hashtag_search_result', {
+            keyword, count: params?.count || 10, ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    /** 综合搜索 (thapi) */
+    generalSearch(keyword: string, params?: { cursor?: number; count?: number }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_general_search_result', {
+            keyword, count: params?.count || 10, ...(params?.cursor ? { cursor: params.cursor } : {})
+        });
+    }
+
+    /** 创作者搜索洞察 */
+    getCreatorSearchInsights(keyword: string, params?: { country_code?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_creator_search_insights', {
+            keyword, ...(params?.country_code ? { country_code: params.country_code } : {})
+        });
+    }
+
+    /** 创作者搜索洞察趋势 */
+    getCreatorSearchInsightsTrend(keyword: string, params?: { country_code?: string }): Observable<any> {
+        return this.api.tikhubGet('/v1/tiktok/app/v3/fetch_creator_search_insights_trend', {
+            keyword, ...(params?.country_code ? { country_code: params.country_code } : {})
+        });
+    }
+
+}

+ 71 - 0
src/app/echarts.ts

@@ -0,0 +1,71 @@
+import * as echarts from 'echarts/core';
+import {
+  LineChart,
+  BarChart,
+  PieChart,
+  ScatterChart,
+  EffectScatterChart,
+  GaugeChart,
+  HeatmapChart,
+  SankeyChart,
+  SunburstChart,
+  PictorialBarChart,
+  CustomChart,
+} from 'echarts/charts';
+import {
+  GridComponent,
+  PolarComponent,
+  CalendarComponent,
+  GraphicComponent,
+  ToolboxComponent,
+  TooltipComponent,
+  AxisPointerComponent,
+  TitleComponent,
+  MarkPointComponent,
+  MarkLineComponent,
+  MarkAreaComponent,
+  LegendComponent,
+  DataZoomComponent,
+  VisualMapComponent,
+  AriaComponent,
+  DatasetComponent,
+  TransformComponent,
+} from 'echarts/components';
+import { LabelLayout, UniversalTransition } from 'echarts/features';
+import { CanvasRenderer } from 'echarts/renderers';
+
+echarts.use([
+  LineChart,
+  BarChart,
+  PieChart,
+  ScatterChart,
+  EffectScatterChart,
+  GaugeChart,
+  HeatmapChart,
+  SankeyChart,
+  SunburstChart,
+  PictorialBarChart,
+  CustomChart,
+  GridComponent,
+  PolarComponent,
+  CalendarComponent,
+  GraphicComponent,
+  ToolboxComponent,
+  TooltipComponent,
+  AxisPointerComponent,
+  TitleComponent,
+  MarkPointComponent,
+  MarkLineComponent,
+  MarkAreaComponent,
+  LegendComponent,
+  DataZoomComponent,
+  VisualMapComponent,
+  AriaComponent,
+  DatasetComponent,
+  TransformComponent,
+  LabelLayout,
+  UniversalTransition,
+  CanvasRenderer,
+]);
+
+export default echarts;

+ 24 - 0
src/app/parse.ts

@@ -0,0 +1,24 @@
+import { FmodeParse } from 'fmode-ng/core';
+import { RUNTIME_CONFIG } from './core/config/runtime-config';
+
+// fmode-ng 的 Parse 封装在底层 fetch 失败时会直接调用 alert(err)(见
+// node_modules/fmode-ng/esm2022/lib/core/parse/fmode.object.mjs 第 193 行),
+// 业务层 catchError 拦不到。这里在入口处覆盖 window.alert,只放行业务代码
+// 主动传入的字符串(例如 alert('删除成功')),把 SDK 抛进来的 Error/对象
+// 形参降级为 console.warn,避免整页被弹窗阻塞。
+if (typeof window !== 'undefined' && typeof window.alert === 'function') {
+  const nativeAlert = window.alert.bind(window);
+  (window as any).alert = (message?: unknown) => {
+    if (typeof message === 'string') {
+      nativeAlert(message);
+      return;
+    }
+    console.warn('[GlobalAlertFilter] 已拦截 SDK 弹窗(非字符串):', message);
+  };
+}
+
+export const MSQParse = FmodeParse.initialize({
+  appId: RUNTIME_CONFIG.parseAppId,
+  serverURL: RUNTIME_CONFIG.parseServerUrl,
+  config: 'saas-voc'
+});

+ 0 - 0
apps/web/src/assets/data/demashi-summary.json → src/assets/data/demashi-summary.json


+ 5 - 0
src/assets/images/placeholder.svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
+  <rect width="120" height="120" fill="#f0f1f3" rx="8"/>
+  <path d="M45 65l10-15 8 10 12-18 10 23H35z" fill="#c9cdd4"/>
+  <circle cx="48" cy="45" r="7" fill="#c9cdd4"/>
+</svg>

BIN
src/assets/images/鍒锋柊.png


+ 1 - 0
src/favicon.ico

@@ -0,0 +1 @@
+<!-- Placeholder for favicon.ico - In a real project, this would be a binary ICO file -->

+ 220 - 0
src/index.html

@@ -0,0 +1,220 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <title>国内电商 VOC - 德玛仕案例</title>
+  <base href="/">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="description" content="基于原 AI-VOC 框架的国内电商用户声音分析模板。">
+  <meta name="keywords" content="VOC,用户反馈,国内电商,京东,德玛仕">
+  <link rel="icon" type="image/x-icon" href="favicon.ico">
+  
+  <!-- 预加载关键字体 -->
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  
+  <!-- 优化的字体加载 -->
+  <style>
+    /* 防止字体闪烁的临时样式 */
+    body {
+      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+      margin: 0;
+      padding: 0;
+      background-color: #E4E8EC;
+    }
+
+    /* ═══ 拟态加载屏 ═══ */
+    .initial-loader {
+      position: fixed; inset: 0;
+      background: #E4E8EC;
+      display: flex; flex-direction: column;
+      justify-content: center; align-items: center;
+      z-index: 9999;
+      overflow: hidden;
+    }
+
+    /* 中心拟态卡片 */
+    .loader-card {
+      position: relative; z-index: 1;
+      display: flex; flex-direction: column; align-items: center;
+      padding: 48px 56px;
+      border-radius: 24px;
+      background: #E4E8EC;
+      box-shadow:
+        8px 8px 16px #b6b9ba,
+        -8px -8px 16px #fafafd;
+      opacity: 0;
+      animation: cardReveal 1s cubic-bezier(0.16,1,0.3,1) 0.3s forwards;
+    }
+
+    /* Logo 文字 */
+    .loader-logo {
+      font-size: 28px; font-weight: 700; letter-spacing: 2px;
+      color: #343A40;
+      margin-bottom: 32px;
+      opacity: 0;
+      animation: textSlideUp 0.8s cubic-bezier(0.16,1,0.3,1) 0.6s forwards;
+    }
+    .loader-logo span {
+      display: inline-block;
+      opacity: 0;
+    }
+    .loader-logo span:nth-child(1) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 0.7s forwards; }
+    .loader-logo span:nth-child(2) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 0.78s forwards; }
+    .loader-logo span:nth-child(3) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 0.86s forwards; }
+    .loader-logo span:nth-child(4) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 0.94s forwards; }
+    .loader-logo span:nth-child(5) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 1.02s forwards; }
+    .loader-logo span:nth-child(6) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 1.10s forwards; }
+    .loader-logo span:nth-child(7) { animation: charReveal 0.5s cubic-bezier(0.16,1,0.3,1) 1.18s forwards; }
+
+    /* 拟态进度条容器 */
+    .loader-track {
+      width: 200px; height: 6px;
+      border-radius: 3px;
+      background: #dcdfe2;
+      box-shadow:
+        inset 2px 2px 4px #b6b9ba,
+        inset -2px -2px 4px #fafafd;
+      overflow: hidden;
+      margin-bottom: 20px;
+      opacity: 0;
+      animation: fadeIn 0.5s ease 1.2s forwards;
+    }
+    .loader-bar {
+      height: 100%; width: 0;
+      border-radius: 3px;
+      background: linear-gradient(90deg, #343A40, #6366f1, #343A40);
+      background-size: 200% 100%;
+      animation: barFill 2.5s cubic-bezier(0.4,0,0.2,1) 1.4s forwards,
+                 barShimmer 1.5s linear 1.4s infinite;
+    }
+
+    /* 脉冲点阵 */
+    .loader-dots {
+      display: flex; gap: 8px; margin-bottom: 16px;
+      opacity: 0;
+      animation: fadeIn 0.5s ease 1.3s forwards;
+    }
+    .loader-dot {
+      width: 8px; height: 8px; border-radius: 50%;
+      background: #c8cdd2;
+      box-shadow:
+        2px 2px 4px #b6b9ba,
+        -2px -2px 4px #fafafd;
+      animation: dotPulse 1.8s ease-in-out infinite;
+    }
+    .loader-dot:nth-child(1) { animation-delay: 0s; }
+    .loader-dot:nth-child(2) { animation-delay: 0.3s; }
+    .loader-dot:nth-child(3) { animation-delay: 0.6s; }
+
+    /* 加载文字 */
+    .loader-text {
+      font-size: 13px; color: #6b7280; letter-spacing: 0.5px;
+      opacity: 0;
+      animation: fadeIn 0.6s ease 1.5s forwards;
+    }
+
+    /* ═══ Keyframes ═══ */
+    @keyframes cardReveal {
+      from { opacity: 0; transform: scale(0.92) translateY(24px); }
+      to   { opacity: 1; transform: scale(1) translateY(0); }
+    }
+    @keyframes textSlideUp {
+      from { opacity: 0; transform: translateY(16px); }
+      to   { opacity: 1; transform: translateY(0); }
+    }
+    @keyframes charReveal {
+      from { opacity: 0; transform: translateY(12px) scale(0.8); }
+      to   { opacity: 1; transform: translateY(0) scale(1); }
+    }
+    @keyframes fadeIn {
+      to { opacity: 1; }
+    }
+    @keyframes barFill {
+      0%   { width: 0; }
+      40%  { width: 55%; }
+      70%  { width: 78%; }
+      100% { width: 100%; }
+    }
+    @keyframes barShimmer {
+      0%   { background-position: 200% 0; }
+      100% { background-position: -200% 0; }
+    }
+    @keyframes dotPulse {
+      0%, 100% {
+        transform: scale(1);
+        background: #c8cdd2;
+        box-shadow: 2px 2px 4px #b6b9ba, -2px -2px 4px #fafafd;
+      }
+      50% {
+        transform: scale(1.4);
+        background: #343A40;
+        box-shadow: inset 1px 1px 3px #b6b9ba, inset -1px -1px 3px #fafafd;
+      }
+    }
+
+    /* 退出动画 */
+    .initial-loader.fade-out {
+      animation: loaderExit 0.6s cubic-bezier(0.4,0,0.2,1) forwards;
+    }
+    @keyframes loaderExit {
+      0%   { opacity: 1; transform: scale(1); }
+      100% { opacity: 0; transform: scale(1.05); filter: blur(8px); }
+    }
+  </style>
+</head>
+<body>
+  <!-- 初始加载动画 -->
+  <div id="initial-loader" class="initial-loader">
+    <div class="loader-card">
+      <div class="loader-logo">
+        <span>国内</span>
+        <span>电商</span>
+        <span>V</span>
+        <span>O</span>
+        <span>C</span>
+      </div>
+      <div class="loader-track">
+        <div class="loader-bar"></div>
+      </div>
+      <div class="loader-dots">
+        <div class="loader-dot"></div>
+        <div class="loader-dot"></div>
+        <div class="loader-dot"></div>
+      </div>
+      <div class="loader-text">正在加载国内电商 VOC...</div>
+    </div>
+  </div>
+  
+  <!-- Angular应用根组件 -->
+  <app-root></app-root>
+  
+  <!-- 移除加载动画的脚本 -->
+  <script>
+    (function () {
+      function hideLoader() {
+        var loader = document.getElementById('initial-loader');
+        if (!loader || loader.classList.contains('fade-out')) { return; }
+        loader.classList.add('fade-out');
+        setTimeout(function () {
+          if (loader && loader.parentNode) { loader.parentNode.removeChild(loader); }
+        }, 400);
+      }
+      var appRoot = document.querySelector('app-root');
+      if (appRoot && 'MutationObserver' in window) {
+        var obs = new MutationObserver(function () {
+          if (appRoot.children.length > 0 || (appRoot.textContent || '').trim().length > 0) {
+            obs.disconnect();
+            hideLoader();
+          }
+        });
+        obs.observe(appRoot, { childList: true, subtree: true });
+      } else {
+        window.addEventListener('load', hideLoader);
+      }
+      // Fallback: force-remove the loader after 10s if the app never renders.
+      setTimeout(hideLoader, 10000);
+    })();
+  </script>
+</body>
+</html>

+ 2 - 2
apps/web/src/main.ts → src/main.ts

@@ -1,6 +1,6 @@
 import { bootstrapApplication } from '@angular/platform-browser';
-import { appConfig } from './app/app.config';
 import { AppComponent } from './app/app.component';
+import { appConfig } from './app/app.config';
 
 bootstrapApplication(AppComponent, appConfig)
-  .catch((err) => console.error(err));
+  .catch((err) => console.error(err));

+ 11 - 0
src/modules/action-suggestion/action-suggestion-shell.component.ts

@@ -0,0 +1,11 @@
+import { Component } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterModule } from '@angular/router';
+
+@Component({
+  selector: 'app-action-suggestion-shell',
+  standalone: true,
+  imports: [CommonModule, RouterModule],
+  template: `<router-outlet></router-outlet>`
+})
+export class ActionSuggestionShellComponent {}

+ 898 - 0
src/modules/action-suggestion/action-suggestion.component.html

@@ -0,0 +1,898 @@
+<div class="action-suggestion-container">
+  @if (fromTodoId) {
+  <div class="todo-banner">
+    <span class="todo-banner-icon">📋</span>
+    <span class="todo-banner-text">来自待办事项:您有待处理的行动建议,请在看板中查看并更新状态</span>
+  </div>
+  }
+  <app-page-header
+    title="决策支持 - 行动建议"
+    eyebrow="Decision Support"
+    description="聚合 VOC 痛点、产品迭代、策略趋势三大数据源,统一看板跟踪闭环"
+    icon="◇">
+  </app-page-header>
+
+  @if (!loading) {
+    <div class="workorder-summary-row">
+      <app-summary-metric-card label="工单总数" [value]="workOrderSummary.total" tone="blue" [description]="workOrderSummary.active + ' 个处理中'" icon="▦"></app-summary-metric-card>
+      <app-summary-metric-card label="逾期风险" [value]="workOrderSummary.overdue" [tone]="workOrderSummary.overdue > 0 ? 'red' : 'green'" [description]="workOrderSummary.dueSoon + ' 个临近到期'" icon="!"></app-summary-metric-card>
+      <app-summary-metric-card label="待验证" [value]="workOrderSummary.verifying" tone="green" [description]="workOrderSummary.completed + ' 个已归档'" icon="✓"></app-summary-metric-card>
+      <app-summary-metric-card label="潜在收益/止损" [value]="'$' + (workOrderSummary.potentialRecovery | number:'1.0-0')" tone="purple" description="按未闭环建议估算" icon="↗"></app-summary-metric-card>
+    </div>
+  }
+
+  <app-filter-panel class="action-filter-panel" title="建议筛选" description="按类型、优先级和状态筛选行动建议;识别到商品设计师时自动提供设计师维度。">
+    <div class="action-filter-grid">
+      <msq-dropdown [options]="asTypeOpts" placeholder="所有类型" (valueChange)="filterType = $event; applyFilter()"></msq-dropdown>
+      <msq-dropdown [options]="asPriorityOpts" placeholder="所有优先级" (valueChange)="filterPriority = $event; applyFilter()"></msq-dropdown>
+      <msq-dropdown [options]="asStatusOpts" placeholder="所有状态" (valueChange)="filterStatus = $event; applyFilter()"></msq-dropdown>
+      @if (designerOptions.length) {
+        <msq-dropdown [options]="designerOptions" placeholder="所有设计师" (valueChange)="filterDesigner = $event; applyFilter()"></msq-dropdown>
+      }
+      <span class="header-stat">共 {{ filteredSuggestions.length }} 条建议</span>
+    </div>
+  </app-filter-panel>
+
+  @if (loading) {
+    <app-loading-spinner text="正在从 VOC 痛点、功能满足度、市场趋势中聚合行动建议..."></app-loading-spinner>
+  } @else {
+    <!-- 顶部预警条:P0 紧急 / 逾期摘要 -->
+    @if (actionAlertHeadline.hasAlert) {
+      <app-alert-card
+        class="action-alert-row"
+        tone="red"
+        icon="🔴"
+        [title]="actionAlertHeadline.title"
+        [description]="actionAlertHeadline.description"
+        [primaryMetric]="actionAlertHeadline.count + ''">
+      </app-alert-card>
+    }
+
+    <!-- 3 个子页快速入口(体验优化 / 产品迭代 / 策略调整) -->
+    <app-entry-card-grid columns="3" gap="md" class="action-entry-grid">
+      <app-entry-card
+        icon="✨"
+        title="体验优化提案"
+        subtitle="客户体验细节"
+        description="基于隐性需求或体验痛点的优化提案,确保可跟踪、可落地。"
+        tone="blue"
+        variant="filled"
+        cta="查看提案"
+        (activate)="goToActionPage('experience')">
+      </app-entry-card>
+      <app-entry-card
+        icon="▲"
+        title="产品迭代方向"
+        subtitle="功能/设计/选品"
+        description="从客户反馈中提炼下一步迭代方向,指导产品团队规划。"
+        tone="green"
+        variant="filled"
+        cta="查看迭代"
+        (activate)="goToActionPage('iteration')">
+      </app-entry-card>
+      <app-entry-card
+        icon="◆"
+        title="长期策略调整"
+        subtitle="品牌/业务方向"
+        description="基于趋势性反馈调整品牌与业务的中长期布局。"
+        tone="purple"
+        variant="filled"
+        cta="查看策略"
+        (activate)="goToActionPage('strategy')">
+      </app-entry-card>
+    </app-entry-card-grid>
+
+    @if (verifiedEffectRankings.length) {
+      <div class="effect-ranking-panel">
+        <div class="effect-ranking-header">
+          <div>
+            <h2>已处理建议效果榜</h2>
+            <p>按验收方向计算改善幅度,用于沉淀复盘案例和优先推广有效动作。</p>
+          </div>
+          <span>{{ verifiedEffectRankings.length }} 条已验证</span>
+        </div>
+        <div class="effect-ranking-list">
+          @for (effect of verifiedEffectRankings; track effect.suggestion.id; let i = $index) {
+            <button class="effect-ranking-card" (click)="selectSuggestion(effect.suggestion)">
+              <span class="effect-rank">TOP {{ i + 1 }}</span>
+              <div class="effect-main">
+                <strong>{{ effect.suggestion.title }}</strong>
+                <span>{{ effect.metric }}:{{ effect.before }} → {{ effect.after }}</span>
+                <small>{{ effect.ownerLabel }} · {{ effect.acceptanceLabel }}</small>
+              </div>
+              <span class="effect-score" [class.positive]="effect.effectScore >= 0" [class.negative]="effect.effectScore < 0">
+                {{ effect.effectLabel }}
+              </span>
+            </button>
+          }
+        </div>
+      </div>
+    }
+
+    <div class="view-mode-bar">
+      <div class="view-mode-tabs" role="tablist" aria-label="视图模式">
+        <button class="view-mode-btn" [class.active]="viewMode === 'kanban'" (click)="setViewMode('kanban')" role="tab" [attr.aria-selected]="viewMode === 'kanban'">按状态(看板)</button>
+        <button class="view-mode-btn" [class.active]="viewMode === 'priority'" (click)="setViewMode('priority')" role="tab" [attr.aria-selected]="viewMode === 'priority'">按优先级(折叠)</button>
+      </div>
+    </div>
+
+    @if (viewMode === 'priority') {
+      <app-priority-accordion
+        [sections]="prioritySections"
+        [(expandedIds)]="expandedPriorityIds"
+      >
+        <ng-template #sectionBody let-section>
+          @if (getSuggestionsByPriority(section.id).length === 0) {
+            <app-empty-state title="该优先级下暂无建议" size="sm" icon="📋" variant="table"></app-empty-state>
+          } @else {
+            <div class="priority-group-grid">
+              @for (suggestion of getSuggestionsByPriority(section.id); track suggestion.id) {
+                <div class="suggestion-card priority-view"
+                     [class.priority-p0]="suggestion.priority === 'P0'"
+                     [class.priority-p1]="suggestion.priority === 'P1'"
+                     (click)="selectSuggestion(suggestion)">
+                  <div class="card-badges">
+                    @if (suggestion.workOrder) {
+                      <span class="workorder-badge">{{ suggestion.workOrder.id }}</span>
+                    }
+                    <span class="type-badge" [class]="suggestion.type">{{ getTypeName(suggestion.type) }}</span>
+                    <span class="status-tag" [class]="suggestion.status">{{ getStatusName(suggestion.status) }}</span>
+                  </div>
+                  <h3 class="card-title">{{ suggestion.title }}</h3>
+                  <p class="card-desc">{{ suggestion.description | slice:0:80 }}...</p>
+                  <div class="card-footer">
+                    <div class="assignee">
+                      <span class="avatar">{{ getPrimaryOwnerLabel(suggestion).charAt(0) }}</span>
+                      <span class="name">{{ getPrimaryOwnerLabel(suggestion) }}</span>
+                    </div>
+                    <div class="date">{{ suggestion.createdDate | date:'MM/dd' }}</div>
+                  </div>
+                </div>
+              }
+            </div>
+          }
+        </ng-template>
+      </app-priority-accordion>
+    } @else {
+    <div class="kanban-board">
+      @for (col of kanbanColumns; track col.value) {
+        <div class="kanban-column" [class]="'col-' + col.value">
+          <div class="column-header">
+            <span class="column-title">{{ col.label }}</span>
+            <span class="column-count">{{ getSuggestionsByStatus(col.value).length }}</span>
+          </div>
+          <div class="column-content">
+            @for (suggestion of getSuggestionsByStatus(col.value); track suggestion.id) {
+              <div class="suggestion-card"
+                   [class.priority-p0]="suggestion.priority === 'P0'"
+                   [class.priority-p1]="suggestion.priority === 'P1'"
+                   (click)="selectSuggestion(suggestion)">
+
+                <div class="card-badges">
+                  @if (suggestion.workOrder) {
+                    <span class="workorder-badge">{{ suggestion.workOrder.id }}</span>
+                  }
+                  <span class="type-badge" [class]="suggestion.type">
+                    {{ getTypeName(suggestion.type) }}
+                  </span>
+                  <span class="priority-badge" [class]="suggestion.priority">
+                    {{ suggestion.priority }}
+                  </span>
+                  @if (suggestion.issueTaxonomy) {
+                    <span class="taxonomy-badge" [class]="suggestion.issueTaxonomy.confidence">
+                      {{ suggestion.issueTaxonomy.categoryLabel }}
+                    </span>
+                  }
+                </div>
+
+                <h3 class="card-title">{{ suggestion.title }}</h3>
+                <p class="card-desc">{{ suggestion.description | slice:0:80 }}...</p>
+
+                @if (getDimensionTags(suggestion).length > 0) {
+                  <div class="dimension-tags">
+                    @for (tag of getDimensionTags(suggestion); track tag) {
+                      <span class="dimension-tag">{{ tag }}</span>
+                    }
+                  </div>
+                }
+
+                @if (suggestion.workOrder) {
+                  <div class="workorder-strip" [class]="suggestion.workOrder.slaStatus">
+                    <div class="wo-line">
+                      <span>{{ getWorkOrderSlaLabel(suggestion.workOrder.slaStatus) }}</span>
+                      <span>{{ getWorkOrderDueLabel(suggestion) }}</span>
+                    </div>
+                    <div class="wo-next">{{ suggestion.workOrder.nextAction }}</div>
+                    @if (suggestion.workOrder.acceptance) {
+                      <div class="wo-acceptance">
+                        验收:{{ suggestion.workOrder.acceptance.metric }} · {{ getWorkOrderAcceptanceDirectionLabel(suggestion.workOrder.acceptance.targetDirection) }}
+                      </div>
+                    }
+                  </div>
+                }
+
+                <div class="card-footer">
+                  <div class="assignee">
+                    <span class="avatar">{{ getPrimaryOwnerLabel(suggestion).charAt(0) }}</span>
+                    <span class="name">{{ getPrimaryOwnerLabel(suggestion) }}</span>
+                  </div>
+                  <div class="date">{{ suggestion.createdDate | date:'MM/dd' }}</div>
+                </div>
+                <div class="card-priority-score">优先级评分: {{ suggestion.priorityScore ?? '-' }}</div>
+
+                <div class="confidence-bar" title="AI分析置信度 {{ suggestion.rootCause.confidence * 100 | number:'1.0-0' }}%">
+                  <div class="bar-fill" [style.width.%]="suggestion.rootCause.confidence * 100"></div>
+                </div>
+
+                <!-- 卡片级状态操作按钮 -->
+                @if (getNextStatuses(suggestion.status).length > 0) {
+                  <div class="card-actions">
+                    @for (next of getNextStatuses(suggestion.status); track next.value) {
+                      @if (next.value === 'verifying') {
+                        <button class="card-action-btn verify" (click)="openVerifyForm(suggestion, $event)">{{ next.label }}</button>
+                      } @else {
+                        <button class="card-action-btn" [class]="next.value" (click)="updateStatus(next.value, suggestion); $event.stopPropagation()">{{ next.label }}</button>
+                      }
+                    }
+                  </div>
+                }
+
+                <!-- 验证结果展示 -->
+                @if (suggestion.verificationResult) {
+                  <div class="card-verify-result">
+                    <span class="verify-metric">{{ suggestion.verificationResult.metric }}</span>
+                    <span class="verify-change" [class.positive]="suggestion.verificationResult.changeRate > 0" [class.negative]="suggestion.verificationResult.changeRate < 0">
+                      {{ suggestion.verificationResult.changeRate > 0 ? '+' : '' }}{{ suggestion.verificationResult.changeRate }}%
+                    </span>
+                  </div>
+                }
+              </div>
+            }
+            @if (getSuggestionsByStatus(col.value).length === 0) {
+              <app-empty-state title="暂无" size="sm" icon="📋"></app-empty-state>
+            }
+          </div>
+        </div>
+      }
+    </div>
+    }
+  }
+
+  <!-- 效果验证弹窗 -->
+  @if (showVerifyForm && verifyTarget) {
+    <div class="verify-backdrop" (click)="cancelVerify()"></div>
+    <div class="verify-modal">
+      <h3>效果验证 — {{ verifyTarget.title }}</h3>
+      @if (verifyTarget.workOrder?.acceptance) {
+        <div class="verify-recommendation">
+          <strong>推荐验收口径</strong>
+          <span>{{ verifyTarget.workOrder?.acceptance?.metric }} · {{ verifyTarget.workOrder?.acceptance?.baselineWindow }} vs {{ verifyTarget.workOrder?.acceptance?.verificationWindow }}</span>
+          <small>{{ verifyTarget.workOrder?.acceptance?.successCriteria }}</small>
+        </div>
+      }
+      <div class="verify-field">
+        <label>验证指标名称</label>
+        <input type="text" [(ngModel)]="verifyForm.metric" placeholder="如: 差评率、退货率、好评提及率" />
+      </div>
+      <div class="verify-row">
+        <div class="verify-field">
+          <label>执行前数值</label>
+          <input type="number" [(ngModel)]="verifyForm.before" />
+        </div>
+        <div class="verify-field">
+          <label>执行后数值</label>
+          <input type="number" [(ngModel)]="verifyForm.after" />
+        </div>
+      </div>
+      @if (verifyForm.before !== 0) {
+        <div class="verify-preview">
+          预计变化率: {{ ((verifyForm.after - verifyForm.before) / (verifyForm.before > 0 ? verifyForm.before : 1) * 100) | number:'1.1-1' }}%
+        </div>
+      }
+      <div class="verify-actions">
+        <button class="btn btn-primary" [disabled]="!verifyForm.metric" (click)="submitVerify()">提交验证</button>
+        <button class="btn btn-outline" (click)="cancelVerify()">取消</button>
+      </div>
+    </div>
+  }
+
+  <!-- 详情居中弹窗 -->
+  @if (selectedSuggestion) {
+    <div class="detail-backdrop" (click)="closeModal()"></div>
+    <div class="detail-modal">
+      <div class="modal-header">
+        <div class="modal-title-row">
+          <div class="tags">
+            <span class="priority-tag" [class]="selectedSuggestion.priority">{{ selectedSuggestion.priority }}</span>
+            <span class="status-tag" [class]="selectedSuggestion.status">{{ getStatusName(selectedSuggestion.status) }}</span>
+            <span class="type-tag" [class]="selectedSuggestion.type">{{ getTypeName(selectedSuggestion.type) }}</span>
+          </div>
+          <button class="close-btn" (click)="closeModal()">&times;</button>
+        </div>
+        <div class="modal-title-line">
+          <h1 class="modal-title">{{ selectedSuggestion.title }}</h1>
+          <div class="modal-title-actions">
+            @if (editMode) {
+              <button class="edit-save-btn" [disabled]="editSaving" (click)="saveSuggestionEdits()">{{ editSaving ? '保存中...' : '保存' }}</button>
+              <button class="edit-cancel-btn" [disabled]="editSaving" (click)="cancelEditSuggestion()">取消</button>
+            } @else {
+              <button class="edit-btn" (click)="startEditSuggestion()">编辑</button>
+            }
+            <div class="push-trigger-wrap">
+              <button class="push-btn" (click)="togglePushPanel()">推送</button>
+            @if (showPushPanel) {
+              <div class="push-popover-backdrop" (click)="togglePushPanel()"></div>
+              <div class="push-popover">
+                <div class="push-popover-header">
+                  <strong>分享到</strong>
+                  <button class="push-config-toggle" (click)="togglePushConfig()">{{ showPushConfig ? '返回' : '⚙ 配置' }}</button>
+                </div>
+
+                @if (!showPushConfig) {
+                  <div class="push-share-options">
+                    <button class="share-option" (click)="shareToDingTalk(selectedSuggestion)">
+                      <span class="share-icon dingtalk-icon">钉</span>
+                      <span class="share-label">钉钉</span>
+                    </button>
+                    <button class="share-option" (click)="copyShareContent(selectedSuggestion)">
+                      <span class="share-icon copy-icon">⎘</span>
+                      <span class="share-label">复制</span>
+                    </button>
+                  </div>
+
+                  @if (pushTargets.length) {
+                    <div class="push-targets-section">
+                      <div class="push-targets-title">Webhook 机器人</div>
+                      @for (target of pushTargets; track target.id) {
+                        <div class="push-target-row">
+                          <span class="target-name" title="{{ target.webhook }}">{{ target.name }}</span>
+                          <button class="target-push-btn" [disabled]="pushingTargetId === target.id" (click)="pushToTarget(selectedSuggestion, target)">
+                            {{ pushingTargetId === target.id ? '推送中...' : '推送' }}
+                          </button>
+                          <button class="target-remove-btn" (click)="removePushTarget(target)">&times;</button>
+                        </div>
+                      }
+                    </div>
+                  } @else {
+                    <div class="push-no-target-hint">
+                      <p>推荐使用 <strong>Webhook 机器人</strong> 推送到钉钉群,无需安装桌面客户端。</p>
+                      <button class="hint-config-btn" (click)="togglePushConfig()">立即配置机器人</button>
+                    </div>
+                  }
+                } @else {
+                  <div class="push-config-form">
+                    <div class="config-guide">
+                      <div class="guide-title">如何配置钉钉机器人?</div>
+                      <ol class="guide-steps">
+                        <li>打开钉钉群 → 群设置 → 智能群助手</li>
+                        <li>点击"添加机器人" → 选择"自定义"</li>
+                        <li>安全设置选"加签",复制密钥填入下方</li>
+                        <li>复制 Webhook 地址粘贴到下方</li>
+                      </ol>
+                    </div>
+                    <label class="config-label">名称 <span class="required">*</span></label>
+                    <input class="config-input" [(ngModel)]="newTarget.name" placeholder="如:产品群机器人" />
+                    <label class="config-label">Webhook 地址 <span class="required">*</span></label>
+                    <input class="config-input" [(ngModel)]="newTarget.webhook" placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." />
+                    <label class="config-label">签名密钥(可选)</label>
+                    <input class="config-input" [(ngModel)]="newTarget.secret" placeholder="SEC..." />
+                    <label class="config-label">&#64;指定人(手机号,多个用逗号分隔,选填)</label>
+                    <input class="config-input" [(ngModel)]="newTarget.atMobiles" placeholder="13800000000, 13900000000" />
+                    <button class="config-save-btn" (click)="savePushTarget()">保存机器人</button>
+                  </div>
+                }
+
+                @if (pushResultMsg) {
+                  <div class="push-popover-msg">{{ pushResultMsg }}</div>
+                }
+              </div>
+            }
+            </div>
+          </div>
+        </div>
+        @if (editError) {
+          <div class="edit-error">{{ editError }}</div>
+        }
+        <div class="meta-info">
+          <span>{{ selectedSuggestion.createdDate | date:'yyyy-MM-dd HH:mm' }}</span>
+          <span>ID: {{ selectedSuggestion.id }}</span>
+          @if (selectedSuggestion.assignee) {
+            <span>负责方: {{ selectedSuggestion.assignee }}</span>
+          }
+          @if (selectedSuggestion.designerNames?.length) {
+            <span>设计师: {{ (selectedSuggestion.designerNames || []).join('、') }}</span>
+          }
+          @if (selectedSuggestion.operatorNames?.length) {
+            <span>负责人: {{ (selectedSuggestion.operatorNames || []).join('、') }}</span>
+          }
+          @if (selectedSuggestion.workOrder) {
+            <span>工单: {{ selectedSuggestion.workOrder.id }}</span>
+            <span>{{ getWorkOrderSlaLabel(selectedSuggestion.workOrder.slaStatus) }}</span>
+          }
+          @if (selectedSuggestion.issueTaxonomy) {
+            <span>归因: {{ selectedSuggestion.issueTaxonomy.categoryLabel }}</span>
+          }
+        </div>
+      </div>
+
+      <div class="modal-body">
+        <!-- 左列:数据面板 -->
+        <div class="modal-col-left">
+          @if (editMode && editForm) {
+            <div class="edit-detail-form">
+              <div class="edit-section">
+                <h3>基础信息</h3>
+                <div class="edit-grid two">
+                  <label class="edit-field wide">
+                    <span>工单标题</span>
+                    <input [(ngModel)]="editForm.title" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>问题描述</span>
+                    <textarea rows="4" [(ngModel)]="editForm.description"></textarea>
+                  </label>
+                  <label class="edit-field">
+                    <span>类型</span>
+                    <select [(ngModel)]="editForm.type">
+                      @for (option of asTypeOpts; track option.value) {
+                        <option [value]="option.value">{{ option.label }}</option>
+                      }
+                    </select>
+                  </label>
+                  <label class="edit-field">
+                    <span>优先级</span>
+                    <select [(ngModel)]="editForm.priority">
+                      @for (option of asPriorityOpts; track option.value) {
+                        <option [value]="option.value">{{ option.label }}</option>
+                      }
+                    </select>
+                  </label>
+                  <label class="edit-field">
+                    <span>状态</span>
+                    <select [(ngModel)]="editForm.status">
+                      @for (option of asStatusOpts; track option.value) {
+                        <option [value]="option.value">{{ option.label }}</option>
+                      }
+                    </select>
+                  </label>
+                  <label class="edit-field">
+                    <span>负责方</span>
+                    <input [(ngModel)]="editForm.assignee" placeholder="例如:产品部 / 张三" />
+                  </label>
+                  <label class="edit-field">
+                    <span>截止日期</span>
+                    <input type="date" [(ngModel)]="editForm.dueDate" />
+                  </label>
+                  <label class="edit-field">
+                    <span>优先级评分</span>
+                    <input type="number" [(ngModel)]="editForm.priorityScore" />
+                  </label>
+                </div>
+              </div>
+
+              <div class="edit-section">
+                <h3>工单闭环</h3>
+                <div class="edit-grid two">
+                  <label class="edit-field">
+                    <span>责任人</span>
+                    <input [(ngModel)]="editForm.workOrder.owner" />
+                  </label>
+                  <label class="edit-field">
+                    <span>SLA 到期日</span>
+                    <input type="date" [(ngModel)]="editForm.workOrder.slaDueAt" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>下一步动作</span>
+                    <textarea rows="2" [(ngModel)]="editForm.workOrder.nextAction"></textarea>
+                  </label>
+                  <label class="edit-field">
+                    <span>验收指标</span>
+                    <input [(ngModel)]="editForm.workOrder.acceptance.metric" />
+                  </label>
+                  <label class="edit-field">
+                    <span>验收方向</span>
+                    <select [(ngModel)]="editForm.workOrder.acceptance.targetDirection">
+                      @for (option of acceptanceDirectionOpts; track option.value) {
+                        <option [value]="option.value">{{ option.label }}</option>
+                      }
+                    </select>
+                  </label>
+                  <label class="edit-field">
+                    <span>基准窗口</span>
+                    <input [(ngModel)]="editForm.workOrder.acceptance.baselineWindow" />
+                  </label>
+                  <label class="edit-field">
+                    <span>验证窗口</span>
+                    <input [(ngModel)]="editForm.workOrder.acceptance.verificationWindow" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>数据来源</span>
+                    <input [(ngModel)]="editForm.workOrder.acceptance.dataSource" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>成功标准</span>
+                    <textarea rows="2" [(ngModel)]="editForm.workOrder.acceptance.successCriteria"></textarea>
+                  </label>
+                </div>
+              </div>
+
+              <div class="edit-section">
+                <h3>归因与根因</h3>
+                <div class="edit-grid two">
+                  <label class="edit-field">
+                    <span>问题类型</span>
+                    <input [(ngModel)]="editForm.taxonomy.categoryLabel" />
+                  </label>
+                  <label class="edit-field">
+                    <span>建议责任方</span>
+                    <input [(ngModel)]="editForm.taxonomy.ownerHint" />
+                  </label>
+                  <label class="edit-field">
+                    <span>归因置信度</span>
+                    <select [(ngModel)]="editForm.taxonomy.confidence">
+                      @for (option of taxonomyConfidenceOpts; track option.value) {
+                        <option [value]="option.value">{{ option.label }}</option>
+                      }
+                    </select>
+                  </label>
+                  <label class="edit-field">
+                    <span>根因置信度(%)</span>
+                    <input type="number" min="0" max="100" [(ngModel)]="editForm.rootCause.confidence" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>归因关键词</span>
+                    <textarea rows="2" [(ngModel)]="editForm.taxonomy.matchedKeywords" placeholder="每行一个,或用逗号分隔"></textarea>
+                  </label>
+                  <label class="edit-field">
+                    <span>根因因子</span>
+                    <input [(ngModel)]="editForm.rootCause.factor" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>根因描述</span>
+                    <textarea rows="3" [(ngModel)]="editForm.rootCause.description"></textarea>
+                  </label>
+                  <label class="edit-field wide">
+                    <span>证据链</span>
+                    <textarea rows="4" [(ngModel)]="editForm.rootCause.evidence" placeholder="每行一条证据"></textarea>
+                  </label>
+                </div>
+              </div>
+
+              <div class="edit-section">
+                <h3>影响评估与行动计划</h3>
+                <div class="edit-grid three">
+                  <label class="edit-field">
+                    <span>受影响用户</span>
+                    <input type="number" [(ngModel)]="editForm.impact.affectedUsers" />
+                  </label>
+                  <label class="edit-field">
+                    <span>潜在损失</span>
+                    <input type="number" [(ngModel)]="editForm.impact.potentialLoss" />
+                  </label>
+                  <label class="edit-field">
+                    <span>预计 ROI</span>
+                    <input type="number" [(ngModel)]="editForm.impact.estimatedROI" />
+                  </label>
+                  <label class="edit-field wide">
+                    <span>行动计划</span>
+                    <textarea rows="4" [(ngModel)]="editForm.actionPlan" placeholder="每行一个步骤"></textarea>
+                  </label>
+                </div>
+              </div>
+
+              <div class="edit-section">
+                <h3>责任维度与验证结果</h3>
+                <div class="edit-grid two">
+                  <label class="edit-field">
+                    <span>设计师</span>
+                    <textarea rows="2" [(ngModel)]="editForm.dimensions.designerNames" placeholder="每行一个"></textarea>
+                  </label>
+                  <label class="edit-field">
+                    <span>负责人</span>
+                    <textarea rows="2" [(ngModel)]="editForm.dimensions.operatorNames" placeholder="每行一个"></textarea>
+                  </label>
+                  <label class="edit-field">
+                    <span>款号/SKU</span>
+                    <textarea rows="2" [(ngModel)]="editForm.dimensions.productSkus" placeholder="每行一个"></textarea>
+                  </label>
+                  <label class="edit-field">
+                    <span>配送方式</span>
+                    <textarea rows="2" [(ngModel)]="editForm.dimensions.fulfillmentTypes" placeholder="每行一个"></textarea>
+                  </label>
+                  <label class="edit-check wide">
+                    <input type="checkbox" [(ngModel)]="editForm.verification.enabled" />
+                    <span>记录效果验证结果</span>
+                  </label>
+                  @if (editForm.verification.enabled) {
+                    <label class="edit-field">
+                      <span>验证指标</span>
+                      <input [(ngModel)]="editForm.verification.metric" />
+                    </label>
+                    <label class="edit-field">
+                      <span>前值</span>
+                      <input type="number" [(ngModel)]="editForm.verification.before" />
+                    </label>
+                    <label class="edit-field">
+                      <span>后值</span>
+                      <input type="number" [(ngModel)]="editForm.verification.after" />
+                    </label>
+                    <label class="edit-field">
+                      <span>变化率(%)</span>
+                      <input type="number" [(ngModel)]="editForm.verification.changeRate" />
+                    </label>
+                  }
+                </div>
+              </div>
+            </div>
+          } @else {
+          @if (selectedSuggestion.workOrder) {
+            <div class="detail-section workorder-detail" [class]="selectedSuggestion.workOrder.slaStatus">
+              <h3>工单闭环</h3>
+              <div class="workorder-grid">
+                <div>
+                  <label>工单号</label>
+                  <strong>{{ selectedSuggestion.workOrder.id }}</strong>
+                </div>
+                <div>
+                  <label>SLA</label>
+                  <strong>{{ getWorkOrderSlaLabel(selectedSuggestion.workOrder.slaStatus) }}</strong>
+                </div>
+                <div>
+                  <label>到期</label>
+                  <strong>{{ getWorkOrderDueLabel(selectedSuggestion) }}</strong>
+                </div>
+                <div>
+                  <label>责任人</label>
+                  <strong>{{ selectedSuggestion.workOrder.owner }}</strong>
+                </div>
+              </div>
+              <div class="workorder-next-action">
+                <label>下一步动作</label>
+                <span>{{ selectedSuggestion.workOrder.nextAction }}</span>
+              </div>
+              @if (selectedSuggestion.workOrder.acceptance) {
+                <div class="workorder-acceptance">
+                  <label>验收口径</label>
+                  <div class="acceptance-grid">
+                    <div>
+                      <span>指标</span>
+                      <strong>{{ selectedSuggestion.workOrder.acceptance.metric }}</strong>
+                    </div>
+                    <div>
+                      <span>窗口</span>
+                      <strong>{{ selectedSuggestion.workOrder.acceptance.baselineWindow }} vs {{ selectedSuggestion.workOrder.acceptance.verificationWindow }}</strong>
+                    </div>
+                    <div>
+                      <span>方向</span>
+                      <strong>{{ getWorkOrderAcceptanceDirectionLabel(selectedSuggestion.workOrder.acceptance.targetDirection) }}</strong>
+                    </div>
+                    <div>
+                      <span>数据源</span>
+                      <strong>{{ selectedSuggestion.workOrder.acceptance.dataSource }}</strong>
+                    </div>
+                  </div>
+                  <p>{{ selectedSuggestion.workOrder.acceptance.successCriteria }}</p>
+                </div>
+              }
+            </div>
+          }
+
+          @if (selectedSuggestion.issueTaxonomy) {
+            <div class="detail-section taxonomy-detail" [class]="selectedSuggestion.issueTaxonomy.confidence">
+              <h3>统一问题归因</h3>
+              <div class="taxonomy-grid">
+                <div>
+                  <label>问题类型</label>
+                  <strong>{{ selectedSuggestion.issueTaxonomy.categoryLabel }}</strong>
+                </div>
+                <div>
+                  <label>建议责任方</label>
+                  <strong>{{ selectedSuggestion.issueTaxonomy.ownerHint }}</strong>
+                </div>
+                <div>
+                  <label>置信度</label>
+                  <strong>{{ getIssueTaxonomyConfidenceLabel(selectedSuggestion.issueTaxonomy.confidence) }}</strong>
+                </div>
+              </div>
+              @if (selectedSuggestion.issueTaxonomy.matchedKeywords.length) {
+                <div class="taxonomy-keywords">
+                  @for (keyword of selectedSuggestion.issueTaxonomy.matchedKeywords; track keyword) {
+                    <span>{{ keyword }}</span>
+                  }
+                </div>
+              }
+            </div>
+          }
+
+          <div class="detail-section root-cause">
+            <h3>根因分析 (置信度: {{ selectedSuggestion.rootCause.confidence | percent }})</h3>
+            <div class="cause-card">
+              <div class="cause-factor">{{ selectedSuggestion.rootCause.factor }}</div>
+              <p>{{ selectedSuggestion.rootCause.description }}</p>
+              @if (selectedSuggestion.rootCause.evidence.length > 0) {
+                <div class="evidence-list">
+                  <h4>证据链:</h4>
+                  <ul>
+                    @for (ev of selectedSuggestion.rootCause.evidence; track ev) {
+                      <li>{{ ev }}</li>
+                    }
+                  </ul>
+                </div>
+              }
+            </div>
+          </div>
+
+          <div class="detail-section impact">
+            <h3>影响面评估</h3>
+            <div class="impact-grid">
+              <div class="impact-item">
+                <label>受影响用户</label>
+                <div class="value">{{ selectedSuggestion.impact.affectedUsers }}人</div>
+              </div>
+              <div class="impact-item">
+                <label>潜在损失</label>
+                <div class="value loss">${{ selectedSuggestion.impact.potentialLoss | number }}</div>
+              </div>
+              <div class="impact-item">
+                <label>预计ROI</label>
+                <div class="value gain">${{ selectedSuggestion.impact.estimatedROI | number }}</div>
+              </div>
+              <div class="impact-item">
+                <label>优先级评分</label>
+                <div class="value">{{ selectedSuggestion.priorityScore ?? '-' }}</div>
+              </div>
+            </div>
+          </div>
+
+          @if (getDimensionTags(selectedSuggestion).length > 0 || selectedSuggestion.affectedProducts?.length) {
+            <div class="detail-section ownership">
+              <h3>责任归属</h3>
+              @if (getDimensionTags(selectedSuggestion).length > 0) {
+                <div class="owner-tags">
+                  @for (tag of getDimensionTags(selectedSuggestion); track tag) {
+                    <span>{{ tag }}</span>
+                  }
+                </div>
+              }
+              @if (selectedSuggestion.affectedProducts?.length) {
+                <div class="affected-product-list">
+                  @for (product of selectedSuggestion.affectedProducts || []; track product.asin) {
+                    <div class="affected-product-item">
+                      <div class="product-info">
+                        <app-product-identity
+                          [imageUrl]="product.photo || ''"
+                          [title]="product.title"
+                          [asin]="product.asin"
+                          [productSku]="product.productSku || ''"
+                          [sellerSku]="product.sellerSku || ''"
+                          [subtitle]="product.matchCount + ' 条匹配'"
+                          size="sm">
+                        </app-product-identity>
+                        <div class="product-owner">
+                          @if (product.designerName) {
+                            <span>设计师 {{ product.designerName }}</span>
+                          }
+                          @if (product.operatorName) {
+                            <span>负责人 {{ product.operatorName }}</span>
+                          }
+                          @if (product.fulfillmentType) {
+                            <span>{{ product.fulfillmentType }}</span>
+                          }
+                        </div>
+                      </div>
+                    </div>
+                  }
+                </div>
+              }
+            </div>
+          }
+
+          @if (selectedSuggestion.actionPlan && selectedSuggestion.actionPlan.length > 0) {
+            <div class="detail-section">
+              <h3>行动计划</h3>
+              <div class="action-plan-list">
+                @for (step of selectedSuggestion.actionPlan; track step) {
+                  <div class="plan-step">{{ step }}</div>
+                }
+              </div>
+            </div>
+          }
+
+          @if (selectedSuggestion.verificationResult) {
+            <div class="detail-section verification">
+              <h3>效果验证</h3>
+              <div class="verification-card">
+                <div class="metric-name">{{ selectedSuggestion.verificationResult.metric }}</div>
+                <div class="metric-change">
+                  <span class="before">{{ selectedSuggestion.verificationResult.before }}</span>
+                  <span class="arrow">&rarr;</span>
+                  <span class="after">{{ selectedSuggestion.verificationResult.after }}</span>
+                  <span class="rate" [class.positive]="selectedSuggestion.verificationResult.changeRate > 0">
+                    {{ selectedSuggestion.verificationResult.changeRate > 0 ? '+' : '' }}{{ selectedSuggestion.verificationResult.changeRate }}%
+                  </span>
+                </div>
+                @if (selectedSuggestion.workOrder?.acceptance) {
+                  <div class="verification-criteria">
+                    {{ selectedSuggestion.workOrder?.acceptance?.baselineWindow }} vs {{ selectedSuggestion.workOrder?.acceptance?.verificationWindow }} · {{ selectedSuggestion.workOrder?.acceptance?.successCriteria }}
+                  </div>
+                }
+              </div>
+            </div>
+          }
+
+          <!-- 状态流转操作 -->
+          <div class="detail-section actions">
+            <h3>状态操作</h3>
+            <div class="action-buttons">
+              @for (next of getNextStatuses(selectedSuggestion.status); track next.value) {
+                @if (next.value === 'verifying') {
+                  <button class="btn btn-success" (click)="openVerifyForm(selectedSuggestion, $event)">{{ next.label }}</button>
+                } @else if (next.value === 'archived' || next.value === 'rejected') {
+                  <button class="btn btn-outline" (click)="updateStatus(next.value)">{{ next.label }}</button>
+                } @else {
+                  <button class="btn btn-primary" (click)="updateStatus(next.value)">{{ next.label }}</button>
+                }
+              }
+            </div>
+          </div>
+          }
+        </div>
+
+        <!-- 右列:AI 分析建议 -->
+        <div class="modal-col-right">
+          <div class="ai-panel">
+            <div class="ai-panel-header">
+              <h3>AI 深度分析</h3>
+              <button class="btn btn-ai" [disabled]="aiLoading" (click)="requestAiAnalysis()">
+                @if (aiLoading) {
+                  <span class="ai-spinner"></span> 分析中...
+                } @else {
+                  生成分析
+                }
+              </button>
+            </div>
+            <div class="ai-panel-body">
+              @if (aiEvidenceContext) {
+                <div class="ai-evidence-summary" [class]="aiEvidenceContext.confidence">
+                  <span>证据链 {{ aiEvidenceContext.evidence.length }} 条</span>
+                  <span>样本 {{ aiEvidenceContext.sampleCount }}</span>
+                  <span>置信度 {{ aiEvidenceContext.confidence }}</span>
+                  @if (aiEvidenceContext.warnings.length) {
+                    <span class="evidence-warning">{{ aiEvidenceContext.warnings.length }} 项风险提示</span>
+                  }
+                </div>
+              }
+              @if (aiError) {
+                <app-error-state
+                  variant="inline"
+                  icon="⚠"
+                  [description]="aiError"
+                  [showRetry]="false"></app-error-state>
+              }
+              @if (aiText) {
+                <div class="ai-text markdown-body" [innerHTML]="aiText | markdown"></div>
+              }
+              @if (aiLoading && !aiText) {
+                <div class="ai-loading-placeholder">
+                  <div class="ai-spinner-large"></div>
+                  <p class="ai-step">{{ aiLoadingStep }}</p>
+                  <p class="ai-elapsed">已等待 {{ aiElapsed }}s</p>
+                </div>
+              }
+              @if (aiLoading && aiText) {
+                <div class="ai-streaming-hint">
+                  <span class="ai-spinner"></span> 持续输出中...
+                </div>
+              }
+              @if (!aiText && !aiLoading && !aiError) {
+                <div class="ai-placeholder">
+                  <p>点击「生成分析」,AI 将基于当前建议的根因、影响面和行动计划等上下文数据,给出深层分析和落地建议。</p>
+                </div>
+              }
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  }
+</div>

+ 1785 - 0
src/modules/action-suggestion/action-suggestion.component.scss

@@ -0,0 +1,1785 @@
+.action-suggestion-container {
+  padding: 24px;
+  background-color: #f3f4f6;
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.todo-banner {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 12px 20px;
+  margin-bottom: 16px;
+  background: linear-gradient(135deg, #ede9fe, #e0e7ff);
+  border-radius: 10px;
+  border: 1px solid #c7d2fe;
+}
+.todo-banner-icon { font-size: 18px; }
+.todo-banner-text { font-size: 13px; color: #4338ca; font-weight: 500; }
+
+
+.action-filter-panel {
+  display: block;
+  margin-bottom: 18px;
+}
+
+.action-filter-grid {
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 12px;
+  align-items: center;
+  width: 100%;
+
+  msq-dropdown {
+    flex: 0 0 160px;
+  }
+}
+
+.dingtalk-user {
+  font-size: 12px;
+  border-radius: 6px;
+  padding: 6px 10px;
+  text-align: center;
+
+  &.ok {
+    color: #065f46;
+    background: #ecfdf5;
+    border: 1px solid #a7f3d0;
+  }
+
+  &.warn {
+    color: #92400e;
+    background: #fffbeb;
+    border: 1px solid #fde68a;
+  }
+}
+
+.header-btn {
+  border: none;
+  border-radius: 6px;
+  padding: 7px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  cursor: pointer;
+
+  &.btn-login {
+    background: #1677ff;
+    color: #fff;
+
+    &:hover { background: #0b5ed7; }
+    &:disabled { opacity: 0.6; cursor: not-allowed; }
+  }
+
+  &.btn-logout {
+    background: #f3f4f6;
+    color: #374151;
+    border: 1px solid #d1d5db;
+
+    &:hover { background: #e5e7eb; }
+  }
+}
+
+.header-stat {
+  font-size: 13px;
+  color: #6b7280;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
+@media (max-width: 768px) {
+  .action-filter-grid {
+    flex-wrap: wrap;
+
+    msq-dropdown {
+      flex: 1 1 150px;
+    }
+  }
+
+  .detail-modal .modal-col-left .edit-grid.two,
+  .detail-modal .modal-col-left .edit-grid.three {
+    grid-template-columns: 1fr;
+  }
+}
+
+.workorder-summary-row {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+  margin-bottom: 18px;
+}
+
+/* 顶部预警条 + 子页入口卡 */
+.action-alert-row { display: block; margin-bottom: 14px; }
+.action-entry-grid { display: block; margin-bottom: 18px; }
+
+.effect-ranking-panel {
+  margin-bottom: 18px;
+  padding: 14px;
+  border-radius: 12px;
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
+}
+
+.effect-ranking-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  h2 {
+    margin: 0;
+    color: #111827;
+    font-size: 15px;
+    font-weight: 700;
+  }
+
+  p {
+    margin: 4px 0 0;
+    color: #6b7280;
+    font-size: 12px;
+  }
+
+  > span {
+    padding: 4px 8px;
+    border-radius: 999px;
+    background: #ecfdf5;
+    color: #047857;
+    font-size: 12px;
+    font-weight: 700;
+    white-space: nowrap;
+  }
+}
+
+.effect-ranking-list {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.effect-ranking-card {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+  padding: 12px;
+  border-radius: 10px;
+  border: 1px solid #d1fae5;
+  background: #f0fdf4;
+  cursor: pointer;
+  text-align: left;
+  transition: transform 0.2s, box-shadow 0.2s;
+
+  &:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 4px 10px rgba(16, 185, 129, 0.12);
+  }
+
+  .effect-rank {
+    flex-shrink: 0;
+    padding: 4px 7px;
+    border-radius: 999px;
+    background: #10b981;
+    color: #fff;
+    font-size: 11px;
+    font-weight: 800;
+  }
+
+  .effect-main {
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+    min-width: 0;
+    flex: 1;
+
+    strong,
+    span,
+    small {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    strong {
+      color: #111827;
+      font-size: 13px;
+      font-weight: 700;
+    }
+
+    span {
+      color: #047857;
+      font-size: 12px;
+      font-weight: 600;
+    }
+
+    small {
+      color: #6b7280;
+      font-size: 11px;
+    }
+  }
+
+  .effect-score {
+    flex-shrink: 0;
+    padding: 5px 8px;
+    border-radius: 8px;
+    font-size: 12px;
+    font-weight: 800;
+
+    &.positive {
+      background: #dcfce7;
+      color: #166534;
+    }
+
+    &.negative {
+      background: #fee2e2;
+      color: #991b1b;
+    }
+  }
+}
+
+.view-mode-bar {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  margin-bottom: 12px;
+}
+
+.view-mode-tabs {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px;
+  background: #f1f5f9;
+  border: 1px solid #e2e8f0;
+  border-radius: 10px;
+}
+
+.view-mode-btn {
+  appearance: none;
+  background: transparent;
+  border: none;
+  padding: 6px 14px;
+  border-radius: 8px;
+  color: #475569;
+  font-size: 13px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: background 0.15s, color 0.15s;
+
+  &:hover {
+    color: #1e293b;
+  }
+
+  &.active {
+    background: #ffffff;
+    color: #1e293b;
+    box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
+  }
+}
+
+.priority-group-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+  gap: 14px;
+  padding-top: 12px;
+
+  .suggestion-card.priority-view {
+    cursor: pointer;
+    background: #ffffff;
+    border: 1px solid #e5e7eb;
+    border-radius: 12px;
+    padding: 14px 16px;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
+
+    &:hover {
+      transform: translateY(-2px);
+      box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
+      border-color: #cbd5e1;
+    }
+
+    &.priority-p0 {
+      border-left: 3px solid #dc2626;
+    }
+
+    &.priority-p1 {
+      border-left: 3px solid #d97706;
+    }
+
+    .card-badges {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      font-size: 11px;
+    }
+
+    .card-title {
+      margin: 0;
+      font-size: 14px;
+      font-weight: 700;
+      color: #0f172a;
+      line-height: 1.35;
+    }
+
+    .card-desc {
+      margin: 0;
+      font-size: 12px;
+      color: #64748b;
+      line-height: 1.5;
+    }
+
+    .card-footer {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 8px;
+      margin-top: 4px;
+      padding-top: 8px;
+      border-top: 1px dashed #e2e8f0;
+      font-size: 11px;
+      color: #64748b;
+
+      .assignee {
+        display: inline-flex;
+        align-items: center;
+        gap: 6px;
+
+        .avatar {
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          width: 22px;
+          height: 22px;
+          border-radius: 50%;
+          background: #f1f5f9;
+          color: #334155;
+          font-size: 11px;
+          font-weight: 700;
+        }
+
+        .name {
+          color: #334155;
+          font-weight: 600;
+        }
+      }
+    }
+  }
+}
+
+.kanban-board {
+  display: flex;
+  gap: 24px;
+  overflow-x: auto;
+  flex: 1;
+  padding-bottom: 24px;
+
+  .kanban-column {
+    flex: 0 0 320px;
+    background: #e5e7eb;
+    border-radius: 8px;
+    padding: 16px;
+    display: flex;
+    flex-direction: column;
+    max-height: calc(100vh - 140px);
+
+    .column-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 16px;
+      padding: 0 4px;
+
+      .column-title {
+        font-weight: 600;
+        color: #374151;
+      }
+
+      .column-count {
+        background: #d1d5db;
+        padding: 2px 8px;
+        border-radius: 12px;
+        font-size: 12px;
+        color: #4b5563;
+      }
+    }
+
+    .column-content {
+      overflow-y: auto;
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      padding-right: 4px;
+
+      &::-webkit-scrollbar {
+        width: 4px;
+      }
+      &::-webkit-scrollbar-thumb {
+        background: #9ca3af;
+        border-radius: 2px;
+      }
+    }
+  }
+}
+
+.suggestion-card {
+  background: white;
+  border-radius: 6px;
+  padding: 16px;
+  box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+  cursor: pointer;
+  transition: transform 0.2s, box-shadow 0.2s;
+  border-left: 4px solid transparent;
+
+  &:hover {
+    transform: translateY(-2px);
+    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+  }
+
+  &.priority-p0 {
+    border-left-color: #ef4444;
+  }
+
+  &.priority-p1 {
+    border-left-color: #f59e0b;
+  }
+
+  .card-badges {
+    display: flex;
+    justify-content: space-between;
+    gap: 6px;
+    flex-wrap: wrap;
+    margin-bottom: 8px;
+    
+    .type-badge, .priority-badge, .workorder-badge, .taxonomy-badge {
+      font-size: 11px;
+      padding: 2px 6px;
+      border-radius: 4px;
+      text-transform: uppercase;
+    }
+
+    .workorder-badge {
+      background: #f5f3ff;
+      color: #5b21b6;
+      font-weight: 700;
+    }
+
+    .taxonomy-badge {
+      background: #ecfeff;
+      color: #155e75;
+      font-weight: 700;
+
+      &.high {
+        background: #dcfce7;
+        color: #166534;
+      }
+
+      &.medium {
+        background: #e0f2fe;
+        color: #075985;
+      }
+
+      &.low {
+        background: #fef3c7;
+        color: #92400e;
+      }
+    }
+
+    .type-badge {
+      background: #eff6ff;
+      color: #1e40af;
+      &.product_optimization { background: #e0e7ff; color: #3730a3; }
+      &.operation_strategy { background: #ecfdf5; color: #065f46; }
+      &.customer_service { background: #fff7ed; color: #9a3412; }
+    }
+
+    .priority-badge {
+      font-weight: 600;
+      &.P0 { background: #fef2f2; color: #991b1b; }
+      &.P1 { background: #fffbeb; color: #92400e; }
+      &.P2 { background: #f3f4f6; color: #374151; }
+    }
+  }
+
+  .card-title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #111827;
+    margin: 0 0 8px;
+    line-height: 1.4;
+  }
+
+  .card-desc {
+    font-size: 12px;
+    color: #6b7280;
+    margin: 0 0 12px;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .dimension-tags {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-bottom: 12px;
+
+    .dimension-tag {
+      max-width: 100%;
+      padding: 3px 7px;
+      border-radius: 999px;
+      background: #f0f9ff;
+      color: #0369a1;
+      font-size: 11px;
+      line-height: 1.4;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  .workorder-strip {
+    margin-bottom: 12px;
+    padding: 8px 10px;
+    border-radius: 8px;
+    background: #f8fafc;
+    border: 1px solid #e5e7eb;
+    font-size: 11px;
+
+    &.due_soon {
+      background: #fffbeb;
+      border-color: #fde68a;
+      color: #92400e;
+    }
+
+    &.overdue {
+      background: #fef2f2;
+      border-color: #fecaca;
+      color: #991b1b;
+    }
+
+    &.done {
+      background: #ecfdf5;
+      border-color: #a7f3d0;
+      color: #065f46;
+    }
+
+    .wo-line {
+      display: flex;
+      justify-content: space-between;
+      gap: 8px;
+      margin-bottom: 4px;
+      font-weight: 700;
+    }
+
+    .wo-next {
+      color: #6b7280;
+      line-height: 1.4;
+    }
+
+    .wo-acceptance {
+      margin-top: 6px;
+      padding-top: 6px;
+      border-top: 1px dashed #d1d5db;
+      color: #4b5563;
+      font-weight: 600;
+      line-height: 1.4;
+    }
+  }
+
+  .card-footer {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 8px;
+    font-size: 12px;
+    color: #9ca3af;
+
+    .assignee {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      
+      .avatar {
+        width: 20px;
+        height: 20px;
+        background: #e5e7eb;
+        border-radius: 50%;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        font-size: 10px;
+        color: #4b5563;
+      }
+    }
+  }
+
+  .card-priority-score {
+    margin-bottom: 8px;
+    font-size: 11px;
+    color: #4b5563;
+    font-weight: 600;
+  }
+
+  .confidence-bar {
+    height: 4px;
+    background: #f3f4f6;
+    border-radius: 2px;
+    overflow: hidden;
+    
+    .bar-fill {
+      height: 100%;
+      background: linear-gradient(90deg, #60a5fa, #3b82f6);
+      border-radius: 2px;
+    }
+  }
+
+  .card-actions {
+    display: flex;
+    gap: 6px;
+    margin-top: 10px;
+    flex-wrap: wrap;
+
+    .card-action-btn {
+      flex: 1;
+      padding: 5px 8px;
+      border: none;
+      border-radius: 6px;
+      font-size: 11px;
+      font-weight: 600;
+      cursor: pointer;
+      transition: background 0.2s;
+      min-width: 60px;
+      text-align: center;
+
+      &.analyzing, &.in_progress { background: #dbeafe; color: #1d4ed8; &:hover { background: #bfdbfe; } }
+      &.verify, &.verifying { background: #d1fae5; color: #065f46; &:hover { background: #a7f3d0; } }
+      &.archived { background: #f3f4f6; color: #6b7280; &:hover { background: #e5e7eb; } }
+    }
+  }
+
+  .card-verify-result {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-top: 8px;
+    padding: 6px 10px;
+    background: #ecfdf5;
+    border-radius: 6px;
+    font-size: 11px;
+
+    .verify-metric { color: #065f46; font-weight: 500; }
+    .verify-change { font-weight: 700; &.positive { color: #059669; } &.negative { color: #dc2626; } }
+  }
+}
+
+// ==================== 详情居中弹窗 ====================
+
+.detail-backdrop {
+  position: fixed;
+  top: 0; left: 0; width: 100%; height: 100%;
+  background: rgba(0,0,0,0.45);
+  z-index: 100;
+}
+
+.detail-modal {
+  position: fixed;
+  top: 50%; left: 50%;
+  transform: translate(-50%, -50%);
+  width: 90vw;
+  max-width: 1100px;
+  max-height: 85vh;
+  background: #fff;
+  border-radius: 14px;
+  box-shadow: 0 12px 48px rgba(0,0,0,0.2);
+  z-index: 110;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+
+  .modal-header {
+    padding: 20px 28px 16px;
+    border-bottom: 1px solid #e5e7eb;
+    flex-shrink: 0;
+
+    .modal-title-row {
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-start;
+
+      .tags {
+        display: flex;
+        gap: 8px;
+        flex-wrap: wrap;
+
+        .priority-tag, .status-tag, .type-tag {
+          font-size: 11px;
+          padding: 2px 8px;
+          border-radius: 4px;
+          font-weight: 600;
+        }
+        .priority-tag.P0 { background: #fee2e2; color: #991b1b; }
+        .priority-tag.P1 { background: #fef3c7; color: #92400e; }
+        .priority-tag.P2 { background: #dbeafe; color: #1d4ed8; }
+        .priority-tag.P3 { background: #d1fae5; color: #065f46; }
+        .status-tag { background: #e5e7eb; color: #374151; }
+        .status-tag.pending { background: #fef3c7; color: #92400e; }
+        .status-tag.analyzing { background: #e0e7ff; color: #4338ca; }
+        .status-tag.in_progress { background: #dbeafe; color: #1d4ed8; }
+        .status-tag.verifying { background: #d1fae5; color: #065f46; }
+        .status-tag.archived { background: #f3f4f6; color: #6b7280; }
+        .type-tag {
+          &.product_optimization { background: #e0e7ff; color: #3730a3; }
+          &.operation_strategy { background: #ecfdf5; color: #065f46; }
+          &.customer_service { background: #fff7ed; color: #9a3412; }
+        }
+      }
+
+      .close-btn {
+        background: none; border: none; font-size: 26px;
+        cursor: pointer; color: #9ca3af; line-height: 1;
+        &:hover { color: #4b5563; }
+      }
+    }
+
+    .modal-title-line {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 16px;
+      margin: 10px 0 6px;
+    }
+
+    .modal-title-actions {
+      display: inline-flex;
+      align-items: center;
+      gap: 8px;
+      flex-shrink: 0;
+    }
+
+    .edit-btn,
+    .edit-save-btn,
+    .edit-cancel-btn {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      padding: 7px 14px;
+      border-radius: 8px;
+      font-size: 12px;
+      font-weight: 700;
+      cursor: pointer;
+      white-space: nowrap;
+      border: 1px solid #d1d5db;
+      background: #fff;
+      color: #374151;
+
+      &:disabled {
+        opacity: 0.6;
+        cursor: default;
+      }
+    }
+
+    .edit-btn:hover,
+    .edit-cancel-btn:hover {
+      background: #f3f4f6;
+    }
+
+    .edit-save-btn {
+      border-color: #2563eb;
+      background: #2563eb;
+      color: #fff;
+
+      &:hover {
+        background: #1d4ed8;
+      }
+    }
+
+    .edit-error {
+      margin-top: 8px;
+      padding: 8px 10px;
+      border-radius: 8px;
+      background: #fef2f2;
+      color: #b91c1c;
+      font-size: 12px;
+      font-weight: 600;
+    }
+
+    .modal-title {
+      font-size: 20px; font-weight: 700; color: #111827;
+      margin: 0;
+      flex: 1;
+      min-width: 0;
+    }
+
+    .push-trigger-wrap {
+      position: relative;
+      flex-shrink: 0;
+
+      .push-btn {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        gap: 6px;
+        padding: 7px 16px;
+        border: none;
+        border-radius: 8px;
+        font-size: 12px;
+        font-weight: 600;
+        cursor: pointer;
+        color: #fff;
+        background: linear-gradient(135deg, #818cf8, #6366f1);
+        transition: opacity 0.2s;
+        white-space: nowrap;
+        &:hover { opacity: 0.9; }
+      }
+    }
+
+    .push-popover-backdrop {
+      position: fixed;
+      top: 0; left: 0; right: 0; bottom: 0;
+      z-index: 119;
+    }
+
+    .push-popover {
+      position: absolute;
+      top: calc(100% + 8px);
+      right: 0;
+      z-index: 120;
+      width: 280px;
+      background: #fff;
+      border: 1px solid #e5e7eb;
+      border-radius: 12px;
+      box-shadow: 0 10px 32px rgba(0,0,0,0.15);
+      padding: 14px;
+
+      .push-popover-header {
+        margin-bottom: 12px;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        strong { font-size: 14px; color: #111827; }
+
+        .push-config-toggle {
+          background: none;
+          border: none;
+          font-size: 12px;
+          color: #6b7280;
+          cursor: pointer;
+          padding: 2px 6px;
+          border-radius: 4px;
+          &:hover { color: #111827; background: #f3f4f6; }
+        }
+      }
+
+      .push-targets-section {
+        margin-top: 12px;
+        padding-top: 12px;
+        border-top: 1px solid #f0f0f0;
+
+        .push-targets-title {
+          font-size: 11px;
+          color: #9ca3af;
+          font-weight: 600;
+          text-transform: uppercase;
+          letter-spacing: 0.5px;
+          margin-bottom: 8px;
+        }
+
+        .push-target-row {
+          display: flex;
+          align-items: center;
+          gap: 6px;
+          padding: 6px 0;
+
+          .target-name {
+            flex: 1;
+            font-size: 13px;
+            color: #374151;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+
+          .target-push-btn {
+            padding: 3px 10px;
+            border: none;
+            border-radius: 6px;
+            font-size: 11px;
+            font-weight: 600;
+            color: #fff;
+            background: #3370ff;
+            cursor: pointer;
+            white-space: nowrap;
+            &:hover { opacity: 0.9; }
+            &:disabled { opacity: 0.5; cursor: default; }
+          }
+
+          .target-remove-btn {
+            background: none;
+            border: none;
+            font-size: 16px;
+            color: #d1d5db;
+            cursor: pointer;
+            padding: 0 2px;
+            line-height: 1;
+            &:hover { color: #ef4444; }
+          }
+        }
+      }
+
+      .push-no-target-hint {
+        margin-top: 12px;
+        padding: 10px 12px;
+        background: #fffbeb;
+        border-radius: 8px;
+        border: 1px solid #fde68a;
+        text-align: center;
+
+        p {
+          margin: 0 0 8px;
+          font-size: 12px;
+          color: #92400e;
+          line-height: 1.5;
+          strong { color: #78350f; }
+        }
+
+        .hint-config-btn {
+          padding: 6px 16px;
+          border: none;
+          border-radius: 6px;
+          font-size: 12px;
+          font-weight: 600;
+          color: #fff;
+          background: #f59e0b;
+          cursor: pointer;
+          &:hover { background: #d97706; }
+        }
+      }
+
+      .push-config-form {
+        .config-guide {
+          background: #f0f7ff;
+          border-radius: 8px;
+          padding: 10px 12px;
+          margin-bottom: 12px;
+
+          .guide-title {
+            font-size: 12px;
+            font-weight: 600;
+            color: #1d4ed8;
+            margin-bottom: 6px;
+          }
+
+          .guide-steps {
+            margin: 0;
+            padding-left: 18px;
+            font-size: 11px;
+            color: #4b5563;
+            line-height: 1.7;
+            li { margin-bottom: 2px; }
+          }
+        }
+
+        .config-label {
+          display: block;
+          font-size: 12px;
+          font-weight: 500;
+          color: #374151;
+          margin-bottom: 4px;
+          margin-top: 8px;
+          .required { color: #ef4444; }
+        }
+
+        .config-input {
+          width: 100%;
+          padding: 7px 10px;
+          border: 1px solid #e5e7eb;
+          border-radius: 6px;
+          font-size: 12px;
+          color: #111827;
+          box-sizing: border-box;
+          transition: border-color 0.15s;
+          &::placeholder { color: #9ca3af; }
+          &:focus { outline: none; border-color: #3370ff; }
+        }
+
+        .config-save-btn {
+          width: 100%;
+          margin-top: 12px;
+          padding: 8px;
+          border: none;
+          border-radius: 8px;
+          font-size: 13px;
+          font-weight: 600;
+          color: #fff;
+          background: linear-gradient(135deg, #3370ff, #2563eb);
+          cursor: pointer;
+          &:hover { opacity: 0.9; }
+        }
+      }
+
+      .push-share-options {
+        display: flex;
+        gap: 16px;
+        justify-content: center;
+
+        .share-option {
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          gap: 6px;
+          background: none;
+          border: none;
+          cursor: pointer;
+          padding: 8px 12px;
+          border-radius: 10px;
+          transition: background 0.15s;
+          &:hover { background: #f3f4f6; }
+
+          .share-icon {
+            width: 44px;
+            height: 44px;
+            border-radius: 12px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            font-size: 18px;
+            font-weight: 700;
+            color: #fff;
+          }
+          .dingtalk-icon { background: #3370ff; }
+          .copy-icon { background: #6b7280; font-size: 20px; }
+          .share-label {
+            font-size: 12px;
+            color: #374151;
+            font-weight: 500;
+          }
+        }
+      }
+
+      .push-popover-msg {
+        padding: 8px 10px;
+        font-size: 12px;
+        color: #2563eb;
+        border-radius: 6px;
+        background: #eff6ff;
+        margin-top: 10px;
+        text-align: center;
+      }
+    }
+
+    .meta-info {
+      font-size: 12px; color: #9ca3af;
+      display: flex; gap: 16px; flex-wrap: wrap;
+    }
+  }
+
+  .modal-body {
+    display: flex;
+    flex: 1;
+    overflow: hidden;
+    min-height: 0;
+  }
+
+  .modal-col-left {
+    flex: 1 1 50%;
+    overflow-y: auto;
+    padding: 20px 24px;
+    border-right: 1px solid #f3f4f6;
+
+    .edit-detail-form {
+      display: flex;
+      flex-direction: column;
+      gap: 16px;
+    }
+
+    .edit-section {
+      padding: 14px;
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+      background: #fff;
+
+      h3 {
+        margin: 0 0 12px;
+        color: #111827;
+        font-size: 14px;
+        font-weight: 700;
+      }
+    }
+
+    .edit-grid {
+      display: grid;
+      gap: 12px;
+
+      &.two {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+      }
+
+      &.three {
+        grid-template-columns: repeat(3, minmax(0, 1fr));
+      }
+
+      .wide {
+        grid-column: 1 / -1;
+      }
+    }
+
+    .edit-field {
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+
+      span {
+        color: #6b7280;
+        font-size: 11px;
+        font-weight: 700;
+      }
+
+      input,
+      select,
+      textarea {
+        width: 100%;
+        box-sizing: border-box;
+        border: 1px solid #d1d5db;
+        border-radius: 8px;
+        padding: 8px 10px;
+        color: #111827;
+        background: #f9fafb;
+        font-size: 13px;
+        line-height: 1.4;
+
+        &:focus {
+          outline: none;
+          border-color: #2563eb;
+          background: #fff;
+          box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+        }
+      }
+
+      textarea {
+        resize: vertical;
+        min-height: 40px;
+      }
+    }
+
+    .edit-check {
+      display: inline-flex;
+      align-items: center;
+      gap: 8px;
+      color: #374151;
+      font-size: 13px;
+      font-weight: 700;
+
+      input {
+        width: 16px;
+        height: 16px;
+      }
+    }
+
+    .detail-section {
+      margin-bottom: 20px;
+      padding-bottom: 20px;
+      border-bottom: 1px solid #f3f4f6;
+
+      &:last-child { border-bottom: none; padding-bottom: 0; }
+
+      h3 { font-size: 14px; margin: 0 0 10px; color: #374151; font-weight: 600; }
+
+      .cause-card {
+        background: #fffbeb;
+        border: 1px solid #fcd34d;
+        border-radius: 8px;
+        padding: 14px;
+
+        .cause-factor { font-weight: 600; color: #92400e; margin-bottom: 6px; font-size: 13px; }
+        p { margin: 0 0 10px; font-size: 13px; color: #b45309; line-height: 1.5; }
+        .evidence-list {
+          h4 { font-size: 11px; margin: 0 0 4px; color: #92400e; }
+          ul { margin: 0; padding-left: 16px; li { font-size: 12px; color: #b45309; line-height: 1.6; } }
+        }
+      }
+
+      .impact-grid {
+        display: grid;
+        grid-template-columns: repeat(4, 1fr);
+        gap: 10px;
+        .impact-item {
+          background: #f9fafb; padding: 10px; border-radius: 8px; text-align: center;
+          label { font-size: 11px; color: #6b7280; display: block; margin-bottom: 4px; }
+          .value { font-weight: 600; color: #111827; font-size: 14px; }
+          .value.loss { color: #ef4444; }
+          .value.gain { color: #10b981; }
+        }
+      }
+
+      &.workorder-detail {
+        padding: 14px;
+        border: 1px solid #e5e7eb;
+        border-radius: 10px;
+        background: #f8fafc;
+
+        &.due_soon {
+          background: #fffbeb;
+          border-color: #fde68a;
+        }
+
+        &.overdue {
+          background: #fef2f2;
+          border-color: #fecaca;
+        }
+
+        &.done {
+          background: #ecfdf5;
+          border-color: #a7f3d0;
+        }
+
+        .workorder-grid {
+          display: grid;
+          grid-template-columns: repeat(4, 1fr);
+          gap: 10px;
+          margin-bottom: 10px;
+
+          div {
+            display: flex;
+            flex-direction: column;
+            gap: 4px;
+          }
+
+          label {
+            font-size: 11px;
+            color: #6b7280;
+          }
+
+          strong {
+            font-size: 13px;
+            color: #111827;
+          }
+        }
+
+        .workorder-next-action {
+          display: flex;
+          flex-direction: column;
+          gap: 4px;
+          padding-top: 10px;
+          border-top: 1px dashed #d1d5db;
+
+          label {
+            font-size: 11px;
+            color: #6b7280;
+          }
+
+          span {
+            font-size: 13px;
+            color: #374151;
+            font-weight: 600;
+          }
+        }
+
+        .workorder-acceptance {
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+          margin-top: 10px;
+          padding-top: 10px;
+          border-top: 1px dashed #d1d5db;
+
+          > label {
+            font-size: 11px;
+            color: #6b7280;
+          }
+
+          .acceptance-grid {
+            display: grid;
+            grid-template-columns: repeat(2, 1fr);
+            gap: 8px;
+
+            div {
+              padding: 8px;
+              border-radius: 8px;
+              background: #fff;
+              border: 1px solid #e5e7eb;
+            }
+
+            span {
+              display: block;
+              margin-bottom: 3px;
+              color: #6b7280;
+              font-size: 11px;
+            }
+
+            strong {
+              color: #111827;
+              font-size: 12px;
+              line-height: 1.35;
+            }
+          }
+
+          p {
+            margin: 0;
+            color: #92400e;
+            font-size: 12px;
+            font-weight: 600;
+            line-height: 1.5;
+          }
+        }
+      }
+
+      &.taxonomy-detail {
+        padding: 14px;
+        border: 1px solid #bae6fd;
+        border-radius: 10px;
+        background: #f0f9ff;
+
+        &.high {
+          background: #ecfdf5;
+          border-color: #a7f3d0;
+        }
+
+        &.low {
+          background: #fffbeb;
+          border-color: #fde68a;
+        }
+
+        .taxonomy-grid {
+          display: grid;
+          grid-template-columns: repeat(3, 1fr);
+          gap: 10px;
+          margin-bottom: 10px;
+
+          div {
+            display: flex;
+            flex-direction: column;
+            gap: 4px;
+          }
+
+          label {
+            font-size: 11px;
+            color: #6b7280;
+          }
+
+          strong {
+            font-size: 13px;
+            color: #111827;
+          }
+        }
+
+        .taxonomy-keywords {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+
+          span {
+            padding: 3px 8px;
+            border-radius: 999px;
+            background: #fff;
+            color: #0369a1;
+            font-size: 11px;
+            font-weight: 600;
+          }
+        }
+      }
+
+      .owner-tags {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 8px;
+        margin-bottom: 12px;
+
+        span {
+          padding: 4px 9px;
+          border-radius: 999px;
+          background: #eef2ff;
+          color: #3730a3;
+          font-size: 12px;
+          font-weight: 600;
+        }
+      }
+
+      .affected-product-list {
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+      }
+
+      .affected-product-item {
+        display: flex;
+        gap: 12px;
+        padding: 10px;
+        border: 1px solid #e5e7eb;
+        border-radius: 10px;
+        background: #f9fafb;
+
+        .product-photo {
+          width: 48px;
+          height: 48px;
+          border-radius: 8px;
+          object-fit: cover;
+          background: #e5e7eb;
+          flex-shrink: 0;
+
+          &.placeholder {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            color: #6b7280;
+            font-size: 11px;
+            font-weight: 700;
+          }
+        }
+
+        .product-info {
+          min-width: 0;
+          flex: 1;
+        }
+
+        .product-title {
+          font-size: 13px;
+          font-weight: 600;
+          color: #111827;
+          line-height: 1.4;
+          margin-bottom: 4px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .product-meta,
+        .product-owner {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 6px;
+          font-size: 11px;
+          color: #6b7280;
+        }
+
+        .product-owner {
+          margin-top: 6px;
+
+          span {
+            padding: 2px 6px;
+            border-radius: 999px;
+            background: #fff;
+            border: 1px solid #e5e7eb;
+            color: #4b5563;
+          }
+        }
+      }
+
+      .action-buttons {
+        display: flex; gap: 10px; margin-top: 12px;
+        &.single {
+          margin-top: 10px;
+        }
+        .btn {
+          flex: 1; padding: 9px; border-radius: 8px; border: none;
+          cursor: pointer; font-weight: 600; font-size: 13px;
+          &.btn-primary { background: #6366f1; color: white; &:hover { background: #4f46e5; } }
+          &.btn-success { background: #059669; color: white; &:hover { background: #047857; } }
+          &.btn-outline { background: white; border: 1px solid #d1d5db; color: #374151; &:hover { background: #f3f4f6; } }
+          &.btn-ding { background: #1677ff; color: white; &:hover { background: #0b5ed7; } &:disabled { opacity: 0.6; cursor: not-allowed; } }
+        }
+      }
+
+      .push-result {
+        margin-top: 8px;
+        font-size: 12px;
+        color: #2563eb;
+      }
+    }
+
+    .action-plan-list {
+      display: flex; flex-direction: column; gap: 6px;
+      .plan-step {
+        padding: 8px 12px; background: #f9fafb; border: 1px solid #e5e7eb;
+        border-radius: 8px; font-size: 13px; color: #374151; line-height: 1.5;
+      }
+    }
+
+    .verification-card {
+      background: #ecfdf5; padding: 14px; border-radius: 8px; border: 1px solid #10b981;
+      .metric-name { font-size: 13px; color: #065f46; margin-bottom: 6px; }
+      .metric-change {
+        display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px;
+        .arrow { color: #9ca3af; }
+        .rate { color: #059669; }
+        .rate.positive { color: #059669; }
+      }
+      .verification-criteria {
+        margin-top: 8px;
+        padding-top: 8px;
+        border-top: 1px dashed #a7f3d0;
+        color: #047857;
+        font-size: 12px;
+        line-height: 1.5;
+      }
+    }
+  }
+
+  .modal-col-right {
+    flex: 1 1 50%;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+  }
+}
+
+// ==================== AI 分析面板 ====================
+
+.ai-panel {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  .ai-panel-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 16px 20px;
+    border-bottom: 1px solid #f3f4f6;
+    flex-shrink: 0;
+
+    h3 { font-size: 14px; font-weight: 700; color: #374151; margin: 0; }
+
+    .btn-ai {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      padding: 7px 16px;
+      border: none;
+      border-radius: 8px;
+      font-size: 12px;
+      font-weight: 600;
+      cursor: pointer;
+      background: linear-gradient(135deg, #818cf8, #6366f1);
+      color: #fff;
+      transition: opacity 0.2s;
+      &:hover { opacity: 0.9; }
+      &:disabled { opacity: 0.55; cursor: not-allowed; }
+    }
+  }
+
+  .ai-panel-body {
+    flex: 1;
+    overflow-y: auto;
+    padding: 16px 20px;
+
+    .ai-evidence-summary {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      margin-bottom: 12px;
+      padding: 10px 12px;
+      border-radius: 8px;
+      background: #f8fafc;
+      border: 1px solid #e5e7eb;
+      font-size: 12px;
+      color: #4b5563;
+
+      &.high {
+        background: #ecfdf5;
+        border-color: #a7f3d0;
+        color: #065f46;
+      }
+
+      &.medium {
+        background: #eff6ff;
+        border-color: #bfdbfe;
+        color: #1e40af;
+      }
+
+      &.low {
+        background: #fffbeb;
+        border-color: #fde68a;
+        color: #92400e;
+      }
+
+      .evidence-warning {
+        font-weight: 700;
+      }
+    }
+
+    .ai-text {
+      font-size: 13px;
+      line-height: 1.7;
+      color: #374151;
+      white-space: pre-wrap;
+      word-break: break-word;
+
+      :deep(h1), :deep(h2), :deep(h3) { font-size: 14px; font-weight: 700; margin: 14px 0 6px; color: #111827; }
+      :deep(ul), :deep(ol) { padding-left: 18px; margin: 6px 0; }
+      :deep(li) { margin-bottom: 4px; }
+      :deep(strong) { color: #111827; }
+      :deep(code) { background: #f3f4f6; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
+    }
+
+    .ai-placeholder {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+      min-height: 200px;
+      p {
+        text-align: center;
+        color: #9ca3af;
+        font-size: 13px;
+        max-width: 260px;
+        line-height: 1.6;
+      }
+    }
+
+    .ai-loading-placeholder {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      min-height: 200px;
+      gap: 10px;
+      .ai-step { color: #4b5563; font-size: 13px; font-weight: 500; margin: 0; }
+      .ai-elapsed { color: #9ca3af; font-size: 12px; margin: 0; }
+    }
+
+    .ai-streaming-hint {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 8px 0 4px;
+      font-size: 12px;
+      color: #6366f1;
+      font-weight: 500;
+      .ai-spinner {
+        border-color: rgba(99,102,241,0.25);
+        border-top-color: #6366f1;
+      }
+    }
+
+    .markdown-body {
+      h1, h2, h3, h4 { font-size: 14px; font-weight: 700; margin: 16px 0 8px; color: #111827; }
+      h1 { font-size: 16px; }
+      h2 { font-size: 15px; }
+      p { margin: 6px 0; }
+      ul, ol { padding-left: 20px; margin: 6px 0; }
+      li { margin-bottom: 4px; line-height: 1.6; }
+      strong { color: #111827; }
+      em { color: #4b5563; }
+      code { background: #f3f4f6; padding: 1px 5px; border-radius: 4px; font-size: 12px; }
+      blockquote {
+        margin: 8px 0; padding: 8px 14px;
+        border-left: 3px solid #6366f1; background: #f5f3ff;
+        color: #4338ca; font-size: 13px;
+      }
+      table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 12px; }
+      th, td { padding: 6px 10px; border: 1px solid #e5e7eb; text-align: left; }
+      th { background: #f9fafb; font-weight: 600; }
+      hr { border: none; border-top: 1px solid #e5e7eb; margin: 12px 0; }
+    }
+  }
+}
+
+.ai-spinner {
+  display: inline-block;
+  width: 14px; height: 14px;
+  border: 2px solid rgba(255,255,255,0.35);
+  border-top-color: #fff;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+.ai-spinner-large {
+  width: 32px; height: 32px;
+  border: 3px solid #e5e7eb;
+  border-top-color: #6366f1;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+// 效果验证弹窗
+.verify-backdrop {
+  position: fixed;
+  top: 0; left: 0; width: 100%; height: 100%;
+  background: rgba(0,0,0,0.4);
+  z-index: 200;
+}
+.verify-modal {
+  position: fixed;
+  top: 50%; left: 50%;
+  transform: translate(-50%, -50%);
+  background: #fff;
+  border-radius: 12px;
+  padding: 28px 32px;
+  width: 420px;
+  box-shadow: 0 8px 32px rgba(0,0,0,0.18);
+  z-index: 210;
+
+  h3 { font-size: 16px; font-weight: 700; color: #111827; margin: 0 0 20px; }
+
+  .verify-field {
+    margin-bottom: 14px;
+    label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 6px; }
+    input { width: 100%; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 8px; }
+  }
+  .verify-recommendation {
+    display: flex;
+    flex-direction: column;
+    gap: 5px;
+    margin-bottom: 14px;
+    padding: 10px;
+    border-radius: 8px;
+    background: #fffbeb;
+    border: 1px solid #fde68a;
+
+    strong {
+      color: #92400e;
+      font-size: 13px;
+    }
+
+    span {
+      color: #374151;
+      font-size: 12px;
+      font-weight: 600;
+    }
+
+    small {
+      color: #b45309;
+      line-height: 1.5;
+    }
+  }
+  .verify-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+  .verify-preview {
+    padding: 8px 12px; background: #f0fdf4; border-radius: 6px;
+    font-size: 13px; color: #065f46; font-weight: 600; margin-bottom: 14px;
+  }
+  .verify-actions {
+    display: flex; gap: 12px; margin-top: 4px;
+    .btn { flex: 1; padding: 9px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-weight: 600; }
+    .btn-primary { background: #6366f1; color: #fff; &:hover { background: #4f46e5; } &:disabled { opacity: 0.5; cursor: not-allowed; } }
+    .btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; &:hover { background: #f9fafb; } }
+  }
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  color: #6b7280;
+
+  .spinner {
+    width: 40px;
+    height: 40px;
+    border: 3px solid #e5e7eb;
+    border-top-color: #3b82f6;
+    border-radius: 50%;
+    animation: spin 1s linear infinite;
+    margin-bottom: 16px;
+  }
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}

+ 1334 - 0
src/modules/action-suggestion/action-suggestion.component.ts

@@ -0,0 +1,1334 @@
+import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef, NgZone } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Subscription, forkJoin, of } from 'rxjs';
+import { finalize, map, catchError } from 'rxjs/operators';
+import { ActionSuggestionService } from '../shared/services/action-suggestion.service';
+import { TodoStatusStore } from '../shared/services/todo-status-store.service';
+import { AiAnalysisService } from '../shared/services/ai-analysis.service';
+import { AiEvidenceService } from '../shared/services/ai-evidence.service';
+import { PageDataContextService } from '../shared/services/page-data-context.service';
+import { MarkdownPipe } from '../shared/pipes/markdown.pipe';
+import { SizeChartService } from '../shared/services/size-chart.service';
+import { ReturnDepthAnalysisService } from '../shared/services/return-depth-analysis.service';
+import { AiEvidenceContext } from '../shared/interfaces/ai-evidence.interface';
+import { 
+  ActionSuggestion, 
+  SuggestionStatus, 
+  SuggestionType,
+  WorkOrderAcceptanceDirection,
+  WorkOrderSlaStatus,
+  PushTarget
+} from '../shared/interfaces/action-suggestion.interface';
+import { FilterPanelComponent } from '../shared/components/filter-panel/filter-panel.component';
+import { MsqDropdownComponent, MsqDropdownOption } from '../shared/components/msq-dropdown/msq-dropdown.component';
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.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';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { PriorityAccordionComponent, PriorityAccordionSection } from '../shared/components/priority-accordion/priority-accordion.component';
+import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
+import { AlertCardComponent } from '../shared/components/alert-card/alert-card.component';
+import { EntryCardComponent } from '../shared/components/entry-card/entry-card.component';
+import { EntryCardGridComponent } from '../shared/components/entry-card/entry-card-grid.component';
+
+interface SuggestionEditForm {
+  title: string;
+  description: string;
+  type: SuggestionType;
+  priority: ActionSuggestion['priority'];
+  status: SuggestionStatus;
+  assignee: string;
+  dueDate: string;
+  priorityScore: number | null;
+  workOrder: {
+    owner: string;
+    nextAction: string;
+    slaDueAt: string;
+    acceptance: {
+      metric: string;
+      baselineWindow: string;
+      verificationWindow: string;
+      targetDirection: WorkOrderAcceptanceDirection;
+      successCriteria: string;
+      dataSource: string;
+    };
+  };
+  taxonomy: {
+    categoryLabel: string;
+    ownerHint: string;
+    confidence: 'high' | 'medium' | 'low';
+    matchedKeywords: string;
+  };
+  rootCause: {
+    factor: string;
+    description: string;
+    confidence: number;
+    evidence: string;
+  };
+  impact: {
+    affectedUsers: number;
+    potentialLoss: number;
+    estimatedROI: number;
+  };
+  dimensions: {
+    designerNames: string;
+    operatorNames: string;
+    productSkus: string;
+    fulfillmentTypes: string;
+  };
+  actionPlan: string;
+  verification: {
+    enabled: boolean;
+    metric: string;
+    before: number;
+    after: number;
+    changeRate: number;
+  };
+}
+
+type ActionPriority = ActionSuggestion['priority'];
+
+interface ActionAlertHeadline {
+  hasAlert: boolean;
+  title: string;
+  description: string;
+  count: number;
+}
+
+interface WorkOrderSummary {
+  total: number;
+  active: number;
+  overdue: number;
+  dueSoon: number;
+  verifying: number;
+  completed: number;
+  potentialRecovery: number;
+}
+
+interface VerifiedEffectRanking {
+  suggestion: ActionSuggestion;
+  metric: string;
+  before: number;
+  after: number;
+  changeRate: number;
+  effectScore: number;
+  effectLabel: string;
+  ownerLabel: string;
+  acceptanceLabel: string;
+}
+
+const EMPTY_ACTION_ALERT_HEADLINE: ActionAlertHeadline = {
+  hasAlert: false,
+  title: '',
+  description: '',
+  count: 0
+};
+
+const EMPTY_WORK_ORDER_SUMMARY: WorkOrderSummary = {
+  total: 0,
+  active: 0,
+  overdue: 0,
+  dueSoon: 0,
+  verifying: 0,
+  completed: 0,
+  potentialRecovery: 0
+};
+
+@Component({
+  selector: 'app-action-suggestion',
+  standalone: true,
+  imports: [CommonModule, FormsModule, FilterPanelComponent, MarkdownPipe, MsqDropdownComponent, PageHeaderComponent, SummaryMetricCardComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent, PriorityAccordionComponent, ProductIdentityComponent, AlertCardComponent, EntryCardComponent, EntryCardGridComponent],
+  templateUrl: './action-suggestion.component.html',
+  styleUrls: ['./action-suggestion.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ActionSuggestionComponent implements OnInit, OnDestroy {
+
+  loading = false;
+  suggestions: ActionSuggestion[] = [];
+  filteredSuggestions: ActionSuggestion[] = [];
+  selectedSuggestion: ActionSuggestion | null = null;
+  designerOptions: MsqDropdownOption[] = [];
+  editMode = false;
+  editSaving = false;
+  editError = '';
+  editForm: SuggestionEditForm | null = null;
+
+  filterType = '';
+  filterPriority = '';
+  filterStatus = '';
+  filterDesigner = '';
+
+  readonly asTypeOpts: MsqDropdownOption[] = [
+    { value: 'product_optimization', label: '产品优化' },
+    { value: 'operation_strategy', label: '运营策略' },
+    { value: 'customer_service', label: '客户服务' }
+  ];
+  readonly asPriorityOpts: MsqDropdownOption[] = [
+    { value: 'P0', label: 'P0 (紧急)' },
+    { value: 'P1', label: 'P1 (重要)' },
+    { value: 'P2', label: 'P2 (一般)' },
+    { value: 'P3', label: 'P3 (低)' }
+  ];
+  readonly asStatusOpts: MsqDropdownOption[] = [
+    { value: 'pending', label: '待处理' },
+    { value: 'analyzing', label: '分析中' },
+    { value: 'in_progress', label: '执行中' },
+    { value: 'verifying', label: '效果验证' },
+    { value: 'archived', label: '已归档' }
+  ];
+  readonly acceptanceDirectionOpts: { value: WorkOrderAcceptanceDirection; label: string }[] = [
+    { value: 'decrease', label: '下降为达标' },
+    { value: 'increase', label: '提升为达标' },
+    { value: 'maintain', label: '保持稳定' }
+  ];
+  readonly taxonomyConfidenceOpts: Array<{ value: 'high' | 'medium' | 'low'; label: string }> = [
+    { value: 'high', label: '高置信' },
+    { value: 'medium', label: '中置信' },
+    { value: 'low', label: '低置信' }
+  ];
+
+  kanbanColumns: { value: SuggestionStatus; label: string }[] = [
+    { value: 'pending', label: '待处理' },
+    { value: 'analyzing', label: '分析中' },
+    { value: 'in_progress', label: '执行中' },
+    { value: 'verifying', label: '效果验证' },
+    { value: 'archived', label: '已归档' }
+  ];
+
+  viewMode: 'kanban' | 'priority' = 'kanban';
+  expandedPriorityIds: string[] = ['P0', 'P1'];
+
+  readonly priorityOrder: ActionPriority[] = ['P0', 'P1', 'P2', 'P3'];
+  private readonly emptySuggestions: ActionSuggestion[] = [];
+  private suggestionsByPriority = new Map<ActionPriority, ActionSuggestion[]>();
+  private suggestionsByStatus = new Map<SuggestionStatus, ActionSuggestion[]>();
+  private cachedPrioritySections: PriorityAccordionSection[] = [];
+  private cachedActionAlertHeadline: ActionAlertHeadline = EMPTY_ACTION_ALERT_HEADLINE;
+  private cachedWorkOrderSummary: WorkOrderSummary = EMPTY_WORK_ORDER_SUMMARY;
+  private cachedVerifiedEffectRankings: VerifiedEffectRanking[] = [];
+  private readonly primaryOwnerLabelCache = new WeakMap<ActionSuggestion, string>();
+  private readonly dimensionTagsCache = new WeakMap<ActionSuggestion, string[]>();
+  private readonly emptyNextStatusOptions: { value: SuggestionStatus; label: string }[] = [];
+  private readonly nextStatusOptionsByStatus: Partial<Record<SuggestionStatus, { value: SuggestionStatus; label: string }[]>> = {
+    pending: [
+      { value: 'analyzing', label: '开始分析' },
+      { value: 'in_progress', label: '直接执行' },
+      { value: 'archived', label: '暂不处理' }
+    ],
+    analyzing: [
+      { value: 'in_progress', label: '采纳执行' },
+      { value: 'archived', label: '暂不处理' }
+    ],
+    in_progress: [
+      { value: 'verifying', label: '提交验证' }
+    ],
+    verifying: [
+      { value: 'archived', label: '确认归档' },
+      { value: 'in_progress', label: '继续执行' }
+    ],
+    archived: []
+  };
+
+  get prioritySections(): PriorityAccordionSection[] {
+    return this.cachedPrioritySections;
+  }
+
+  getSuggestionsByPriority(priority: ActionPriority): ActionSuggestion[] {
+    return this.suggestionsByPriority.get(priority) || this.emptySuggestions;
+  }
+
+  setViewMode(mode: 'kanban' | 'priority'): void {
+    this.viewMode = mode;
+    this.cdr.markForCheck();
+  }
+
+  /** 跳转到行动建议子页(体验/迭代/策略) */
+  goToActionPage(target: 'experience' | 'iteration' | 'strategy'): void {
+    this.router.navigate(['/action-suggestion', target]);
+  }
+
+  /** 顶部预警条文案:P0 / 逾期摘要 */
+  get actionAlertHeadline(): ActionAlertHeadline {
+    return this.cachedActionAlertHeadline;
+  }
+
+  fromTodoId: string | null = null;
+  focusedSuggestionId: string | null = null;
+
+  // 效果验证表单
+  showVerifyForm = false;
+  verifyTarget: ActionSuggestion | null = null;
+  verifyForm = { metric: '', before: 0, after: 0 };
+
+  // AI 分析
+  aiText = '';
+  aiLoading = false;
+  aiError = '';
+  aiLoadingStep = '';
+  aiElapsed = 0;
+  aiEvidenceContext?: AiEvidenceContext;
+  private aiSub?: Subscription;
+  private aiTimer: any = null;
+  pushResultMsg = '';
+  showPushPanel = false;
+  showPushConfig = false;
+  pushTargets: PushTarget[] = [];
+  pushingTargetId = '';
+  newTarget = { name: '', webhook: '', secret: '', atMobiles: '' };
+
+  constructor(
+    private suggestionService: ActionSuggestionService,
+    private cdr: ChangeDetectorRef,
+    private route: ActivatedRoute,
+    private router: Router,
+    private todoStatusStore: TodoStatusStore,
+    private aiService: AiAnalysisService,
+    private aiEvidenceService: AiEvidenceService,
+    private pageContext: PageDataContextService,
+    private ngZone: NgZone,
+    private sizeChartService: SizeChartService,
+    private returnAnalysis: ReturnDepthAnalysisService
+  ) {}
+
+  ngOnInit(): void {
+    this.fromTodoId = this.route.snapshot.queryParamMap.get('fromTodo');
+    this.focusedSuggestionId =
+      this.route.snapshot.queryParamMap.get('fromWorkOrder') ||
+      this.route.snapshot.queryParamMap.get('suggestionId');
+    this.loadData();
+  }
+
+  loadData(): void {
+    this.loading = true;
+    this.suggestionService.getSuggestions()
+      .pipe(finalize(() => {
+        this.loading = false;
+        this.cdr.markForCheck();
+      }))
+      .subscribe(data => {
+        this.suggestions = data;
+        this.designerOptions = this.toDropdownOptions(data.flatMap(s => this.getSuggestionDesignerNames(s)));
+        if (this.filterDesigner && !this.designerOptions.some(option => option.value === this.filterDesigner)) {
+          this.filterDesigner = '';
+        }
+        this.applyFilter();
+        this.openFocusedSuggestion();
+      });
+  }
+
+  applyFilter(): void {
+    let list = [...this.suggestions];
+    if (this.filterType) list = list.filter(s => s.type === this.filterType);
+    if (this.filterPriority) list = list.filter(s => s.priority === this.filterPriority);
+    if (this.filterStatus) list = list.filter(s => s.status === this.filterStatus);
+    if (this.filterDesigner) list = list.filter(s => this.getSuggestionDesignerNames(s).includes(this.filterDesigner));
+    this.filteredSuggestions = list;
+    this.refreshFilteredSuggestionDerivations();
+    this.cdr.markForCheck();
+  }
+
+  getSuggestionsByStatus(status: SuggestionStatus): ActionSuggestion[] {
+    return this.suggestionsByStatus.get(status) || this.emptySuggestions;
+  }
+
+  get workOrderSummary(): WorkOrderSummary {
+    return this.cachedWorkOrderSummary;
+  }
+
+  get verifiedEffectRankings(): VerifiedEffectRanking[] {
+    return this.cachedVerifiedEffectRankings;
+  }
+
+  private refreshFilteredSuggestionDerivations(): void {
+    const byPriority = new Map<ActionPriority, ActionSuggestion[]>();
+    const byStatus = new Map<SuggestionStatus, ActionSuggestion[]>();
+    const p0: ActionSuggestion[] = [];
+    const overdue: ActionSuggestion[] = [];
+    const dueSoon: ActionSuggestion[] = [];
+    const verified: VerifiedEffectRanking[] = [];
+    let active = 0;
+    let verifying = 0;
+    let completed = 0;
+    let potentialRecovery = 0;
+
+    for (const priority of this.priorityOrder) {
+      byPriority.set(priority, []);
+    }
+    for (const column of this.kanbanColumns) {
+      byStatus.set(column.value, []);
+    }
+
+    for (const suggestion of this.filteredSuggestions) {
+      byPriority.get(suggestion.priority)?.push(suggestion);
+      byStatus.get(suggestion.status)?.push(suggestion);
+
+      const isOpen = suggestion.status !== 'archived' && suggestion.status !== 'rejected';
+      if (isOpen) {
+        active++;
+        potentialRecovery += Math.max(suggestion.impact.estimatedROI || 0, suggestion.impact.potentialLoss || 0);
+        if (suggestion.priority === 'P0') {
+          p0.push(suggestion);
+        }
+      }
+      if (suggestion.workOrder?.slaStatus === 'overdue') {
+        overdue.push(suggestion);
+      } else if (suggestion.workOrder?.slaStatus === 'due_soon') {
+        dueSoon.push(suggestion);
+      }
+      if (suggestion.status === 'verifying') {
+        verifying++;
+      }
+      if (suggestion.status === 'archived') {
+        completed++;
+      }
+      if (suggestion.verificationResult) {
+        const direction = suggestion.workOrder?.acceptance?.targetDirection || 'decrease';
+        const effectScore = direction === 'decrease'
+          ? -suggestion.verificationResult.changeRate
+          : direction === 'increase'
+            ? suggestion.verificationResult.changeRate
+            : Math.abs(suggestion.verificationResult.changeRate);
+        verified.push({
+          suggestion,
+          metric: suggestion.verificationResult.metric,
+          before: suggestion.verificationResult.before,
+          after: suggestion.verificationResult.after,
+          changeRate: suggestion.verificationResult.changeRate,
+          effectScore,
+          effectLabel: effectScore >= 0 ? `改善 ${Math.abs(effectScore).toFixed(1)}%` : `待复盘 ${Math.abs(effectScore).toFixed(1)}%`,
+          ownerLabel: suggestion.workOrder?.owner || this.getPrimaryOwnerLabel(suggestion),
+          acceptanceLabel: suggestion.workOrder?.acceptance?.successCriteria || '按提交验证结果评估'
+        });
+      }
+    }
+
+    this.suggestionsByPriority = byPriority;
+    this.suggestionsByStatus = byStatus;
+    this.cachedWorkOrderSummary = {
+      total: this.filteredSuggestions.length,
+      active,
+      overdue: overdue.length,
+      dueSoon: dueSoon.length,
+      verifying,
+      completed,
+      potentialRecovery
+    };
+    this.cachedActionAlertHeadline = this.buildActionAlertHeadline(p0, overdue, dueSoon);
+    this.cachedPrioritySections = this.buildPrioritySections(byPriority);
+    this.cachedVerifiedEffectRankings = verified
+      .sort((a, b) => b.effectScore - a.effectScore)
+      .slice(0, 3);
+  }
+
+  private buildActionAlertHeadline(
+    p0: ActionSuggestion[],
+    overdue: ActionSuggestion[],
+    dueSoon: ActionSuggestion[]
+  ): ActionAlertHeadline {
+    const hasAlert = p0.length > 0 || overdue.length > 0;
+    if (!hasAlert) {
+      return EMPTY_ACTION_ALERT_HEADLINE;
+    }
+    const title = p0.length > 0
+      ? `今日必须处理:${p0.length} 项 P0 紧急建议`
+      : `${overdue.length} 项工单已逾期需立即处理`;
+    const head = p0.slice(0, 2).map(s => s.title).join(' · ') || overdue.slice(0, 2).map(s => s.title).join(' · ');
+    const description = `${head || '请尽快推进闭环'} | 临近到期 ${dueSoon.length} 项 / 已逾期 ${overdue.length} 项`;
+    return { hasAlert, title, description, count: p0.length + overdue.length };
+  }
+
+  private buildPrioritySections(byPriority: Map<ActionPriority, ActionSuggestion[]>): PriorityAccordionSection[] {
+    const toneMap: Record<ActionPriority, PriorityAccordionSection['tone']> = {
+      P0: 'red',
+      P1: 'amber',
+      P2: 'blue',
+      P3: 'default'
+    };
+    const labelMap: Record<ActionPriority, string> = {
+      P0: 'P0 紧急',
+      P1: 'P1 重要',
+      P2: 'P2 一般',
+      P3: 'P3 低'
+    };
+    const descMap: Record<ActionPriority, string> = {
+      P0: '优先处理,需在本周内推进验收',
+      P1: '重点跟踪,影响面较大',
+      P2: '常规推进',
+      P3: '待规划或观察中'
+    };
+    return this.priorityOrder.map(priority => {
+      const count = byPriority.get(priority)?.length || 0;
+      return {
+        id: priority,
+        title: labelMap[priority],
+        subtitle: descMap[priority],
+        tone: toneMap[priority],
+        badge: count || '—',
+        icon: priority === 'P0' ? '!' : priority === 'P1' ? '▲' : priority === 'P2' ? '●' : '○',
+        disabled: count === 0
+      };
+    });
+  }
+
+  getTypeName(type: SuggestionType): string {
+    const map: Record<SuggestionType, string> = {
+      'product_optimization': '产品优化',
+      'operation_strategy': '运营策略',
+      'customer_service': '客户服务'
+    };
+    return map[type] || type;
+  }
+
+  getStatusName(status: SuggestionStatus): string {
+    const col = this.kanbanColumns.find(c => c.value === status);
+    return col ? col.label : status;
+  }
+
+  getPrimaryOwnerLabel(suggestion: ActionSuggestion): string {
+    const cached = this.primaryOwnerLabelCache.get(suggestion);
+    if (cached) return cached;
+    const label = suggestion.designerNames?.[0] || suggestion.operatorNames?.[0] || suggestion.assignee || '待分配';
+    this.primaryOwnerLabelCache.set(suggestion, label);
+    return label;
+  }
+
+  getDimensionTags(suggestion: ActionSuggestion): string[] {
+    const cached = this.dimensionTagsCache.get(suggestion);
+    if (cached) return cached;
+    const tags = [
+      ...(suggestion.designerNames || []).map(name => `设计师:${name}`),
+      ...(suggestion.operatorNames || []).map(name => `负责人:${name}`),
+      ...(suggestion.productSkus || []).map(sku => `款号:${sku}`),
+      ...(suggestion.fulfillmentTypes || []).map(type => `配送:${type}`)
+    ].slice(0, 6);
+    this.dimensionTagsCache.set(suggestion, tags);
+    return tags;
+  }
+
+  getWorkOrderSlaLabel(status?: WorkOrderSlaStatus): string {
+    const map: Record<WorkOrderSlaStatus, string> = {
+      normal: 'SLA正常',
+      due_soon: '临近到期',
+      overdue: '已逾期',
+      done: '已闭环'
+    };
+    return status ? map[status] : '未设置SLA';
+  }
+
+  getWorkOrderDueLabel(suggestion: ActionSuggestion): string {
+    const workOrder = suggestion.workOrder;
+    if (!workOrder?.slaDueAt) return '无截止日期';
+    if (workOrder.slaStatus === 'done') return '已闭环';
+    if (workOrder.daysRemaining === undefined) return new Date(workOrder.slaDueAt).toLocaleDateString('zh-CN');
+    if (workOrder.daysRemaining < 0) return `逾期 ${Math.abs(workOrder.daysRemaining)} 天`;
+    if (workOrder.daysRemaining === 0) return '今日到期';
+    return `${workOrder.daysRemaining} 天后到期`;
+  }
+
+  getWorkOrderAcceptanceDirectionLabel(direction?: WorkOrderAcceptanceDirection): string {
+    const map: Record<WorkOrderAcceptanceDirection, string> = {
+      decrease: '下降为达标',
+      increase: '提升为达标',
+      maintain: '保持稳定'
+    };
+    return direction ? map[direction] : '未设置';
+  }
+
+  getIssueTaxonomyConfidenceLabel(confidence?: NonNullable<ActionSuggestion['issueTaxonomy']>['confidence']): string {
+    const map: Record<NonNullable<ActionSuggestion['issueTaxonomy']>['confidence'], string> = {
+      high: '高置信',
+      medium: '中置信',
+      low: '低置信'
+    };
+    return confidence ? map[confidence] : '未识别';
+  }
+
+  selectSuggestion(suggestion: ActionSuggestion): void {
+    this.selectedSuggestion = suggestion;
+    this.editMode = false;
+    this.editSaving = false;
+    this.editError = '';
+    this.editForm = null;
+    this.aiText = '';
+    this.aiLoading = false;
+    this.aiError = '';
+    this.aiEvidenceContext = undefined;
+    this.aiSub?.unsubscribe();
+  }
+
+  closeModal(): void {
+    this.selectedSuggestion = null;
+    this.editMode = false;
+    this.editSaving = false;
+    this.editError = '';
+    this.editForm = null;
+    this.aiSub?.unsubscribe();
+    this.aiText = '';
+    this.aiLoading = false;
+    this.aiEvidenceContext = undefined;
+    this.showPushPanel = false;
+    this.pushResultMsg = '';
+  }
+
+  ngOnDestroy(): void {
+    this.aiSub?.unsubscribe();
+    this.stopAiTimer();
+  }
+
+  startEditSuggestion(): void {
+    if (!this.selectedSuggestion) return;
+    this.editForm = this.createEditForm(this.selectedSuggestion);
+    this.editMode = true;
+    this.editError = '';
+    this.cdr.markForCheck();
+  }
+
+  cancelEditSuggestion(): void {
+    this.editMode = false;
+    this.editSaving = false;
+    this.editError = '';
+    this.editForm = null;
+    this.cdr.markForCheck();
+  }
+
+  saveSuggestionEdits(): void {
+    if (!this.selectedSuggestion || !this.editForm || this.editSaving) return;
+    const updated = this.buildSuggestionFromEditForm(this.selectedSuggestion, this.editForm);
+    const detailPatch = this.buildSuggestionDetailPatch(updated);
+    this.editSaving = true;
+    this.editError = '';
+    this.suggestionService.updateSuggestionStatus(updated.id, updated.status, {
+      assignee: updated.assignee,
+      verificationResult: updated.verificationResult,
+      detailPatch,
+      eventType: 'detail_edit'
+    }).subscribe({
+      next: () => {
+        this.replaceSuggestion(updated);
+        this.selectedSuggestion = { ...updated };
+        this.editMode = false;
+        this.editSaving = false;
+        this.editForm = null;
+        this.aiText = '';
+        this.aiEvidenceContext = undefined;
+        this.applyFilter();
+        this.setLinkedTodoStatus(updated.id, this.toTodoStatus(updated.status));
+      },
+      error: () => {
+        this.editSaving = false;
+        this.editError = '保存失败,请重试';
+        this.cdr.markForCheck();
+      }
+    });
+  }
+
+  /** 从卡片或抽屉中更新状态 */
+  updateStatus(status: SuggestionStatus, suggestion?: ActionSuggestion): void {
+    const target = suggestion || this.selectedSuggestion;
+    if (!target) return;
+
+    const id = target.id;
+    this.suggestionService.updateSuggestionStatus(id, status, { eventType: 'status_update' }).subscribe(() => {
+      target.status = status;
+      target.updatedDate = new Date();
+      target.workOrder = this.buildLocalWorkOrderMeta(target);
+
+      if (this.selectedSuggestion?.id === id) {
+        this.selectedSuggestion = { ...target };
+      }
+      const index = this.suggestions.findIndex(s => s.id === id);
+      if (index !== -1) {
+        this.suggestions[index] = { ...target };
+      }
+      this.applyFilter();
+
+      this.setLinkedTodoStatus(id, this.toTodoStatus(status));
+    });
+  }
+
+  /** 获取当前状态可流转的下一步状态列表 */
+  getNextStatuses(status: SuggestionStatus): { value: SuggestionStatus; label: string }[] {
+    return this.nextStatusOptionsByStatus[status] || this.emptyNextStatusOptions;
+  }
+
+  // ==================== 效果验证 ====================
+
+  openVerifyForm(suggestion: ActionSuggestion, event: Event): void {
+    event.stopPropagation();
+    this.verifyTarget = suggestion;
+    this.verifyForm = { metric: suggestion.workOrder?.acceptance?.metric || suggestion.verificationResult?.metric || '', before: 0, after: 0 };
+    this.showVerifyForm = true;
+    this.cdr.markForCheck();
+  }
+
+  cancelVerify(): void {
+    this.showVerifyForm = false;
+    this.verifyTarget = null;
+  }
+
+  submitVerify(): void {
+    if (!this.verifyTarget || !this.verifyForm.metric) return;
+    const id = this.verifyTarget.id;
+    const before = this.verifyForm.before;
+    const after = this.verifyForm.after;
+    const changeRate = before !== 0 ? Math.round(((after - before) / Math.abs(before)) * 100 * 10) / 10 : 0;
+    const vr = { metric: this.verifyForm.metric, before, after, changeRate };
+
+    this.suggestionService.updateSuggestionStatus(id, 'verifying', { verificationResult: vr, eventType: 'verification' }).subscribe(() => {
+      if (this.verifyTarget) {
+        this.verifyTarget.status = 'verifying';
+        this.verifyTarget.verificationResult = vr;
+        this.verifyTarget.updatedDate = new Date();
+        this.verifyTarget.workOrder = this.buildLocalWorkOrderMeta(this.verifyTarget);
+
+        const index = this.suggestions.findIndex(s => s.id === id);
+        if (index !== -1) this.suggestions[index] = { ...this.verifyTarget };
+        if (this.selectedSuggestion?.id === id) this.selectedSuggestion = { ...this.verifyTarget };
+        this.applyFilter();
+        this.setLinkedTodoStatus(id, this.toTodoStatus('verifying'));
+      }
+      this.showVerifyForm = false;
+      this.verifyTarget = null;
+    });
+  }
+
+  private openFocusedSuggestion(): void {
+    if (!this.focusedSuggestionId) return;
+    const target = this.suggestions.find(s => s.id === this.focusedSuggestionId);
+    if (!target) return;
+    this.selectedSuggestion = target;
+    this.focusedSuggestionId = null;
+    this.cdr.markForCheck();
+  }
+
+  private setLinkedTodoStatus(id: string, status: 'pending' | 'in_progress' | 'done' | 'dismissed'): void {
+    this.todoStatusStore.setStatus(id, status);
+    this.todoStatusStore.setStatus(`action-${id}`, status);
+    if (this.fromTodoId === id || this.fromTodoId === `action-${id}`) {
+      this.fromTodoId = null;
+    }
+  }
+
+  private toTodoStatus(status: SuggestionStatus): 'pending' | 'in_progress' | 'done' | 'dismissed' {
+    if (status === 'archived' || status === 'verifying') return 'done';
+    if (status === 'rejected') return 'dismissed';
+    if (status === 'analyzing' || status === 'in_progress') return 'in_progress';
+    return 'pending';
+  }
+
+  private createEditForm(s: ActionSuggestion): SuggestionEditForm {
+    const workOrder = s.workOrder || this.buildLocalWorkOrderMeta(s);
+    const acceptance = workOrder.acceptance || this.buildLocalWorkOrderAcceptanceMeta(s);
+    return {
+      title: s.title || '',
+      description: s.description || '',
+      type: s.type,
+      priority: s.priority,
+      status: s.status,
+      assignee: s.assignee || '',
+      dueDate: this.toDateInput(s.dueDate || workOrder.slaDueAt),
+      priorityScore: s.priorityScore ?? null,
+      workOrder: {
+        owner: workOrder.owner || s.assignee || '',
+        nextAction: workOrder.nextAction || '',
+        slaDueAt: this.toDateInput(workOrder.slaDueAt || s.dueDate),
+        acceptance: {
+          metric: acceptance?.metric || '',
+          baselineWindow: acceptance?.baselineWindow || '',
+          verificationWindow: acceptance?.verificationWindow || '',
+          targetDirection: acceptance?.targetDirection || 'decrease',
+          successCriteria: acceptance?.successCriteria || '',
+          dataSource: acceptance?.dataSource || ''
+        }
+      },
+      taxonomy: {
+        categoryLabel: s.issueTaxonomy?.categoryLabel || '',
+        ownerHint: s.issueTaxonomy?.ownerHint || '',
+        confidence: s.issueTaxonomy?.confidence || 'medium',
+        matchedKeywords: (s.issueTaxonomy?.matchedKeywords || []).join('\n')
+      },
+      rootCause: {
+        factor: s.rootCause.factor || '',
+        description: s.rootCause.description || '',
+        confidence: Math.round((s.rootCause.confidence || 0) * 100),
+        evidence: (s.rootCause.evidence || []).join('\n')
+      },
+      impact: {
+        affectedUsers: s.impact.affectedUsers || 0,
+        potentialLoss: s.impact.potentialLoss || 0,
+        estimatedROI: s.impact.estimatedROI || 0
+      },
+      dimensions: {
+        designerNames: (s.designerNames || []).join('\n'),
+        operatorNames: (s.operatorNames || []).join('\n'),
+        productSkus: (s.productSkus || []).join('\n'),
+        fulfillmentTypes: (s.fulfillmentTypes || []).join('\n')
+      },
+      actionPlan: (s.actionPlan || []).join('\n'),
+      verification: {
+        enabled: Boolean(s.verificationResult),
+        metric: s.verificationResult?.metric || '',
+        before: s.verificationResult?.before || 0,
+        after: s.verificationResult?.after || 0,
+        changeRate: s.verificationResult?.changeRate || 0
+      }
+    };
+  }
+
+  private buildSuggestionFromEditForm(base: ActionSuggestion, form: SuggestionEditForm): ActionSuggestion {
+    const dueDate = this.parseDateInput(form.dueDate);
+    const slaDueAt = this.parseDateInput(form.workOrder.slaDueAt) || dueDate;
+    const now = new Date();
+    const assignee = form.assignee.trim() || form.workOrder.owner.trim() || undefined;
+    const updated: ActionSuggestion = {
+      ...base,
+      title: form.title.trim() || base.title,
+      description: form.description.trim(),
+      type: form.type,
+      priority: form.priority,
+      status: form.status,
+      assignee,
+      dueDate,
+      priorityScore: form.priorityScore === null || form.priorityScore === undefined ? undefined : Number(form.priorityScore),
+      updatedDate: now,
+      rootCause: {
+        ...base.rootCause,
+        factor: form.rootCause.factor.trim() || base.rootCause.factor,
+        description: form.rootCause.description.trim(),
+        confidence: this.clamp(Number(form.rootCause.confidence) / 100, 0, 1),
+        evidence: this.splitEditableLines(form.rootCause.evidence),
+        relatedAttributes: base.rootCause.relatedAttributes || {}
+      },
+      impact: {
+        affectedUsers: Number(form.impact.affectedUsers) || 0,
+        potentialLoss: Number(form.impact.potentialLoss) || 0,
+        estimatedCost: base.impact.estimatedCost || 0,
+        estimatedROI: Number(form.impact.estimatedROI) || 0
+      },
+      actionPlan: this.splitEditableLines(form.actionPlan),
+      designerNames: this.splitEditableList(form.dimensions.designerNames),
+      operatorNames: this.splitEditableList(form.dimensions.operatorNames),
+      productSkus: this.splitEditableList(form.dimensions.productSkus),
+      fulfillmentTypes: this.splitEditableList(form.dimensions.fulfillmentTypes)
+    };
+
+    const baseWorkOrder = base.workOrder || this.buildLocalWorkOrderMeta(updated);
+    const daysRemaining = this.getDaysRemaining(slaDueAt);
+    updated.workOrder = {
+      ...baseWorkOrder,
+      owner: form.workOrder.owner.trim() || assignee || baseWorkOrder.owner,
+      nextAction: form.workOrder.nextAction.trim() || this.getWorkOrderNextAction(updated),
+      slaDueAt,
+      slaStatus: this.getLocalSlaStatus(updated.status, daysRemaining),
+      daysRemaining,
+      updatedAt: now,
+      acceptance: {
+        metric: form.workOrder.acceptance.metric.trim(),
+        baselineWindow: form.workOrder.acceptance.baselineWindow.trim(),
+        verificationWindow: form.workOrder.acceptance.verificationWindow.trim(),
+        targetDirection: form.workOrder.acceptance.targetDirection,
+        successCriteria: form.workOrder.acceptance.successCriteria.trim(),
+        dataSource: form.workOrder.acceptance.dataSource.trim()
+      }
+    };
+
+    updated.issueTaxonomy = {
+      category: base.issueTaxonomy?.category || 'unknown',
+      categoryLabel: form.taxonomy.categoryLabel.trim() || base.issueTaxonomy?.categoryLabel || '未识别',
+      ownerHint: form.taxonomy.ownerHint.trim() || base.issueTaxonomy?.ownerHint || '待分配',
+      confidence: form.taxonomy.confidence,
+      matchedKeywords: this.splitEditableList(form.taxonomy.matchedKeywords),
+      sourceSignals: base.issueTaxonomy?.sourceSignals || []
+    };
+
+    if (form.verification.enabled) {
+      updated.verificationResult = {
+        metric: form.verification.metric.trim() || updated.workOrder.acceptance?.metric || '效果指标',
+        before: Number(form.verification.before) || 0,
+        after: Number(form.verification.after) || 0,
+        changeRate: Number(form.verification.changeRate) || 0
+      };
+    } else {
+      updated.verificationResult = undefined;
+    }
+
+    return updated;
+  }
+
+  private buildSuggestionDetailPatch(s: ActionSuggestion): Record<string, any> {
+    return {
+      title: s.title,
+      description: s.description,
+      type: s.type,
+      priority: s.priority,
+      priorityScore: s.priorityScore,
+      status: s.status,
+      assignee: s.assignee ?? null,
+      dueDate: s.dueDate ? new Date(s.dueDate).toISOString() : null,
+      workOrder: s.workOrder ? {
+        ...s.workOrder,
+        slaDueAt: s.workOrder.slaDueAt ? new Date(s.workOrder.slaDueAt).toISOString() : null,
+        createdAt: s.workOrder.createdAt ? new Date(s.workOrder.createdAt).toISOString() : null,
+        updatedAt: s.workOrder.updatedAt ? new Date(s.workOrder.updatedAt).toISOString() : null
+      } : undefined,
+      issueTaxonomy: s.issueTaxonomy,
+      rootCause: s.rootCause,
+      impact: s.impact,
+      actionPlan: s.actionPlan,
+      designerNames: s.designerNames,
+      operatorNames: s.operatorNames,
+      productSkus: s.productSkus,
+      fulfillmentTypes: s.fulfillmentTypes,
+      verificationResult: s.verificationResult || null
+    };
+  }
+
+  private replaceSuggestion(updated: ActionSuggestion): void {
+    const index = this.suggestions.findIndex(s => s.id === updated.id);
+    if (index !== -1) {
+      this.suggestions[index] = { ...updated };
+    }
+  }
+
+  private toDateInput(value: any): string {
+    if (!value) return '';
+    const date = value instanceof Date ? value : new Date(value);
+    return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
+  }
+
+  private parseDateInput(value: string): Date | undefined {
+    if (!value) return undefined;
+    const date = new Date(`${value}T00:00:00`);
+    return Number.isNaN(date.getTime()) ? undefined : date;
+  }
+
+  private splitEditableLines(value: string): string[] {
+    return String(value || '')
+      .split(/\r?\n/)
+      .map(item => item.trim())
+      .filter(Boolean);
+  }
+
+  private splitEditableList(value: string): string[] {
+    return String(value || '')
+      .split(/[\r\n,,、]+/)
+      .map(item => item.trim())
+      .filter(Boolean);
+  }
+
+  private getDaysRemaining(dueAt?: Date): number | undefined {
+    if (!dueAt) return undefined;
+    return Math.ceil((dueAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000));
+  }
+
+  private getLocalSlaStatus(status: SuggestionStatus, daysRemaining?: number): WorkOrderSlaStatus {
+    if (status === 'archived' || status === 'rejected') return 'done';
+    if (daysRemaining === undefined) return 'normal';
+    if (daysRemaining < 0) return 'overdue';
+    if (daysRemaining <= 2) return 'due_soon';
+    return 'normal';
+  }
+
+  private clamp(value: number, min: number, max: number): number {
+    if (!Number.isFinite(value)) return min;
+    return Math.min(max, Math.max(min, value));
+  }
+
+  // ==================== AI 分析 ====================
+
+  requestAiAnalysis(): void {
+    if (!this.selectedSuggestion || this.aiLoading) return;
+    this.aiSub?.unsubscribe();
+    this.aiText = '';
+    this.aiError = '';
+    this.aiEvidenceContext = undefined;
+    this.aiLoading = true;
+    this.aiLoadingStep = '正在加载专业数据...';
+    this.startAiTimer();
+    this.cdr.markForCheck();
+
+    const s = this.selectedSuggestion;
+
+    // 先异步收集领域专业数据,再构建 prompt
+    this.gatherDomainContext(s).subscribe(domainContext => {
+      if (!this.selectedSuggestion || this.selectedSuggestion.id !== s.id) return; // 用户已切换
+      const evidenceContext = this.aiEvidenceService.buildActionSuggestionContext(s, domainContext);
+      this.aiEvidenceContext = evidenceContext;
+      const rawPrompt = this.buildAiPrompt(s, domainContext, evidenceContext);
+      const prompt = this.pageContext.buildFullPrompt(rawPrompt);
+
+      this.aiSub = this.aiService.streamAnalysis(prompt, undefined, {
+        temperature: 0.5,
+        outputStyleTargetKey: 'actionSuggestion.detail.system'
+      }).subscribe({
+        next: (state) => {
+          this.ngZone.run(() => {
+            if (state.text) this.aiText = state.text;
+            if (state.error) { this.aiError = state.error; this.aiLoading = false; this.stopAiTimer(); }
+            if (state.complete) { this.aiLoading = false; this.stopAiTimer(); }
+            this.cdr.markForCheck();
+          });
+        },
+        error: () => {
+          this.ngZone.run(() => {
+            this.aiError = 'AI 分析请求失败,请重试';
+            this.aiLoading = false;
+            this.stopAiTimer();
+            this.cdr.markForCheck();
+          });
+        }
+      });
+    });
+  }
+
+  togglePushPanel(): void {
+    this.showPushPanel = !this.showPushPanel;
+    this.pushResultMsg = '';
+    this.showPushConfig = false;
+    if (this.showPushPanel) {
+      this.pushTargets = this.suggestionService.getPushTargets();
+    }
+    this.cdr.markForCheck();
+  }
+
+  togglePushConfig(): void {
+    this.showPushConfig = !this.showPushConfig;
+    if (this.showPushConfig) {
+      this.newTarget = { name: '', webhook: '', secret: '', atMobiles: '' };
+    }
+    this.cdr.markForCheck();
+  }
+
+  savePushTarget(): void {
+    const name = this.newTarget.name.trim();
+    const webhook = this.newTarget.webhook.trim();
+    if (!name || !webhook) {
+      this.pushResultMsg = '请填写名称和 Webhook 地址';
+      this.cdr.markForCheck();
+      return;
+    }
+    const atMobiles = this.newTarget.atMobiles
+      .split(/[,,\s]+/)
+      .map(s => s.trim())
+      .filter(Boolean);
+    this.suggestionService.savePushTarget({
+      id: 'target_' + Date.now(),
+      name,
+      type: 'group',
+      webhook,
+      secret: this.newTarget.secret.trim() || undefined,
+      atMobiles: atMobiles.length ? atMobiles : undefined
+    });
+    this.pushTargets = this.suggestionService.getPushTargets();
+    this.showPushConfig = false;
+    this.pushResultMsg = `已保存「${name}」`;
+    this.cdr.markForCheck();
+  }
+
+  removePushTarget(target: PushTarget): void {
+    this.suggestionService.removePushTarget(target.id);
+    this.pushTargets = this.suggestionService.getPushTargets();
+    this.pushResultMsg = `已移除「${target.name}」`;
+    this.cdr.markForCheck();
+  }
+
+  pushToTarget(suggestion: ActionSuggestion, target: PushTarget): void {
+    this.pushingTargetId = target.id;
+    this.pushResultMsg = '';
+    this.cdr.markForCheck();
+    this.suggestionService.pushToTarget(suggestion, target).subscribe(res => {
+      this.pushResultMsg = res.message;
+      this.pushingTargetId = '';
+      this.cdr.markForCheck();
+    });
+  }
+
+  shareToDingTalk(suggestion: ActionSuggestion): void {
+    const text = this.suggestionService.buildShareText(suggestion);
+    const encoded = encodeURIComponent(text);
+    const url = `dingtalk://dingtalkclient/action/sendmsg?content=${encoded}`;
+    try {
+      const w = window.open(url, '_blank');
+      if (!w) throw new Error('blocked');
+      this.pushResultMsg = '正在打开钉钉客户端…';
+    } catch {
+      this.pushResultMsg = '无法打开钉钉客户端,请确认已安装桌面版钉钉,或使用 Webhook 机器人推送';
+    }
+    this.cdr.markForCheck();
+  }
+
+  copyShareContent(suggestion: ActionSuggestion): void {
+    const text = this.suggestionService.buildShareText(suggestion);
+    navigator.clipboard.writeText(text).then(() => {
+      this.pushResultMsg = '已复制到剪贴板,可粘贴到任意聊天';
+      this.cdr.markForCheck();
+    }).catch(() => {
+      this.pushResultMsg = '复制失败,请手动复制';
+      this.cdr.markForCheck();
+    });
+  }
+
+  private startAiTimer(): void {
+    this.stopAiTimer();
+    this.aiElapsed = 0;
+    this.aiLoadingStep = '正在发送至 AI 模型...';
+    const steps = [
+      { at: 3, text: 'AI 正在分析建议数据...' },
+      { at: 8, text: '正在生成深度洞察...' },
+      { at: 15, text: '正在整理输出格式...' },
+      { at: 25, text: '即将完成,请稍候...' }
+    ];
+    this.aiTimer = setInterval(() => {
+      this.aiElapsed++;
+      for (const s of steps) {
+        if (this.aiElapsed >= s.at) this.aiLoadingStep = s.text;
+      }
+      this.cdr.markForCheck();
+    }, 1000);
+  }
+
+  private stopAiTimer(): void {
+    if (this.aiTimer) { clearInterval(this.aiTimer); this.aiTimer = null; }
+  }
+
+  /**
+   * 根据建议的 relatedAttributes 动态加载专业领域数据
+   * sizing → 尺码表知识库
+   * quality/packaging/logistics/after_sale → 退货概览 + 退货原因归因
+   * 通用 → 退货概览指标
+   */
+  private gatherDomainContext(s: ActionSuggestion): import('rxjs').Observable<string> {
+    const attrs = s.rootCause.relatedAttributes || {};
+    const category = attrs['category'] || '';
+    const factor = (s.rootCause.factor || '').toLowerCase();
+    const tasks: Record<string, import('rxjs').Observable<string>> = {};
+
+    // 尺码相关 → 注入尺码表知识库
+    const isSizing = category === 'sizing'
+      || /尺码|尺寸|size|sizing|偏小|偏大|fit/.test(factor);
+    if (isSizing) {
+      tasks['sizeChart'] = this.sizeChartService.getCharts().pipe(
+        map(charts => this.sizeChartService.formatForAI(charts)),
+        catchError(() => of(''))
+      );
+    }
+
+    // 退货/质量/物流相关 → 注入退货概览数据
+    const needsReturnData = /quality|packaging|logistics|after_sale|process/.test(category)
+      || /退货|退款|质量|物流|包装|return|refund|quality|defect/.test(factor);
+    if (needsReturnData || isSizing) {
+      tasks['returnOverview'] = this.returnAnalysis.getOverviewMetrics().pipe(
+        map(m => [
+          `## 退货概览数据(实时)`,
+          `- 总退货率: ${m.totalReturnRate}%`,
+          `- 退货订单数: ${m.returnOrderCount}`,
+          `- 预估损失金额: $${m.estimatedLossAmount}`,
+          `- 可挽回金额: $${m.recoverableAmount}`
+        ].join('\n')),
+        catchError(() => of(''))
+      );
+      tasks['returnReasons'] = this.returnAnalysis.getReturnReasonAttribution().pipe(
+        map(attr => {
+          const pd = attr?.paretoData;
+          if (!pd?.reasons?.length) return '';
+          const total = pd.counts.reduce((a, b) => a + b, 0);
+          const top5 = pd.reasons.slice(0, 5);
+          const lines = [`## 退货原因 TOP5(帕累托)`];
+          top5.forEach((reason, i) => {
+            const count = pd.counts[i] || 0;
+            const pct = total > 0 ? ((count / total) * 100).toFixed(1) : '-';
+            lines.push(`${i + 1}. ${reason}: ${count}件 (${pct}%)`);
+          });
+          return lines.join('\n');
+        }),
+        catchError(() => of(''))
+      );
+    }
+
+    // 没有需要加载的专业数据 → 直接返回空
+    if (Object.keys(tasks).length === 0) return of('');
+
+    return forkJoin(tasks).pipe(
+      map(results => {
+        return Object.values(results).filter(v => !!v).join('\n\n');
+      }),
+      catchError(() => of(''))
+    );
+  }
+
+  private buildLocalWorkOrderMeta(s: ActionSuggestion): NonNullable<ActionSuggestion['workOrder']> {
+    const dueAt = s.dueDate ? new Date(s.dueDate) : undefined;
+    const now = new Date();
+    const daysRemaining = dueAt
+      ? Math.ceil((dueAt.getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
+      : undefined;
+    const done = s.status === 'archived' || s.status === 'rejected';
+    const slaStatus: WorkOrderSlaStatus = done ? 'done'
+      : daysRemaining === undefined ? 'normal'
+      : daysRemaining < 0 ? 'overdue'
+      : daysRemaining <= 2 ? 'due_soon'
+      : 'normal';
+
+    return {
+      id: s.workOrder?.id || this.buildWorkOrderId(s.id),
+      sourceId: s.id,
+      owner: s.assignee || s.operatorNames?.[0] || s.designerNames?.[0] || '待分配',
+      nextAction: this.getWorkOrderNextAction(s),
+      slaDueAt: dueAt,
+      slaStatus,
+      daysRemaining,
+      acceptance: this.buildLocalWorkOrderAcceptanceMeta(s),
+      createdAt: s.createdDate,
+      updatedAt: s.updatedDate
+    };
+  }
+
+  private buildWorkOrderId(id: string): string {
+    const compact = id.replace(/[^a-zA-Z0-9]/g, '').slice(-8).toUpperCase() || 'UNKNOWN';
+    return `WO-${compact}`;
+  }
+
+  private getWorkOrderNextAction(s: ActionSuggestion): string {
+    if (s.status === 'pending') return '确认责任人并开始分析';
+    if (s.status === 'analyzing') return '完成原因复核并采纳执行';
+    if (s.status === 'in_progress') return '推进执行并提交效果验证';
+    if (s.status === 'verifying') return '复核验证结果并归档';
+    if (s.status === 'archived') return '已闭环归档';
+    if (s.status === 'rejected') return '已驳回,无需处理';
+    return s.actionPlan?.[0] || '查看详情';
+  }
+
+  private buildLocalWorkOrderAcceptanceMeta(s: ActionSuggestion): NonNullable<ActionSuggestion['workOrder']>['acceptance'] {
+    const existing = s.workOrder?.acceptance;
+    if (existing) {
+      const metric = s.verificationResult?.metric || existing.metric;
+      return {
+        ...existing,
+        metric,
+        successCriteria: existing.successCriteria || `${metric}${existing.targetDirection === 'increase' ? '提升' : '下降'},且无新增高风险VOC/退货信号`
+      };
+    }
+    const text = [
+      s.title,
+      s.description,
+      s.rootCause.factor,
+      s.issueTaxonomy?.categoryLabel || '',
+      s.issueTaxonomy?.ownerHint || ''
+    ].join(' ').toLowerCase();
+    const isReturnRelated = text.includes('退货') || text.includes('return');
+    const isListingRelated = text.includes('listing') || text.includes('描述') || text.includes('图片') || text.includes('色差');
+    const isServiceRelated = s.type === 'customer_service' || text.includes('客服') || text.includes('售后');
+    const metric = s.verificationResult?.metric || (isReturnRelated ? '退货率'
+      : isListingRelated ? 'Listing相关差评提及率'
+      : isServiceRelated ? '客服/售后负反馈率'
+      : '差评率');
+    const targetDirection: WorkOrderAcceptanceDirection = metric.includes('好评') || metric.includes('满意') ? 'increase' : 'decrease';
+    return {
+      metric,
+      baselineWindow: '执行前30天',
+      verificationWindow: '执行后7-30天',
+      targetDirection,
+      successCriteria: targetDirection === 'increase' ? `${metric}提升,且无新增高风险VOC/退货信号` : `${metric}下降,且无新增高风险VOC/退货信号`,
+      dataSource: isReturnRelated ? 'ReturnRecord + Product/SKU匹配' : 'SorftimeReviews + ProductMonitoring'
+    };
+  }
+
+  private buildAiPrompt(s: ActionSuggestion, domainContext = '', evidenceContext?: AiEvidenceContext): string {
+    const lines: string[] = [
+      `## 系统背景`,
+      `你正在为一个亚马逊跨境电商 VOC(Voice of Customer)智能分析系统提供决策支持。`,
+      `该系统聚合了用户评论、退货数据、竞品对比、市场趋势等多维度数据,自动生成行动建议。`,
+      `请基于以下行动建议的上下文数据,给出专业、可落地的深度分析。`,
+      ``,
+      `## 建议信息`,
+      `- 标题: ${s.title}`,
+      `- 类型: ${this.getTypeName(s.type)}`,
+      `- 优先级: ${s.priority}`,
+      `- 当前状态: ${this.getStatusName(s.status)}`,
+      `- 负责方: ${s.assignee || '未分配'}`,
+      `- 责任设计师: ${s.designerNames?.join('、') || '未分配'}`,
+      `- 运营负责人: ${s.operatorNames?.join('、') || '未分配'}`,
+      `- 关联SKU: ${[...(s.productSkus || []), ...(s.sellerSkus || [])].slice(0, 6).join('、') || '无'}`,
+      `- 配送方式: ${s.fulfillmentTypes?.join('、') || '无'}`,
+      ``,
+      `## 问题描述`,
+      s.description,
+      ``,
+      `## 根因分析(AI 置信度: ${(s.rootCause.confidence * 100).toFixed(0)}%)`,
+      `- 因素: ${s.rootCause.factor}`,
+      `- 描述: ${s.rootCause.description}`,
+    ];
+    if (s.issueTaxonomy) {
+      lines.push(`- 统一问题归因: ${s.issueTaxonomy.categoryLabel}(${this.getIssueTaxonomyConfidenceLabel(s.issueTaxonomy.confidence)},建议责任方: ${s.issueTaxonomy.ownerHint})`);
+      if (s.issueTaxonomy.matchedKeywords.length) {
+        lines.push(`- 归因关键词: ${s.issueTaxonomy.matchedKeywords.join('、')}`);
+      }
+    }
+    if (s.rootCause.evidence?.length) {
+      lines.push(`- 证据: ${s.rootCause.evidence.join(';')}`);
+    }
+    if (s.rootCause.relatedAttributes && Object.keys(s.rootCause.relatedAttributes).length > 0) {
+      lines.push(`- 关联属性: ${Object.entries(s.rootCause.relatedAttributes).map(([k, v]) => `${k}=${v}`).join(',')}`);
+    }
+    lines.push(``, `## 影响面`);
+    lines.push(`- 受影响用户: ${s.impact.affectedUsers}人`);
+    lines.push(`- 潜在损失: $${s.impact.potentialLoss}`);
+    lines.push(`- 预估成本: $${s.impact.estimatedCost}`);
+    lines.push(`- 预计ROI: $${s.impact.estimatedROI}`);
+    if (s.actionPlan?.length) {
+      lines.push(``, `## 已有行动计划`);
+      s.actionPlan.forEach((step, i) => lines.push(`${i + 1}. ${step}`));
+    }
+    if (s.affectedProducts?.length) {
+      lines.push(``, `## 受影响产品与责任归属`);
+      s.affectedProducts.slice(0, 6).forEach((product, index) => {
+        lines.push(`${index + 1}. ${product.asin} / ${product.productSku || product.sellerSku || '-'},设计师:${product.designerName || '-'},负责人:${product.operatorName || '-'},匹配数:${product.matchCount}`);
+      });
+    }
+    if (s.workOrder?.acceptance) {
+      const acceptance = s.workOrder.acceptance;
+      lines.push(``, `## 工单验收口径`);
+      lines.push(`- 验收指标: ${acceptance.metric}`);
+      lines.push(`- 对比窗口: ${acceptance.baselineWindow} vs ${acceptance.verificationWindow}`);
+      lines.push(`- 达标方向: ${this.getWorkOrderAcceptanceDirectionLabel(acceptance.targetDirection)}`);
+      lines.push(`- 成功标准: ${acceptance.successCriteria}`);
+      lines.push(`- 数据来源: ${acceptance.dataSource}`);
+    }
+    if (s.verificationResult) {
+      lines.push(``, `## 效果验证`);
+      lines.push(`- 指标: ${s.verificationResult.metric}`);
+      lines.push(`- 变化: ${s.verificationResult.before} → ${s.verificationResult.after} (${s.verificationResult.changeRate}%)`);
+    }
+    if (s.dueDate) {
+      lines.push(``, `- 截止日期: ${new Date(s.dueDate).toLocaleDateString('zh-CN')}`);
+    }
+    if (domainContext) {
+      lines.push(``, `## 专业领域参考数据`, domainContext);
+    }
+    if (evidenceContext) {
+      lines.push(``, this.aiEvidenceService.formatPromptSection(evidenceContext));
+    }
+    lines.push(``, `请输出:`);
+    lines.push(`1. **深层根因剖析**:从产品、供应链、运营、市场四个维度分析根本原因`);
+    lines.push(`2. **具体执行方案**:给出可落地的 3-5 个步骤,每个步骤包含负责部门、时间节点、验收标准`);
+    lines.push(`3. **风险评估**:实施过程中可能遇到的风险及应对策略`);
+    lines.push(`4. **预期收益**:量化改进后的业务指标变化预期`);
+    return lines.join('\n');
+  }
+
+  private toDropdownOptions(values: string[]): MsqDropdownOption[] {
+    return Array.from(new Set(values.map(value => value.trim()).filter(Boolean)))
+      .map(value => ({ value, label: value }));
+  }
+
+  private getSuggestionDesignerNames(suggestion: ActionSuggestion): string[] {
+    return Array.from(new Set([
+      ...(suggestion.designerNames || []),
+      ...(suggestion.affectedProducts || []).map(product => product.designerName || '')
+    ].map(value => String(value || '').trim()).filter(Boolean)));
+  }
+}

+ 619 - 0
src/modules/action-suggestion/pages/experience-optimization/experience-optimization.component.ts

@@ -0,0 +1,619 @@
+import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { ActionSuggestionService } from '../../../shared/services/action-suggestion.service';
+import { ReturnDepthAnalysisService } from '../../../shared/services/return-depth-analysis.service';
+import { TodoStatusStore } from '../../../shared/services/todo-status-store.service';
+import { CategoryFilterUtils } from '../../../shared/services/category-filter-utils';
+import { ExperienceProposal, ActionSeverity, SuggestionStatus } from '../../../shared/interfaces/action-suggestion.interface';
+import { FilterPanelComponent } from '../../../shared/components/filter-panel/filter-panel.component';
+import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { CustomerProfile } from '../../../shared/interfaces/return-depth-analysis.interface';
+import { forkJoin } from 'rxjs';
+import { finalize, catchError } from 'rxjs/operators';
+import { of } from 'rxjs';
+
+@Component({
+  selector: 'app-experience-optimization',
+  standalone: true,
+  imports: [CommonModule, FormsModule, FilterPanelComponent, MsqDropdownComponent, PageHeaderComponent, LoadingSpinnerComponent, EmptyStateComponent, ContentCardComponent],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="exp-opt-container">
+      @if (fromTodoId) {
+      <div class="todo-banner">
+        <span class="todo-banner-icon">📋</span>
+        <span>来自待办事项:您有待处理的体验优化提案,请在下方列表中操作</span>
+        <button class="todo-banner-done" (click)="markTodoDone()">全部已处理</button>
+      </div>
+      }
+      <app-page-header
+        title="体验优化提案"
+        eyebrow="Action Suggestion"
+        description="基于客户隐性需求或体验痛点,提出细节优化建议,确保可跟踪、可落地"
+        icon="◇">
+      </app-page-header>
+
+      <app-filter-panel class="exp-filter-panel" title="提案筛选" description="按类别、优先级和来源筛选体验优化提案。">
+        <div class="header-filters">
+          <msq-dropdown [options]="eoCatOpts" placeholder="全部类别" (valueChange)="filterCategory = $event; applyFilter()"></msq-dropdown>
+          <msq-dropdown [options]="eoSevOpts" placeholder="全部优先级" (valueChange)="filterSeverity = $event; applyFilter()"></msq-dropdown>
+          <msq-dropdown [options]="eoSrcOpts" placeholder="全部来源" (valueChange)="filterSource = $event; applyFilter()"></msq-dropdown>
+        </div>
+      </app-filter-panel>
+
+      @if (loading) {
+        <app-loading-spinner text="正在从 VOC 痛点、退货数据、客户画像中聚合体验优化建议..."></app-loading-spinner>
+      } @else {
+        <!-- 决策概览面板 -->
+        <div class="insight-panel">
+          <div class="insight-grid">
+            <!-- 优先级分布 -->
+            <app-content-card class="priority-overview" title="优先级分布" density="compact">
+              <div class="priority-bars">
+                <div class="p-bar-row">
+                  <span class="p-label p0">P0</span>
+                  <div class="p-bar-track"><div class="p-bar-fill critical" [style.width.%]="pctBySeverity('critical')"></div></div>
+                  <span class="p-count">{{ countBySeverity('critical') }}</span>
+                </div>
+                <div class="p-bar-row">
+                  <span class="p-label p1">P1</span>
+                  <div class="p-bar-track"><div class="p-bar-fill high" [style.width.%]="pctBySeverity('high')"></div></div>
+                  <span class="p-count">{{ countBySeverity('high') }}</span>
+                </div>
+                <div class="p-bar-row">
+                  <span class="p-label p2">P2</span>
+                  <div class="p-bar-track"><div class="p-bar-fill medium" [style.width.%]="pctBySeverity('medium')"></div></div>
+                  <span class="p-count">{{ countBySeverity('medium') }}</span>
+                </div>
+                <div class="p-bar-row">
+                  <span class="p-label p3">P3</span>
+                  <div class="p-bar-track"><div class="p-bar-fill low" [style.width.%]="pctBySeverity('low')"></div></div>
+                  <span class="p-count">{{ countBySeverity('low') }}</span>
+                </div>
+              </div>
+              @if (urgentCount > 0) {
+                <div class="urgent-alert">{{ urgentCount }} 条需立即处理</div>
+              }
+            </app-content-card>
+
+            <!-- 类别影响力分布 -->
+            <app-content-card class="category-dist" title="问题类别分布" density="compact">
+              <div class="cat-bars">
+                @for (cat of categoryDistribution; track cat.key) {
+                  <div class="cat-bar-row" (click)="filterCategory = cat.key; applyFilter()">
+                    <span class="cat-name">{{ cat.name }}</span>
+                    <div class="cat-bar-track"><div class="cat-bar-fill" [style.width.%]="cat.pct" [style.background]="cat.color"></div></div>
+                    <span class="cat-count">{{ cat.count }}</span>
+                  </div>
+                }
+              </div>
+            </app-content-card>
+
+            <!-- ROI 概算 -->
+            <app-content-card class="roi-card" title="预期投入产出" density="compact">
+              <div class="roi-metric">
+                <span class="roi-label">可挽回退货损失</span>
+                <span class="roi-val">\${{ totalRecoverableAmount | number:'1.0-0' }}</span>
+              </div>
+              <div class="roi-metric">
+                <span class="roi-label">总提及次数</span>
+                <span class="roi-val">{{ totalMentions | number }}</span>
+              </div>
+              <div class="roi-metric">
+                <span class="roi-label">采纳率</span>
+                <div class="adopt-bar">
+                  <div class="adopt-fill" [style.width.%]="adoptionRate"></div>
+                </div>
+                <span class="roi-val">{{ adoptionRate | number:'1.0-0' }}%</span>
+              </div>
+            </app-content-card>
+
+            <!-- 客户画像洞察 -->
+            <app-content-card class="profile-card" title="高退货客群画像" density="compact">
+              @if (customerProfilePreview.length > 0) {
+                @for (p of customerProfilePreview; track p.label) {
+                  <div class="profile-row">
+                    <span class="profile-label">{{ p.label }}</span>
+                    <div class="profile-rate-bar"><div class="profile-rate-fill" [style.width.%]="p.returnRate"></div></div>
+                    <span class="profile-rate">{{ p.returnRate }}%</span>
+                  </div>
+                }
+              } @else {
+                <app-empty-state title="暂无画像数据" size="sm" icon="👤"></app-empty-state>
+              }
+            </app-content-card>
+          </div>
+        </div>
+
+        <!-- 提案列表 -->
+        <div class="list-header">
+          <h2 class="section-title">提案清单({{ filteredProposals.length }} 条)</h2>
+          <div class="sort-toggle">
+            <button class="sort-btn" [class.active]="sortMode === 'priority'" (click)="sortMode = 'priority'; applyFilter()">按优先级</button>
+            <button class="sort-btn" [class.active]="sortMode === 'impact'" (click)="sortMode = 'impact'; applyFilter()">按影响力</button>
+            <button class="sort-btn" [class.active]="sortMode === 'mentions'" (click)="sortMode = 'mentions'; applyFilter()">按提及量</button>
+          </div>
+        </div>
+
+        <div class="proposal-list">
+          @for (item of filteredProposals; track item.id) {
+            <div class="proposal-card" [class]="'severity-' + item.severity" (click)="toggleExpand(item.id)">
+              <div class="card-top">
+                <div class="card-badges">
+                  <span class="badge priority-badge" [class]="item.priority">{{ item.priority }}</span>
+                  <span class="badge cat-badge">{{ getCategoryName(item.category) }}</span>
+                  <span class="badge source-badge">{{ item.source }}</span>
+                  @if (item.mentionCount > 0) {
+                    <span class="badge mention-badge">{{ item.mentionCount }} 次提及</span>
+                  }
+                  @if (item.returnImpact > 0) {
+                    <span class="badge impact-badge">\${{ item.returnImpact | number:'1.0-0' }} 影响</span>
+                  }
+                  @if (item.evidenceConfidence) {
+                    <span class="badge confidence-badge" [class]="item.evidenceConfidence">
+                      {{ getConfidenceLabel(item.evidenceConfidence) }} {{ item.evidenceConfidenceScore || 0 }}分
+                    </span>
+                  }
+                </div>
+                <span class="status-tag" [class]="item.status">{{ getStatusName(item.status) }}</span>
+              </div>
+
+              <div class="card-body">
+                <div class="five-elements">
+                  <div class="element">
+                    <span class="el-icon">🔍</span>
+                    <span class="el-label">问题描述</span>
+                    <p class="el-val">{{ item.problem }}</p>
+                  </div>
+                  <div class="element">
+                    <span class="el-icon">🛠️</span>
+                    <span class="el-label">建议动作</span>
+                    <p class="el-val">{{ item.action }}</p>
+                  </div>
+                  <div class="element-row">
+                    <div class="element small">
+                      <span class="el-icon">👤</span>
+                      <span class="el-label">责任方</span>
+                      <p class="el-val">{{ item.owner }}</p>
+                    </div>
+                    <div class="element small">
+                      <span class="el-icon">📈</span>
+                      <span class="el-label">预期效果</span>
+                      <p class="el-val">{{ item.expectedEffect }}</p>
+                    </div>
+                    <div class="element small">
+                      <span class="el-icon">📅</span>
+                      <span class="el-label">完成时间</span>
+                      <p class="el-val deadline-val" [class.overdue]="isOverdue(item.deadline)">{{ item.deadline }}</p>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+              @if (expandedId === item.id) {
+                <div class="card-detail">
+                  @if (item.affectedProducts && item.affectedProducts.length > 0) {
+                    <div class="detail-section">
+                      <h4>受影响产品({{ item.affectedProducts.length }})</h4>
+                      <div class="affected-products">
+                        @for (ap of item.affectedProducts; track ap.asin) {
+                          <div class="affected-product-card">
+                            @if (ap.photo) {
+                              <img class="ap-photo" [src]="ap.photo" [alt]="ap.asin" />
+                            } @else {
+                              <div class="ap-photo-placeholder">📦</div>
+                            }
+                            <div class="ap-info">
+                              <div class="ap-asin">{{ ap.asin }}</div>
+                              <div class="ap-title" [title]="ap.title">{{ ap.title }}</div>
+                              <div class="ap-metrics">
+                                <span class="ap-metric">{{ ap.matchCount }} 条匹配</span>
+                                @if (ap.negativeRate != null) {
+                                  <span class="ap-metric neg">差评率 {{ ap.negativeRate }}%</span>
+                                }
+                                @if (ap.currentRank) {
+                                  <span class="ap-metric">BSR #{{ ap.currentRank }}</span>
+                                }
+                              </div>
+                            </div>
+                          </div>
+                        }
+                      </div>
+                    </div>
+                  }
+                  <div class="detail-section">
+                    <h4>证据链</h4>
+                    <div class="evidence-meta">
+                      <span>样本 {{ item.evidenceSampleCount || item.mentionCount || 0 }} 条</span>
+                      <span>ASIN 覆盖 {{ item.evidenceRelatedAsinCount || (item.affectedProducts?.length || 0) }} 个</span>
+                      @if (item.evidenceConfidenceReason) {
+                        <span>{{ item.evidenceConfidenceReason }}</span>
+                      }
+                    </div>
+                    @if (item.sourceKeywords && item.sourceKeywords.length > 0) {
+                      <div class="source-keywords">
+                        @for (kw of item.sourceKeywords; track kw) {
+                          <span class="keyword-tag">{{ kw }}</span>
+                        }
+                      </div>
+                    }
+                    <div class="evidence-tags">
+                      @for (ev of item.evidence; track ev) {
+                        <span class="ev-tag">{{ ev }}</span>
+                      }
+                    </div>
+                  </div>
+                  <div class="detail-row">
+                    <div class="detail-section">
+                      <h4>关联场景</h4>
+                      <span class="scene-tag">{{ item.scenarioName }}</span>
+                    </div>
+                    <div class="detail-section">
+                      <h4>退货影响</h4>
+                      <span class="impact-val">\${{ item.returnImpact | number:'1.0-0' }}</span>
+                    </div>
+                  </div>
+                  <div class="detail-actions">
+                    @if (item.status === 'pending') {
+                      <button class="btn btn-primary" (click)="markStatus(item, 'in_progress'); $event.stopPropagation()">采纳执行</button>
+                      <button class="btn btn-outline" (click)="markStatus(item, 'archived'); $event.stopPropagation()">暂不处理</button>
+                    } @else if (item.status === 'in_progress') {
+                      <button class="btn btn-success" (click)="markStatus(item, 'verifying'); $event.stopPropagation()">标记验证中</button>
+                    } @else if (item.status === 'verifying') {
+                      <button class="btn btn-success" (click)="markStatus(item, 'archived'); $event.stopPropagation()">确认完成</button>
+                    }
+                  </div>
+                </div>
+              }
+            </div>
+          }
+
+          @if (filteredProposals.length === 0) {
+            <app-empty-state title="暂无符合条件的体验优化提案" icon="📝" size="sm"></app-empty-state>
+          }
+        </div>
+      }
+    </div>
+  `,
+  styles: [`
+    .exp-opt-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
+    .exp-filter-panel { display: block; margin-bottom: 24px; }
+    .header-filters { display: flex; gap: 12px; flex-wrap: wrap; }
+    .form-select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; font-size: 13px; min-width: 120px; }
+
+    .loading-state { text-align: center; padding: 80px 0; color: #6b7280; }
+    .spinner { width: 36px; height: 36px; border: 3px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 16px; }
+    @keyframes spin { to { transform: rotate(360deg); } }
+
+    /* 决策概览面板 */
+    .insight-panel { margin-bottom: 24px; }
+    .insight-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
+
+    .priority-bars { display: flex; flex-direction: column; gap: 8px; }
+    .p-bar-row { display: flex; align-items: center; gap: 8px; }
+    .p-label { font-size: 11px; font-weight: 700; width: 22px; text-align: center; border-radius: 4px; padding: 1px 0; color: #fff; }
+    .p-label.p0 { background: #ef4444; }
+    .p-label.p1 { background: #f59e0b; }
+    .p-label.p2 { background: #3b82f6; }
+    .p-label.p3 { background: #10b981; }
+    .p-bar-track { flex: 1; height: 8px; background: #f3f4f6; border-radius: 4px; overflow: hidden; }
+    .p-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s; }
+    .p-bar-fill.critical { background: #ef4444; }
+    .p-bar-fill.high { background: #f59e0b; }
+    .p-bar-fill.medium { background: #3b82f6; }
+    .p-bar-fill.low { background: #10b981; }
+    .p-count { font-size: 12px; font-weight: 600; color: #374151; width: 20px; text-align: right; }
+    .urgent-alert { margin-top: 10px; padding: 6px 10px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; font-size: 11px; color: #dc2626; font-weight: 600; text-align: center; }
+
+    .cat-bars { display: flex; flex-direction: column; gap: 6px; }
+    .cat-bar-row { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 2px 0; border-radius: 4px; transition: background 0.2s; }
+    .cat-bar-row:hover { background: #f9fafb; }
+    .cat-name { font-size: 12px; color: #374151; width: 36px; flex-shrink: 0; }
+    .cat-bar-track { flex: 1; height: 8px; background: #f3f4f6; border-radius: 4px; overflow: hidden; }
+    .cat-bar-fill { height: 100%; border-radius: 4px; transition: width 0.4s; }
+    .cat-count { font-size: 12px; font-weight: 600; color: #374151; width: 20px; text-align: right; }
+
+    .roi-card .roi-metric { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
+    .roi-label { font-size: 12px; color: #6b7280; flex: 1; }
+    .roi-val { font-size: 14px; font-weight: 700; color: #111827; }
+    .adopt-bar { flex: 1; height: 6px; background: #f3f4f6; border-radius: 3px; overflow: hidden; }
+    .adopt-fill { height: 100%; background: #6366f1; border-radius: 3px; transition: width 0.4s; }
+
+    .profile-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
+    .profile-label { font-size: 11px; color: #374151; width: 80px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .profile-rate-bar { flex: 1; height: 6px; background: #f3f4f6; border-radius: 3px; overflow: hidden; }
+    .profile-rate-fill { height: 100%; background: #f59e0b; border-radius: 3px; }
+    .profile-rate { font-size: 11px; font-weight: 600; color: #92400e; width: 32px; text-align: right; }
+
+    /* 列表头 */
+    .list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
+    .section-title { font-size: 16px; font-weight: 700; color: #111827; margin: 0; }
+    .sort-toggle { display: flex; gap: 4px; background: #f3f4f6; border-radius: 8px; padding: 2px; }
+    .sort-btn { padding: 5px 12px; font-size: 12px; border: none; background: transparent; border-radius: 6px; cursor: pointer; color: #6b7280; font-weight: 500; }
+    .sort-btn.active { background: #fff; color: #111827; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.08); }
+
+    .proposal-list { display: flex; flex-direction: column; gap: 16px; }
+    .proposal-card { background: #fff; border-radius: 12px; padding: 20px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); cursor: pointer; transition: box-shadow 0.2s, border-color 0.2s; border-left: 4px solid #e5e7eb; }
+    .proposal-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
+    .proposal-card.severity-critical { border-left-color: #ef4444; }
+    .proposal-card.severity-high { border-left-color: #f59e0b; }
+    .proposal-card.severity-medium { border-left-color: #3b82f6; }
+    .proposal-card.severity-low { border-left-color: #10b981; }
+
+    .card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
+    .card-badges { display: flex; gap: 8px; flex-wrap: wrap; }
+    .badge { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
+    .priority-badge { color: #fff; }
+    .priority-badge.P0 { background: #ef4444; }
+    .priority-badge.P1 { background: #f59e0b; }
+    .priority-badge.P2 { background: #3b82f6; }
+    .priority-badge.P3 { background: #10b981; }
+    .cat-badge { background: #f3f4f6; color: #374151; }
+    .source-badge { background: #ede9fe; color: #6366f1; }
+    .mention-badge { background: #fef3c7; color: #92400e; }
+    .impact-badge { background: #dcfce7; color: #166534; }
+    .confidence-badge.high { background: #dcfce7; color: #166534; }
+    .confidence-badge.medium { background: #fef3c7; color: #92400e; }
+    .confidence-badge.low { background: #fee2e2; color: #991b1b; }
+
+    .status-tag { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
+    .status-tag.pending { background: #fef3c7; color: #92400e; }
+    .status-tag.in_progress { background: #dbeafe; color: #1d4ed8; }
+    .status-tag.verifying { background: #d1fae5; color: #065f46; }
+    .status-tag.archived { background: #f3f4f6; color: #6b7280; }
+
+    .five-elements { display: flex; flex-direction: column; gap: 12px; }
+    .element { display: flex; flex-direction: column; gap: 2px; }
+    .element-row { display: flex; gap: 16px; }
+    .element.small { flex: 1; }
+    .el-icon { font-size: 14px; }
+    .el-label { font-size: 11px; color: #9ca3af; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
+    .el-val { font-size: 13px; color: #374151; margin: 0; line-height: 1.5; }
+    .deadline-val.overdue { color: #dc2626; font-weight: 600; }
+
+    .card-detail { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f3f4f6; }
+    .detail-section { margin-bottom: 12px; }
+    .detail-section h4 { font-size: 12px; color: #6b7280; margin: 0 0 6px; }
+    .detail-row { display: flex; gap: 24px; }
+    .evidence-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
+    .evidence-meta span { padding: 2px 8px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px; font-size: 12px; color: #0369a1; }
+    .source-keywords { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
+    .keyword-tag { padding: 2px 8px; background: #f5f3ff; border: 1px solid #ddd6fe; border-radius: 6px; font-size: 12px; color: #5b21b6; }
+    .evidence-tags { display: flex; gap: 6px; flex-wrap: wrap; }
+    .ev-tag { padding: 2px 8px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 12px; color: #4b5563; }
+    .scene-tag { padding: 2px 8px; background: #ede9fe; border-radius: 6px; font-size: 12px; color: #6366f1; }
+    .impact-val { font-size: 16px; font-weight: 700; color: #059669; }
+
+    .affected-products { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 6px; }
+    .affected-product-card { display: flex; gap: 10px; padding: 10px 14px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; min-width: 220px; max-width: 300px; flex: 1; }
+    .ap-photo { width: 48px; height: 48px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
+    .ap-photo-placeholder { width: 48px; height: 48px; border-radius: 6px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
+    .ap-info { flex: 1; min-width: 0; }
+    .ap-asin { font-size: 11px; font-weight: 700; color: #6366f1; font-family: monospace; }
+    .ap-title { font-size: 12px; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 2px 0; }
+    .ap-metrics { display: flex; gap: 8px; flex-wrap: wrap; }
+    .ap-metric { font-size: 11px; color: #6b7280; }
+    .ap-metric.neg { color: #dc2626; font-weight: 600; }
+
+    .detail-actions { display: flex; gap: 12px; margin-top: 12px; }
+    .btn { padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
+    .btn-primary { background: #6366f1; color: #fff; }
+    .btn-primary:hover { background: #4f46e5; }
+    .btn-success { background: #059669; color: #fff; }
+    .btn-success:hover { background: #047857; }
+    .btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
+    .btn-outline:hover { background: #f9fafb; }
+
+    .empty-state { text-align: center; padding: 60px 0; color: #9ca3af; }
+
+    @media (max-width: 1024px) {
+      .insight-grid { grid-template-columns: repeat(2, 1fr); }
+    }
+    @media (max-width: 640px) {
+      .insight-grid { grid-template-columns: 1fr; }
+    }
+  `]
+})
+export class ExperienceOptimizationComponent implements OnInit {
+  loading = false;
+  proposals: ExperienceProposal[] = [];
+  filteredProposals: ExperienceProposal[] = [];
+  customerProfiles: CustomerProfile[] = [];
+  customerProfilePreview: CustomerProfile[] = [];
+  expandedId: string | null = null;
+  filterCategory = '';
+  filterSeverity = '';
+  filterSource = '';
+  sortMode: 'priority' | 'impact' | 'mentions' = 'priority';
+  urgentCount = 0;
+  categoryDistribution: { key: string; name: string; color: string; count: number; pct: number }[] = [];
+  totalRecoverableAmount = 0;
+  totalMentions = 0;
+  adoptionRate = 0;
+  private severityCounts: Record<ActionSeverity, number> = { critical: 0, high: 0, medium: 0, low: 0 };
+  private severityPcts: Record<ActionSeverity, number> = { critical: 0, high: 0, medium: 0, low: 0 };
+
+  private readonly baseEoCatOpts: MsqDropdownOption[] = [
+    { value: 'quality', label: '质量问题' },
+    { value: 'sizing', label: '尺码问题' },
+    { value: 'logistics', label: '物流问题' },
+    { value: 'packaging', label: '包装问题' },
+    { value: 'process', label: '流程问题' },
+    { value: 'after_sale', label: '售后问题' }
+  ];
+  eoCatOpts: MsqDropdownOption[] = [...this.baseEoCatOpts];
+  readonly eoSevOpts: MsqDropdownOption[] = [
+    { value: 'critical', label: 'P0 紧急' },
+    { value: 'high', label: 'P1 重要' },
+    { value: 'medium', label: 'P2 一般' },
+    { value: 'low', label: 'P3 低' }
+  ];
+  readonly eoSrcOpts: MsqDropdownOption[] = [
+    { value: 'VOC痛点分析', label: 'VOC痛点分析' },
+    { value: '退货分析', label: '退货分析' },
+    { value: '异常检测', label: '异常检测' }
+  ];
+
+  private readonly categoryMeta: Record<string, { name: string; color: string }> = {
+    quality: { name: '质量', color: '#ef4444' },
+    sizing: { name: '尺码', color: '#f59e0b' },
+    logistics: { name: '物流', color: '#3b82f6' },
+    packaging: { name: '包装', color: '#8b5cf6' },
+    process: { name: '流程', color: '#06b6d4' },
+    after_sale: { name: '售后', color: '#10b981' }
+  };
+
+  private refreshProposalDerivations(): void {
+    const known = new Map(this.baseEoCatOpts.map(option => [option.value, option]));
+    CategoryFilterUtils.buildOptionsFromItems(this.proposals).forEach(option => {
+      if (!known.has(option.value)) known.set(option.value, option as MsqDropdownOption);
+    });
+    const severityCounts: Record<ActionSeverity, number> = { critical: 0, high: 0, medium: 0, low: 0 };
+    const categoryCounts: Record<string, number> = {};
+    let urgentCount = 0;
+    let totalRecoverableAmount = 0;
+    let totalMentions = 0;
+    let adopted = 0;
+
+    for (const proposal of this.proposals) {
+      severityCounts[proposal.severity] = (severityCounts[proposal.severity] || 0) + 1;
+      categoryCounts[proposal.category] = (categoryCounts[proposal.category] || 0) + 1;
+      if ((proposal.severity === 'critical' || proposal.severity === 'high') && proposal.status === 'pending') {
+        urgentCount++;
+      }
+      if (proposal.status !== 'pending') {
+        adopted++;
+      }
+      totalRecoverableAmount += proposal.returnImpact || 0;
+      totalMentions += proposal.mentionCount || 0;
+    }
+
+    const total = this.proposals.length || 1;
+    this.eoCatOpts = [...known.values()];
+    this.severityCounts = severityCounts;
+    this.severityPcts = {
+      critical: (severityCounts.critical / total) * 100,
+      high: (severityCounts.high / total) * 100,
+      medium: (severityCounts.medium / total) * 100,
+      low: (severityCounts.low / total) * 100
+    };
+    this.urgentCount = urgentCount;
+    this.totalRecoverableAmount = totalRecoverableAmount;
+    this.totalMentions = totalMentions;
+    this.adoptionRate = this.proposals.length ? (adopted / this.proposals.length) * 100 : 0;
+    this.categoryDistribution = Object.entries(categoryCounts)
+      .map(([key, count]) => ({
+        key,
+        name: this.categoryMeta[key]?.name || key,
+        color: this.categoryMeta[key]?.color || '#6b7280',
+        count,
+        pct: (count / total) * 100
+      }))
+      .sort((a, b) => b.count - a.count);
+  }
+
+  fromTodoId: string | null = null;
+
+  constructor(
+    private service: ActionSuggestionService,
+    private returnAnalysis: ReturnDepthAnalysisService,
+    private cdr: ChangeDetectorRef,
+    private route: ActivatedRoute,
+    private todoStatusStore: TodoStatusStore
+  ) {}
+
+  ngOnInit(): void {
+    this.fromTodoId = this.route.snapshot.queryParamMap.get('fromTodo');
+    this.loadData();
+  }
+
+  loadData(): void {
+    this.loading = true;
+    forkJoin({
+      proposals: this.service.generateExperienceProposals(),
+      profiles: this.returnAnalysis.getCustomerProfiles().pipe(catchError(() => of([])))
+    }).pipe(
+      finalize(() => { this.loading = false; this.cdr.markForCheck(); })
+    ).subscribe(({ proposals, profiles }) => {
+      this.proposals = proposals;
+      this.customerProfiles = profiles;
+      this.customerProfilePreview = profiles.slice(0, 3);
+      this.refreshProposalDerivations();
+      this.applyFilter();
+    });
+  }
+
+  applyFilter(): void {
+    let list = [...this.proposals];
+    if (this.filterCategory) list = list.filter(p => CategoryFilterUtils.matchesCategory(p, this.filterCategory));
+    if (this.filterSeverity) list = list.filter(p => p.severity === this.filterSeverity);
+    if (this.filterSource) list = list.filter(p => p.source === this.filterSource);
+
+    const priorityOrder: Record<string, number> = { P0: 0, P1: 1, P2: 2, P3: 3 };
+    if (this.sortMode === 'priority') {
+      list.sort((a, b) => (priorityOrder[a.priority] ?? 3) - (priorityOrder[b.priority] ?? 3));
+    } else if (this.sortMode === 'impact') {
+      list.sort((a, b) => (b.returnImpact || 0) - (a.returnImpact || 0));
+    } else if (this.sortMode === 'mentions') {
+      list.sort((a, b) => (b.mentionCount || 0) - (a.mentionCount || 0));
+    }
+
+    this.filteredProposals = list;
+    this.cdr.markForCheck();
+  }
+
+  countBySeverity(sev: ActionSeverity): number {
+    return this.severityCounts[sev] || 0;
+  }
+
+  pctBySeverity(sev: ActionSeverity): number {
+    return this.severityPcts[sev] || 0;
+  }
+
+  isOverdue(deadline: string): boolean {
+    return new Date(deadline) < new Date();
+  }
+
+  toggleExpand(id: string): void {
+    this.expandedId = this.expandedId === id ? null : id;
+  }
+
+  markStatus(item: ExperienceProposal, status: SuggestionStatus): void {
+    item.status = status;
+    this.refreshProposalDerivations();
+    const todoStatus = status === 'verifying' || status === 'archived' ? 'done' as const
+      : status === 'in_progress' ? 'in_progress' as const
+      : 'pending' as const;
+    this.todoStatusStore.setStatus(`action-${item.id}`, todoStatus);
+    this.cdr.markForCheck();
+  }
+
+  markTodoDone(): void {
+    if (this.fromTodoId) {
+      this.todoStatusStore.markDone(this.fromTodoId);
+      this.fromTodoId = null;
+      this.cdr.markForCheck();
+    }
+  }
+
+  getCategoryName(cat: string): string {
+    return this.categoryMeta[cat]?.name || cat;
+  }
+
+  getStatusName(status: string): string {
+    const map: Record<string, string> = { pending: '待处理', in_progress: '执行中', verifying: '验证中', archived: '已归档', analyzing: '分析中', rejected: '已拒绝' };
+    return map[status] || status;
+  }
+
+  getConfidenceLabel(confidence: 'high' | 'medium' | 'low' | undefined): string {
+    if (!confidence) return '-';
+    const map: Record<'high' | 'medium' | 'low', string> = { high: '高可信', medium: '中可信', low: '低可信' };
+    return map[confidence] || confidence;
+  }
+}

+ 502 - 0
src/modules/action-suggestion/pages/product-iteration/product-iteration.component.ts

@@ -0,0 +1,502 @@
+import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { ActionSuggestionService } from '../../../shared/services/action-suggestion.service';
+import { TodoStatusStore } from '../../../shared/services/todo-status-store.service';
+import { IterationDirective, ActionSeverity, SuggestionStatus } from '../../../shared/interfaces/action-suggestion.interface';
+import { FilterPanelComponent } from '../../../shared/components/filter-panel/filter-panel.component';
+import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
+import { finalize } from 'rxjs/operators';
+
+@Component({
+  selector: 'app-product-iteration',
+  standalone: true,
+  imports: [CommonModule, FormsModule, FilterPanelComponent, MsqDropdownComponent, PageHeaderComponent, SummaryMetricCardComponent, LoadingSpinnerComponent, EmptyStateComponent],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="iter-container">
+      @if (fromTodoId) {
+      <div class="todo-banner">
+        <span class="todo-banner-icon">📋</span>
+        <span>来自待办事项:您有待处理的迭代建议,请在下方列表中操作</span>
+        <button class="todo-banner-done" (click)="markTodoDone()">全部已处理</button>
+      </div>
+      }
+      <app-page-header
+        title="产品迭代方向"
+        eyebrow="Action Suggestion"
+        description="从客户反馈中提炼功能、设计、选品需求,指导产品团队下一步迭代方向"
+        icon="▲">
+      </app-page-header>
+
+      @if (!loading) {
+        <div class="stats-row">
+          <app-summary-metric-card label="分析功能数" [value]="directives.length" tone="blue" icon="◈"></app-summary-metric-card>
+          <app-summary-metric-card label="高缺口(>10%)" [value]="criticalGapCount" [tone]="criticalGapCount > 0 ? 'red' : 'green'" icon="!"></app-summary-metric-card>
+          <app-summary-metric-card label="平均缺口" [value]="(avgGap | number:'1.1-1') + '%'" [tone]="avgGap > 10 ? 'amber' : 'green'" icon="△"></app-summary-metric-card>
+          <app-summary-metric-card label="低于竞品" [value]="belowCompetitorCount" [tone]="belowCompetitorCount > 0 ? 'amber' : 'green'" icon="⬇"></app-summary-metric-card>
+          <app-summary-metric-card label="满意度下降" [value]="decliningCount" [tone]="decliningCount > 0 ? 'red' : 'green'" icon="↘"></app-summary-metric-card>
+        </div>
+      }
+
+      <app-filter-panel class="iter-filter-panel" title="迭代筛选" description="按优先级和满意度趋势筛选迭代建议。">
+        <div class="header-filters">
+          <msq-dropdown [options]="piSevOpts" placeholder="全部优先级" (valueChange)="filterSeverity = $event; applyFilter()"></msq-dropdown>
+          <msq-dropdown [options]="piTrendOpts" placeholder="全部趋势" (valueChange)="filterTrend = $event; applyFilter()"></msq-dropdown>
+        </div>
+      </app-filter-panel>
+
+      @if (loading) {
+        <app-loading-spinner text="正在从功能满足度、竞品对比中生成迭代建议..."></app-loading-spinner>
+      } @else {
+        <!-- 缺口矩阵概览 -->
+        <div class="gap-matrix-section">
+          <div class="matrix-header">
+            <div>
+              <h2 class="section-title">功能缺口矩阵</h2>
+              <p class="section-desc">左上象限 = 高重要度、低满意度 → 优先迭代区域</p>
+            </div>
+            <div class="matrix-legend">
+              <span class="legend-dot" style="background:#ef4444"></span><span class="legend-text">P0</span>
+              <span class="legend-dot" style="background:#f59e0b"></span><span class="legend-text">P1</span>
+              <span class="legend-dot" style="background:#3b82f6"></span><span class="legend-text">P2</span>
+              <span class="legend-dot" style="background:#10b981"></span><span class="legend-text">P3</span>
+            </div>
+          </div>
+          <div class="matrix-grid">
+            <!-- 网格线 -->
+            <div class="grid-line h-line" style="bottom:25%"></div>
+            <div class="grid-line h-line" style="bottom:50%"></div>
+            <div class="grid-line h-line" style="bottom:75%"></div>
+            <div class="grid-line v-line" style="left:25%"></div>
+            <div class="grid-line v-line" style="left:50%"></div>
+            <div class="grid-line v-line" style="left:75%"></div>
+            <!-- 50%十字线 -->
+            <div class="crosshair h-cross" style="bottom:50%"></div>
+            <div class="crosshair v-cross" style="left:50%"></div>
+            <!-- 象限标签 -->
+            <div class="matrix-quadrant q1">优先迭代</div>
+            <div class="matrix-quadrant q2">保持关注</div>
+            <div class="matrix-quadrant q3">锦上添花</div>
+            <div class="matrix-quadrant q4">核心优势</div>
+            <!-- 数据点 -->
+            @for (item of filteredDirectives; track item.id) {
+              <div class="matrix-dot" [class]="'sev-' + item.severity"
+                   [style.left.%]="item.satisfactionRate"
+                   [style.bottom.%]="item.importanceScore"
+                   (mouseenter)="hoveredDot = item.id"
+                   (mouseleave)="hoveredDot = null"
+                   (click)="scrollToCard(item.id); $event.stopPropagation()">
+                @if (hoveredDot === item.id) {
+                  <div class="dot-tooltip">
+                    <strong>{{ item.featureName }}</strong><br/>
+                    满意度 {{ item.satisfactionRate }}% · 重要度 {{ item.importanceScore }}%<br/>
+                    缺口 {{ item.gap }}%
+                  </div>
+                }
+              </div>
+            }
+            <!-- 轴标签 -->
+            <div class="axis-label x-axis">满意度 →</div>
+            <div class="axis-label y-axis">重要度 →</div>
+            <div class="axis-tick x-tick" style="left:0">0</div>
+            <div class="axis-tick x-tick" style="left:50%">50</div>
+            <div class="axis-tick x-tick" style="left:100%">100</div>
+            <div class="axis-tick y-tick" style="bottom:0">0</div>
+            <div class="axis-tick y-tick" style="bottom:50%">50</div>
+            <div class="axis-tick y-tick" style="bottom:100%">100</div>
+          </div>
+        </div>
+
+        <!-- 迭代建议清单 -->
+        <div class="list-header">
+          <h2 class="section-title">迭代建议清单({{ filteredDirectives.length }} 条,按缺口降序)</h2>
+        </div>
+        <div class="directive-list">
+          @for (item of filteredDirectives; track item.id) {
+            <div class="directive-card" [class]="'severity-' + item.severity" [id]="'card-' + item.id" (click)="toggleExpand(item.id)">
+              <div class="card-header">
+                <div class="header-main">
+                  <span class="badge priority-badge" [class]="item.priority">{{ item.priority }}</span>
+                  <span class="feature-name">{{ item.featureName }}</span>
+                  <span class="gap-badge" [class.critical]="item.gap > 15">缺口 {{ item.gap }}%</span>
+                  @if (item.trendDirection === 'down') {
+                    <span class="trend-badge down">↓ 下降</span>
+                  } @else if (item.trendDirection === 'up') {
+                    <span class="trend-badge up">↑ 上升</span>
+                  } @else {
+                    <span class="trend-badge stable">→ 持平</span>
+                  }
+                  @if (item.satisfactionRate < (item.competitorAvg * 20)) {
+                    <span class="comp-warn-badge">低于竞品</span>
+                  }
+                  @if (item.evidenceConfidence) {
+                    <span class="confidence-badge" [class]="item.evidenceConfidence">
+                      {{ getConfidenceLabel(item.evidenceConfidence) }} {{ item.evidenceConfidenceScore || 0 }}分
+                    </span>
+                  }
+                </div>
+                <span class="status-tag" [class]="item.status">{{ getStatusName(item.status) }}</span>
+              </div>
+
+              <div class="card-metrics">
+                <div class="metric">
+                  <span class="metric-label">满意度</span>
+                  <div class="metric-bar-wrap">
+                    <div class="metric-bar sat" [style.width.%]="item.satisfactionRate"></div>
+                  </div>
+                  <span class="metric-val">{{ item.satisfactionRate }}%</span>
+                </div>
+                <div class="metric">
+                  <span class="metric-label">重要度</span>
+                  <div class="metric-bar-wrap">
+                    <div class="metric-bar imp" [style.width.%]="item.importanceScore"></div>
+                  </div>
+                  <span class="metric-val">{{ item.importanceScore }}%</span>
+                </div>
+                <div class="metric comp-metric">
+                  <span class="metric-label">竞品</span>
+                  <span class="comp-stars">{{ item.competitorAvg }} ★</span>
+                </div>
+              </div>
+
+              <div class="five-elements">
+                <div class="element">
+                  <span class="el-label">🔍 问题描述</span>
+                  <p class="el-val">{{ item.problem }}</p>
+                </div>
+                <div class="element">
+                  <span class="el-label">🛠️ 建议动作</span>
+                  <p class="el-val">{{ item.action }}</p>
+                </div>
+                <div class="element-row">
+                  <div class="element small">
+                    <span class="el-label">👤 责任方</span>
+                    <p class="el-val">{{ item.owner }}</p>
+                  </div>
+                  <div class="element small">
+                    <span class="el-label">📈 预期效果</span>
+                    <p class="el-val">{{ item.expectedEffect }}</p>
+                  </div>
+                  <div class="element small">
+                    <span class="el-label">📅 完成时间</span>
+                    <p class="el-val">{{ item.deadline }}</p>
+                  </div>
+                </div>
+              </div>
+
+              @if (expandedId === item.id) {
+                <div class="card-detail">
+                  <div class="detail-row">
+                    <div class="detail-section">
+                      <h4>数据来源</h4>
+                      <span class="source-tag">{{ item.source }}</span>
+                    </div>
+                    <div class="detail-section">
+                      <h4>竞品均分</h4>
+                      <span class="comp-val">{{ item.competitorAvg }} 星</span>
+                    </div>
+                  </div>
+                  <div class="detail-section">
+                    <h4>证据链</h4>
+                    <div class="evidence-meta">
+                      <span>样本 {{ item.evidenceSampleCount || 0 }} 条</span>
+                      <span>ASIN 覆盖 {{ item.evidenceRelatedAsinCount || 0 }} 个</span>
+                      @if (item.evidenceConfidenceReason) {
+                        <span>{{ item.evidenceConfidenceReason }}</span>
+                      }
+                    </div>
+                    @if (item.sourceKeywords && item.sourceKeywords.length > 0) {
+                      <div class="source-keywords">
+                        @for (kw of item.sourceKeywords; track kw) {
+                          <span class="keyword-tag">{{ kw }}</span>
+                        }
+                      </div>
+                    }
+                    <div class="evidence-tags">
+                      @for (ev of item.evidence; track ev) {
+                        <span class="ev-tag">{{ ev }}</span>
+                      }
+                    </div>
+                  </div>
+                  <div class="detail-actions">
+                    @if (item.status === 'pending') {
+                      <button class="btn btn-primary" (click)="markStatus(item, 'in_progress'); $event.stopPropagation()">纳入迭代</button>
+                      <button class="btn btn-outline" (click)="markStatus(item, 'archived'); $event.stopPropagation()">暂缓</button>
+                    } @else if (item.status === 'in_progress') {
+                      <button class="btn btn-success" (click)="markStatus(item, 'verifying'); $event.stopPropagation()">标记验证</button>
+                    }
+                  </div>
+                </div>
+              }
+            </div>
+          }
+
+          @if (filteredDirectives.length === 0) {
+            <app-empty-state title="暂无迭代建议" icon="🔧" size="sm"></app-empty-state>
+          }
+        </div>
+      }
+    </div>
+  `,
+  styles: [`
+    .iter-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
+    .iter-filter-panel { display: block; margin-bottom: 24px; }
+    .header-filters { display: flex; gap: 12px; }
+    .form-select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; font-size: 13px; }
+
+    .loading-state { text-align: center; padding: 80px 0; color: #6b7280; }
+    .spinner { width: 36px; height: 36px; border: 3px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 16px; }
+    @keyframes spin { to { transform: rotate(360deg); } }
+
+    /* 汇总统计 */
+    .stats-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
+
+    .section-title { font-size: 16px; font-weight: 700; color: #111827; margin: 0 0 4px; }
+    .section-desc { font-size: 12px; color: #9ca3af; margin: 0; }
+
+    /* 缺口矩阵 */
+    .gap-matrix-section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
+    .matrix-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; }
+    .matrix-legend { display: flex; align-items: center; gap: 6px; }
+    .legend-dot { width: 10px; height: 10px; border-radius: 50%; margin-left: 8px; }
+    .legend-text { font-size: 11px; color: #6b7280; }
+
+    .matrix-grid { position: relative; width: 100%; height: 320px; background: #fafbfc; border-radius: 8px; border: 1px solid #e5e7eb; overflow: visible; padding: 20px 20px 28px 32px; box-sizing: border-box; }
+    .grid-line { position: absolute; z-index: 0; }
+    .h-line { left: 32px; right: 20px; height: 1px; background: #f3f4f6; }
+    .v-line { top: 20px; bottom: 28px; width: 1px; background: #f3f4f6; }
+    .crosshair { position: absolute; z-index: 1; }
+    .h-cross { left: 32px; right: 20px; height: 1px; background: #d1d5db; border-top: 1px dashed #d1d5db; }
+    .v-cross { top: 20px; bottom: 28px; width: 1px; background: #d1d5db; border-left: 1px dashed #d1d5db; }
+
+    .matrix-quadrant { position: absolute; font-size: 11px; font-weight: 600; z-index: 1; opacity: 0.6; }
+    .q1 { top: 28px; left: 40px; color: #ef4444; }
+    .q2 { top: 28px; right: 28px; color: #10b981; }
+    .q3 { bottom: 36px; left: 40px; color: #9ca3af; }
+    .q4 { bottom: 36px; right: 28px; color: #f59e0b; }
+
+    .matrix-dot { position: absolute; width: 16px; height: 16px; border-radius: 50%; transform: translate(-50%, 50%); z-index: 3; cursor: pointer; transition: transform 0.2s; border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
+    .matrix-dot:hover { transform: translate(-50%, 50%) scale(1.4); z-index: 10; }
+    .matrix-dot.sev-critical { background: #ef4444; }
+    .matrix-dot.sev-high { background: #f59e0b; }
+    .matrix-dot.sev-medium { background: #3b82f6; }
+    .matrix-dot.sev-low { background: #10b981; }
+    .dot-tooltip { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); background: #111827; color: #fff; padding: 8px 12px; border-radius: 8px; font-size: 11px; white-space: nowrap; line-height: 1.5; z-index: 20; pointer-events: none; }
+
+    .axis-label { position: absolute; font-size: 10px; color: #9ca3af; font-weight: 600; }
+    .x-axis { bottom: 4px; right: 20px; }
+    .y-axis { top: 4px; left: 4px; }
+    .axis-tick { position: absolute; font-size: 9px; color: #d1d5db; }
+    .x-tick { bottom: 4px; transform: translateX(-50%); }
+    .y-tick { left: 4px; transform: translateY(50%); }
+
+    /* 列表头 */
+    .list-header { margin-bottom: 16px; }
+
+    /* 迭代清单 */
+    .directive-list { display: flex; flex-direction: column; gap: 16px; }
+    .directive-card { background: #fff; border-radius: 12px; padding: 20px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); cursor: pointer; border-left: 4px solid #e5e7eb; transition: box-shadow 0.2s; }
+    .directive-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
+    .directive-card.severity-critical { border-left-color: #ef4444; }
+    .directive-card.severity-high { border-left-color: #f59e0b; }
+    .directive-card.severity-medium { border-left-color: #3b82f6; }
+    .directive-card.severity-low { border-left-color: #10b981; }
+
+    .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
+    .header-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+    .feature-name { font-size: 15px; font-weight: 700; color: #111827; }
+    .gap-badge { padding: 2px 8px; background: #fef3c7; color: #92400e; border-radius: 8px; font-size: 11px; font-weight: 600; }
+    .gap-badge.critical { background: #fee2e2; color: #991b1b; }
+    .trend-badge { padding: 2px 8px; border-radius: 8px; font-size: 11px; font-weight: 600; }
+    .trend-badge.down { background: #fee2e2; color: #991b1b; }
+    .trend-badge.up { background: #d1fae5; color: #065f46; }
+    .trend-badge.stable { background: #f3f4f6; color: #6b7280; }
+    .comp-warn-badge { padding: 2px 8px; background: #fce7f3; color: #be185d; border-radius: 8px; font-size: 11px; font-weight: 600; }
+    .confidence-badge { padding: 2px 8px; border-radius: 8px; font-size: 11px; font-weight: 600; }
+    .confidence-badge.high { background: #dcfce7; color: #166534; }
+    .confidence-badge.medium { background: #fef3c7; color: #92400e; }
+    .confidence-badge.low { background: #fee2e2; color: #991b1b; }
+
+    .badge { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
+    .priority-badge { color: #fff; }
+    .priority-badge.P0 { background: #ef4444; }
+    .priority-badge.P1 { background: #f59e0b; }
+    .priority-badge.P2 { background: #3b82f6; }
+    .priority-badge.P3 { background: #10b981; }
+
+    .status-tag { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
+    .status-tag.pending { background: #fef3c7; color: #92400e; }
+    .status-tag.in_progress { background: #dbeafe; color: #1d4ed8; }
+    .status-tag.verifying { background: #d1fae5; color: #065f46; }
+    .status-tag.archived { background: #f3f4f6; color: #6b7280; }
+
+    .card-metrics { display: flex; gap: 20px; margin-bottom: 14px; }
+    .metric { display: flex; align-items: center; gap: 8px; flex: 1; }
+    .comp-metric { flex: 0; min-width: 80px; }
+    .metric-label { font-size: 11px; color: #9ca3af; width: 42px; flex-shrink: 0; }
+    .metric-bar-wrap { flex: 1; height: 8px; background: #f3f4f6; border-radius: 4px; overflow: hidden; }
+    .metric-bar { height: 100%; border-radius: 4px; transition: width 0.4s; }
+    .metric-bar.sat { background: #6366f1; }
+    .metric-bar.imp { background: #f59e0b; }
+    .metric-val { font-size: 12px; font-weight: 600; color: #374151; width: 36px; text-align: right; }
+    .comp-stars { font-size: 13px; font-weight: 700; color: #f59e0b; }
+
+    .five-elements { display: flex; flex-direction: column; gap: 10px; }
+    .element-row { display: flex; gap: 16px; }
+    .element.small { flex: 1; }
+    .el-label { font-size: 11px; color: #9ca3af; font-weight: 600; }
+    .el-val { font-size: 13px; color: #374151; margin: 2px 0 0; line-height: 1.5; }
+
+    .card-detail { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f3f4f6; }
+    .detail-section { margin-bottom: 10px; }
+    .detail-section h4 { font-size: 12px; color: #6b7280; margin: 0 0 4px; }
+    .detail-row { display: flex; gap: 24px; }
+    .evidence-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
+    .evidence-meta span { padding: 2px 8px; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 6px; font-size: 12px; color: #0369a1; }
+    .source-keywords { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
+    .keyword-tag { padding: 2px 8px; background: #f5f3ff; border: 1px solid #ddd6fe; border-radius: 6px; font-size: 12px; color: #5b21b6; }
+    .evidence-tags { display: flex; gap: 6px; flex-wrap: wrap; }
+    .ev-tag { padding: 2px 8px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 12px; color: #4b5563; }
+    .source-tag { padding: 2px 8px; background: #ede9fe; border-radius: 6px; font-size: 12px; color: #6366f1; }
+    .comp-val { font-size: 14px; font-weight: 700; color: #f59e0b; }
+
+    .detail-actions { display: flex; gap: 12px; margin-top: 12px; }
+    .btn { padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
+    .btn-primary { background: #6366f1; color: #fff; }
+    .btn-primary:hover { background: #4f46e5; }
+    .btn-success { background: #059669; color: #fff; }
+    .btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
+
+    .empty-state { text-align: center; padding: 60px 0; color: #9ca3af; }
+
+    .todo-banner { display: flex; align-items: center; gap: 10px; padding: 12px 20px; margin-bottom: 16px; background: linear-gradient(135deg, #ede9fe, #e0e7ff); border-radius: 10px; border: 1px solid #c7d2fe; }
+    .todo-banner-icon { font-size: 18px; }
+    .todo-banner span { font-size: 13px; color: #4338ca; font-weight: 500; flex: 1; }
+    .todo-banner-done { padding: 6px 16px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-size: 12px; font-weight: 600; cursor: pointer; }
+    .todo-banner-done:hover { background: #4338ca; }
+  `]
+})
+export class ProductIterationComponent implements OnInit {
+  loading = false;
+  directives: IterationDirective[] = [];
+  filteredDirectives: IterationDirective[] = [];
+  criticalGapCount = 0;
+  avgGap = 0;
+  belowCompetitorCount = 0;
+  decliningCount = 0;
+  expandedId: string | null = null;
+  hoveredDot: string | null = null;
+  filterTrend = '';
+  filterSeverity = '';
+
+  readonly piSevOpts: MsqDropdownOption[] = [
+    { value: 'critical', label: 'P0 紧急' },
+    { value: 'high', label: 'P1 重要' },
+    { value: 'medium', label: 'P2 一般' },
+    { value: 'low', label: 'P3 低' }
+  ];
+  readonly piTrendOpts: MsqDropdownOption[] = [
+    { value: 'down', label: '满意度下降' },
+    { value: 'stable', label: '持平' },
+    { value: 'up', label: '满意度上升' }
+  ];
+  fromTodoId: string | null = null;
+
+  constructor(
+    private service: ActionSuggestionService,
+    private cdr: ChangeDetectorRef,
+    private route: ActivatedRoute,
+    private todoStatusStore: TodoStatusStore
+  ) {}
+
+  ngOnInit(): void {
+    this.fromTodoId = this.route.snapshot.queryParamMap.get('fromTodo');
+    this.loadData();
+  }
+
+  loadData(): void {
+    this.loading = true;
+    this.service.generateIterationDirectives()
+      .pipe(finalize(() => { this.loading = false; this.cdr.markForCheck(); }))
+      .subscribe(data => {
+        this.directives = data;
+        this.refreshDirectiveMetrics();
+        this.applyFilter();
+      });
+  }
+
+  private refreshDirectiveMetrics(): void {
+    let criticalGapCount = 0;
+    let gapSum = 0;
+    let belowCompetitorCount = 0;
+    let decliningCount = 0;
+
+    for (const directive of this.directives) {
+      if (directive.gap > 10) criticalGapCount++;
+      if (directive.satisfactionRate < directive.competitorAvg * 20) belowCompetitorCount++;
+      if (directive.trendDirection === 'down') decliningCount++;
+      gapSum += directive.gap;
+    }
+
+    this.criticalGapCount = criticalGapCount;
+    this.avgGap = this.directives.length ? gapSum / this.directives.length : 0;
+    this.belowCompetitorCount = belowCompetitorCount;
+    this.decliningCount = decliningCount;
+  }
+
+  applyFilter(): void {
+    let list = [...this.directives];
+    if (this.filterTrend) list = list.filter(d => d.trendDirection === this.filterTrend);
+    if (this.filterSeverity) list = list.filter(d => d.severity === this.filterSeverity);
+    this.filteredDirectives = list;
+    this.cdr.markForCheck();
+  }
+
+  scrollToCard(id: string): void {
+    this.expandedId = id;
+    this.cdr.markForCheck();
+    setTimeout(() => {
+      document.getElementById('card-' + id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    }, 50);
+  }
+
+  toggleExpand(id: string): void {
+    this.expandedId = this.expandedId === id ? null : id;
+  }
+
+  markStatus(item: IterationDirective, status: SuggestionStatus): void {
+    item.status = status;
+    // 持久化到 TodoStatusStore,与 Dashboard 待办联动
+    const todoStatus = status === 'verifying' ? 'done' as const
+      : status === 'archived' ? 'dismissed' as const
+      : status === 'in_progress' ? 'in_progress' as const
+      : 'pending' as const;
+    this.todoStatusStore.setStatus(`action-${item.id}`, todoStatus);
+    this.cdr.markForCheck();
+  }
+
+  markTodoDone(): void {
+    if (this.fromTodoId) {
+      this.todoStatusStore.markDone(this.fromTodoId);
+      this.fromTodoId = null;
+      this.cdr.markForCheck();
+    }
+  }
+
+  getStatusName(status: string): string {
+    const map: Record<string, string> = { pending: '待处理', in_progress: '执行中', verifying: '验证中', archived: '已归档' };
+    return map[status] || status;
+  }
+
+  getConfidenceLabel(confidence: 'high' | 'medium' | 'low' | undefined): string {
+    if (!confidence) return '-';
+    const map: Record<'high' | 'medium' | 'low', string> = { high: '高可信', medium: '中可信', low: '低可信' };
+    return map[confidence] || confidence;
+  }
+}

+ 572 - 0
src/modules/action-suggestion/pages/strategy-adjustment/strategy-adjustment.component.ts

@@ -0,0 +1,572 @@
+import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { ActionSuggestionService } from '../../../shared/services/action-suggestion.service';
+import { MonitoringDataService } from '../../../shared/services/monitoring-data.service';
+import { TodoStatusStore } from '../../../shared/services/todo-status-store.service';
+import { StrategyAdjustment, SuggestionStatus } from '../../../shared/interfaces/action-suggestion.interface';
+import { FilterPanelComponent } from '../../../shared/components/filter-panel/filter-panel.component';
+import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { EmotionHealthScore, CoreIssueSummary } from '../../../shared/interfaces/monitoring-interfaces';
+import { forkJoin, of } from 'rxjs';
+import { finalize, catchError } from 'rxjs/operators';
+
+@Component({
+  selector: 'app-strategy-adjustment',
+  standalone: true,
+  imports: [CommonModule, FormsModule, FilterPanelComponent, MsqDropdownComponent, PageHeaderComponent, LoadingSpinnerComponent, EmptyStateComponent, ContentCardComponent],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <div class="strategy-container">
+      @if (fromTodoId) {
+      <div class="todo-banner">
+        <span class="todo-banner-icon">📋</span>
+        <span>来自待办事项:您有待处理的策略调整建议,请在下方列表中操作</span>
+        <button class="todo-banner-done" (click)="markTodoDone()">全部已处理</button>
+      </div>
+      }
+      <app-page-header
+        title="长期策略调整"
+        eyebrow="Action Suggestion"
+        description="基于客户声音中的趋势性反馈,调整品牌/业务长期方向"
+        icon="◆">
+      </app-page-header>
+
+      <app-filter-panel class="strategy-filter-panel" title="策略筛选" description="按维度、周期和风险等级筛选策略建议。">
+        <div class="header-filters">
+          <msq-dropdown [options]="saDimOpts" placeholder="全部维度" (valueChange)="filterDimension = $event; applyFilter()"></msq-dropdown>
+          <msq-dropdown [options]="saHorizonOpts" placeholder="全部周期" (valueChange)="filterHorizon = $event; applyFilter()"></msq-dropdown>
+          <msq-dropdown [options]="saRiskOpts" placeholder="全部风险" (valueChange)="filterRisk = $event; applyFilter()"></msq-dropdown>
+        </div>
+      </app-filter-panel>
+
+      @if (loading) {
+        <app-loading-spinner text="正在从情绪趋势、微趋势、市场预警中生成策略建议..."></app-loading-spinner>
+      } @else {
+        <!-- 情绪健康度 + 信号面板 -->
+        <div class="top-panels">
+          <!-- 情绪健康仪表 -->
+          <app-content-card class="health-gauge-card" title="品牌情绪健康度">
+            @if (healthScore) {
+              <div class="gauge-wrap">
+                <div class="gauge-circle" [class]="healthGrade">
+                  <span class="gauge-val">{{ healthScore.score }}</span>
+                  <span class="gauge-unit">分</span>
+                </div>
+                <div class="gauge-trend" [class]="healthScore.trend">
+                  {{ healthScore.trend === 'up' ? '↑ 好转' : healthScore.trend === 'down' ? '↓ 恶化' : '→ 稳定' }}
+                </div>
+              </div>
+              <div class="health-bars">
+                <div class="h-bar-row">
+                  <span class="h-bar-label">好评</span>
+                  <div class="h-bar-track"><div class="h-bar-fill positive" [style.width.%]="healthScore.positiveRate"></div></div>
+                  <span class="h-bar-val">{{ healthScore.positiveRate | number:'1.0-0' }}%</span>
+                </div>
+                <div class="h-bar-row">
+                  <span class="h-bar-label">中评</span>
+                  <div class="h-bar-track"><div class="h-bar-fill neutral" [style.width.%]="healthScore.neutralRate"></div></div>
+                  <span class="h-bar-val">{{ healthScore.neutralRate | number:'1.0-0' }}%</span>
+                </div>
+                <div class="h-bar-row">
+                  <span class="h-bar-label">差评</span>
+                  <div class="h-bar-track"><div class="h-bar-fill negative" [style.width.%]="healthScore.negativeRate"></div></div>
+                  <span class="h-bar-val">{{ healthScore.negativeRate | number:'1.0-0' }}%</span>
+                </div>
+              </div>
+            } @else {
+              <app-empty-state title="暂无健康度数据" size="sm" icon="📊"></app-empty-state>
+            }
+          </app-content-card>
+
+          <!-- 信号面板 -->
+          <app-content-card class="signal-panel" title="趋势信号总览">
+            <div class="signal-grid">
+              <div class="signal-card opportunity" (click)="filterDimension = 'product_line'; applyFilter()">
+                <div class="signal-icon">🚀</div>
+                <div class="signal-body">
+                  <div class="signal-val">{{ opportunityCount }}</div>
+                  <div class="signal-label">机会信号</div>
+                  <div class="signal-desc">飙升热词 / 需求增长</div>
+                </div>
+              </div>
+              <div class="signal-card warning" (click)="filterRisk = 'high'; applyFilter()">
+                <div class="signal-icon">⚠️</div>
+                <div class="signal-body">
+                  <div class="signal-val">{{ warningCount }}</div>
+                  <div class="signal-label">风险预警</div>
+                  <div class="signal-desc">痛点预警 / 差评趋势</div>
+                </div>
+              </div>
+              <div class="signal-card brand" (click)="filterDimension = 'brand'; applyFilter()">
+                <div class="signal-icon">🏷️</div>
+                <div class="signal-body">
+                  <div class="signal-val">{{ brandCount }}</div>
+                  <div class="signal-label">品牌相关</div>
+                  <div class="signal-desc">口碑 / 品类定位</div>
+                </div>
+              </div>
+              <div class="signal-card market" (click)="filterDimension = 'market'; applyFilter()">
+                <div class="signal-icon">🌐</div>
+                <div class="signal-body">
+                  <div class="signal-val">{{ marketCount }}</div>
+                  <div class="signal-label">市场预警</div>
+                  <div class="signal-desc">市场潜在风险</div>
+                </div>
+              </div>
+            </div>
+          </app-content-card>
+        </div>
+
+        <!-- 核心问题集中度 -->
+        @if (coreIssues.length > 0) {
+          <app-content-card
+            class="core-issues-panel"
+            title="核心问题集中度"
+            subtitle="来自客户声音中最集中的负面话题,揭示需优先应对的系统性问题">
+            <div class="issues-bar-chart">
+              @for (issue of coreIssues; track issue.topic) {
+                <div class="issue-row" [class.negative]="issue.sentiment === 'negative'">
+                  <span class="issue-topic">{{ issue.topic }}</span>
+                  <div class="issue-bar-track">
+                    <div class="issue-bar-fill" [style.width.%]="issue.pct" [class]="issue.sentiment"></div>
+                  </div>
+                  <span class="issue-pct">{{ issue.pct | number:'1.0-0' }}%</span>
+                  <span class="issue-count">{{ issue.count }}条</span>
+                </div>
+              }
+            </div>
+          </app-content-card>
+        }
+
+        <!-- 策略建议列表 -->
+        <div class="list-header">
+          <h2 class="section-title">策略调整清单({{ filteredAdjustments.length }} 条)</h2>
+          <div class="dimension-pills">
+            <button class="pill" [class.active]="filterDimension === ''" (click)="filterDimension = ''; applyFilter()">全部</button>
+            @for (dim of dimensionList; track dim.key) {
+              <button class="pill" [class.active]="filterDimension === dim.key" (click)="filterDimension = dim.key; applyFilter()">
+                {{ dim.name }} ({{ countByDimension(dim.key) }})
+              </button>
+            }
+          </div>
+        </div>
+
+        <div class="strategy-list">
+          @for (item of filteredAdjustments; track item.id) {
+            <div class="strategy-card" [class]="'risk-' + item.riskLevel" (click)="toggleExpand(item.id)">
+              <div class="card-top">
+                <div class="card-left">
+                  <span class="badge priority-badge" [class]="item.priority">{{ item.priority }}</span>
+                  <span class="badge dim-badge" [class]="item.dimension">{{ getDimensionName(item.dimension) }}</span>
+                  <span class="badge horizon-badge">{{ getHorizonName(item.timeHorizon) }}</span>
+                  @if (item.trendKeyword) {
+                    <span class="keyword-tag">{{ item.trendKeyword }}</span>
+                  }
+                  @if (item.trendConfidence) {
+                    <span class="confidence-tag" [class]="'confidence-' + item.trendConfidence">
+                      {{ getConfidenceLabel(item.trendConfidence) }} {{ item.trendConfidenceScore || 0 }}
+                    </span>
+                  }
+                </div>
+                <div class="card-right">
+                  <div class="signal-meter">
+                    <div class="meter-fill" [style.width.%]="clamp(item.signalStrength)" [class]="'risk-' + item.riskLevel"></div>
+                  </div>
+                  <span class="risk-label" [class]="'risk-' + item.riskLevel">{{ getRiskLabel(item.riskLevel) }}</span>
+                </div>
+              </div>
+
+              <div class="five-elements">
+                <div class="element">
+                  <span class="el-label">🔍 问题描述</span>
+                  <p class="el-val">{{ item.problem }}</p>
+                </div>
+                <div class="element">
+                  <span class="el-label">🛠️ 建议动作</span>
+                  <p class="el-val">{{ item.action }}</p>
+                </div>
+                <div class="element-row">
+                  <div class="element small">
+                    <span class="el-label">👤 责任方</span>
+                    <p class="el-val">{{ item.owner }}</p>
+                  </div>
+                  <div class="element small">
+                    <span class="el-label">📈 预期效果</span>
+                    <p class="el-val">{{ item.expectedEffect }}</p>
+                  </div>
+                  <div class="element small">
+                    <span class="el-label">📅 完成时间</span>
+                    <p class="el-val">{{ item.deadline }}</p>
+                  </div>
+                </div>
+              </div>
+
+              @if (expandedId === item.id) {
+                <div class="card-detail">
+                  <div class="detail-row">
+                    <div class="detail-section">
+                      <h4>数据来源</h4>
+                      <span class="source-tag">{{ item.source }}</span>
+                    </div>
+                    <div class="detail-section">
+                      <h4>信号强度</h4>
+                      <span class="strength-val">{{ item.signalStrength }}/100</span>
+                    </div>
+                    @if (item.trendSampleCount || item.trendRelatedAsinCount) {
+                      <div class="detail-section">
+                        <h4>可信度</h4>
+                        <span class="strength-val">{{ item.trendConfidenceScore || 0 }}/100</span>
+                      </div>
+                    }
+                  </div>
+                  @if (item.trendSampleCount || item.trendRelatedAsinCount || item.trendSourceKeywords?.length) {
+                    <div class="trend-evidence-grid">
+                      <div class="trend-evidence-item">
+                        <span class="trend-evidence-label">样本量</span>
+                        <strong>{{ item.trendSampleCount || 0 }}条</strong>
+                      </div>
+                      <div class="trend-evidence-item">
+                        <span class="trend-evidence-label">覆盖ASIN</span>
+                        <strong>{{ item.trendRelatedAsinCount || 0 }}个</strong>
+                      </div>
+                      <div class="trend-evidence-item wide">
+                        <span class="trend-evidence-label">来源词</span>
+                        <strong>{{ item.trendSourceKeywords?.join('、') || '-' }}</strong>
+                      </div>
+                    </div>
+                  }
+                  <div class="detail-section">
+                    <h4>证据链</h4>
+                    <div class="evidence-tags">
+                      @for (ev of item.evidence; track ev) {
+                        <span class="ev-tag">{{ ev }}</span>
+                      }
+                    </div>
+                  </div>
+                  <div class="detail-actions">
+                    @if (item.status === 'pending') {
+                      <button class="btn btn-primary" (click)="markStatus(item, 'in_progress'); $event.stopPropagation()">纳入战略</button>
+                      <button class="btn btn-outline" (click)="markStatus(item, 'archived'); $event.stopPropagation()">持续观察</button>
+                    } @else if (item.status === 'in_progress') {
+                      <button class="btn btn-success" (click)="markStatus(item, 'verifying'); $event.stopPropagation()">标记验证</button>
+                    }
+                  </div>
+                </div>
+              }
+            </div>
+          }
+
+          @if (filteredAdjustments.length === 0) {
+            <app-empty-state title="暂无策略调整建议" icon="📌" size="sm"></app-empty-state>
+          }
+        </div>
+      }
+    </div>
+  `,
+  styles: [`
+    .strategy-container { padding: 24px; max-width: 1280px; margin: 0 auto; }
+    .strategy-filter-panel { display: block; margin-bottom: 24px; }
+    .header-filters { display: flex; gap: 12px; flex-wrap: wrap; }
+
+    .loading-state { text-align: center; padding: 80px 0; color: #6b7280; }
+    .spinner { width: 36px; height: 36px; border: 3px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; margin: 0 auto 16px; }
+    @keyframes spin { to { transform: rotate(360deg); } }
+
+    .section-title { font-size: 16px; font-weight: 700; color: #111827; margin: 0; }
+
+    /* 顶部面板(外壳由 app-content-card 提供) */
+    .top-panels { display: grid; grid-template-columns: 280px 1fr; gap: 16px; margin-bottom: 24px; }
+    .gauge-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
+    .gauge-circle { width: 72px; height: 72px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 4px solid; }
+    .gauge-circle.good { border-color: #10b981; background: #ecfdf5; }
+    .gauge-circle.fair { border-color: #f59e0b; background: #fffbeb; }
+    .gauge-circle.poor { border-color: #ef4444; background: #fef2f2; }
+    .gauge-val { font-size: 22px; font-weight: 800; color: #111827; line-height: 1; }
+    .gauge-unit { font-size: 10px; color: #6b7280; }
+    .gauge-trend { font-size: 13px; font-weight: 600; }
+    .gauge-trend.up { color: #10b981; }
+    .gauge-trend.down { color: #ef4444; }
+    .gauge-trend.stable { color: #6b7280; }
+    .health-bars { display: flex; flex-direction: column; gap: 6px; }
+    .h-bar-row { display: flex; align-items: center; gap: 6px; }
+    .h-bar-label { font-size: 11px; color: #6b7280; width: 28px; }
+    .h-bar-track { flex: 1; height: 6px; background: #f3f4f6; border-radius: 3px; overflow: hidden; }
+    .h-bar-fill { height: 100%; border-radius: 3px; }
+    .h-bar-fill.positive { background: #10b981; }
+    .h-bar-fill.neutral { background: #f59e0b; }
+    .h-bar-fill.negative { background: #ef4444; }
+    .h-bar-val { font-size: 11px; font-weight: 600; color: #374151; width: 32px; text-align: right; }
+
+    .signal-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
+    .signal-card { display: flex; align-items: center; gap: 12px; padding: 14px; border-radius: 10px; background: #f9fafb; border: 1px solid #e5e7eb; cursor: pointer; transition: box-shadow 0.2s; }
+    .signal-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
+    .signal-card.opportunity { border-color: #6366f1; background: #eef2ff; }
+    .signal-card.warning { border-color: #f59e0b; background: #fffbeb; }
+    .signal-card.brand { border-color: #8b5cf6; background: #f5f3ff; }
+    .signal-card.market { border-color: #06b6d4; background: #ecfeff; }
+    .signal-icon { font-size: 24px; }
+    .signal-val { font-size: 22px; font-weight: 700; color: #111827; }
+    .signal-label { font-size: 12px; font-weight: 600; color: #374151; }
+    .signal-desc { font-size: 10px; color: #9ca3af; }
+
+    /* 核心问题集中度(外壳由 app-content-card 提供) */
+    .core-issues-panel { display: block; margin-bottom: 24px; }
+    .issues-bar-chart { display: flex; flex-direction: column; gap: 8px; }
+    .issue-row { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
+    .issue-topic { font-size: 12px; color: #374151; width: 80px; flex-shrink: 0; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .issue-bar-track { flex: 1; height: 10px; background: #f3f4f6; border-radius: 5px; overflow: hidden; }
+    .issue-bar-fill { height: 100%; border-radius: 5px; transition: width 0.4s; }
+    .issue-bar-fill.negative { background: #ef4444; }
+    .issue-bar-fill.positive { background: #10b981; }
+    .issue-pct { font-size: 12px; font-weight: 700; color: #374151; width: 36px; text-align: right; }
+    .issue-count { font-size: 11px; color: #9ca3af; width: 40px; text-align: right; }
+
+    /* 列表头 */
+    .list-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
+    .dimension-pills { display: flex; gap: 6px; flex-wrap: wrap; }
+    .pill { padding: 5px 14px; border: 1px solid #e5e7eb; border-radius: 20px; font-size: 12px; background: #fff; color: #6b7280; cursor: pointer; transition: all 0.2s; }
+    .pill:hover { border-color: #6366f1; color: #6366f1; }
+    .pill.active { background: #6366f1; color: #fff; border-color: #6366f1; }
+
+    /* 策略列表 */
+    .strategy-list { display: flex; flex-direction: column; gap: 16px; }
+    .strategy-card { background: #fff; border-radius: 12px; padding: 20px 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); cursor: pointer; border-left: 4px solid #e5e7eb; transition: box-shadow 0.2s; }
+    .strategy-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
+    .strategy-card.risk-critical { border-left-color: #ef4444; }
+    .strategy-card.risk-high { border-left-color: #f59e0b; }
+    .strategy-card.risk-medium { border-left-color: #3b82f6; }
+    .strategy-card.risk-low { border-left-color: #10b981; }
+
+    .card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
+    .card-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+    .card-right { display: flex; align-items: center; gap: 10px; }
+
+    .badge { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
+    .priority-badge { color: #fff; }
+    .priority-badge.P0 { background: #ef4444; }
+    .priority-badge.P1 { background: #f59e0b; }
+    .priority-badge.P2 { background: #3b82f6; }
+    .priority-badge.P3 { background: #10b981; }
+    .dim-badge { background: #f3f4f6; color: #374151; }
+    .dim-badge.brand { background: #f5f3ff; color: #7c3aed; }
+    .dim-badge.product_line { background: #eef2ff; color: #4f46e5; }
+    .dim-badge.market { background: #ecfeff; color: #0891b2; }
+    .dim-badge.pricing { background: #fef3c7; color: #92400e; }
+    .dim-badge.category { background: #d1fae5; color: #065f46; }
+    .horizon-badge { background: #f3f4f6; color: #6b7280; }
+    .keyword-tag { padding: 2px 8px; background: #ede9fe; color: #6366f1; border-radius: 6px; font-size: 12px; font-weight: 600; }
+    .confidence-tag { padding: 2px 8px; border-radius: 6px; font-size: 12px; font-weight: 600; }
+    .confidence-tag.confidence-high { background: #dcfce7; color: #15803d; }
+    .confidence-tag.confidence-medium { background: #fef3c7; color: #b45309; }
+    .confidence-tag.confidence-low { background: #f3f4f6; color: #6b7280; }
+
+    .signal-meter { width: 60px; height: 6px; background: #f3f4f6; border-radius: 3px; overflow: hidden; }
+    .meter-fill { height: 100%; border-radius: 3px; }
+    .meter-fill.risk-critical { background: #ef4444; }
+    .meter-fill.risk-high { background: #f59e0b; }
+    .meter-fill.risk-medium { background: #3b82f6; }
+    .meter-fill.risk-low { background: #10b981; }
+    .risk-label { font-size: 11px; font-weight: 600; }
+    .risk-label.risk-critical { color: #ef4444; }
+    .risk-label.risk-high { color: #f59e0b; }
+    .risk-label.risk-medium { color: #3b82f6; }
+    .risk-label.risk-low { color: #10b981; }
+
+    .five-elements { display: flex; flex-direction: column; gap: 10px; }
+    .element-row { display: flex; gap: 16px; }
+    .element.small { flex: 1; }
+    .el-label { font-size: 11px; color: #9ca3af; font-weight: 600; }
+    .el-val { font-size: 13px; color: #374151; margin: 2px 0 0; line-height: 1.5; }
+
+    .card-detail { margin-top: 16px; padding-top: 16px; border-top: 1px solid #f3f4f6; }
+    .detail-section { margin-bottom: 10px; }
+    .detail-section h4 { font-size: 12px; color: #6b7280; margin: 0 0 4px; }
+    .detail-row { display: flex; gap: 24px; }
+    .trend-evidence-grid { display: grid; grid-template-columns: 120px 120px 1fr; gap: 10px; margin: 6px 0 12px; }
+    .trend-evidence-item { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
+    .trend-evidence-label { display: block; font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
+    .trend-evidence-item strong { font-size: 12px; color: #374151; font-weight: 700; }
+    .evidence-tags { display: flex; gap: 6px; flex-wrap: wrap; }
+    .ev-tag { padding: 2px 8px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 12px; color: #4b5563; }
+    .source-tag { padding: 2px 8px; background: #ede9fe; border-radius: 6px; font-size: 12px; color: #6366f1; }
+    .strength-val { font-size: 14px; font-weight: 700; color: #6366f1; }
+
+    .detail-actions { display: flex; gap: 12px; margin-top: 12px; }
+    .btn { padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; }
+    .btn-primary { background: #6366f1; color: #fff; }
+    .btn-primary:hover { background: #4f46e5; }
+    .btn-success { background: #059669; color: #fff; }
+    .btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
+
+    .empty-state { text-align: center; padding: 60px 0; color: #9ca3af; }
+
+    @media (max-width: 900px) {
+      .top-panels { grid-template-columns: 1fr; }
+      .signal-grid { grid-template-columns: repeat(2, 1fr); }
+    }
+  `]
+})
+export class StrategyAdjustmentComponent implements OnInit {
+  loading = false;
+  adjustments: StrategyAdjustment[] = [];
+  filteredAdjustments: StrategyAdjustment[] = [];
+  healthScore: EmotionHealthScore | null = null;
+  coreIssues: CoreIssueSummary[] = [];
+  healthGrade = 'fair';
+  opportunityCount = 0;
+  warningCount = 0;
+  brandCount = 0;
+  marketCount = 0;
+  private dimensionCounts = new Map<string, number>();
+  expandedId: string | null = null;
+  filterDimension = '';
+  filterHorizon = '';
+  filterRisk = '';
+
+  readonly saDimOpts: MsqDropdownOption[] = [
+    { value: 'brand', label: '品牌策略' },
+    { value: 'product_line', label: '产品线' },
+    { value: 'market', label: '市场方向' },
+    { value: 'pricing', label: '定价策略' },
+    { value: 'category', label: '品类策略' }
+  ];
+  readonly saHorizonOpts: MsqDropdownOption[] = [
+    { value: 'short', label: '短期 (1-3月)' },
+    { value: 'medium', label: '中期 (3-6月)' },
+    { value: 'long', label: '长期 (6月+)' }
+  ];
+  readonly saRiskOpts: MsqDropdownOption[] = [
+    { value: 'critical', label: '极高' },
+    { value: 'high', label: '高' },
+    { value: 'medium', label: '中等' },
+    { value: 'low', label: '低' }
+  ];
+
+  readonly dimensionList = [
+    { key: 'brand', name: '品牌' },
+    { key: 'product_line', name: '产品线' },
+    { key: 'market', name: '市场' },
+    { key: 'category', name: '品类' }
+  ];
+
+  fromTodoId: string | null = null;
+
+  constructor(
+    private service: ActionSuggestionService,
+    private monitoring: MonitoringDataService,
+    private cdr: ChangeDetectorRef,
+    private route: ActivatedRoute,
+    private todoStatusStore: TodoStatusStore
+  ) {}
+
+  ngOnInit(): void {
+    this.fromTodoId = this.route.snapshot.queryParamMap.get('fromTodo');
+    this.loadData();
+  }
+
+  loadData(): void {
+    this.loading = true;
+    forkJoin({
+      adjustments: this.service.generateStrategyAdjustments(),
+      health: this.monitoring.getEmotionHealthScore().pipe(catchError(() => of(null))),
+      issues: this.monitoring.getCoreIssueSummary().pipe(catchError(() => of([])))
+    }).pipe(
+      finalize(() => { this.loading = false; this.cdr.markForCheck(); })
+    ).subscribe(({ adjustments, health, issues }) => {
+      this.adjustments = adjustments;
+      this.healthScore = health;
+      this.coreIssues = (issues || []).slice(0, 6);
+      this.refreshAdjustmentMetrics();
+      this.applyFilter();
+    });
+  }
+
+  private refreshAdjustmentMetrics(): void {
+    let opportunityCount = 0;
+    let warningCount = 0;
+    let brandCount = 0;
+    let marketCount = 0;
+    const dimensionCounts = new Map<string, number>();
+
+    for (const adjustment of this.adjustments) {
+      if (adjustment.source === '微趋势分析') opportunityCount++;
+      if (adjustment.source === '痛点预警' || adjustment.source === '情绪健康度') warningCount++;
+      if (adjustment.dimension === 'brand' || adjustment.dimension === 'category') brandCount++;
+      if (adjustment.source === '市场预警') marketCount++;
+      dimensionCounts.set(adjustment.dimension, (dimensionCounts.get(adjustment.dimension) || 0) + 1);
+    }
+
+    this.healthGrade = !this.healthScore
+      ? 'fair'
+      : this.healthScore.score >= 70 ? 'good' : this.healthScore.score >= 40 ? 'fair' : 'poor';
+    this.opportunityCount = opportunityCount;
+    this.warningCount = warningCount;
+    this.brandCount = brandCount;
+    this.marketCount = marketCount;
+    this.dimensionCounts = dimensionCounts;
+  }
+
+  applyFilter(): void {
+    let list = [...this.adjustments];
+    if (this.filterDimension) list = list.filter(a => a.dimension === this.filterDimension);
+    if (this.filterHorizon) list = list.filter(a => a.timeHorizon === this.filterHorizon);
+    if (this.filterRisk) list = list.filter(a => a.riskLevel === this.filterRisk);
+    this.filteredAdjustments = list;
+    this.cdr.markForCheck();
+  }
+
+  countByDimension(dim: string): number {
+    return this.dimensionCounts.get(dim) || 0;
+  }
+
+  clamp(val: number): number {
+    return Math.min(Math.max(val, 0), 100);
+  }
+
+  toggleExpand(id: string): void {
+    this.expandedId = this.expandedId === id ? null : id;
+  }
+
+  markStatus(item: StrategyAdjustment, status: SuggestionStatus): void {
+    item.status = status;
+    const todoStatus = status === 'verifying' || status === 'archived' ? 'done' as const
+      : status === 'in_progress' ? 'in_progress' as const
+      : 'pending' as const;
+    this.todoStatusStore.setStatus(`action-${item.id}`, todoStatus);
+    this.cdr.markForCheck();
+  }
+
+  markTodoDone(): void {
+    if (this.fromTodoId) {
+      this.todoStatusStore.markDone(this.fromTodoId);
+      this.fromTodoId = null;
+      this.cdr.markForCheck();
+    }
+  }
+
+  getDimensionName(dim: string): string {
+    const map: Record<string, string> = { brand: '品牌策略', product_line: '产品线', market: '市场方向', pricing: '定价', category: '品类' };
+    return map[dim] || dim;
+  }
+
+  getHorizonName(h: string): string {
+    const map: Record<string, string> = { short: '短期 1-3月', medium: '中期 3-6月', long: '长期 6月+' };
+    return map[h] || h;
+  }
+
+  getRiskLabel(risk: string): string {
+    const map: Record<string, string> = { critical: '极高风险', high: '高风险', medium: '中等', low: '低风险' };
+    return map[risk] || risk;
+  }
+
+  getConfidenceLabel(confidence: string): string {
+    const map: Record<string, string> = { high: '高可信', medium: '中可信', low: '低可信' };
+    return map[confidence] || confidence;
+  }
+}

+ 122 - 0
src/modules/dashboard/DASHBOARD_OVERVIEW.md

@@ -0,0 +1,122 @@
+# Dashboard 模块与 `/dashboard` 指标说明
+
+本文件总结 `src/modules/dashboard` 目录内的主要组件、页面与脚本,并重点解析 `http://localhost:4200/dashboard`(DashboardComponent)页面上各类指标/图表的**数据来源、计算方式**与**交互流程**。
+
+---
+
+## 1. 目录结构概览
+
+| 路径 | 说明 |
+| --- | --- |
+| `dashboard.component.ts / html / scss` | 核心看板页(/dashboard),负责顶层数据加载、指标展示、Tab 切换、工单创建等主界面逻辑。|
+| `pages/designer-performance/*` | 设计师表现看板,聚合设计师/产品差评率、退货等多维指标。|
+| `pages/order-list/*` | 订单列表页面,支持按店铺查看订单详情。|
+
+> 其他依赖(如 `DashboardDataService`、`MonitoringDataService`、`SharedDataCacheService` 等)位于 `src/modules/shared/services`,本文在对应章节引用。
+
+---
+
+## 2. `/dashboard` 页面数据流总览
+
+`DashboardComponent.loadData()` 会在页面初始化或筛选条件变化时执行以下流水线:
+
+1. **计算 ASIN 白名单**:根据 Filter Bar 的店铺 / 类目 / 设计师等维度筛选,调用 `getFilteredAsinScope()`,从缓存的 Product + ProductDetail 记录中挑出匹配 ASIN 集合,供后续评论/风险数据过滤使用 @src/modules/dashboard/dashboard.component.ts#199-321。
+2. **拉取监控口径 VOC 与预警指标**:`monitoringDataService.getVocMetrics()` 返回今日 VOC、最近正向情绪、预警计数等快速指标;结果用于顶部 Summary Card 和 `vocMetrics` 模型 @src/modules/dashboard/dashboard.component.ts#216-241。
+3. **加载趋势数据**:并行请求 `dashboardDataService.getTrendData('voc'|'emotion')`,获取 1 年 VOC 日量与近 30 天情绪分布,随后按粒度聚合、计算同比/环比并绘图 @src/modules/dashboard/dashboard.component.ts#243-257、#869-962。
+4. **核心问题 & 风险**:`monitoringDataService.getCoreIssueSummary()` 和 `getDesignerRiskRanking()` 根据维度筛选返回负向主题排行与设计师风险榜单,用于 Issues / Alert Tab @src/modules/dashboard/dashboard.component.ts#260-295。
+5. **待办/风险告警**:`loadRiskTodosFromMonitoring()` 将监控服务返回的高/中风险商品转换为待办卡片,并统计 alert 数量 @src/modules/dashboard/dashboard.component.ts#550-578。
+6. **必要时清缓存**:筛选条件切换时会同时清除云函数缓存和通用数据缓存,确保跨店铺数据不串行 @src/modules/dashboard/dashboard.component.ts#800-804。
+
+---
+
+## 3. 顶部 Summary Metric 卡片(今日 VOC / 情绪 / 核心问题 / 预警)
+
+| 卡片 | 数据字段 | 数据来源 & 计算 | 说明 |
+| --- | --- | --- | --- |
+| 今日 VOC | `todayVocCount` | `monitoringDataService.getVocMetrics()` 返回的 `todayCount`,表示今日新增评论/反馈量 @src/modules/dashboard/dashboard.component.ts#216-241。| 点击跳转监控趋势页。|
+| 情绪健康 | `scopedPositiveEmotionRate` or `vocMetrics.positiveEmotionRate` | 取监控服务返回的 `recentPositiveRate`;若未来启用 scoped 评论计算,则来自 `applyScopedEmotionMetrics()` 对筛选范围内正向评论占比的计算 @src/modules/dashboard/dashboard.component.ts#216-241、#580-613。| 展示环比趋势,低于 68% 会触发待办。|
+| 核心问题 | `coreIssueCount` | 来自 `monitoringDataService.getCoreIssueSummary()` 过滤出情绪为负的 topic 数量;必要时可调用 `buildCoreIssueItemsFromReviews()` 自行聚类低分评论 @src/modules/dashboard/dashboard.component.ts#260-285、#323-383。| 可下钻至 VOC Feature 页面或创建工单。|
+| 预警数量 | `alertCount` | 首先采用监控服务返回的 `warningCount`;之后 `loadRiskTodosFromMonitoring()` 生成的高/中风险待办会覆盖最终 alertCount(仅统计未完成、critical/high 的 item)@src/modules/dashboard/dashboard.component.ts#220-224、#536-577。| 点击跳转风险预警页。|
+
+每张卡片都绑定 `drillMetric()`,根据卡片类型导航至对应详情模块 @src/modules/dashboard/dashboard.component.ts#963-973。
+
+---
+
+## 4. 趋势 Tab:VOC 趋势柱状图 + 情绪分布饼图
+
+### 4.1 VOC 趋势
+- **原始数据**:`dashboardDataService.getTrendData('voc','1year', asinScope)` 通过 `SharedDataCacheService.getSorftimeReviews()` 获取最多 5000 条评论,再按日期聚合为日量;支持 ASIN 白名单过滤 @src/modules/shared/services/dashboard-data.service.ts#452-578。
+- **粒度转换**:`applyTrendGranularity()` 根据界面选择(天/周/月)聚合数据,并限制展示窗口(最近 30 天 / 12 周 / 6 月)@src/modules/dashboard/dashboard.component.ts#869-914。
+- **同比/环比**:`computeVocCompare()` 用聚合后的最新桶与上一桶计算环比(前一周期值需 ≥10 才输出),并用首个桶近似同比 @src/modules/dashboard/dashboard.component.ts#947-962。
+
+### 4.2 情绪分布
+- **数据来源**:`getTrendData('emotion','30days', asinScope)` 仍来自 Sorftime 评论缓存,根据星级映射正/中/负情绪,并记录 daily 正向占比 + 中性/负向编码字符串 @src/modules/shared/services/dashboard-data.service.ts#462-520。
+- **渲染**:`initEmotionChart()` 计算平均占比后生成饼图,并支持点击 slice 跳转到情绪监控列表 @src/modules/dashboard/dashboard.component.ts#470-535。
+
+---
+
+## 5. Issues Tab:核心问题 Top5 + 待办事项
+
+### 5.1 核心问题 Top5
+- **监控聚合**:`getCoreIssueSummary()` 按维度筛选返回 topic 列表,组件仅保留情绪为负的项并根据 `pct`(负向占比)排序 @src/modules/dashboard/dashboard.component.ts#260-285。
+- **指标推导**:每项 CoreIssueItem 会将 `pct` 直接作为重要度,满足度 = 100 - pct,缺口 = pct;提及次数来自 `count`,紧急度基于缺口阈值映射 @src/modules/dashboard/dashboard.component.ts#270-284。
+- **本地回退**:若后端未返回 topic,可调用 `buildCoreIssueItemsFromReviews()` 依据自定义规则(尺码、质量、面料等关键词)对 1-3 星评论聚类,并以提及比例估算重要度/缺口 @src/modules/dashboard/dashboard.component.ts#338-383。
+- **工单创建**:点击“创建工单”会预填核心问题信息并调用 `DataStoreService.saveBatch('WorkOrder', …)` 将建议推送到行动建议看板 @src/modules/dashboard/dashboard.component.ts#1141-1253。
+
+### 5.2 待办事项 / 预警
+- **来源一**:`alertTodoService.getTodos()` + `buildScopedVocTodos()` 生成与当前筛选范围相关的情绪/评分指标告警(例如正向情绪 < 68%)@src/modules/dashboard/dashboard.component.ts#536-662。
+- **来源二**:`monitoringDataService.getRiskAlertItems()` 转换为“负向反馈偏高”类任务,附带 ASIN 链接与提示语 @src/modules/dashboard/dashboard.component.ts#550-578。
+- **过滤**:如果当前筛选限定 ASIN,会用 `filterTodosByAsinScope()` 只保留与这些 ASIN 关联的任务 @src/modules/dashboard/dashboard.component.ts#615-622。
+
+---
+
+## 6. Alert Tab:设计师风险排行
+
+- **数据源**:`monitoringDataService.getDesignerRiskRanking()`,响应包含设计师姓名、关联运营/开发、平均负评率、严重/警告计数、Top ASIN 列表等字段 @src/modules/dashboard/dashboard.component.ts#287-295。
+- **展示**:`designerRiskRanking` 列表按接口返回顺序展示 TOP N,并在 UI 中突出严重、警告数量与主责人列表(见 `dashboard.component.html` 中的 `designer-risk-section`)。
+
+---
+
+## 7. 筛选与缓存策略
+
+- **Filter Bar**:`FilterBarComponent` 允许切换时间范围、店铺、站点、类目、设计师/运营/开发等维度;`onFiltersChange()` 保存到 `currentFilters`,并清空云函数缓存与共享缓存后重新加载 @src/modules/dashboard/dashboard.component.ts#118-131、#800-804。
+- **Monitoring vs. Local 数据**:大部分指标先走监控/云函数接口(低延迟),若接口无值则回退到本地 Parse/Sorftime 数据。典型例子是 `DashboardDataService.getVocMetrics()`:优先使用云函数 `1tZqfcAys1`,若返回 0 或缺字段,则改为本地评论+退货+订单聚合 @src/modules/shared/services/dashboard-data.service.ts#71-204。
+
+---
+
+## 8. 其他子页面
+
+### 8.1 设计师表现看板 (`pages/designer-performance`)
+- **数据服务**:`DesignerPerformanceService.getDashboardData()`(位于 shared/services)提供设计师排行、统一问题矩阵、商品下钻列表等聚合数据。
+- **组件亮点**:
+  - Summary 卡片、风险排行、问题矩阵、AI 分析 Tab @src/modules/dashboard/pages/designer-performance/designer-performance.component.ts#1-235。
+  - 内置 AI Prompt:将排名、矩阵、商品证据拼接成提示词,要求输出 250 字内的诊断建议 @src/modules/dashboard/pages/designer-performance/designer-performance.component.ts#148-175。
+  - 支持按照设计师、关键词筛选商品列表,并分页展示 @src/modules/dashboard/pages/designer-performance/designer-performance.component.ts#106-143。
+
+### 8.2 订单列表 (`pages/order-list`)
+- 允许根据路由参数 `shopId` 查询 Parse `Order` 表,展示订单状态、买家、金额等信息;交互逻辑位于 `order-list.component.*`。
+
+---
+
+## 9. 快速参考:关键数据接口
+
+| 指标 | 调用链 | 说明 |
+| --- | --- | --- |
+| 今日 VOC / 情绪 | `MonitoringDataService.getVocMetrics()` → `dashboard.component.ts` | 监控聚合接口,毫秒级返回。|
+| VOC 趋势 | `DashboardDataService.getTrendData('voc')` → 评论缓存 | 聚合评论日量,支持 ASIN 白名单。|
+| 情绪分布 | `DashboardDataService.getTrendData('emotion')` | 聚合正/中/负占比,并在饼图点击时带参跳转。|
+| 核心问题 | `MonitoringDataService.getCoreIssueSummary()` (fallback: `buildCoreIssueItemsFromReviews`) | 主题聚合 + 关键词规则双重支持。|
+| 设计师风险 | `MonitoringDataService.getDesignerRiskRanking()` | Alert Tab 的主要数据源。|
+| 工单保存 | `DataStoreService.saveBatch('WorkOrder', …)` | 创建行动建议工单,并携带核心问题快照。|
+
+---
+
+## 10. 后续维护建议
+
+1. **指标扩展**:新增卡片或图表时,优先在 `DashboardDataService` 内封装数据来源,并在 `loadData()` 中保持模块化订阅,便于统一错误处理。
+2. **样本容量监控**:`buildScopedDataQuality()` 已输出数据质量警告,可在 UI 中二次提醒当样本过少或为估算值 @src/modules/dashboard/dashboard.component.ts#664-675。
+3. **性能**:当云函数响应慢或失败频繁时,需关注 `sharedCache` 返回的样本上限(当前 5k),必要时改为增量加载或引入分页。
+
+---
+
+> 文档更新时间:2026-06-04

+ 552 - 0
src/modules/dashboard/dashboard.component.html

@@ -0,0 +1,552 @@
+<div class="dashboard-container">
+  <!-- 面包屑 -->
+  <nav class="breadcrumb">
+    <span>AI-VOC系统</span>
+    <span class="separator">›</span>
+    <span>核心看板</span>
+    <span class="separator">›</span>
+    <span class="current">看板总览</span>
+  </nav>
+
+  <!-- 页面头部 -->
+  <app-page-header
+    title="看板总览"
+    eyebrow="Dashboard"
+    description="核心业务指标、VOC 健康度与预警概览">
+    <div pageHeaderActions class="header-actions">
+      <div class="last-update">
+        最后更新: {{ lastUpdateTime | date:'MM-dd HH:mm' }}
+      </div>
+      <button class="refresh-btn" (click)="refreshData()" [disabled]="loading">
+        <span class="refresh-icon" [class.spinning]="loading" aria-hidden="true"></span>
+        刷新
+      </button>
+    </div>
+  </app-page-header>
+
+  <!-- 筛选控制区域(移至顶部,在指标卡片之前) -->
+  <app-filter-bar
+    [filters]="currentFilters"
+    [timeRangeOptions]="overviewTimeRangeOptions"
+    [collapsible]="true"
+    [collapsed]="true"
+    summaryLabel="筛选区"
+    [enableDesigner]="true"
+    [enableOperator]="true"
+    [enableDeveloper]="true"
+    [enableListingStatus]="true"
+    [enableFulfillmentType]="true"
+    (filtersChange)="onFiltersChange($event)">
+  </app-filter-bar>
+
+  <section class="dashboard-scope-strip" aria-label="当前数据范围">
+    <div class="scope-block primary">
+      <span class="scope-label">当前数据范围</span>
+      <strong>{{ permissionScopeTitle }}</strong>
+      <p>{{ permissionScopeDescription }}</p>
+    </div>
+    <div class="scope-block">
+      <span class="scope-label">当前筛选范围</span>
+      <strong>{{ getTimeRangeLabel() }}</strong>
+      <p>{{ filterScopeDescription }}</p>
+    </div>
+    <div class="scope-block compact">
+      <span class="scope-label">Listing 口径</span>
+      <strong>{{ listingStatusScopeDescription }}</strong>
+      <p>看板指标、预警和前往处理入口均沿用当前权限与筛选范围。</p>
+    </div>
+  </section>
+
+  <!-- ① 核心指标区(顶部)—— 4 张北极星卡片 -->
+  <section class="overview-metrics">
+    <div class="dashboard-metric-cards">
+      <app-summary-metric-card
+        [label]="vocMetricLabel"
+        [value]="(todayVocCount ?? 0) | number:'1.0-0'"
+        unit="条"
+        [description]="vocMetricDescription"
+        tone="blue"
+        size="xl"
+        emphasis="primary"
+        [loading]="loading"
+        [clickable]="!loading"
+        (activate)="drillMetric('todayVoc')">
+      </app-summary-metric-card>
+
+      <app-summary-metric-card
+        label="情绪健康"
+        [value]="(scopedPositiveEmotionRate ?? vocMetrics?.positiveEmotionRate ?? 0) | number:'1.0-1'"
+        unit="%"
+        [description]="emotionMetricDescription"
+        [trendValue]="emotionTrendValue"
+        [trendLabel]="emotionTrendValue ? '较上期' : ''"
+        [trendDirection]="emotionTrendDirection"
+        tone="green"
+        size="xl"
+        emphasis="primary"
+        [loading]="loading"
+        [clickable]="!loading"
+        (activate)="drillMetric('emotion')">
+      </app-summary-metric-card>
+
+      <app-summary-metric-card
+        label="核心问题"
+        [value]="(coreIssueCount ?? 0) | number:'1.0-0'"
+        unit="个"
+        description="待处理关键问题"
+        tone="amber"
+        size="xl"
+        emphasis="primary"
+        [loading]="loading"
+        [clickable]="!loading"
+        (activate)="drillMetric('coreIssue')">
+      </app-summary-metric-card>
+
+      <app-summary-metric-card
+        label="预警数量"
+        [value]="(alertCount ?? 0) | number:'1.0-0'"
+        unit="个"
+        [description]="alertMetricDescription"
+        tone="red"
+        [variant]="hasActiveAlerts ? 'urgent' : 'left-accent'"
+        size="xl"
+        emphasis="primary"
+        [loading]="loading"
+        [clickable]="!loading"
+        (activate)="drillMetric('alert')">
+      </app-summary-metric-card>
+    </div>
+
+    @if (vocMetrics?.dataQuality) {
+    <div class="data-quality-strip" [class]="vocMetrics!.dataQuality!.status">
+      <div class="dq-main">
+        <span class="dq-title">数据可信度</span>
+        <span class="dq-badge" [class]="vocMetrics!.dataQuality!.status">
+          {{ getDataQualityStatusLabel(vocMetrics!.dataQuality!.status) }}
+        </span>
+        <span class="dq-badge confidence" [class]="vocMetrics!.dataQuality!.confidence">
+          {{ getDataQualityConfidenceLabel(vocMetrics!.dataQuality!.confidence) }}
+        </span>
+        <span class="dq-source">{{ vocMetrics!.dataQuality!.sourceLabel }}</span>
+      </div>
+      <div class="dq-meta">
+        <span>样本 {{ vocMetrics!.dataQuality!.sampleCount | number }}</span>
+        @if (vocMetrics!.dataQuality!.coverageRate !== undefined) {
+          <span>覆盖 {{ vocMetrics!.dataQuality!.coverageRate }}%</span>
+        }
+        @if (vocMetrics!.dataQuality!.refreshedAt) {
+          <span>刷新 {{ vocMetrics!.dataQuality!.refreshedAt | date:'MM-dd HH:mm' }}</span>
+        }
+        @if (vocMetrics!.dataQuality!.isEstimated) {
+          <span class="dq-warning">含估算</span>
+        }
+        @for (warning of getDataQualityWarnings(vocMetrics!.dataQuality!); track warning) {
+          <span class="dq-warning">{{ warning }}</span>
+        }
+      </div>
+    </div>
+    }
+  </section>
+
+  <!-- 页内 Tab 切换:问题概览 / 趋势 / 预警 -->
+  <app-board-tabs
+    class="dashboard-board-tabs"
+    [tabs]="pageTabs"
+    [activeKey]="activeTab"
+    (activeKeyChange)="setActiveTab($event)">
+  </app-board-tabs>
+
+  <!-- 预警 Tab:设计师风险排行 -->
+  @if (activeTab === 'alert' && designerRiskRanking.length > 0) {
+  <section class="designer-risk-section">
+    <app-content-card
+      title="设计师风险排行"
+      visualLevel="section"
+      headingLevel="h2"
+      [hasHeaderActions]="true">
+      <span contentCardActions class="panel-hint">按严重预警、警告预警与平均差评率排序</span>
+      <div class="designer-risk-list">
+        @for (item of designerRiskRanking; track item.designerName; let i = $index) {
+        <div class="designer-risk-item">
+          <span class="designer-risk-rank">#{{ i + 1 }}</span>
+          <div class="designer-risk-main">
+            <div class="designer-risk-head">
+              <span class="designer-risk-name">{{ item.designerName }}</span>
+              @if (item.operatorNames.length) {
+              <span class="designer-risk-operators">负责人:{{ item.operatorNames.slice(0, 2).join(' / ') }}</span>
+              }
+            </div>
+            <div class="designer-risk-bar">
+              <span class="designer-risk-fill" [style.width.%]="getRiskBarWidth(item.avgNegativeRate)"></span>
+            </div>
+            @if (item.topAsins.length) {
+            <div class="designer-risk-asins">
+              @for (riskAsin of item.topAsins; track riskAsin.asin) {
+              <a class="designer-risk-asin" [href]="'https://www.amazon.com/dp/' + riskAsin.asin" target="_blank">
+                {{ riskAsin.asin }} · {{ riskAsin.negativeRate }}%
+              </a>
+              }
+            </div>
+            }
+          </div>
+          <div class="designer-risk-metrics">
+            <span class="designer-risk-metric critical">{{ item.criticalCount }} 严重</span>
+            <span class="designer-risk-metric warning">{{ item.warningCount }} 警告</span>
+            <span class="designer-risk-metric">{{ item.productCount }} 商品</span>
+            <strong class="designer-risk-rate">{{ item.avgNegativeRate }}%</strong>
+          </div>
+        </div>
+        }
+      </div>
+    </app-content-card>
+  </section>
+  }
+
+  <!-- 趋势 Tab:②③ 中部:VOC总量趋势(左)+ 情绪分布(右) -->
+  @if (activeTab === 'trend') {
+  <section class="overview-middle">
+    <!-- ② VOC总量趋势 -->
+    <app-content-card
+      class="panel-trend"
+      title="VOC总量趋势"
+      headingLevel="h3"
+      [hasHeaderActions]="true">
+      <div class="panel-sub">
+        <!-- 同比 / 环比 -->
+        <span class="compare-pill">
+          环比:
+          <strong [class.up]="isCompareRateUp(vocCompare?.chainRate)" [class.down]="isCompareRateDown(vocCompare?.chainRate)">
+            {{ formatCompareRate(vocCompare?.chainRate) }}
+          </strong>
+        </span>
+        <span class="compare-pill">
+          同比:
+          <strong [class.up]="isCompareRateUp(vocCompare?.yearRate)" [class.down]="isCompareRateDown(vocCompare?.yearRate)">
+            {{ formatCompareRate(vocCompare?.yearRate) }}
+          </strong>
+        </span>
+      </div>
+      <div class="chart-area" [class.loading]="loading">
+        @if (loading) {
+        <div class="chart-loading">
+          <app-loading-spinner text="加载中..." size="md"></app-loading-spinner>
+        </div>
+        } @else if (vocTrendData.length === 0) {
+        <app-empty-state title="暂无VOC趋势数据" description="当前筛选条件下无匹配的评论数据,请尝试切换粒度或调整筛选范围" size="sm"></app-empty-state>
+        } @else {
+        <div echarts [options]="vocChartOption" class="echarts-chart" style="height: 100%; width: 100%;"></div>
+        }
+      </div>
+    </app-content-card>
+
+    <!-- ③ 情绪分布(饼图) -->
+    <app-content-card
+      class="panel-emotion"
+      title="情绪分布"
+      headingLevel="h3">
+      <div class="chart-area" [class.loading]="loading">
+        @if (loading) {
+        <div class="chart-loading">
+          <app-loading-spinner text="加载中..." size="md"></app-loading-spinner>
+        </div>
+        } @else if (emotionTrendData.length === 0) {
+        <app-empty-state title="暂无情绪分布数据" description="当前筛选条件下无匹配的评论数据" size="sm"></app-empty-state>
+        } @else {
+        <div echarts [options]="emotionChartOption" class="echarts-chart" style="height: 100%; width: 100%;"
+          (chartClick)="onEmotionSliceClick($event)"></div>
+        }
+      </div>
+    </app-content-card>
+  </section>
+  }
+
+  <!-- 问题概览 Tab:④⑤ 底部:核心问题 Top5(左)+ 待办事项(右) -->
+  @if (activeTab === 'issues') {
+  <section class="overview-bottom">
+    <!-- ④ 核心问题 Top5 -->
+    <app-content-card
+      class="panel-issues"
+      title="核心问题 Top5"
+      visualLevel="section"
+      headingLevel="h2"
+      [hasHeaderActions]="true">
+      <span contentCardActions class="panel-hint">按影响程度排序</span>
+      <div class="issue-list">
+        @if (coreIssues.length === 0) {
+        <app-empty-state title="暂无数据" icon="--" size="sm"></app-empty-state>
+        } @else {
+        @for (issue of coreIssues; track issue.id; let i = $index) {
+        <div class="issue-item">
+          <div class="issue-main">
+            <span class="issue-rank">{{ i + 1 }}</span>
+            <span class="issue-title">{{ issue.name }}</span>
+            <span class="meta-item urgency" [class]="issue.urgency">{{ issue.urgencyLabel }}</span>
+          </div>
+          <div class="issue-meta">
+            <span class="meta-item">重要度 <strong>{{ issue.importance }}%</strong></span>
+            <span class="meta-item">满足度 <strong>{{ issue.satisfaction }}%</strong></span>
+            <span class="meta-item gap">缺口 <strong>{{ issue.satisfactionGap }} 分</strong></span>
+            <span class="meta-item">提及 <strong>{{ issue.mentionCount }}</strong> 次</span>
+            @if (issue.userSegment) {
+              <span class="meta-item segment">{{ issue.userSegment }}</span>
+            }
+          </div>
+          <div class="issue-actions">
+            <button class="issue-btn primary" (click)="navigateToIssueDetail(issue)">详细分析</button>
+            <button class="issue-btn" (click)="createWorkOrder(issue)">创建工单</button>
+          </div>
+        </div>
+        }
+        }
+      </div>
+    </app-content-card>
+
+    <!-- ⑤ 待办事项 -->
+    <app-content-card
+      class="panel-todo"
+      title="待办事项"
+      headingLevel="h3"
+      [hasHeaderActions]="true">
+      <span contentCardActions class="panel-hint">{{ pendingCount }} 项待处理</span>
+      <div class="todo-list-wrap">
+        @if (todoItems.length === 0) {
+        <app-empty-state title="暂无待办" icon="--" size="sm"></app-empty-state>
+        } @else {
+        @for (item of visibleTodos; track item.id) {
+        <div class="todo-item-compact" [class]="item.urgency"
+          [class.done]="item.status === 'done' || item.status === 'dismissed'"
+          [class.in-progress]="item.status === 'in_progress'">
+          <div class="todo-row-top">
+            <span class="todo-module-tag" [style.background]="getModuleColor(item.module)">{{ getModuleLabel(item.module) }}</span>
+            <span class="todo-urgency-tag" [class]="item.urgency">{{ getUrgencyLabel(item.urgency) }}</span>
+            <span class="todo-title-text">{{ item.title }}</span>
+          </div>
+          <div class="todo-detail-row">
+            <span class="todo-summary">{{ item.summary }}</span>
+            <div class="todo-actions">
+              <button class="todo-btn navigate" (click)="navigateToTodo(item)">
+                {{ item.status === 'in_progress' ? '继续处理' : '前往处理' }}
+              </button>
+              <button class="todo-btn dismiss" (click)="dismissTodo(item.id)">忽略</button>
+            </div>
+          </div>
+        </div>
+        }
+        @if (hasMoreTodos) {
+        <button class="todo-expand-btn" (click)="toggleTodoExpand()">
+          {{ todoExpanded ? '收起' : '展开全部 (' + todoItems.length + ')' }}
+          <span class="expand-arrow" [class.up]="todoExpanded">▾</span>
+        </button>
+        }
+        }
+      </div>
+    </app-content-card>
+  </section>
+  }
+
+  <!-- 预警 Tab 无数据空态 -->
+  @if (activeTab === 'alert' && designerRiskRanking.length === 0) {
+  <section class="tab-empty-section">
+    <app-empty-state title="暂无预警" description="当前无设计师风险预警" icon="--" size="md"></app-empty-state>
+  </section>
+  }
+
+  <!-- ④ 工单创建对话框(核心问题 → 创建工单) -->
+  @if (showWorkOrderDialog) {
+  <div class="wo-overlay" (click)="closeWorkOrderDialog()">
+    <div class="wo-container" (click)="$event.stopPropagation()">
+      <div class="wo-header">
+        <h3>创建工单</h3>
+        <button class="wo-close" (click)="closeWorkOrderDialog()" [disabled]="workOrderSaving">×</button>
+      </div>
+
+      <form class="wo-form" (ngSubmit)="submitWorkOrder()" #woForm="ngForm">
+        <div class="wo-field">
+          <label class="wo-label required">工单标题</label>
+          <input type="text" class="wo-input" name="woTitle"
+                 [(ngModel)]="workOrderForm.title" required maxlength="120"
+                 placeholder="工单标题" />
+        </div>
+
+        <div class="wo-row">
+          <div class="wo-field">
+            <label class="wo-label">优先级</label>
+            <select class="wo-input" name="woPriority" [(ngModel)]="workOrderForm.priority">
+              <option value="P0">P0 紧急</option>
+              <option value="P1">P1 重要</option>
+              <option value="P2">P2 关注</option>
+              <option value="P3">P3 提示</option>
+            </select>
+          </div>
+          <div class="wo-field">
+            <label class="wo-label">截止日期</label>
+            <input type="date" class="wo-input" name="woDueDate"
+                   [(ngModel)]="workOrderForm.dueDate" />
+          </div>
+        </div>
+
+        <div class="wo-field">
+          <label class="wo-label">负责人</label>
+          <input type="text" class="wo-input" name="woAssignee"
+                 [(ngModel)]="workOrderForm.assignee"
+                 placeholder="可选,例如:张三 / 产品组" />
+        </div>
+
+        <div class="wo-field">
+          <label class="wo-label">问题描述</label>
+          <textarea class="wo-textarea" name="woDesc" rows="5"
+                    [(ngModel)]="workOrderForm.description"></textarea>
+        </div>
+
+        @if (workOrderError) {
+          <app-error-state
+            variant="inline"
+            icon="⚠"
+            [description]="workOrderError"
+            [showRetry]="false"></app-error-state>
+        }
+
+        <div class="wo-actions">
+          <button type="button" class="wo-btn wo-btn-cancel"
+                  (click)="closeWorkOrderDialog()" [disabled]="workOrderSaving">取消</button>
+          <button type="submit" class="wo-btn wo-btn-submit"
+                  [disabled]="!woForm.valid || workOrderSaving">
+            {{ workOrderSaving ? '保存中...' : '创建工单' }}
+          </button>
+        </div>
+      </form>
+    </div>
+  </div>
+  }
+
+  <!-- ── 核心问题详细分析弹窗 ── -->
+  @if (selectedCoreIssue) {
+  <div class="ci-modal-overlay" (click)="closeCoreIssueModal()">
+    <div class="ci-modal" (click)="$event.stopPropagation()">
+      <div class="ci-modal-header">
+        <div>
+          <div class="ci-modal-title">{{ selectedCoreIssue.name }}</div>
+          <div class="ci-modal-subtitle">{{ selectedCoreIssue.mentionCount | number }} 次提及</div>
+        </div>
+        <button class="ci-modal-close" (click)="closeCoreIssueModal()">×</button>
+      </div>
+      <div class="ci-modal-body">
+        <div class="ci-section">
+          <div class="ci-section-title">指标数据</div>
+          <div class="ci-field"><span class="ci-label">重要度</span><span class="ci-value">{{ selectedCoreIssue.importance }}%</span></div>
+          <div class="ci-field"><span class="ci-label">满足度</span><span class="ci-value">{{ selectedCoreIssue.satisfaction }}%</span></div>
+          <div class="ci-field"><span class="ci-label">提及次数</span><span class="ci-value">{{ selectedCoreIssue.mentionCount | number }}</span></div>
+          <div class="ci-field"><span class="ci-label">优先级</span><span class="ci-value">{{ selectedCoreIssue.priority || '-' }}</span></div>
+          <div class="ci-field"><span class="ci-label">需求强度评分</span><span class="ci-value">{{ ciGetDemandStrengthScore(selectedCoreIssue) }} 分</span></div>
+          @if (selectedCoreIssue.userSegment) {
+          <div class="ci-field"><span class="ci-label">用户群体</span><span class="ci-value">{{ selectedCoreIssue.userSegment }}</span></div>
+          }
+        </div>
+
+        <div class="ci-section">
+          <div class="ci-section-title">需求缺口分析</div>
+          <div class="ci-gap-bar">
+            <div class="ci-gap-header">
+              <span>满足度 {{ selectedCoreIssue.satisfaction }}% → 重要度 {{ selectedCoreIssue.importance }}%</span>
+              <span class="ci-gap-score" [ngClass]="ciGetGapLevel(selectedCoreIssue)">缺口 {{ selectedCoreIssue.importance - selectedCoreIssue.satisfaction }} 分</span>
+            </div>
+            <div class="ci-gap-track">
+              <div class="ci-gap-fill-left" [style.width.%]="selectedCoreIssue.satisfaction"></div>
+              <div class="ci-gap-fill-right" [style.left.%]="selectedCoreIssue.satisfaction" [style.width.%]="selectedCoreIssue.importance - selectedCoreIssue.satisfaction"></div>
+            </div>
+            <div class="ci-gap-legend"><span>当前满足度</span><span>待改进区间</span></div>
+          </div>
+          <app-content-card class="ci-insight-box" title="业务影响评估" density="compact" variant="muted" tone="purple">
+            <div class="ci-insight-row">
+              <span class="ci-insight-label">改进紧急度</span>
+              <span class="ci-score-indicator" [ngClass]="ciGetUrgencyLevel(selectedCoreIssue)">{{ ciGetUrgencyLabel(selectedCoreIssue) }}</span>
+            </div>
+            <div class="ci-insight-row">
+              <span class="ci-insight-label">需求强度评级</span>
+              <span class="ci-insight-value" [ngClass]="ciGetDemandStrengthClass(selectedCoreIssue)">{{ ciGetDemandStrengthLabel(selectedCoreIssue) }}</span>
+            </div>
+            <div class="ci-insight-row">
+              <span class="ci-insight-label">预计退货率影响</span>
+              <span class="ci-insight-value" [ngClass]="selectedCoreIssue.satisfaction < 50 ? 'red' : 'amber'">{{ ciGetReturnImpact(selectedCoreIssue) }}</span>
+            </div>
+            <div class="ci-insight-row">
+              <span class="ci-insight-label">转化提升潜力</span>
+              <span class="ci-insight-value green">{{ ciGetConversionPotential(selectedCoreIssue) }}</span>
+            </div>
+            <div class="ci-insight-row">
+              <span class="ci-insight-label">改进 ROI 评级</span>
+              <span class="ci-insight-value" [ngClass]="ciGetRoiColor(selectedCoreIssue)">{{ ciGetRoiLabel(selectedCoreIssue) }}</span>
+            </div>
+          </app-content-card>
+        </div>
+
+        @if (ciGetRelatedPainPoints(selectedCoreIssue).length) {
+        <div class="ci-section">
+          <div class="ci-section-title">关联痛点(自动匹配)</div>
+          @for (pain of ciGetRelatedPainPoints(selectedCoreIssue); track pain.id) {
+          <app-alert-card class="ci-alert" title="需关注" icon="⚠" tone="amber" density="compact">
+            <strong>{{ pain.painPoint }}</strong>({{ ciGetSeverityLabel(pain.severity) }},{{ pain.affectedUsers }}% 用户受影响)—— {{ pain.description }}
+          </app-alert-card>
+          }
+        </div>
+        }
+
+        @if (selectedCoreIssue.requirement) {
+        <div class="ci-section">
+          <div class="ci-section-title">需求描述</div>
+          <app-alert-card class="ci-alert" title="提示" icon="ℹ" tone="blue" density="compact">{{ selectedCoreIssue.requirement }}</app-alert-card>
+        </div>
+        }
+
+        @if (selectedCoreIssue.relatedKeywords.length) {
+        <div class="ci-section">
+          <div class="ci-section-title">相关关键词</div>
+          <div class="ci-tags">
+            @for (kw of selectedCoreIssue.relatedKeywords; track kw) {
+            <span class="ci-tag">{{ kw }}</span>
+            }
+          </div>
+        </div>
+        }
+
+        @if (selectedCoreIssue.examples.length) {
+        <div class="ci-section">
+          <div class="ci-section-title">用户原声</div>
+          @for (ex of selectedCoreIssue.examples; track ex) {
+          <app-alert-card class="ci-alert" title="用户反馈" icon="💬" tone="blue" density="compact">{{ ex }}</app-alert-card>
+          }
+        </div>
+        }
+
+        <div class="ci-section">
+          <div class="ci-section-title">建议行动路径</div>
+          <div class="ci-action-steps">
+            @for (step of ciGetActionSteps(selectedCoreIssue); track step.title; let i = $index) {
+            <app-content-card class="ci-action-step" density="compact" variant="muted">
+              <div class="ci-step-number">{{ i + 1 }}</div>
+              <div class="ci-step-content">
+                <div class="ci-step-title">{{ step.title }}</div>
+                <div class="ci-step-desc">{{ step.desc }}</div>
+              </div>
+            </app-content-card>
+            }
+          </div>
+        </div>
+
+        @if (selectedCoreIssue.quantifiedBenefit || selectedCoreIssue.productSuggestion) {
+        <div class="ci-section">
+          <div class="ci-section-title">量化收益</div>
+          @if (selectedCoreIssue.quantifiedBenefit) {
+          <div class="ci-field full"><span class="ci-label">改善预估</span><span class="ci-value">{{ selectedCoreIssue.quantifiedBenefit }}</span></div>
+          }
+          @if (selectedCoreIssue.productSuggestion) {
+          <app-alert-card class="ci-alert" title="建议" icon="✓" tone="green" density="compact">{{ selectedCoreIssue.productSuggestion }}</app-alert-card>
+          }
+        </div>
+        }
+      </div>
+    </div>
+  </div>
+  }
+
+</div>

+ 2080 - 0
src/modules/dashboard/dashboard.component.scss

@@ -0,0 +1,2080 @@
+.dashboard-container {
+  min-height: 100vh;
+  background: #F9FAFB;
+  padding: 24px;
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.last-update {
+  font-size: 13px;
+  color: #868E96;
+}
+
+.refresh-btn {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 16px;
+  background: #343A40;
+  color: #FFFFFF;
+  border: none;
+  border-radius: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: background 0.2s ease, transform 0.15s ease;
+
+  &:hover:not(:disabled) {
+    background: #1E1E20;
+    transform: translateY(-1px);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+.refresh-icon {
+  width: 14px;
+  height: 14px;
+  border: 2px solid currentColor;
+  border-left-color: transparent;
+  border-radius: 50%;
+  transition: transform 0.5s ease;
+
+  &.spinning {
+    animation: spin 1s linear infinite;
+  }
+}
+
+.dashboard-scope-strip {
+  display: grid;
+  grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.4fr) minmax(220px, .9fr);
+  gap: 12px;
+  margin: 14px 0 18px;
+}
+
+.scope-block {
+  min-width: 0;
+  padding: 12px 14px;
+  border: 1px solid #E5E7EB;
+  border-radius: 8px;
+  background: #FFFFFF;
+
+  &.primary {
+    border-left: 3px solid #2563EB;
+  }
+
+  &.compact {
+    border-left: 3px solid #F59E0B;
+  }
+
+  .scope-label {
+    display: block;
+    margin-bottom: 5px;
+    color: #64748B;
+    font-size: 11px;
+    font-weight: 700;
+  }
+
+  strong {
+    display: block;
+    color: #111827;
+    font-size: 14px;
+    line-height: 1.35;
+  }
+
+  p {
+    margin: 5px 0 0;
+    color: #64748B;
+    font-size: 12px;
+    line-height: 1.45;
+    word-break: break-word;
+  }
+}
+
+/* ─── 1️⃣ 顶部4个北极星指标卡片 ─── */
+.north-star-section {
+  margin-bottom: 24px;
+}
+
+.north-star-cards {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 16px;
+}
+
+.north-star-card {
+  background: #FFFFFF;
+  border-radius: 10px;
+  padding: 20px;
+  border: 1px solid #E9ECEF;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+  cursor: pointer;
+  transition: all 0.2s ease;
+  position: relative;
+  overflow: hidden;
+
+  &:hover {
+    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+    transform: translateY(-2px);
+  }
+
+  &.warning {
+    border-color: #f59e0b;
+    background: linear-gradient(135deg, #fff 0%, #fffbeb 100%);
+  }
+
+  &.critical {
+    border-color: #ef4444;
+    background: linear-gradient(135deg, #fff 0%, #fef2f2 100%);
+    animation: pulse-border 2s ease-in-out infinite;
+  }
+}
+
+@keyframes pulse-border {
+  0%, 100% { border-color: #ef4444; }
+  50% { border-color: #fca5a5; }
+}
+
+.ns-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.ns-title {
+  font-size: 13px;
+  font-weight: 500;
+  color: #868E96;
+}
+
+.ns-alert {
+  font-size: 14px;
+}
+
+.ns-value {
+  display: flex;
+  align-items: baseline;
+  gap: 4px;
+  margin-bottom: 6px;
+}
+
+.ns-number {
+  font-size: 32px;
+  font-weight: 700;
+  color: #1f2937;
+  line-height: 1;
+}
+
+.ns-unit {
+  font-size: 14px;
+  color: #868E96;
+  font-weight: 500;
+}
+
+.ns-desc {
+  font-size: 11px;
+  color: #9ca3af;
+}
+
+.ns-loading-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(255, 255, 255, 0.8);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  backdrop-filter: blur(2px);
+}
+
+.designer-risk-section {
+  margin-bottom: 24px;
+}
+
+// 顶层粒度筛选条(Tab 瘦身:从 trend tab 内部提升)
+.page-granularity-bar {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 10px 16px;
+  margin-bottom: 16px;
+  border-radius: 10px;
+  background: #fff;
+  border: 1px solid #e5e7eb;
+  flex-wrap: wrap;
+
+  .pg-label {
+    font-size: 12px;
+    font-weight: 600;
+    color: #4b5563;
+    letter-spacing: 0.3px;
+  }
+  .pg-hint {
+    font-size: 11px;
+    color: #9ca3af;
+    margin-left: auto;
+  }
+}
+
+// 趋势卡内粒度指示(提升后仅保留只读提示)
+.trend-gran-indicator {
+  font-size: 12px;
+  color: #6b7280;
+  strong {
+    color: #111827;
+    font-weight: 700;
+    margin-left: 2px;
+  }
+}
+
+// 页内 Tab 切换栏
+.dashboard-board-tabs {
+  display: block;
+  margin-bottom: 20px;
+}
+
+// Tab 无数据空态
+.tab-empty-section {
+  margin: 40px 0;
+}
+
+.designer-risk-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.designer-risk-item {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+  padding: 12px;
+  border-radius: 8px;
+  background: #f9fafb;
+  border-left: 3px solid #ef4444;
+}
+
+.designer-risk-rank {
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  background: #fee2e2;
+  color: #991b1b;
+  font-size: 12px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.designer-risk-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.designer-risk-head {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 7px;
+}
+
+.designer-risk-name {
+  font-size: 14px;
+  font-weight: 700;
+  color: #111827;
+}
+
+.designer-risk-operators {
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.designer-risk-bar {
+  height: 7px;
+  background: #f3f4f6;
+  border-radius: 999px;
+  overflow: hidden;
+}
+
+.designer-risk-fill {
+  display: block;
+  height: 100%;
+  border-radius: 999px;
+  background: linear-gradient(90deg, #fbbf24, #ef4444);
+}
+
+.designer-risk-asins {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+  margin-top: 7px;
+}
+
+.designer-risk-asin {
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 999px;
+  background: #fef2f2;
+  color: #dc2626;
+  text-decoration: none;
+  font-family: monospace;
+}
+
+.designer-risk-asin:hover {
+  background: #fee2e2;
+}
+
+.designer-risk-metrics {
+  display: flex;
+  align-items: center;
+  gap: 7px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  min-width: 270px;
+}
+
+.designer-risk-metric {
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 999px;
+  background: #f3f4f6;
+  color: #4b5563;
+}
+
+.designer-risk-metric.critical {
+  background: #fee2e2;
+  color: #dc2626;
+}
+
+.designer-risk-metric.warning {
+  background: #fef3c7;
+  color: #d97706;
+}
+
+.designer-risk-rate {
+  font-size: 16px;
+  font-weight: 800;
+  color: #ef4444;
+  min-width: 54px;
+  text-align: right;
+}
+
+/* ─── 待办事项 ─── */
+.todo-section {
+  background: #f9fafb;
+  border-radius: 12px;
+  padding: 16px 20px;
+  border: 1px solid #f0f1f3;
+}
+
+.todo-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.todo-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #111827;
+  margin: 0;
+}
+
+.todo-count {
+  font-size: 11px;
+  font-weight: 600;
+  color: #ef4444;
+  background: #fef2f2;
+  padding: 2px 10px;
+  border-radius: 10px;
+}
+
+/* 待办筛选栏 */
+.todo-filters {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 12px;
+  padding: 10px 12px;
+  background: #fff;
+  border-radius: 8px;
+  border: 1px solid #e5e7eb;
+}
+
+.todo-filter-group {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.todo-filter-label {
+  font-size: 11px;
+  font-weight: 600;
+  color: #9ca3af;
+  white-space: nowrap;
+}
+
+.todo-filter-pills {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.todo-pill {
+  padding: 3px 10px;
+  font-size: 11px;
+  font-weight: 500;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  background: #fff;
+  color: #6b7280;
+  cursor: pointer;
+  transition: all 0.15s;
+  white-space: nowrap;
+
+  &:hover {
+    border-color: #d1d5db;
+    background: #f9fafb;
+  }
+
+  &.active {
+    background: #1f2937;
+    color: #fff;
+    border-color: #1f2937;
+  }
+
+  &.active[style*="--pill-color"] {
+    background: var(--pill-color);
+    border-color: var(--pill-color);
+    color: #fff;
+  }
+
+  &.urgency-critical {
+    &.active { background: #ef4444; border-color: #ef4444; color: #fff; }
+    &:not(.active) { color: #ef4444; border-color: #fecaca; }
+  }
+  &.urgency-high {
+    &.active { background: #f97316; border-color: #f97316; color: #fff; }
+    &:not(.active) { color: #f97316; border-color: #fed7aa; }
+  }
+  &.urgency-medium {
+    &.active { background: #eab308; border-color: #eab308; color: #fff; }
+    &:not(.active) { color: #ca8a04; border-color: #fef08a; }
+  }
+  &.urgency-low {
+    &.active { background: #6b7280; border-color: #6b7280; color: #fff; }
+    &:not(.active) { color: #9ca3af; border-color: #e5e7eb; }
+  }
+}
+
+.todo-filter-reset {
+  margin-left: auto;
+  font-size: 11px;
+  color: #3b82f6;
+  background: none;
+  border: none;
+  cursor: pointer;
+  font-weight: 500;
+  padding: 2px 4px;
+  &:hover { text-decoration: underline; }
+}
+
+.todo-expand-btn {
+  margin-left: auto;
+  font-size: 12px;
+  font-weight: 500;
+  color: #6b7280;
+  background: none;
+  border: none;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 2px 6px;
+  border-radius: 4px;
+
+  &:hover { color: #111827; background: #f3f4f6; }
+
+  .expand-arrow {
+    display: inline-block;
+    transition: transform 0.2s;
+    &.up { transform: rotate(180deg); }
+  }
+}
+
+.todo-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.todo-item {
+  display: flex;
+  border-radius: 8px;
+  background: #fff;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+  overflow: hidden;
+  transition: box-shadow 0.15s, opacity 0.25s;
+
+  &:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
+  &.done { opacity: 0.4; pointer-events: none; }
+  &.in-progress { background: #f0fdf4; border: 1px solid #bbf7d0; }
+
+  .todo-urgency-bar {
+    width: 3px;
+    flex-shrink: 0;
+  }
+
+  &.critical .todo-urgency-bar { background: #ef4444; }
+  &.high     .todo-urgency-bar { background: #f59e0b; }
+  &.medium   .todo-urgency-bar { background: #3b82f6; }
+  &.low      .todo-urgency-bar { background: #9ca3af; }
+}
+
+.todo-body {
+  flex: 1;
+  padding: 10px 14px;
+  min-width: 0;
+}
+
+.todo-row-top {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: nowrap;
+}
+
+.todo-module-tag {
+  font-size: 10px;
+  font-weight: 600;
+  color: #fff;
+  padding: 1px 7px;
+  border-radius: 3px;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
+.todo-urgency-tag {
+  font-size: 10px;
+  font-weight: 600;
+  padding: 1px 7px;
+  border-radius: 3px;
+  white-space: nowrap;
+  flex-shrink: 0;
+
+  &.critical { background: #fef2f2; color: #dc2626; }
+  &.high     { background: #fffbeb; color: #d97706; }
+  &.medium   { background: #eff6ff; color: #2563eb; }
+  &.low      { background: #f3f4f6; color: #6b7280; }
+}
+
+.todo-status-tag {
+  font-size: 10px;
+  font-weight: 600;
+  padding: 1px 7px;
+  border-radius: 3px;
+  white-space: nowrap;
+  flex-shrink: 0;
+
+  &.in-progress { background: #d1fae5; color: #065f46; }
+}
+
+.todo-title-text {
+  font-size: 13px;
+  font-weight: 600;
+  color: #111827;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  min-width: 0;
+}
+
+.todo-metric {
+  font-size: 11px;
+  color: #9ca3af;
+  margin-left: auto;
+  white-space: nowrap;
+  flex-shrink: 0;
+
+  strong {
+    color: #374151;
+    font-weight: 700;
+  }
+}
+
+.todo-detail-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-top: 4px;
+}
+
+.todo-summary {
+  font-size: 12px;
+  color: #9ca3af;
+  line-height: 1.4;
+  flex: 1;
+  min-width: 0;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.todo-actions {
+  display: flex;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+.todo-btn {
+  font-size: 11px;
+  font-weight: 600;
+  padding: 3px 12px;
+  border-radius: 5px;
+  border: none;
+  cursor: pointer;
+  transition: background 0.15s;
+
+  &.navigate {
+    background: #111827;
+    color: #fff;
+    &:hover { background: #374151; }
+  }
+
+  &.dismiss {
+    background: #f3f4f6;
+    color: #9ca3af;
+    &:hover { background: #e5e7eb; color: #374151; }
+  }
+}
+
+// 指标网格
+.metrics-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+  margin-bottom: 32px;
+}
+
+.section-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #343A40;
+  margin: 0 0 16px 0;
+}
+
+// 北极星指标
+.north-star-metrics {
+  background: #FFFFFF;
+  padding: 24px;
+  border-radius: 8px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+  border: 1px solid #E9ECEF;
+}
+
+.metrics-row {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+  gap: 24px;
+}
+
+.metric-group {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.group-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #343A40;
+  margin: 0;
+  padding-bottom: 8px;
+  border-bottom: 1px solid #E9ECEF;
+}
+
+.metric-cards {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+// 趋势图表
+.trends-section {
+  background: #FFFFFF;
+  padding: 24px;
+  border-radius: 8px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+  border: 1px solid #E9ECEF;
+  margin-bottom: 32px;
+}
+
+.trend-charts {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+  gap: 24px;
+}
+
+.chart-container {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.chart-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.granularity-switch {
+  display: flex;
+  gap: 4px;
+}
+
+.gran-btn {
+  padding: 4px 12px;
+  font-size: 12px;
+  font-weight: 500;
+  border: 1px solid #E9ECEF;
+  border-radius: 4px;
+  background: #fff;
+  color: #6b7280;
+  cursor: pointer;
+  transition: all 0.15s;
+
+  &:hover { background: #f3f4f6; }
+  &.active {
+    background: #343A40;
+    color: #fff;
+    border-color: #343A40;
+  }
+}
+
+.chart-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #343A40;
+  margin: 0;
+}
+
+.chart-placeholder {
+  height: 280px;
+  border: 1px solid #E9ECEF;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #FAFBFC;
+  position: relative;
+
+  &.loading {
+    background: #f9f9f9;
+  }
+}
+
+.chart-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+  color: #666;
+}
+
+/* ─── 4️⃣ + 5️⃣ 底部双栏 ─── */
+.bottom-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 24px;
+}
+
+.core-problems-section {
+  background: #FFFFFF;
+  padding: 20px 24px;
+  border-radius: 8px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
+  border: 1px solid #E9ECEF;
+}
+
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+
+  .section-title {
+    margin: 0;
+  }
+}
+
+.btn-link {
+  font-size: 13px;
+  color: #3b82f6;
+  background: none;
+  border: none;
+  cursor: pointer;
+  font-weight: 500;
+  &:hover { text-decoration: underline; }
+}
+
+.problems-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.problem-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px;
+  border-radius: 8px;
+  background: #f9fafb;
+  cursor: pointer;
+  transition: all 0.15s;
+  border-left: 3px solid transparent;
+
+  &:hover { background: #f3f4f6; }
+
+  &.critical {
+    border-left-color: #ef4444;
+    background: #fef2f2;
+  }
+  &.warning {
+    border-left-color: #f59e0b;
+    background: #fffbeb;
+  }
+  &.normal {
+    border-left-color: #3b82f6;
+  }
+}
+
+.problem-rank {
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  background: #e5e7eb;
+  color: #374151;
+  font-size: 12px;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  .critical & { background: #fecaca; color: #991b1b; }
+  .warning & { background: #fde68a; color: #92400e; }
+}
+
+.problem-body {
+  flex: 1;
+  min-width: 0;
+}
+
+.problem-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #1f2937;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.problem-meta {
+  display: flex;
+  gap: 12px;
+  margin-top: 2px;
+  font-size: 12px;
+  color: #6b7280;
+}
+
+.problem-severity-tag {
+  font-size: 11px;
+  font-weight: 600;
+  padding: 2px 8px;
+  border-radius: 4px;
+  flex-shrink: 0;
+
+  &.critical { background: #fef2f2; color: #dc2626; }
+  &.warning  { background: #fffbeb; color: #d97706; }
+  &.normal   { background: #eff6ff; color: #2563eb; }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 32px 16px;
+  color: #9ca3af;
+  font-size: 14px;
+}
+
+.section-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+  padding: 32px;
+  color: #9ca3af;
+  font-size: 13px;
+}
+
+// 加载动画
+.loading-spinner {
+  width: 24px;
+  height: 24px;
+  border: 2px solid #e5e5e5;
+  border-top: 2px solid #343A40;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  0% { transform: rotate(0deg); }
+  100% { transform: rotate(360deg); }
+}
+
+// 响应式设计
+@media (max-width: 1200px) {
+  .dashboard-scope-strip {
+    grid-template-columns: 1fr;
+  }
+
+  .north-star-cards {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .metrics-row {
+    grid-template-columns: 1fr;
+  }
+
+  .bottom-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 768px) {
+  .dashboard-container {
+    padding: 12px;
+  }
+
+  .header-content {
+    flex-direction: column;
+    gap: 16px;
+    align-items: flex-start;
+  }
+
+  .page-title {
+    font-size: 24px;
+  }
+
+  .north-star-cards {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .trend-charts {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 480px) {
+  .dashboard-container {
+    padding: 8px;
+  }
+
+  .north-star-cards {
+    grid-template-columns: 1fr;
+  }
+
+  .header-content,
+  .north-star-metrics,
+  .trends-section,
+  .core-problems-section,
+  .todo-section {
+    padding: 16px;
+  }
+
+  .chart-placeholder {
+    height: 200px;
+  }
+
+  .ns-number {
+    font-size: 24px;
+  }
+}
+
+/* ═══════════════════════════════════════════════
+   看板总览 · 新骨架样式(5 区域布局)
+   ═══════════════════════════════════════════════ */
+
+// 面包屑
+.breadcrumb {
+  font-size: 13px;
+  color: #6c757d;
+  margin-bottom: 12px;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+
+  .separator {
+    color: #adb5bd;
+  }
+
+  .current {
+    color: #343A40;
+    font-weight: 500;
+  }
+}
+
+// ① 核心指标区
+.overview-metrics {
+  margin-bottom: 20px;
+
+  .dashboard-metric-cards {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 20px;
+
+    @media (max-width: 1024px) {
+      grid-template-columns: repeat(2, 1fr);
+    }
+
+    @media (max-width: 600px) {
+      grid-template-columns: 1fr;
+    }
+  }
+
+  .dashboard-metric-card {
+    appearance: none;
+    border: 1px solid #E5E7EB;
+    border-radius: 8px;
+    background: #FFFFFF;
+    min-height: 148px;
+    padding: 24px;
+    position: relative;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    text-align: left;
+    color: inherit;
+    font-family: inherit;
+    cursor: pointer;
+    transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      height: 4px;
+      background: var(--metric-color);
+    }
+
+    &:hover:not(:disabled) {
+      transform: translateY(-2px);
+      box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
+      border-color: #D1D5DB;
+    }
+
+    &:disabled {
+      cursor: wait;
+    }
+
+    &.voc { --metric-color: #3B82F6; }
+    &.health { --metric-color: #10B981; }
+    &.problem { --metric-color: #F59E0B; }
+    &.alert { --metric-color: #EF4444; }
+  }
+
+  .metric-label {
+    color: #6B7280;
+    font-size: 13px;
+    font-weight: 600;
+    line-height: 1.4;
+    margin-bottom: 10px;
+  }
+
+  .metric-value {
+    display: flex;
+    align-items: baseline;
+    gap: 6px;
+    color: var(--metric-color);
+    margin-bottom: 6px;
+
+    strong {
+      font-size: 36px;
+      font-weight: 800;
+      line-height: 1.05;
+    }
+
+    .metric-unit {
+      color: #9CA3AF;
+      font-size: 14px;
+      font-weight: 500;
+    }
+  }
+
+  .metric-description,
+  .metric-trend {
+    color: #6B7280;
+    font-size: 13px;
+    font-weight: 500;
+    line-height: 1.5;
+    margin-top: auto;
+  }
+
+  .metric-trend {
+    &.increase { color: #10B981; }
+    &.decrease { color: #EF4444; }
+    &.stable { color: #6B7280; }
+  }
+
+  .dashboard-card-loading {
+    position: absolute;
+    inset: 0;
+    background: rgba(255, 255, 255, 0.74);
+    backdrop-filter: blur(2px);
+
+    &::after {
+      content: '';
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      width: 22px;
+      height: 22px;
+      margin: -11px 0 0 -11px;
+      border: 2px solid #E5E7EB;
+      border-top-color: #111827;
+      border-radius: 50%;
+      animation: spin 1s linear infinite;
+    }
+  }
+
+  .data-quality-strip {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 12px;
+    margin-top: 14px;
+    padding: 10px 12px;
+    border-radius: 8px;
+    background: #f8fafc;
+    border: 1px solid #e5e7eb;
+    font-size: 12px;
+
+    &.estimated,
+    &.partial {
+      background: #fffbeb;
+      border-color: #fde68a;
+    }
+
+    &.empty {
+      background: #fef2f2;
+      border-color: #fecaca;
+    }
+
+    .dq-main,
+    .dq-meta {
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 8px;
+      min-width: 0;
+    }
+
+    .dq-title {
+      font-weight: 700;
+      color: #374151;
+    }
+
+    .dq-source {
+      color: #6b7280;
+    }
+
+    .dq-badge {
+      padding: 2px 8px;
+      border-radius: 999px;
+      font-weight: 700;
+      background: #e0f2fe;
+      color: #0369a1;
+
+      &.real,
+      &.high {
+        background: #dcfce7;
+        color: #166534;
+      }
+
+      &.aggregated,
+      &.medium {
+        background: #e0f2fe;
+        color: #075985;
+      }
+
+      &.estimated,
+      &.partial,
+      &.low {
+        background: #fef3c7;
+        color: #92400e;
+      }
+
+      &.empty {
+        background: #fee2e2;
+        color: #991b1b;
+      }
+    }
+
+    .dq-meta {
+      justify-content: flex-end;
+      color: #6b7280;
+    }
+
+    .dq-warning {
+      color: #b45309;
+      font-weight: 600;
+    }
+
+    @media (max-width: 900px) {
+      align-items: flex-start;
+      flex-direction: column;
+    }
+  }
+}
+
+// ②③ 中部面板
+.overview-middle {
+  display: grid;
+  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
+  gap: 20px;
+  margin-bottom: 20px;
+
+  @media (max-width: 1024px) {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+
+// ④⑤ 底部面板
+.overview-bottom {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 20px;
+  margin-bottom: 20px;
+
+  @media (max-width: 1024px) {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}
+
+// 中部/底部面板内的通用样式(app-content-card 提供外壳)
+.overview-middle,
+.overview-bottom {
+  > app-content-card {
+    min-height: 320px;
+  }
+}
+
+.panel-hint {
+  font-size: 12px;
+  color: #868E96;
+}
+
+.panel-sub {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 12px;
+
+  .compare-pill {
+    font-size: 12px;
+    color: #6c757d;
+    background: #F8F9FA;
+    padding: 4px 10px;
+    border-radius: 12px;
+
+    strong {
+      color: #343A40;
+      font-weight: 600;
+
+      &.up { color: #10b981; }
+      &.down { color: #ef4444; }
+    }
+  }
+}
+
+.chart-area {
+  flex: 1;
+  min-height: 260px;
+  position: relative;
+
+  .echarts-chart {
+    width: 100%;
+    height: 100%;
+  }
+
+  .chart-loading {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    color: #868E96;
+    font-size: 13px;
+  }
+}
+
+// ② 趋势粒度切换按钮
+.granularity-switch {
+  display: inline-flex;
+  background: #F8F9FA;
+  border-radius: 6px;
+  padding: 2px;
+
+  .gran-btn {
+    border: none;
+    background: transparent;
+    padding: 4px 12px;
+    font-size: 12px;
+    color: #6c757d;
+    border-radius: 4px;
+    cursor: pointer;
+    transition: all 0.15s ease;
+
+    &:hover:not(.active) {
+      color: #343A40;
+    }
+
+    &.active {
+      background: #FFFFFF;
+      color: #343A40;
+      font-weight: 600;
+      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+    }
+  }
+}
+
+// ④ 核心问题 Top5
+.issue-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+  border: 1px solid #E5E7EB;
+  border-radius: 8px;
+  background: #FFFFFF;
+  overflow: hidden;
+
+  .issue-item {
+    background: #FFFFFF;
+    border-bottom: 1px solid #F3F4F6;
+    padding: 20px 24px;
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    transition: background 0.2s ease;
+
+    &:hover {
+      background: #F9FAFB;
+    }
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .issue-main {
+      display: flex;
+      align-items: center;
+      gap: 16px;
+      flex-wrap: wrap;
+
+      .issue-rank {
+        width: 32px;
+        height: 32px;
+        border-radius: 6px;
+        background: #F3F4F6;
+        color: #9CA3AF;
+        font-size: 14px;
+        font-weight: 700;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        flex: 0 0 32px;
+      }
+
+      .issue-title {
+        flex: 1;
+        min-width: 180px;
+        color: #111827;
+        font-size: 16px;
+        font-weight: 700;
+      }
+
+      .urgency {
+        padding: 6px 12px;
+        border-radius: 6px;
+        font-size: 12px;
+        font-weight: 700;
+
+        &.critical { background: #FEF2F2; color: #DC2626; }
+        &.high { background: #FEF2F2; color: #DC2626; }
+        &.medium { background: #FFFBEB; color: #D97706; }
+        &.low { background: #F0F9FF; color: #0369A1; }
+      }
+    }
+
+    .issue-meta {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 10px 24px;
+      margin-left: 48px;
+      color: #6B7280;
+      font-size: 13px;
+
+      .meta-item {
+        strong {
+          color: #111827;
+          font-weight: 700;
+        }
+
+        &.urgency {
+          padding: 6px 12px;
+          border-radius: 6px;
+          font-size: 12px;
+          font-weight: 700;
+
+          &.critical { background: #FEF2F2; color: #DC2626; }
+          &.high { background: #FEF2F2; color: #DC2626; }
+          &.medium { background: #FFFBEB; color: #D97706; }
+          &.low { background: #F0F9FF; color: #0369A1; }
+        }
+
+        &.segment {
+          color: #374151;
+        }
+      }
+    }
+
+    .issue-actions {
+      display: flex;
+      gap: 8px;
+      margin-left: 48px;
+
+      .issue-btn {
+        border: 1px solid #D1D5DB;
+        border-radius: 6px;
+        background: #FFFFFF;
+        color: #495057;
+        cursor: pointer;
+        font-size: 13px;
+        font-weight: 600;
+        padding: 8px 16px;
+        transition: all 0.15s ease;
+
+        &:hover {
+          background: #F8F9FA;
+          border-color: #ADB5BD;
+        }
+
+        &.primary {
+          background: #111827;
+          color: #FFFFFF;
+          border-color: #111827;
+
+          &:hover {
+            background: #1F2937;
+          }
+        }
+      }
+    }
+
+    @media (max-width: 640px) {
+      padding: 18px 16px;
+
+      .issue-main {
+        gap: 12px;
+      }
+
+      .issue-meta,
+      .issue-actions {
+        margin-left: 0;
+      }
+
+      .issue-actions {
+        flex-wrap: wrap;
+      }
+    }
+  }
+}
+
+// ⑤ 待办事项精简列表(复用 todo 样式,此处仅容器)
+.todo-list-wrap {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  overflow-y: auto;
+  max-height: 600px;
+
+  .todo-item-compact {
+    background: #F8F9FA;
+    border: 1px solid #E9ECEF;
+    border-radius: 6px;
+    padding: 10px 12px;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    min-width: 0;
+    word-break: break-word;
+
+    &.critical { border-left: 3px solid #C92A2A; }
+    &.high { border-left: 3px solid #D9480F; }
+    &.medium { border-left: 3px solid #B08000; }
+    &.low { border-left: 3px solid #1971C2; }
+
+    &.done { opacity: 0.55; }
+    &.in-progress { background: #FFF9DB; }
+
+    .todo-row-top {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+
+    .todo-detail-row {
+      display: flex;
+      justify-content: space-between;
+      gap: 8px;
+      flex-wrap: wrap;
+
+      .todo-summary {
+        flex: 1;
+        min-width: 0;
+        font-size: 12px;
+        color: #6c757d;
+        line-height: 1.5;
+      }
+
+      .todo-actions {
+        display: flex;
+        gap: 6px;
+        flex-shrink: 0;
+
+        .todo-btn {
+          font-size: 12px;
+          padding: 3px 10px;
+          border: 1px solid #DEE2E6;
+          background: #FFFFFF;
+          border-radius: 4px;
+          cursor: pointer;
+          color: #495057;
+          white-space: nowrap;
+
+          &:hover { background: #F8F9FA; }
+
+          &.navigate {
+            background: #343A40;
+            color: #FFFFFF;
+            border-color: #343A40;
+
+            &:hover { background: #1E1E20; }
+          }
+        }
+      }
+    }
+
+    .todo-title-text {
+      font-size: 13px;
+      color: #343A40;
+      font-weight: 500;
+    }
+
+    .todo-module-tag,
+    .todo-urgency-tag {
+      font-size: 11px;
+      padding: 2px 8px;
+      border-radius: 10px;
+      color: #FFFFFF;
+      white-space: nowrap;
+
+      &.critical { background: #C92A2A; }
+      &.high { background: #D9480F; }
+      &.medium { background: #B08000; }
+      &.low { background: #1971C2; }
+    }
+  }
+
+  .todo-expand-btn {
+    background: transparent;
+    border: 1px dashed #CED4DA;
+    color: #6c757d;
+    padding: 6px;
+    border-radius: 4px;
+    cursor: pointer;
+    font-size: 12px;
+
+    &:hover { background: #F8F9FA; }
+
+    .expand-arrow {
+      display: inline-block;
+      transition: transform 0.15s ease;
+      margin-left: 4px;
+
+      &.up { transform: rotate(180deg); }
+    }
+  }
+}
+
+.issue-list {
+  overflow-y: auto;
+  max-height: 600px;
+}
+
+.empty-state {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ADB5BD;
+  font-size: 13px;
+  padding: 40px 0;
+}
+
+/* ─── 工单创建对话框 ─── */
+.wo-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(15, 23, 42, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+  animation: woFadeIn 0.15s ease-out;
+}
+
+@keyframes woFadeIn {
+  from { opacity: 0; }
+  to   { opacity: 1; }
+}
+
+.wo-container {
+  background: #FFFFFF;
+  width: 520px;
+  max-width: calc(100vw - 32px);
+  max-height: calc(100vh - 64px);
+  border-radius: 12px;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.25);
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.wo-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px 20px;
+  border-bottom: 1px solid #EEF2F6;
+
+  h3 {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 600;
+    color: #0F172A;
+  }
+}
+
+.wo-close {
+  width: 28px;
+  height: 28px;
+  border: none;
+  background: transparent;
+  font-size: 22px;
+  line-height: 1;
+  color: #94A3B8;
+  cursor: pointer;
+  border-radius: 4px;
+
+  &:hover { background: #F1F5F9; color: #475569; }
+  &:disabled { cursor: not-allowed; opacity: 0.4; }
+}
+
+.wo-form {
+  padding: 16px 20px 20px;
+  overflow-y: auto;
+}
+
+.wo-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+}
+
+.wo-field {
+  margin-bottom: 14px;
+  display: flex;
+  flex-direction: column;
+}
+
+.wo-label {
+  font-size: 12px;
+  font-weight: 500;
+  color: #475569;
+  margin-bottom: 6px;
+
+  &.required::after {
+    content: ' *';
+    color: #EF4444;
+  }
+}
+
+.wo-input,
+.wo-textarea {
+  width: 100%;
+  padding: 8px 10px;
+  border: 1px solid #CBD5E1;
+  border-radius: 6px;
+  font-size: 13px;
+  color: #0F172A;
+  background: #FFFFFF;
+  font-family: inherit;
+  box-sizing: border-box;
+  transition: border-color 0.15s, box-shadow 0.15s;
+
+  &:focus {
+    outline: none;
+    border-color: #3B82F6;
+    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
+  }
+}
+
+.wo-textarea {
+  resize: vertical;
+  min-height: 80px;
+  line-height: 1.5;
+}
+
+.wo-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  margin-top: 8px;
+}
+
+.wo-btn {
+  padding: 8px 18px;
+  border-radius: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  border: 1px solid transparent;
+  transition: background 0.15s, border-color 0.15s;
+
+  &:disabled { cursor: not-allowed; opacity: 0.55; }
+}
+
+.wo-btn-cancel {
+  background: #FFFFFF;
+  border-color: #CBD5E1;
+  color: #475569;
+
+  &:hover:not(:disabled) { background: #F8FAFC; border-color: #94A3B8; }
+}
+
+.wo-btn-submit {
+  background: #3B82F6;
+  color: #FFFFFF;
+
+  &:hover:not(:disabled) { background: #2563EB; }
+}
+
+// ── 核心问题详细分析弹窗 ──
+.ci-modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.45);
+  z-index: 1000;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  animation: ciFadeIn 0.2s ease;
+}
+
+@keyframes ciFadeIn {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+.ci-modal {
+  background: #FFFFFF;
+  border-radius: 12px;
+  width: 640px;
+  max-width: 92vw;
+  max-height: 85vh;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
+  animation: ciSlideUp 0.25s ease;
+}
+
+@keyframes ciSlideUp {
+  from { opacity: 0; transform: translateY(24px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.ci-modal-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  padding: 20px 24px 16px;
+  border-bottom: 1px solid #E5E7EB;
+  flex-shrink: 0;
+}
+
+.ci-modal-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #111827;
+  line-height: 1.4;
+}
+
+.ci-modal-subtitle {
+  font-size: 13px;
+  color: #6B7280;
+  margin-top: 2px;
+}
+
+.ci-modal-close {
+  width: 32px;
+  height: 32px;
+  border: none;
+  background: #F3F4F6;
+  border-radius: 6px;
+  font-size: 18px;
+  color: #6B7280;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  transition: background 0.15s;
+
+  &:hover { background: #E5E7EB; color: #111827; }
+}
+
+.ci-modal-body {
+  padding: 20px 24px 24px;
+  overflow-y: auto;
+  flex: 1;
+}
+
+.ci-section {
+  margin-bottom: 20px;
+
+  &:last-child { margin-bottom: 0; }
+}
+
+.ci-section-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #374151;
+  margin-bottom: 10px;
+  padding-bottom: 6px;
+  border-bottom: 1px solid #F3F4F6;
+}
+
+.ci-field {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  margin-right: 16px;
+  margin-bottom: 6px;
+  font-size: 13px;
+
+  &.full { display: flex; margin-right: 0; }
+}
+
+.ci-label {
+  color: #6B7280;
+}
+
+.ci-value {
+  color: #111827;
+  font-weight: 500;
+}
+
+// 缺口分析条
+.ci-gap-bar {
+  margin-bottom: 14px;
+}
+
+.ci-gap-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-size: 13px;
+  color: #374151;
+  margin-bottom: 6px;
+}
+
+.ci-gap-score {
+  font-weight: 600;
+  font-size: 13px;
+  padding: 2px 8px;
+  border-radius: 4px;
+
+  &.critical { background: #FEE2E2; color: #DC2626; }
+  &.high { background: #FEF3C7; color: #D97706; }
+  &.medium { background: #DBEAFE; color: #2563EB; }
+  &.low { background: #D1FAE5; color: #059669; }
+}
+
+.ci-gap-track {
+  position: relative;
+  height: 8px;
+  background: #F3F4F6;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.ci-gap-fill-left {
+  position: absolute;
+  top: 0;
+  left: 0;
+  height: 100%;
+  background: #3B82F6;
+  border-radius: 4px 0 0 4px;
+}
+
+.ci-gap-fill-right {
+  position: absolute;
+  top: 0;
+  height: 100%;
+  background: #F59E0B;
+  border-radius: 0 4px 4px 0;
+}
+
+.ci-gap-legend {
+  display: flex;
+  justify-content: space-between;
+  font-size: 11px;
+  color: #9CA3AF;
+  margin-top: 4px;
+}
+
+// 业务影响评估行
+.ci-insight-box {
+  margin-top: 10px;
+}
+
+.ci-insight-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 0;
+  font-size: 13px;
+
+  & + .ci-insight-row { border-top: 1px solid #F3F4F6; }
+}
+
+.ci-insight-label {
+  color: #6B7280;
+}
+
+.ci-insight-value, .ci-score-indicator {
+  font-weight: 500;
+  font-size: 13px;
+
+  &.critical { color: #DC2626; }
+  &.high { color: #D97706; }
+  &.medium { color: #2563EB; }
+  &.low { color: #059669; }
+  &.strength-high { color: #DC2626; }
+  &.strength-medium { color: #D97706; }
+  &.strength-low { color: #6B7280; }
+  &.green { color: #059669; }
+  &.blue { color: #2563EB; }
+  &.amber { color: #D97706; }
+  &.red { color: #DC2626; }
+}
+
+// 标签
+.ci-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.ci-tag {
+  display: inline-block;
+  padding: 3px 10px;
+  background: #EFF6FF;
+  color: #2563EB;
+  border-radius: 12px;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+// 行动步骤
+.ci-action-steps {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.ci-action-step {
+  ::ng-deep .content-card-body {
+    display: flex;
+    align-items: flex-start;
+    gap: 10px;
+  }
+}
+
+.ci-step-number {
+  width: 24px;
+  height: 24px;
+  border-radius: 50%;
+  background: #3B82F6;
+  color: #FFFFFF;
+  font-size: 12px;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.ci-step-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.ci-step-title {
+  font-size: 13px;
+  font-weight: 600;
+  color: #111827;
+  margin-bottom: 2px;
+}
+
+.ci-step-desc {
+  font-size: 12px;
+  color: #6B7280;
+  line-height: 1.5;
+}
+
+// 提醒卡片间距
+.ci-alert {
+  margin-bottom: 8px;
+
+  &:last-child { margin-bottom: 0; }
+}

+ 1495 - 0
src/modules/dashboard/dashboard.component.ts

@@ -0,0 +1,1495 @@
+import { ChangeDetectorRef, Component, OnInit, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router, RouterModule } from '@angular/router';
+import { Subject, takeUntil, combineLatest, merge, Observable } from 'rxjs';
+import { NgxEchartsModule } from 'ngx-echarts';
+import type { EChartsOption } from 'echarts';
+
+import { DashboardDataService } from '../shared/services/dashboard-data.service';
+import { AlertTodoService } from '../shared/services/alert-todo.service';
+import { TodoStatusStore } from '../shared/services/todo-status-store.service';
+import { CurrentPermissionScope, PermissionService } from '../shared/services/permission.service';
+import { ProductDevelopmentDataService } from '../shared/services/product-development-data.service';
+import { DataStoreService } from '../../app/core/services/data-store.service';
+import { SharedDataCacheService } from '../shared/services/shared-data-cache.service';
+import { MonitoringDataService } from '../shared/services/monitoring-data.service';
+import { ReviewDataUtils } from '../shared/services/review-data-utils';
+import { catchError, of, map } from 'rxjs';
+import { FilterBarComponent, OVERVIEW_TIME_RANGE_OPTIONS, TimeRangeOption } from '../shared/components/filter-bar/filter-bar.component';
+import { 
+  VocMetrics, 
+  FilterOptions, 
+  TimeSeriesData 
+} from '../shared/interfaces/dashboard-interfaces';
+import { DataQualityMeta, buildDataQualityMeta, getDataQualityConfidenceLabel as formatDataQualityConfidenceLabel, getDataQualityStatusLabel as formatDataQualityStatusLabel } from '../shared/interfaces/data-quality.interface';
+import { TodoItem, TodoModule, TODO_MODULE_META } from '../shared/interfaces/alert-todo.interface';
+import { DesignerRiskRankingItem } from '../shared/interfaces/monitoring-interfaces';
+import { CoreRequirement, PainPoint } from '../shared/interfaces/product-development-interface';
+import { AlertCardComponent } from '../shared/components/alert-card/alert-card.component';
+
+// 核心问题项 = 核心需求缺口(来自 RequirementMiningData.coreRequirements)
+export interface CoreIssueItem {
+  id: string;
+  name: string;            // 需求名
+  importance: number;      // 重要度(%)
+  satisfaction: number;    // 满足度(%)
+  satisfactionGap: number; // 缺口 = importance - satisfaction
+  userSegment: string;     // 用户群
+  mentionCount: number;    // 提及次数
+  // 计算属性:缺口大小决定的紧急度
+  urgency: 'critical' | 'high' | 'medium' | 'low';
+  urgencyLabel: string;
+}
+
+// 同比/环比对比数据
+export interface VocCompareData {
+  chainRate: number | null;       // 环比(%)
+  yearRate: number | null;        // 同比(%)
+}
+
+type TrendGranularity = 'day' | 'week' | 'month';
+
+// 工单表单数据(保存到 Parse 的 WorkOrder 表)
+export interface WorkOrderFormData {
+  title: string;
+  description: string;
+  priority: 'P0' | 'P1' | 'P2' | 'P3';
+  assignee: string;
+  dueDate: string;          // YYYY-MM-DD
+  sourceType: 'core_issue';
+  sourceId: string;         // 关联的 CoreIssueItem.id
+  sourceSnapshot: {         // 创建时的快照,便于工单单独消费
+    name: string;
+    importance: number;
+    satisfaction: number;
+    satisfactionGap: number;
+    userSegment: string;
+    mentionCount: number;
+  };
+}
+
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
+import { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
+import { ErrorStateComponent } from '../shared/components/error-state/error-state.component';
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
+import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
+import { BoardTabsComponent, BoardTabItem } from '../shared/components/board-tabs/board-tabs.component';
+
+type DashboardTabKey = 'issues' | 'trend' | 'alert';
+interface ScopedVocMetrics {
+  reviews: any[];
+  periodReviews: any[];
+  positiveEmotionRate: number;
+  satisfactionScore: number;
+  vocVolume: number;
+}
+
+@Component({
+  selector: 'app-dashboard',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    RouterModule,
+    FilterBarComponent,
+    NgxEchartsModule,
+    PageHeaderComponent,
+    EmptyStateComponent,
+    LoadingSpinnerComponent,
+    ErrorStateComponent,
+    ContentCardComponent,
+    SummaryMetricCardComponent,
+    BoardTabsComponent,
+    AlertCardComponent
+  ],
+  templateUrl: './dashboard.component.html',
+  styleUrls: ['./dashboard.component.scss']
+})
+export class DashboardComponent implements OnInit, OnDestroy {
+  private destroy$ = new Subject<void>();
+  private loadCancel$ = new Subject<void>();
+  
+  loading = false;
+  permissionScopeLoading = false;
+  permissionScopeError = '';
+  currentPermissionScope: CurrentPermissionScope | null = null;
+  lastUpdateTime = new Date();
+  currentFilters: FilterOptions = { timeRange: '1year' };
+  readonly overviewTimeRangeOptions: TimeRangeOption[] = OVERVIEW_TIME_RANGE_OPTIONS;
+  
+  vocMetrics?: VocMetrics;
+  todoItems: TodoItem[] = [];
+  todoExpanded = false;
+  readonly todoCollapsedCount = 4;
+  pendingCount = 0;
+  visibleTodos: TodoItem[] = [];
+  hasMoreTodos = false;
+  vocTrendData: TimeSeriesData[] = [];     // 用于绘图,按当前粒度聚合后切片
+  emotionTrendData: TimeSeriesData[] = [];
+  maxVocValue = 1500;
+  // 一年期日级原始数据(用于按粒度聚合 + 同比计算)
+  private vocBaseDailyData: TimeSeriesData[] = [];
+
+  // ─── ① 核心指标区字段(数据接入在第3步) ───
+  todayVocCount?: number;            // 今日 VOC 条数(Parse: Review + Comment)
+  vocMetricUsesProductRatingFallback = false;
+  vocMetricUsesListingVolume = false;
+  scopedPositiveEmotionRate?: number;
+  coreIssueCount?: number;           // 核心问题数(Parse: PainPoint 未闭环)
+  alertCount?: number;               // 预警数量(Parse: Alert 活跃)
+
+  // ─── 页内 Tab 切换 ───
+  activeTab: DashboardTabKey = 'issues';
+  pageTabs: BoardTabItem[] = this.buildPageTabs();
+  setActiveTab(key: string): void {
+    this.activeTab = key as DashboardTabKey;
+  }
+
+  private buildPageTabs(): BoardTabItem[] {
+    return [
+      {
+        key: 'issues',
+        label: '问题概览',
+        iconKey: 'dashboard',
+        badges: this.coreIssueCount ? [{ label: this.coreIssueCount, tone: 'blue' }] : undefined
+      },
+      { key: 'trend', label: '趋势', iconKey: 'trendCapture' },
+      {
+        key: 'alert',
+        label: '预警',
+        iconKey: 'alert',
+        badges: this.alertCount ? [{ label: this.alertCount, tone: 'red' }] : undefined
+      }
+    ];
+  }
+
+  private refreshPageTabs(): void {
+    this.pageTabs = this.buildPageTabs();
+  }
+
+  private refreshTodoView(): void {
+    this.pendingCount = this.todoItems.reduce((count, item) => count + (item.status === 'pending' ? 1 : 0), 0);
+    this.visibleTodos = this.todoExpanded ? this.todoItems : this.todoItems.slice(0, this.todoCollapsedCount);
+    this.hasMoreTodos = this.todoItems.length > this.todoCollapsedCount;
+  }
+
+  // ─── ② VOC 趋势粒度 & 同比/环比(数据接入在第4步) ───
+  trendGranularity: TrendGranularity = 'day';
+  vocCompare?: VocCompareData;
+
+  // ─── ④ 核心问题 Top5 列表 ───
+  coreIssues: CoreIssueItem[] = [];
+  coreRequirementsRaw: CoreRequirement[] = [];
+  painPointsRaw: PainPoint[] = [];
+  selectedCoreIssue: CoreRequirement | null = null;
+  designerRiskRanking: DesignerRiskRankingItem[] = [];
+
+  // ─── ④ 工单创建对话框状态 ───
+  showWorkOrderDialog = false;
+  workOrderSaving = false;
+  workOrderError = '';
+  workOrderForm: WorkOrderFormData = this.emptyWorkOrderForm();
+  private currentIssueForOrder?: CoreIssueItem;
+
+  // ECharts 配置
+  vocChartOption: EChartsOption = {};
+  emotionChartOption: EChartsOption = {};
+
+  constructor(
+    private dashboardDataService: DashboardDataService,
+    private alertTodoService: AlertTodoService,
+    private todoStatusStore: TodoStatusStore,
+    private pdData: ProductDevelopmentDataService,
+    private dataStore: DataStoreService,
+    private sharedCache: SharedDataCacheService,
+    private monitoringDataService: MonitoringDataService,
+    private permissionService: PermissionService,
+    private router: Router,
+    private cdr: ChangeDetectorRef
+  ) {}
+
+  ngOnInit() {
+    this.loadPermissionScope();
+    this.loadData();
+  }
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  private loadPermissionScope(force = false): void {
+    this.permissionScopeLoading = true;
+    this.permissionScopeError = '';
+    this.permissionService.loadCurrentScope(force)
+      .then(scope => {
+        this.currentPermissionScope = scope;
+        this.permissionScopeLoading = false;
+        this.cdr.detectChanges();
+      })
+      .catch(error => {
+        console.warn('[Dashboard] 权限范围加载失败:', error);
+        this.currentPermissionScope = null;
+        this.permissionScopeLoading = false;
+        this.permissionScopeError = '权限范围加载失败,业务数据按空授权保护,不展示未确认范围';
+        this.cdr.detectChanges();
+      });
+  }
+
+  loadData() {
+    this.loadCancel$.next();
+    this.loading = true;
+    this.vocMetrics = undefined;
+    this.todayVocCount = undefined;
+    this.scopedPositiveEmotionRate = undefined;
+    this.coreIssueCount = undefined;
+    this.alertCount = undefined;
+    this.vocMetricUsesProductRatingFallback = false;
+    this.vocMetricUsesListingVolume = false;
+    this.todoItems = [];
+    this.refreshTodoView();
+    this.refreshPageTabs();
+    this.designerRiskRanking = [];
+    this.coreIssues = [];
+    this.vocTrendData = [];
+    this.emotionTrendData = [];
+    this.vocBaseDailyData = [];
+    this.vocCompare = undefined;
+    this.vocChartOption = {};
+    this.emotionChartOption = {};
+    this.cdr.detectChanges();
+    const stop$ = merge(this.destroy$, this.loadCancel$);
+
+    // 先计算 ASIN 范围(维度筛选生效时仅保留匹配的产品 ASIN),再加载所有数据
+    this.getFilteredAsinScope().pipe(
+      takeUntil(stop$)
+    ).subscribe((asinScope: Set<string> | null) => {
+      const dimensionFilters = this.getMonitoringDimensionFilters();
+      const matchReview = (r: any): boolean =>
+        !asinScope || asinScope.has((r.asin || r.Asin || r.rawData?.Asin || '').trim().toUpperCase());
+
+      this.monitoringDataService.getVocMetrics(dimensionFilters).pipe(
+        takeUntil(stop$),
+        catchError(() => of({ vocCount: 0, vocTrend: 0, warningCount: 0, todayCount: 0, yesterdayCount: 0, recentPositiveRate: 0 }))
+      ).subscribe(metrics => {
+        this.vocMetricUsesProductRatingFallback = !!(metrics as any).productRatingFallback;
+        const listingReviewVolume = Number((metrics as any).listingReviewVolume || (metrics as any).productRatingCount || 0);
+        const periodReviewCount = Number((metrics as any).periodReviewCount || 0);
+        const scopedReviewCount = Number((metrics as any).scopedReviewCount || metrics.vocCount || 0);
+        this.vocMetricUsesListingVolume = listingReviewVolume > 0 && listingReviewVolume >= Math.max(periodReviewCount, scopedReviewCount);
+        this.todayVocCount = metrics.todayCount || 0;
+        this.scopedPositiveEmotionRate = metrics.recentPositiveRate || 0;
+        if ((this.alertCount ?? 0) === 0) {
+          this.alertCount = metrics.warningCount || 0;
+          this.refreshPageTabs();
+        }
+        this.vocMetrics = {
+          positiveEmotionRate: metrics.recentPositiveRate || 0,
+          satisfactionScore: 0,
+          returnRate: 0,
+          vocVolume: metrics.vocCount || 0,
+          aiConfidence: 100,
+          sampleCoverage: metrics.vocCount > 0 ? 100 : 0,
+          trend: {
+            value: metrics.recentPositiveRate || 0,
+            change: metrics.vocTrend || 0,
+            changeType: metrics.vocTrend > 0 ? 'increase' : (metrics.vocTrend < 0 ? 'decrease' : 'stable')
+          },
+          dataQuality: this.buildScopedDataQuality(scopedReviewCount, scopedReviewCount, periodReviewCount, {
+            productRatingFallback: !!(metrics as any).productRatingFallback,
+            productCount: Number((metrics as any).productCount || 0),
+            avgProductRating: Number((metrics as any).avgProductRating || 0),
+            listingReviewVolume
+          })
+        };
+        this.loading = false;
+        this.lastUpdateTime = new Date();
+        this.cdr.detectChanges();
+      });
+
+      // 1) VocMetrics — 云函数快速返回,秒级渲染
+      // 2) 趋势图表 — 拉 1 年日级数据(传入 asinScope 以按产品维度筛选评论)
+      combineLatest([
+        this.dashboardDataService.getTrendData('voc', '1year', asinScope),
+        this.dashboardDataService.getTrendData('emotion', '30days', asinScope)
+      ]).pipe(
+        takeUntil(stop$)
+      ).subscribe({
+        next: ([vocDaily, emotionTrend]) => {
+          this.vocBaseDailyData = vocDaily || [];
+          this.emotionTrendData = emotionTrend;
+          this.applyTrendGranularity();
+          this.initEmotionChart();
+          this.cdr.detectChanges();
+        },
+        error: (e) => console.warn('Trend data load failed:', e)
+      });
+
+      // 4) 核心问题数 + 核心问题 Top5 — 走监控数据层评论聚合口径
+      this.monitoringDataService.getCoreIssueSummary(dimensionFilters).pipe(
+        takeUntil(stop$),
+        catchError(() => of([]))
+      ).subscribe(items => {
+        const negativeItems = (items || [])
+          .filter((item: any) => item.sentiment === 'negative')
+          .sort((a: any, b: any) => Number(b.pct || 0) - Number(a.pct || 0));
+        this.coreIssueCount = negativeItems.length;
+        this.refreshPageTabs();
+        this.coreIssues = negativeItems
+          .slice(0, 5)
+          .map((item: any, index: number) => {
+            const pct = Math.round(Number(item.pct || 0));
+            return {
+              id: `voc-core-${index}-${item.topic || 'topic'}`,
+              name: item.topic || 'VOC问题',
+              importance: pct,
+              satisfaction: Math.max(0, 100 - pct),
+              satisfactionGap: pct,
+              userSegment: '负向评论样本',
+              mentionCount: Number(item.count || 0),
+              urgency: pct >= 30 ? 'critical' : (pct >= 15 ? 'high' : 'medium'),
+              urgencyLabel: pct >= 30 ? '严重' : (pct >= 15 ? '高' : '中')
+            } as CoreIssueItem;
+          });
+        this.cdr.detectChanges();
+      });
+
+      // 5) 设计师风险排行(已接受维度筛选)
+      this.loadRiskTodosFromMonitoring(dimensionFilters, stop$);
+      this.monitoringDataService.getDesignerRiskRanking(dimensionFilters).pipe(
+        takeUntil(stop$),
+        catchError(() => of([]))
+      ).subscribe(items => {
+        this.designerRiskRanking = items || [];
+        this.cdr.detectChanges();
+      });
+    });
+  }
+
+  /**
+   * 计算当前维度筛选对应的 ASIN 白名单。
+   * 无维度筛选时返回 null(表示当前账号授权范围内全部通过)。
+   */
+  private getFilteredAsinScope(): Observable<Set<string> | null> {
+    const monFilters = this.currentFilters as any;
+    if (!this.monitoringDataService.hasProductDimensionFilters(monFilters)) {
+      return of(null);
+    }
+    return this.sharedCache.getProducts(5000).pipe(
+      map(products => {
+        const asins = new Set<string>();
+        (products || []).forEach((p: any) => {
+          if (this.monitoringDataService.matchesProductDimensionFilters(p, monFilters)) {
+            const asin = (p.asin || '').trim().toUpperCase();
+            if (asin) asins.add(asin);
+          }
+        });
+        return asins;
+      }),
+      catchError(() => of(null as Set<string> | null))
+    );
+  }
+
+  private loadCoreIssuesFromReviews(asinScope: Set<string> | null, matchReview: (r: any) => boolean, stop$: Observable<unknown>): void {
+    this.sharedCache.getSorftimeReviews(5000).pipe(
+      takeUntil(stop$),
+      map(reviews => {
+        const allReviews = reviews || [];
+        const scopedReviews = allReviews.filter(matchReview);
+        return this.buildCoreIssueItemsFromReviews(scopedReviews);
+      }),
+      catchError(() => of([] as CoreIssueItem[]))
+    ).subscribe(items => {
+      this.coreIssues = items;
+      this.coreIssueCount = items.length;
+      this.refreshPageTabs();
+    });
+  }
+
+  private buildCoreIssueItemsFromReviews(reviews: any[]): CoreIssueItem[] {
+    const negativeReviews = (reviews || []).filter(r => {
+      const star = ReviewDataUtils.getReviewStar(r);
+      return star > 0 && star <= 3;
+    });
+    const rules: Array<{ key: string; name: string; segment: string; patterns: RegExp[] }> = [
+      { key: 'size-fit', name: '尺码/版型问题', segment: '尺码敏感用户', patterns: [/size|sizing|fit|small|large|tight|loose|short|long|petite|plus/i, /尺码|尺寸|偏小|偏大|合身|版型|太紧|太松/] },
+      { key: 'quality', name: '质量/做工问题', segment: '质量敏感用户', patterns: [/quality|poorly|cheap|defect|broken|tear|ripped|seam|stitch|hole/i, /质量|做工|破损|开线|脱线|瑕疵|坏了/] },
+      { key: 'fabric', name: '面料/舒适度问题', segment: '舒适度敏感用户', patterns: [/fabric|material|thin|scratchy|itchy|soft|comfortable|see through|sheer/i, /面料|材质|舒适|刺痒|透|太薄|柔软/] },
+      { key: 'color-desc', name: '颜色/描述不符', segment: '外观预期用户', patterns: [/color|colour|picture|photo|description|different|not as shown|misleading/i, /颜色|色差|图片|描述|不符|不一样/] },
+      { key: 'wash-durability', name: '洗护/耐用性问题', segment: '复购场景用户', patterns: [/wash|washed|shrink|shrunk|fade|pilling|durable/i, /洗|缩水|褪色|起球|耐用/] },
+      { key: 'shipping-package', name: '包装/履约问题', segment: '交付体验用户', patterns: [/shipping|delivery|package|missing|late|arrived/i, /物流|包装|发货|漏发|缺少|晚到/] }
+    ];
+    const buckets = new Map<string, { rule: typeof rules[number]; count: number; stars: number[] }>();
+    negativeReviews.forEach(review => {
+      const text = `${ReviewDataUtils.getReviewTitle(review)} ${ReviewDataUtils.getReviewContent(review)}`;
+      const star = ReviewDataUtils.getReviewStar(review);
+      const matched = rules.find(rule => rule.patterns.some(pattern => pattern.test(text)));
+      const rule = matched || { key: 'general-negative', name: '负面体验集中问题', segment: '差评用户', patterns: [] };
+      const bucket = buckets.get(rule.key) || { rule, count: 0, stars: [] };
+      bucket.count++;
+      bucket.stars.push(star);
+      buckets.set(rule.key, bucket);
+    });
+    const maxCount = Math.max(1, ...Array.from(buckets.values()).map(bucket => bucket.count));
+    return Array.from(buckets.values())
+      .sort((a, b) => b.count - a.count)
+      .slice(0, 5)
+      .map((bucket, index) => {
+        const avgStar = bucket.stars.reduce((sum, star) => sum + star, 0) / Math.max(bucket.stars.length, 1);
+        const importance = Math.min(99, Math.max(45, Math.round((bucket.count / maxCount) * 70 + 25)));
+        const satisfaction = Math.max(10, Math.min(80, Math.round(avgStar / 5 * 100)));
+        const satisfactionGap = Math.max(8, importance - satisfaction);
+        return {
+          id: `review_issue_${bucket.rule.key}_${index}`,
+          name: bucket.rule.name,
+          importance,
+          satisfaction,
+          satisfactionGap,
+          userSegment: bucket.rule.segment,
+          mentionCount: bucket.count,
+          urgency: satisfactionGap >= 60 ? 'critical' : satisfactionGap >= 40 ? 'high' : satisfactionGap >= 20 ? 'medium' : 'low',
+          urgencyLabel: satisfactionGap >= 60 ? '紧急' : satisfactionGap >= 40 ? '重要' : satisfactionGap >= 20 ? '关注' : '提示'
+        } as CoreIssueItem;
+      });
+  }
+
+  /** 把 CoreRequirement 转成视图模型 CoreIssueItem,根据缺口幅度计算紧急度 */
+  private toCoreIssueItem(r: { id: string; name: string; importance: number; satisfaction: number; satisfactionGap: number; userSegment: string; mentionCount: number }): CoreIssueItem {
+    const gap = r.satisfactionGap;
+    let urgency: CoreIssueItem['urgency'];
+    let urgencyLabel: string;
+    if (gap >= 60) { urgency = 'critical'; urgencyLabel = '紧急'; }
+    else if (gap >= 40) { urgency = 'high'; urgencyLabel = '重要'; }
+    else if (gap >= 20) { urgency = 'medium'; urgencyLabel = '关注'; }
+    else { urgency = 'low'; urgencyLabel = '提示'; }
+    return {
+      id: r.id,
+      name: r.name,
+      importance: r.importance,
+      satisfaction: r.satisfaction,
+      satisfactionGap: gap,
+      userSegment: r.userSegment,
+      mentionCount: r.mentionCount,
+      urgency,
+      urgencyLabel
+    };
+  }
+
+  initVocChart() {
+    const g = this.trendGranularity;
+    const dates = this.vocTrendData.map(item => this.formatBucketLabel(new Date(item.timestamp), g));
+    const values = this.vocTrendData.map(item => item.value);
+    const movingAverage = values.map((_, idx) => {
+      const start = Math.max(0, idx - 6);
+      const window = values.slice(start, idx + 1);
+      const avg = window.reduce((sum, value) => sum + value, 0) / Math.max(window.length, 1);
+      return Number(avg.toFixed(1));
+    });
+
+    this.vocChartOption = {
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: { type: 'cross' }
+      },
+      legend: {
+        data: ['VOC日量', '7日均线'],
+        bottom: 0
+      },
+      grid: {
+        left: '3%',
+        right: '3%',
+        bottom: '14%',
+        top: '12%',
+        containLabel: true
+      },
+      toolbox: {
+        feature: {
+          saveAsImage: { title: '导出图片' }
+        }
+      },
+      xAxis: {
+        type: 'category',
+        boundaryGap: false,
+        data: dates
+      },
+      yAxis: {
+        type: 'value',
+        name: '评论量',
+        minInterval: 1
+      },
+      series: [
+        {
+          name: 'VOC日量',
+          type: 'bar',
+          data: values,
+          barMaxWidth: 18,
+          itemStyle: { color: '#60a5fa', borderRadius: [4, 4, 0, 0] }
+        },
+        {
+          name: '7日均线',
+          type: 'line',
+          data: movingAverage,
+          smooth: true,
+          symbol: 'none',
+          itemStyle: { color: '#10b981' },
+          lineStyle: { width: 2 }
+        }
+      ]
+    };
+  }
+
+  initEmotionChart() {
+    // ③ 情绪分布饼图:聚合 emotionTrendData 真实数据为 正向/中性/负向 占比
+    // emotionTrendData.value = 正向占比(%);category = "中性占比,负向占比"
+    let posSum = 0, neuSum = 0, negSum = 0;
+    for (const d of this.emotionTrendData) {
+      posSum += d.value || 0;
+      if (d.category) {
+        const parts = d.category.split(',');
+        neuSum += Number(parts[0]) || 0;
+        negSum += Number(parts[1]) || 0;
+      }
+    }
+    const n = Math.max(this.emotionTrendData.length, 1);
+    const positive = Number((posSum / n).toFixed(1));
+    const neutral = Number((neuSum / n).toFixed(1));
+    const negative = Number((negSum / n).toFixed(1));
+
+    this.emotionChartOption = {
+      tooltip: {
+        trigger: 'item',
+        formatter: '{b}<br/>占比: {c}% ({d}%)'
+      },
+      legend: {
+        bottom: 0,
+        data: ['正向', '中性', '负向']
+      },
+      toolbox: {
+        feature: {
+          saveAsImage: { title: '导出图片' }
+        }
+      },
+      series: [
+        {
+          name: '情绪分布',
+          type: 'pie',
+          radius: ['45%', '70%'],
+          center: ['50%', '45%'],
+          avoidLabelOverlap: true,
+          label: {
+            show: true,
+            formatter: '{b}\n{d}%'
+          },
+          emphasis: {
+            label: { show: true, fontSize: 14, fontWeight: 'bold' },
+            scaleSize: 6
+          },
+          labelLine: { show: true },
+          data: [
+            { value: positive, name: '正向', itemStyle: { color: '#10b981' } },
+            { value: neutral, name: '中性', itemStyle: { color: '#f59e0b' } },
+            { value: negative, name: '负向', itemStyle: { color: '#ef4444' } }
+          ]
+        }
+      ]
+    };
+  }
+
+  // ③ 情绪饼图切片点击 -> 下钻到具体评论(数据接入在第5步打通路由参数)
+  onEmotionSliceClick(event: any) {
+    const polarity = event?.name; // '正向' / '中性' / '负向'
+    const polarityMap: Record<string, string> = { '正向': 'positive', '中性': 'neutral', '负向': 'negative' };
+    const code = polarityMap[polarity];
+    if (!code) return;
+    this.router.navigate(['/monitoring/emotion'], { queryParams: this.buildNavigationQueryParams({ polarity: code }) });
+  }
+
+  private loadTodosForScope(asinScope: Set<string> | null, stop$: Observable<any>, metrics: ScopedVocMetrics): void {
+    this.alertTodoService.getTodos().pipe(
+      takeUntil(stop$),
+      map(items => this.filterTodosByAsinScope(items, asinScope))
+    ).subscribe(items => {
+      this.todoItems = [...this.buildScopedVocTodos(metrics), ...items];
+      // 预警数量 = critical + high 级未闭环待办数
+      this.alertCount = this.todoItems.filter(t =>
+        (t.urgency === 'critical' || t.urgency === 'high') &&
+        t.status !== 'done' && t.status !== 'dismissed'
+      ).length;
+      this.refreshTodoView();
+      this.refreshPageTabs();
+      this.cdr.detectChanges();
+    });
+  }
+
+  private loadRiskTodosFromMonitoring(filters: any, stop$: Observable<any>): void {
+    this.monitoringDataService.getRiskAlertItems(filters).pipe(
+      takeUntil(stop$),
+      catchError(() => of([]))
+    ).subscribe(items => {
+      const now = new Date();
+      this.todoItems = (items || [])
+        .filter((item: any) => item.severity === 'critical' || item.severity === 'warning')
+        .slice(0, 20)
+        .map((item: any) => ({
+          id: `risk-${item.asin}`,
+          module: 'voc_monitoring',
+          urgency: item.severity === 'critical' ? 'critical' : 'high',
+          status: 'pending',
+          title: `${item.asin || '产品'} 负向反馈偏高`,
+          summary: `负向率 ${Number(item.negativeRate || 0).toFixed(1)}%,评论样本 ${item.totalReviews || 0}`,
+          metric: { label: '负向率', value: `${Number(item.negativeRate || 0).toFixed(1)}%` },
+          actionHint: '进入产品 VOC 深度洞察查看该产品评论与问题集中点',
+          routerLink: '/voc-insight/detail',
+          routerQueryParams: item.asin ? { asin: item.asin } : undefined,
+          createdAt: now,
+          sourceId: `risk-${item.asin}`
+        } as TodoItem));
+      this.alertCount = this.todoItems.filter(t =>
+        (t.urgency === 'critical' || t.urgency === 'high') &&
+        t.status !== 'done' && t.status !== 'dismissed'
+      ).length;
+      this.refreshTodoView();
+      this.refreshPageTabs();
+      this.cdr.detectChanges();
+    });
+  }
+
+  private applyScopedEmotionMetrics(reviews: any[]): ScopedVocMetrics {
+    const periodReviews = (reviews || []).filter(r => this.isReviewInCurrentPeriod(r));
+    const validReviews = (reviews || []).filter(r => ReviewDataUtils.getReviewStar(r) > 0);
+    if (!validReviews.length) {
+      this.scopedPositiveEmotionRate = 0;
+      this.vocMetrics = {
+        positiveEmotionRate: 0,
+        satisfactionScore: 0,
+        returnRate: 0,
+        vocVolume: 0,
+        aiConfidence: 100,
+        sampleCoverage: 0,
+        trend: { value: 0, change: 0, changeType: 'stable' },
+      dataQuality: this.buildScopedDataQuality(0, reviews.length, periodReviews.length)
+      };
+      return { reviews, periodReviews, positiveEmotionRate: 0, satisfactionScore: 0, vocVolume: 0 };
+    }
+
+    const positive = validReviews.filter(r => ReviewDataUtils.classifySentiment(ReviewDataUtils.getReviewStar(r)) === 'positive').length;
+    const positiveRate = Number((positive / validReviews.length * 100).toFixed(1));
+    const avgRating = Number((validReviews.reduce((sum, r) => sum + ReviewDataUtils.getReviewStar(r), 0) / validReviews.length).toFixed(2));
+    this.scopedPositiveEmotionRate = positiveRate;
+    this.vocMetrics = {
+      positiveEmotionRate: positiveRate,
+      satisfactionScore: avgRating,
+      returnRate: 0,
+      vocVolume: validReviews.length,
+      aiConfidence: 100,
+      sampleCoverage: 100,
+      trend: { value: positiveRate, change: 0, changeType: 'stable' },
+      dataQuality: this.buildScopedDataQuality(validReviews.length, reviews.length, periodReviews.length)
+    };
+    return { reviews, periodReviews, positiveEmotionRate: positiveRate, satisfactionScore: avgRating, vocVolume: validReviews.length };
+  }
+
+  private filterTodosByAsinScope(items: TodoItem[], asinScope: Set<string> | null): TodoItem[] {
+    if (!asinScope) return items || [];
+    return (items || []).filter(item => {
+      const asins = this.extractAsinsFromTodo(item);
+      if (!asins.length) return false;
+      return asins.some(asin => asinScope.has(asin));
+    });
+  }
+
+  private buildScopedVocTodos(metrics: ScopedVocMetrics): TodoItem[] {
+    const todos: TodoItem[] = [];
+    const now = new Date();
+    if (!metrics.vocVolume) return todos;
+
+    if (metrics.positiveEmotionRate < 68) {
+      todos.push({
+        id: 'scoped-voc-emotion-low',
+        module: 'voc_monitoring',
+        urgency: metrics.positiveEmotionRate < 60 ? 'critical' : 'high',
+        status: 'pending',
+        title: '当前筛选范围正向情绪偏低',
+        summary: `当前筛选范围正向情绪占比 ${metrics.positiveEmotionRate}%,低于 68% 目标值`,
+        metric: { label: '正向情绪', value: `${metrics.positiveEmotionRate}%` },
+        actionHint: '进入情绪分布页面查看当前店铺/维度下的低分评论和集中问题',
+        routerLink: '/monitoring/emotion',
+        createdAt: now,
+        sourceId: 'scoped-voc-emotion'
+      });
+    }
+
+    if (metrics.satisfactionScore < 4.2) {
+      todos.push({
+        id: 'scoped-voc-satisfaction-low',
+        module: 'voc_monitoring',
+        urgency: metrics.satisfactionScore < 3.5 ? 'critical' : 'high',
+        status: 'pending',
+        title: '当前筛选范围评分低于警戒线',
+        summary: `当前筛选范围平均评分 ${metrics.satisfactionScore},低于 4.2 目标值`,
+        metric: { label: '平均评分', value: `${metrics.satisfactionScore}` },
+        actionHint: '进入 VOC 洞察页面分析该店铺/维度下的低分原因',
+        routerLink: '/voc-insight',
+        createdAt: now,
+        sourceId: 'scoped-voc-satisfaction'
+      });
+    }
+
+    return todos;
+  }
+
+  private buildScopedDataQuality(
+    sampleCount: number,
+    scopedSampleCount: number,
+    periodSampleCount: number,
+    options: { productRatingFallback?: boolean; productCount?: number; avgProductRating?: number; listingReviewVolume?: number } = {}
+  ): DataQualityMeta {
+    if (options.productRatingFallback) {
+      return buildDataQualityMeta({
+        status: sampleCount > 0 ? 'estimated' : 'empty',
+        sourceLabel: '商品累计评分/评论数',
+        sampleCount,
+        coverageRate: options.productCount ? 100 : 0,
+        sources: [{ name: 'Product.ratings/ratingsCount', type: 'database', recordCount: options.productCount || 0 }],
+        formula: `当前筛选范围内 Product.ratingsCount 求和;情绪健康按商品累计评分 ${options.avgProductRating || 0} 折算`,
+        warnings: ['当前店铺 ASIN 在 SorftimeReviews 中暂无评论明细,日期筛选无法下钻到逐日评论,已降级使用商品累计评分口径'],
+        missingFields: ['SorftimeReviews asin match']
+      });
+    }
+    if ((options.listingReviewVolume || 0) > sampleCount) {
+      return buildDataQualityMeta({
+        status: sampleCount > 0 ? 'partial' : 'estimated',
+        sourceLabel: 'Listing评论声量 / 评论样本聚合',
+        sampleCount,
+        coverageRate: options.listingReviewVolume ? Number((sampleCount / options.listingReviewVolume * 100).toFixed(1)) : 0,
+        sources: [
+          { name: 'Product.ratingsCount', type: 'database', recordCount: options.productCount || 0 },
+          { name: 'SorftimeReviews', type: 'database', recordCount: sampleCount }
+        ],
+        formula: `顶部声量使用当前筛选范围 Product.ratingsCount 求和;情绪健康、问题和趋势使用 SorftimeReviews 可分析评论样本 ${sampleCount} 条`,
+        warnings: [`可分析评论样本 ${sampleCount.toLocaleString('zh-CN')} 条,低于 Listing 累计评论声量 ${(options.listingReviewVolume || 0).toLocaleString('zh-CN')} 条`],
+        missingFields: []
+      });
+    }
+    return buildDataQualityMeta({
+      status: sampleCount > 0 ? 'aggregated' : 'empty',
+      sourceLabel: '当前筛选评论聚合',
+      sampleCount,
+      coverageRate: scopedSampleCount > 0 ? Number((sampleCount / scopedSampleCount * 100).toFixed(1)) : 0,
+      sources: [{ name: 'SorftimeReviews', type: 'database', recordCount: sampleCount }],
+      formula: '按当前店铺/站点/类目等筛选出的 ASIN 白名单过滤评论;今日VOC单独按所选时间范围统计',
+      warnings: sampleCount > 0 && periodSampleCount === 0 ? ['所选时间范围内暂无新增评论,情绪指标展示当前店铺累计样本'] : (sampleCount > 0 ? [] : ['当前筛选范围内暂无评论样本']),
+      missingFields: []
+    });
+  }
+
+  private isReviewInCurrentPeriod(review: any): boolean {
+    const ts = ReviewDataUtils.parseReviewDate(review);
+    if (!ts) return false;
+    const { start, end } = this.getCurrentDateRange();
+    return ts >= start.getTime() && ts <= end.getTime();
+  }
+
+  private getCurrentDateRange(): { start: Date; end: Date } {
+    const now = new Date();
+    const end = new Date(now);
+    end.setHours(23, 59, 59, 999);
+    const start = new Date(end);
+
+    if (this.currentFilters.timeRange === 'custom' && this.currentFilters.customDateRange?.start && this.currentFilters.customDateRange?.end) {
+      const customStart = new Date(this.currentFilters.customDateRange.start);
+      const customEnd = new Date(this.currentFilters.customDateRange.end);
+      customStart.setHours(0, 0, 0, 0);
+      customEnd.setHours(23, 59, 59, 999);
+      return { start: customStart, end: customEnd };
+    }
+
+    switch (this.currentFilters.timeRange) {
+      case 'today':
+        start.setHours(0, 0, 0, 0);
+        break;
+      case 'thisWeek': {
+        const dow = start.getDay();
+        const offset = dow === 0 ? 6 : dow - 1;
+        start.setDate(start.getDate() - offset);
+        start.setHours(0, 0, 0, 0);
+        break;
+      }
+      case 'thisMonth':
+        start.setDate(1);
+        start.setHours(0, 0, 0, 0);
+        break;
+      case '7days':
+        start.setDate(start.getDate() - 6);
+        start.setHours(0, 0, 0, 0);
+        break;
+      case '3months':
+        start.setMonth(start.getMonth() - 3);
+        start.setHours(0, 0, 0, 0);
+        break;
+      case '1year':
+        start.setFullYear(start.getFullYear() - 1);
+        start.setHours(0, 0, 0, 0);
+        break;
+      case '30days':
+      default:
+        start.setDate(start.getDate() - 29);
+        start.setHours(0, 0, 0, 0);
+        break;
+    }
+    return { start, end };
+  }
+
+  private extractAsinsFromTodo(item: TodoItem): string[] {
+    const values = [
+      item.id,
+      item.sourceId,
+      item.summary,
+      item.title,
+      item.actionHint,
+      ...(Object.values(item.routerQueryParams || {}))
+    ];
+    const found = new Set<string>();
+    values.forEach(value => {
+      String(value || '').toUpperCase().match(/B0[A-Z0-9]{8}/g)?.forEach(asin => found.add(asin));
+    });
+    return [...found];
+  }
+
+  get hasActiveAlerts(): boolean {
+    return (this.alertCount ?? 0) > 0;
+  }
+
+  get vocMetricLabel(): string {
+    if (this.vocMetricUsesListingVolume) return '评论声量';
+    return this.vocMetricUsesProductRatingFallback ? 'VOC样本' : '周期VOC';
+  }
+
+  get vocMetricDescription(): string {
+    if (this.vocMetricUsesListingVolume) return '当前筛选商品累计评论量';
+    return this.vocMetricUsesProductRatingFallback
+      ? '商品累计评论数口径'
+      : '所选时间范围内反馈声量';
+  }
+
+  get emotionMetricDescription(): string {
+    return this.vocMetrics?.trend ? '' : '正向情绪占比';
+  }
+
+  get emotionTrendValue(): string {
+    const change = this.vocMetrics?.trend?.change;
+    if (change === undefined || change === null) {
+      return '';
+    }
+    return `${Math.abs(change).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 1 })}%`;
+  }
+
+  get emotionTrendDirection(): 'up' | 'down' | 'flat' {
+    const changeType = this.vocMetrics?.trend?.changeType;
+    if (changeType === 'increase') {
+      return 'up';
+    }
+    if (changeType === 'decrease') {
+      return 'down';
+    }
+    return 'flat';
+  }
+
+  get alertMetricDescription(): string {
+    return this.hasActiveAlerts ? '触发阈值的紧急预警' : '当前无触发预警';
+  }
+
+  get permissionScopeTitle(): string {
+    if (this.permissionScopeLoading) return '数据范围加载中';
+    if (this.permissionScopeError) return '数据范围未确认';
+    const scope = this.currentPermissionScope;
+    if (!scope) return '数据范围待加载';
+    if (scope.status === 'disabled') return '账号权限已停用';
+    if (scope.isAdmin || scope.productScope?.all) return '管理员全部授权范围';
+    return '账号权限范围';
+  }
+
+  get permissionScopeDescription(): string {
+    if (this.permissionScopeLoading) return '正在读取当前账号可见的店铺、设计师与产品权限';
+    if (this.permissionScopeError) return this.permissionScopeError;
+    const scope = this.currentPermissionScope;
+    if (!scope) return '尚未取得权限范围';
+    if (scope.status === 'disabled') return '当前账号权限状态为停用,业务数据仅供排查';
+    if (scope.isAdmin || scope.productScope?.all) return '当前账号具备全部产品授权,页面仍按当前筛选条件统计';
+
+    const productScope = scope.productScope || {};
+    const stores = this.formatPermissionOptionNames(productScope.stores, productScope.storeKeys, '店铺');
+    const designers = this.formatPermissionOptionNames(productScope.designers, productScope.designerKeys, '设计师');
+    const mode = productScope.mode === 'store_designer_matrix' ? '店铺/设计师组合权限' : '店铺与设计师交集权限';
+    return [stores, designers, mode].filter(Boolean).join(' · ') || '当前账号仅可查看已授权产品数据';
+  }
+
+  get filterScopeDescription(): string {
+    const parts = [
+      `时间:${this.getTimeRangeLabel()}`,
+      `店铺:${this.formatArrayLabel(this.currentFilters.storeNames?.length ? this.currentFilters.storeNames : this.currentFilters.store, '全部店铺')}`,
+      `站点:${this.formatArrayLabel(this.currentFilters.site, '全部站点')}`,
+      `类目:${this.formatArrayLabel(this.currentFilters.categoryNodeIds?.length ? this.currentFilters.categoryNodeIds : this.currentFilters.category, '全部类目')}`,
+      `设计师:${this.formatArrayLabel(this.currentFilters.designerName, '全部设计师')}`,
+      `负责人:${this.formatArrayLabel(this.currentFilters.operatorName, '全部负责人')}`,
+      `开发:${this.formatArrayLabel(this.currentFilters.developerName, '全部开发')}`,
+      `配送:${this.formatArrayLabel(this.currentFilters.fulfillmentType, '全部配送')}`
+    ];
+    return parts.join(' · ');
+  }
+
+  get listingStatusScopeDescription(): string {
+    const statuses = this.currentFilters.itemStatus || [];
+    if (!statuses.length) return 'Listing 状态:全部状态参与统计';
+    return `Listing 状态:${statuses.map(status => this.getListingStatusLabel(Number(status))).join(' / ')} 参与统计`;
+  }
+
+  toggleTodoExpand(): void {
+    this.todoExpanded = !this.todoExpanded;
+    this.refreshTodoView();
+  }
+
+  onFiltersChange(filters: FilterOptions) {
+    this.currentFilters = filters;
+    this.dashboardDataService.invalidateCfCache();
+    this.loadData();
+  }
+
+  refreshData() {
+    this.loadData();
+  }
+
+  // ─── 待办事项方法 ───
+
+  getModuleLabel(module: TodoModule): string {
+    return TODO_MODULE_META[module]?.label || module;
+  }
+
+  getModuleColor(module: TodoModule): string {
+    return TODO_MODULE_META[module]?.color || '#6b7280';
+  }
+
+  getUrgencyLabel(urgency: string): string {
+    const map: Record<string, string> = { critical: '紧急', high: '重要', medium: '关注', low: '提示' };
+    return map[urgency] || urgency;
+  }
+
+  navigateToTodo(item: TodoItem): void {
+    if (!item.routerLink) {
+      console.warn('[dashboard] 待办缺少 routerLink,无法跳转', item.id);
+      return;
+    }
+    const extras: any = {};
+    if (item.routerFragment) extras.fragment = item.routerFragment;
+    const qp = this.buildNavigationQueryParams({ ...(item.routerQueryParams || {}), fromTodo: item.sourceId || item.id });
+    extras.queryParams = qp;
+    this.todoStatusStore.markInProgress(item.sourceId || item.id);
+    this.router.navigate([item.routerLink], extras).then(ok => {
+      if (!ok) console.warn('[dashboard] 待办跳转失败,路由不存在或被守卫拦截', item.routerLink);
+    }).catch(err => console.error('[dashboard] 待办跳转异常', item.routerLink, err));
+  }
+
+  dismissTodo(id: string): void {
+    const item = this.todoItems.find(t => t.id === id);
+    if (item) this.todoStatusStore.dismiss(item.sourceId || item.id);
+    this.todoItems = this.todoItems.filter(t => t.id !== id);
+    this.refreshTodoView();
+  }
+
+  // ─── 状态判断(异常红色高亮触发) ───
+  getEmotionStatus(value?: number): 'normal' | 'warning' | 'critical' {
+    if (!value) return 'normal';
+    if (value < 60) return 'critical';
+    if (value < 68) return 'warning';
+    return 'normal';
+  }
+
+  // 预警数量超阈值时红色高亮
+  getAlertStatus(value?: number): 'normal' | 'warning' | 'critical' {
+    if (value == null) return 'normal';
+    if (value >= 5) return 'critical';
+    if (value >= 3) return 'warning';
+    return 'normal';
+  }
+
+  getDataQualityStatusLabel(status?: DataQualityMeta['status']): string {
+    return status ? formatDataQualityStatusLabel(status) : '未知';
+  }
+
+  getDataQualityConfidenceLabel(confidence?: DataQualityMeta['confidence']): string {
+    return confidence ? formatDataQualityConfidenceLabel(confidence) : '未知置信';
+  }
+
+  getDataQualityWarnings(meta?: DataQualityMeta): string[] {
+    if (!meta) return [];
+    return [...(meta.warnings || []), ...(meta.missingFields || []).map(field => `缺少字段:${field}`)];
+  }
+
+  // ─── ② 趋势粒度切换 ───
+  switchGranularity(g: TrendGranularity) {
+    if (this.trendGranularity === g) return;
+    this.trendGranularity = g;
+    this.applyTrendGranularity();
+  }
+
+  /**
+   * 按当前粒度聚合 vocBaseDailyData,更新 vocTrendData / vocCompare / 重绘图
+   * - day  : 取最近 30 天
+   * - week : ISO 周聚合,取最近 12 周
+   * - month: 月聚合,取最近 6 月
+   * 同比 = 当前最新桶 vs 序列中最早桶(≈1 年前同期)
+   * 环比 = 当前最新桶 vs 上一桶
+   */
+  private applyTrendGranularity() {
+    const allBuckets = this.aggregateByGranularity(this.vocBaseDailyData, this.trendGranularity);
+    const displayCount = this.trendGranularity === 'day' ? 30
+                        : this.trendGranularity === 'week' ? 12
+                        : 6;
+    this.vocTrendData = allBuckets.slice(-displayCount);
+    this.vocCompare = this.computeVocCompare(allBuckets);
+    this.maxVocValue = Math.max(...this.vocTrendData.map(d => d.value), 1);
+    this.initVocChart();
+  }
+
+  private aggregateByGranularity(daily: TimeSeriesData[], g: TrendGranularity): TimeSeriesData[] {
+    const sorted = [...daily].sort((a, b) =>
+      new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
+    );
+    if (g === 'day') return sorted;
+
+    // 按周/月分桶
+    const map = new Map<string, { ts: Date; sum: number }>();
+    sorted.forEach(d => {
+      const dt = new Date(d.timestamp);
+      const bucketStart = g === 'week' ? this.weekStart(dt) : new Date(dt.getFullYear(), dt.getMonth(), 1);
+      const key = bucketStart.toISOString().slice(0, 10);
+      const existing = map.get(key);
+      if (existing) existing.sum += d.value;
+      else map.set(key, { ts: bucketStart, sum: d.value });
+    });
+    return [...map.values()]
+      .sort((a, b) => a.ts.getTime() - b.ts.getTime())
+      .map(({ ts, sum }) => ({ timestamp: ts, value: Math.round(sum) }));
+  }
+
+  /** 按粒度格式化 X 轴标签 */
+  private formatBucketLabel(d: Date, g: TrendGranularity): string {
+    const m = d.getMonth() + 1;
+    const day = d.getDate();
+    if (g === 'month') return `${d.getFullYear()}/${m}`;
+    // day 与 week 都用 MM/DD(week 是周起点日期)
+    return `${m}/${day}`;
+  }
+
+  /** 周一 00:00 作为 ISO 周起点 */
+  private weekStart(d: Date): Date {
+    const dt = new Date(d.getFullYear(), d.getMonth(), d.getDate());
+    const dow = dt.getDay(); // 0=Sun..6=Sat
+    const offset = dow === 0 ? 6 : dow - 1;
+    dt.setDate(dt.getDate() - offset);
+    return dt;
+  }
+
+  formatCompareRate(rate: number | null | undefined): string {
+    if (rate === null || rate === undefined) return '--';
+    return `${rate > 0 ? '+' : ''}${rate}%`;
+  }
+
+  isCompareRateUp(rate: number | null | undefined): boolean {
+    return typeof rate === 'number' && rate > 0;
+  }
+
+  isCompareRateDown(rate: number | null | undefined): boolean {
+    return typeof rate === 'number' && rate < 0;
+  }
+
+  private computeVocCompare(buckets: TimeSeriesData[]): VocCompareData | undefined {
+    if (buckets.length < 2) return undefined;
+    const cur = buckets[buckets.length - 1].value;
+    const prev = buckets[buckets.length - 2].value;
+    const chainRate = this.calcStableCompareRate(cur, prev);
+    // 同比:最早桶 ≈ 1 年前同期(基于 1 年日数据)
+    const yearAgo = buckets[0].value;
+    const yearRate = this.calcStableCompareRate(cur, yearAgo);
+    return { chainRate, yearRate };
+  }
+
+  private calcStableCompareRate(current: number, previous: number): number | null {
+    if (previous < 10) return null;
+    return Number((((current - previous) / previous) * 100).toFixed(1));
+  }
+
+  // ─── ① 核心指标卡片点击下钻 ───
+  drillMetric(metric: 'todayVoc' | 'emotion' | 'coreIssue' | 'alert') {
+    const routeMap: Record<string, string[]> = {
+      todayVoc: ['/monitoring/trend'],
+      emotion: ['/monitoring/emotion'],
+      coreIssue: ['/voc-insight/feature'],
+      alert: ['/monitoring/risk-alert']
+    };
+    const target = routeMap[metric];
+    if (target) this.router.navigate(target, { queryParams: this.buildNavigationQueryParams({ sourceMetric: metric }) });
+  }
+
+  getRiskBarWidth(value: number): number {
+    return Math.min(Math.max(value, 3), 100);
+  }
+
+  private getMonitoringDimensionFilters(): any {
+    return {
+      timeRange: this.currentFilters.timeRange,
+      customDateRange: this.currentFilters.customDateRange,
+      store: this.currentFilters.store,
+      storeNames: this.currentFilters.storeNames,
+      site: this.currentFilters.site,
+      category: this.currentFilters.category,
+      categoryNodeIds: this.currentFilters.categoryNodeIds,
+      designerName: this.currentFilters.designerName,
+      operatorName: this.currentFilters.operatorName,
+      developerName: this.currentFilters.developerName,
+      productSku: this.currentFilters.productSku,
+      sellerSku: this.currentFilters.sellerSku,
+      itemStatus: this.currentFilters.itemStatus,
+      fulfillmentType: this.currentFilters.fulfillmentType
+    };
+  }
+
+  private buildNavigationQueryParams(extra: Record<string, any> = {}): Record<string, any> {
+    return {
+      timeRange: this.currentFilters.timeRange,
+      customStart: this.currentFilters.customDateRange?.start || undefined,
+      customEnd: this.currentFilters.customDateRange?.end || undefined,
+      store: this.currentFilters.store,
+      storeNames: this.currentFilters.storeNames,
+      site: this.currentFilters.site,
+      category: this.currentFilters.category,
+      categoryNodeIds: this.currentFilters.categoryNodeIds,
+      designerName: this.currentFilters.designerName,
+      operatorName: this.currentFilters.operatorName,
+      developerName: this.currentFilters.developerName,
+      productSku: this.currentFilters.productSku,
+      sellerSku: this.currentFilters.sellerSku,
+      itemStatus: this.currentFilters.itemStatus,
+      fulfillmentType: this.currentFilters.fulfillmentType,
+      ...extra
+    };
+  }
+
+  private formatArrayLabel(values: Array<string | number> | undefined, fallback: string, maxItems = 2): string {
+    const list = (values || []).map(value => String(value || '').trim()).filter(Boolean);
+    if (!list.length) return fallback;
+    const visible = list.slice(0, maxItems).join(' / ');
+    return list.length > maxItems ? `${visible} 等 ${list.length} 项` : visible;
+  }
+
+  private formatPermissionOptionNames(options: any[] | undefined, keys: string[] | undefined, fallbackPrefix: string): string {
+    const names = (options || [])
+      .map(option => String(option?.label || option?.name || option?.title || option?.value || option?.key || '').trim())
+      .filter(Boolean);
+    if (names.length) return `${fallbackPrefix}:${this.formatArrayLabel(names, `未限定${fallbackPrefix}`)}`;
+    const keyList = (keys || []).filter(Boolean);
+    if (keyList.length) return `${fallbackPrefix}:${this.formatArrayLabel(keyList, `未限定${fallbackPrefix}`)}`;
+    return `${fallbackPrefix}:未限定`;
+  }
+
+  getTimeRangeLabel(): string {
+    const value = this.currentFilters.timeRange || '1year';
+    const match = this.overviewTimeRangeOptions.find(option => option.value === value);
+    if (value === 'custom' && this.currentFilters.customDateRange?.start && this.currentFilters.customDateRange?.end) {
+      return `${this.currentFilters.customDateRange.start} 至 ${this.currentFilters.customDateRange.end}`;
+    }
+    return match?.label || value;
+  }
+
+  private getListingStatusLabel(status: number): string {
+    const map: Record<number, string> = {
+      1: '在售',
+      2: '停售',
+      3: '下架',
+      4: '已删除'
+    };
+    return map[status] || String(status);
+  }
+
+  // ─── ④ 核心问题相关 ───
+  navigateToIssueDetail(issue: CoreIssueItem) {
+    this.selectedCoreIssue = this.coreRequirementsRaw.find(r => r.id === issue.id) || this.buildCoreRequirementFromIssue(issue);
+  }
+
+  private buildCoreRequirementFromIssue(issue: CoreIssueItem): CoreRequirement {
+    return {
+      id: issue.id,
+      requirement: `${issue.name} 当前重要度 ${issue.importance}%,满足度 ${issue.satisfaction}%,存在 ${issue.satisfactionGap} 分需求缺口。`,
+      requirementKey: issue.id,
+      name: issue.name,
+      frequency: issue.mentionCount,
+      mentionCount: issue.mentionCount,
+      importance: issue.importance,
+      satisfaction: issue.satisfaction,
+      satisfactionGap: issue.satisfactionGap,
+      userSegment: issue.userSegment || '核心问题用户',
+      examples: [],
+      relatedKeywords: [],
+      priority: issue.urgency === 'critical' ? 'P0' : issue.urgency === 'high' ? 'P1' : 'P2'
+    };
+  }
+
+  closeCoreIssueModal() {
+    this.selectedCoreIssue = null;
+  }
+
+  // ─── 核心问题详情弹窗辅助方法 ───
+  ciGetDemandStrengthScore(req: CoreRequirement): number {
+    if (typeof req.demandStrengthScore === 'number' && Number.isFinite(req.demandStrengthScore)) return Math.round(req.demandStrengthScore);
+    const gap = req.importance - req.satisfaction;
+    return Math.min(100, Math.round(req.importance * 0.4 + gap * 0.35 + Math.min(req.mentionCount / 50, 25)));
+  }
+
+  ciGetDemandStrengthClass(req: CoreRequirement): string {
+    const s = this.ciGetDemandStrengthScore(req);
+    if (s >= 80) return 'strength-high';
+    if (s >= 65) return 'strength-medium';
+    return 'strength-low';
+  }
+
+  ciGetDemandStrengthLabel(req: CoreRequirement): string {
+    const s = this.ciGetDemandStrengthScore(req);
+    if (s >= 80) return `高强度(${s})`;
+    if (s >= 65) return `中强度(${s})`;
+    return `待提升(${s})`;
+  }
+
+  ciGetGapLevel(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    if (gap >= 60) return 'critical';
+    if (gap >= 40) return 'high';
+    if (gap >= 20) return 'medium';
+    return 'low';
+  }
+
+  ciGetUrgencyLevel(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    const score = Math.min(100, Math.round(gap * 0.5 + (req.importance / 100) * 30 + (req.mentionCount > 1000 ? 20 : req.mentionCount > 500 ? 10 : 0)));
+    if (score >= 80) return 'critical';
+    if (score >= 60) return 'high';
+    if (score >= 40) return 'medium';
+    return 'low';
+  }
+
+  ciGetUrgencyLabel(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    const score = Math.min(100, Math.round(gap * 0.5 + (req.importance / 100) * 30 + (req.mentionCount > 1000 ? 20 : req.mentionCount > 500 ? 10 : 0)));
+    if (score >= 80) return '极高 - 立即行动';
+    if (score >= 60) return '较高 - 本迭代处理';
+    if (score >= 40) return '中等 - 排入计划';
+    return '可观察';
+  }
+
+  ciGetReturnImpact(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    if (gap >= 60 && req.mentionCount >= 1000) return '高风险:预计贡献退货率 5-8%';
+    if (gap >= 40 && req.mentionCount >= 500) return '中风险:预计贡献退货率 2-5%';
+    if (gap >= 20) return '低风险:预计贡献退货率 1-2%';
+    return '影响较小:< 1%';
+  }
+
+  ciGetConversionPotential(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    if (gap >= 60) return '改善后预计转化率提升 15-25%';
+    if (gap >= 40) return '改善后预计转化率提升 8-15%';
+    if (gap >= 20) return '改善后预计转化率提升 3-8%';
+    return '改善后预计转化率提升 1-3%';
+  }
+
+  ciGetRoiLabel(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    const roi = gap * req.importance / 100;
+    if (roi >= 50) return 'S 级(优先投入)';
+    if (roi >= 30) return 'A 级(建议投入)';
+    if (roi >= 15) return 'B 级(可择机投入)';
+    return 'C 级(低优先级)';
+  }
+
+  ciGetRoiColor(req: CoreRequirement): string {
+    const gap = req.importance - req.satisfaction;
+    const roi = gap * req.importance / 100;
+    if (roi >= 50) return 'green';
+    if (roi >= 30) return 'blue';
+    if (roi >= 15) return 'amber';
+    return 'red';
+  }
+
+  ciGetSeverityLabel(severity: string): string {
+    if (severity === 'high') return '严重';
+    if (severity === 'medium') return '中等';
+    return '轻微';
+  }
+
+  ciGetRelatedPainPoints(req: CoreRequirement): PainPoint[] {
+    if (!this.painPointsRaw?.length) return [];
+    const name = (req.name || '').toLowerCase();
+    const keywords = (req.relatedKeywords || []).map(k => k.toLowerCase());
+    return this.painPointsRaw.filter(p => {
+      const painText = `${p.painPoint} ${p.description}`.toLowerCase();
+      return painText.includes(name) || keywords.some(kw => painText.includes(kw));
+    }).slice(0, 3);
+  }
+
+  ciGetActionSteps(req: CoreRequirement): { title: string; desc: string }[] {
+    const gap = req.importance - req.satisfaction;
+    const steps: { title: string; desc: string }[] = [];
+    if (gap >= 40) {
+      steps.push({ title: '紧急验证', desc: `收集近 30 天「${req.name}」相关退货原因和差评,定位具体失败场景` });
+      steps.push({ title: '打样改进', desc: `针对满足度 ${req.satisfaction}% 的薄弱环节设计 2-3 个改进方案,45 天内完成打样` });
+      steps.push({ title: '小批量验证', desc: '选择改进方案进行小批量生产,通过 A/B 测试评估转化率和退货率变化' });
+      steps.push({ title: '全量推广', desc: `确认改善达标后全量推广,目标满足度提升至 ${Math.min(req.importance, req.satisfaction + gap * 0.6)}%+` });
+    } else if (gap >= 20) {
+      steps.push({ title: '数据诊断', desc: `分析「${req.name}」用户反馈中的具体改进方向,区分核心痛点和次要诉求` });
+      steps.push({ title: '方案设计', desc: '基于 ROI 评级制定改进方案,优先投入产出比最高的方向' });
+      steps.push({ title: '迭代落地', desc: `纳入下一版产品迭代,预计 60 天内可完成改进并上线验证` });
+    } else {
+      steps.push({ title: '持续监控', desc: `当前满足度 ${req.satisfaction}% 表现尚可,保持监控即可` });
+      steps.push({ title: '微调优化', desc: '如有成本可控的优化机会,可在日常迭代中同步推进' });
+    }
+    return steps;
+  }
+
+  /** 打开工单创建对话框,预填核心问题数据 */
+  createWorkOrder(issue: CoreIssueItem) {
+    this.currentIssueForOrder = issue;
+    this.workOrderError = '';
+    // 紧急度 → 优先级映射
+    const priorityMap: Record<CoreIssueItem['urgency'], WorkOrderFormData['priority']> = {
+      critical: 'P0', high: 'P1', medium: 'P2', low: 'P3'
+    };
+    // 默认截止日期 = 7 天后(按优先级越紧越短,可由用户改)
+    const dueOffsetDays: Record<CoreIssueItem['urgency'], number> = {
+      critical: 3, high: 7, medium: 14, low: 30
+    };
+    const due = new Date();
+    due.setDate(due.getDate() + dueOffsetDays[issue.urgency]);
+    const dueDateStr = due.toISOString().slice(0, 10);
+
+    const description = [
+      `用户群: ${issue.userSegment || '—'}`,
+      `重要度: ${issue.importance}%`,
+      `满足度: ${issue.satisfaction}%`,
+      `缺口: ${issue.satisfactionGap} 分`,
+      `提及次数: ${issue.mentionCount} 次`
+    ].join('\n');
+
+    this.workOrderForm = {
+      title: `[需求缺口] ${issue.name}`,
+      description,
+      priority: priorityMap[issue.urgency],
+      assignee: '',
+      dueDate: dueDateStr,
+      sourceType: 'core_issue',
+      sourceId: issue.id,
+      sourceSnapshot: {
+        name: issue.name,
+        importance: issue.importance,
+        satisfaction: issue.satisfaction,
+        satisfactionGap: issue.satisfactionGap,
+        userSegment: issue.userSegment,
+        mentionCount: issue.mentionCount
+      }
+    };
+    this.showWorkOrderDialog = true;
+  }
+
+  closeWorkOrderDialog() {
+    if (this.workOrderSaving) return;
+    this.showWorkOrderDialog = false;
+    this.currentIssueForOrder = undefined;
+    this.workOrderError = '';
+    this.workOrderForm = this.emptyWorkOrderForm();
+  }
+
+  /** 提交工单到 Parse WorkOrder 表 */
+  submitWorkOrder() {
+    if (this.workOrderSaving) return;
+    const f = this.workOrderForm;
+    if (!f.title?.trim()) {
+      this.workOrderError = '标题不能为空';
+      return;
+    }
+    this.workOrderSaving = true;
+    this.workOrderError = '';
+
+    // 构造工单记录:title 作为 uniqueKey 不合适(同名缺口可能多次创建工单),
+    // 使用 sourceId + createdAt 隐含唯一;不传 uniqueKey 走纯插入
+    const suggestionId = this.buildDashboardWorkOrderSuggestionId(f.sourceId);
+    const dueDate = f.dueDate ? new Date(f.dueDate) : undefined;
+    const record = {
+      title: f.title.trim(),
+      description: f.description,
+      priority: f.priority,
+      dueDate: dueDate && !Number.isNaN(dueDate.getTime()) ? dueDate : undefined,
+      status: 'pending',
+      issueType: f.sourceType,
+      issueId: f.sourceId,
+      productId: suggestionId,
+      createdBy: f.assignee?.trim() || 'dashboard',
+      aiAnalysisSnapshot: {
+        suggestionId,
+        actionSuggestionStatus: 'pending',
+        sourceType: f.sourceType,
+        sourceId: f.sourceId,
+        assignee: f.assignee?.trim() || '',
+        sourceSnapshot: f.sourceSnapshot
+      }
+    };
+
+    this.dataStore.saveBatch('WorkOrder', [record]).pipe(
+      takeUntil(this.destroy$),
+      catchError(err => {
+        console.error('[Dashboard] 创建工单失败:', err);
+        this.workOrderError = err?.message || '保存失败,请重试';
+        this.workOrderSaving = false;
+        return of(null);
+      })
+    ).subscribe(result => {
+      if (result === null) return;  // 错误已处理
+      if (!result?.length) {
+        this.workOrderSaving = false;
+        this.workOrderError = '保存失败,请重试';
+        return;
+      }
+      this.workOrderSaving = false;
+      this.showWorkOrderDialog = false;
+      this.currentIssueForOrder = undefined;
+      this.workOrderForm = this.emptyWorkOrderForm();
+      // 简单的成功反馈
+      console.log('[Dashboard] 工单创建成功:', record.title);
+      // 可选:用 alert 给即时反馈,避免没有 toast 服务时用户感知不到
+      alert(`工单已创建,将进入行动建议看板继续闭环:${record.title}`);
+      this.router.navigate(['/action-suggestion'], { queryParams: { fromWorkOrder: suggestionId } });
+    });
+  }
+
+  private buildDashboardWorkOrderSuggestionId(sourceId: string): string {
+    const safeSourceId = String(sourceId || 'core-issue')
+      .replace(/[^a-zA-Z0-9_-]/g, '')
+      .slice(0, 48) || 'core-issue';
+    return `dashboard-core-issue-wo-${safeSourceId}-${Date.now()}`;
+  }
+
+  private emptyWorkOrderForm(): WorkOrderFormData {
+    return {
+      title: '',
+      description: '',
+      priority: 'P2',
+      assignee: '',
+      dueDate: '',
+      sourceType: 'core_issue',
+      sourceId: '',
+      sourceSnapshot: {
+        name: '', importance: 0, satisfaction: 0,
+        satisfactionGap: 0, userSegment: '', mentionCount: 0
+      }
+    };
+  }
+}

+ 329 - 0
src/modules/dashboard/pages/designer-performance/designer-performance.component.html

@@ -0,0 +1,329 @@
+<div class="designer-performance-page">
+  <div class="page-actions">
+    <button class="ghost-btn" (click)="openRiskAlert()">风险预警</button>
+    <button class="ghost-btn" (click)="openReturnAnalysis()">退货分析</button>
+  </div>
+
+  @if (data || loading) {
+    <section class="summary-grid">
+      <app-summary-metric-card
+        label="设计师数"
+        [value]="data ? data.summary.designerCount : null"
+        description="当前筛选结果设计师"
+        tone="blue"
+        icon="👤"
+        [loading]="loading && !data">
+      </app-summary-metric-card>
+      <app-summary-metric-card
+        label="产品数"
+        [value]="data ? data.summary.totalProducts : null"
+        [description]="data ? ('在售 ' + data.summary.activeProducts + ' 个') : ''"
+        icon="📦"
+        [loading]="loading && !data">
+      </app-summary-metric-card>
+      <app-summary-metric-card
+        label="VOC 总量"
+        [value]="data ? data.summary.totalVoc : null"
+        [description]="data ? ('平均差评率 ' + data.summary.avgNegativeRate + '%') : ''"
+        tone="amber"
+        icon="💬"
+        [loading]="loading && !data">
+      </app-summary-metric-card>
+      <app-summary-metric-card
+        label="严重风险"
+        [value]="data ? highRiskCount : null"
+        [description]="data ? ('高危设计师 / 退货 ' + data.summary.totalReturns + ' 件') : ''"
+        tone="red"
+        icon="⚠️"
+        [loading]="loading && !data">
+      </app-summary-metric-card>
+    </section>
+  }
+
+  <app-filter-bar
+    [filters]="currentFilters"
+    [enableDesigner]="true"
+    [designerOptions]="designerFilterOptions"
+    [enableOperator]="true"
+    [enableListingStatus]="true"
+    [enableFulfillmentType]="true"
+    (filtersChange)="onFilterChange($event)">
+  </app-filter-bar>
+
+  @if (loading) {
+    <app-loading-spinner text="设计师表现数据加载中..."></app-loading-spinner>
+  }
+
+  @if (error) {
+    <app-error-state [title]="error" [showRetry]="true" (retry)="loadData()"></app-error-state>
+  }
+
+  @if (data && !loading) {
+    <!-- 高危设计师预警条 -->
+    @if (highRiskCount > 0) {
+      <app-alert-card
+        class="designer-alert-row"
+        tone="red"
+        icon="⚠️"
+        [title]="highRiskCount + ' 位设计师风险偏高需关注'"
+        [description]="'高危: ' + highRiskHeadline + ' | 总设计师: ' + data.summary.designerCount + ' 位'"
+        [primaryMetric]="highRiskCount + ''">
+      </app-alert-card>
+    }
+
+    <!-- 页内 Tab 切换:风险排行 / 趋势图 / 问题分布 / AI分析 -->
+    <app-board-tabs
+      class="designer-board-tabs"
+      [tabs]="pageTabs"
+      [activeKey]="activeTab"
+      (activeKeyChange)="setActiveTab($event)">
+    </app-board-tabs>
+
+    <!-- 风险排行 Tab:设计师健康排行 + 商品下钻(主次分栏) -->
+    @if (activeTab === 'ranking') {
+    <app-split-panel class="designer-ranking-split" ratio="2-1" stackBelow="md">
+      <app-content-card
+        splitPanelSide
+        class="ranking-panel"
+        title="设计师健康排行"
+        subtitle="按健康分升序展示,点击设计师可筛选商品下钻。"
+        [hasHeaderActions]="!!selectedDesigner">
+        @if (selectedDesigner) {
+          <button contentCardActions class="text-btn" (click)="clearDesignerSelection()">清除筛选</button>
+        }
+        <div class="ranking-sort-bar" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:4px 0 8px;font-size:12px;color:#6b7280;">
+          <span>排序:</span>
+          <button type="button" class="sort-th" [class.active]="rankingSortKey === 'health'" (click)="setRankingSort('health')" style="background:none;border:1px solid #e5e7eb;border-radius:6px;padding:3px 10px;cursor:pointer;">健康分 {{ getRankingSortIndicator('health') }}</button>
+          <button type="button" class="sort-th" [class.active]="rankingSortKey === 'rating'" (click)="setRankingSort('rating')" style="background:none;border:1px solid #e5e7eb;border-radius:6px;padding:3px 10px;cursor:pointer;">评分 {{ getRankingSortIndicator('rating') }}</button>
+          <button type="button" class="sort-th" [class.active]="rankingSortKey === 'negative'" (click)="setRankingSort('negative')" style="background:none;border:1px solid #e5e7eb;border-radius:6px;padding:3px 10px;cursor:pointer;">差评率 {{ getRankingSortIndicator('negative') }}</button>
+        </div>
+        <div class="ranking-list">
+          @for (item of sortedRankings(); track item.designerName) {
+            <button class="ranking-item" [class.active]="selectedDesigner === item.designerName" (click)="selectDesigner(item.designerName)">
+              <div class="ranking-top">
+                <div>
+                  <strong>{{ item.designerName }}</strong>
+                  <span>{{ item.operatorNames.join(' / ') || '未分配负责人' }}</span>
+                </div>
+                <em [class]="'risk-pill ' + item.riskLevel">{{ getRiskLevelLabel(item.riskLevel) }}</em>
+              </div>
+              <div class="health-line">
+                <span [style.width.%]="getHealthBarWidth(item.healthScore)"></span>
+              </div>
+              <div class="ranking-metrics">
+                <span>健康分 {{ item.healthScore }}</span>
+                <span>评分 {{ item.avgRating || '-' }}</span>
+                <span>产品 {{ item.productCount }}</span>
+                <span>差评 {{ item.negativeRate }}%</span>
+                <span>退货 {{ item.returnCount }}</span>
+              </div>
+              @if (item.topReasons.length) {
+                <div class="reason-tags">
+                  @for (reason of item.topReasons.slice(0, 3); track reason) {
+                    <span>{{ reason }}</span>
+                  }
+                </div>
+              }
+            </button>
+          } @empty {
+            <app-empty-state title="暂无设计师表现数据" icon="👤" size="sm"></app-empty-state>
+          }
+        </div>
+      </app-content-card>
+
+      <app-data-table-shell
+        splitPanelMain
+        class="products-panel"
+        title="商品下钻列表"
+        [subtitle]="(selectedDesigner || '全部设计师') + ' · 按风险等级与差评率排序'"
+        [count]="filteredProducts.length"
+        variant="analytics"
+        [hasToolbar]="true"
+        [hasPagination]="filteredProducts.length > productPageSize"
+        [empty]="filteredProducts.length === 0"
+        emptyTitle="暂无匹配商品"
+        emptyDescription="尝试调整搜索关键词或清除设计师筛选。"
+        emptyIcon="📦">
+        <input dataTableToolbar class="search-input" [ngModel]="productSearch" (ngModelChange)="onProductSearchChange($event)" placeholder="搜索 ASIN / SKU / 标题 / 负责人" />
+        <table class="product-table">
+          <thead>
+            <tr>
+              <th>商品</th>
+              <th>归属</th>
+              <th>
+                <button type="button" class="sort-th" [class.active]="productSortKey === 'rating'" (click)="setProductSort('rating')">
+                  评分结构 {{ getProductSortIndicator('rating') }}
+                </button>
+              </th>
+              <th>
+                <button type="button" class="sort-th" [class.active]="productSortKey === 'negative'" (click)="setProductSort('negative')">
+                  差评 {{ getProductSortIndicator('negative') }}
+                </button>
+              </th>
+              <th>退货</th>
+              <th>
+                <button type="button" class="sort-th" [class.active]="productSortKey === 'risk'" (click)="setProductSort('risk')">
+                  风险 {{ getProductSortIndicator('risk') }}
+                </button>
+              </th>
+            </tr>
+          </thead>
+          <tbody>
+            @for (product of visibleProducts; track product.asin) {
+              <tr>
+                <td>
+                  <div class="product-info">
+                    <app-product-identity
+                      [imageUrl]="product.photo"
+                      [title]="product.title || product.asin"
+                      [asin]="product.asin"
+                      [productSku]="product.productSku || ''"
+                      [sellerSku]="product.sellerSku || ''"
+                      size="md">
+                    </app-product-identity>
+                  </div>
+                </td>
+                <td>
+                  <div class="owner-tags">
+                    <span>{{ product.designerName }}</span>
+                    @if (product.operatorName) { <span>{{ product.operatorName }}</span> }
+                    @if (product.fulfillmentType) { <span>{{ product.fulfillmentType }}</span> }
+                  </div>
+                </td>
+                <td>
+                  <div class="rating-structure-cell">
+                    <div class="rating-score">
+                      <strong>{{ product.rating || '-' }}</strong>
+                      <small>{{ getTotalStarCount(product.starDistribution) || product.ratingsCount }} 条</small>
+                    </div>
+                    <div class="mini-star-bars">
+                      @for (star of getStarDistributionDesc(product.starDistribution); track star.star) {
+                        <span>
+                          <em>{{ star.star }}★</em>
+                          <i><b [style.width.%]="star.pct"></b></i>
+                          <em>{{ star.count }}</em>
+                        </span>
+                      }
+                    </div>
+                  </div>
+                </td>
+                <td>
+                  <strong>{{ product.negativeReviewCount }}</strong>
+                  <small>差评率 {{ product.negativeRate }}%</small>
+                </td>
+                <td>
+                  <strong>{{ product.returnCount }}</strong>
+                  <small>退货占比 {{ product.returnRate }}%</small>
+                </td>
+                <td>
+                  <span [class]="'product-risk ' + product.riskLevel">{{ getProductRiskLabel(product.riskLevel) }}</span>
+                  @if (product.alerts.length) {
+                    <small>{{ product.alerts[0].message }}</small>
+                  }
+                </td>
+              </tr>
+            }
+          </tbody>
+        </table>
+        <div dataTablePagination class="product-pagination">
+          <div class="pagination-info">
+            显示 {{ productPageStart }}-{{ productPageEnd }} / {{ filteredProducts.length }} 个商品
+          </div>
+          <div class="pagination-size">
+            <span>每页</span>
+            <select [ngModel]="productPageSize" (ngModelChange)="onProductPageSizeChange($event)">
+              @for (size of productPageSizeOptions; track size) {
+                <option [value]="size">{{ size }}</option>
+              }
+            </select>
+          </div>
+          <div class="pagination-actions">
+            <button class="page-btn" [disabled]="productDisplayPage === 1" (click)="onProductPageChange(productDisplayPage - 1)">上一页</button>
+            <span>第 {{ productDisplayPage }} / {{ productTotalPages }} 页</span>
+            <button class="page-btn" [disabled]="productDisplayPage === productTotalPages" (click)="onProductPageChange(productDisplayPage + 1)">下一页</button>
+          </div>
+        </div>
+      </app-data-table-shell>
+    </app-split-panel>
+    }
+
+    <!-- 趋势图 Tab:预留占位 -->
+    @if (activeTab === 'trend') {
+    <section class="tab-empty-section">
+      <app-empty-state
+        title="趋势图暂未实现"
+        description="后续将集成设计师健康度/差评率/退货率时间序列图表"
+        icon="📈"
+        size="md">
+      </app-empty-state>
+    </section>
+    }
+
+    <!-- 问题分布 Tab:设计师 × 问题矩阵 -->
+    @if (activeTab === 'issues') {
+    <section class="tab-section">
+      <app-data-table-shell
+        class="matrix-panel"
+        title="设计师 × 问题矩阵"
+        subtitle="融合 VOC 预警类型与退货原因,定位高频问题。"
+        variant="analytics"
+        [empty]="matrixRows.length === 0"
+        emptyTitle="暂无问题矩阵数据"
+        emptyIcon="📊">
+        <table class="matrix-table">
+          <thead>
+            <tr>
+              <th>设计师</th>
+              @for (issue of data.issueMatrix.issues; track issue) {
+                <th>{{ issue }}</th>
+              }
+              <th>TOP问题</th>
+            </tr>
+          </thead>
+          <tbody>
+            @for (row of matrixRows; track row.designerName) {
+              <tr>
+                <td class="designer-cell">{{ row.designerName }}</td>
+                @for (cell of row.cells; track cell.issue) {
+                  <td>
+                    <span class="matrix-cell"
+                          [class]="cell.riskLevel"
+                          [title]="cell.issueTaxonomy ? cell.issueTaxonomy.categoryLabel + ' / ' + cell.issueTaxonomy.ownerHint + ' / ' + getIssueTaxonomyConfidenceLabel(cell.issueTaxonomy.confidence) : cell.issue">
+                      {{ cell.count }}
+                      <small>{{ cell.pct }}%</small>
+                      @if (cell.count > 0 && cell.issueTaxonomy) {
+                        <small class="taxonomy-owner">{{ cell.issueTaxonomy.ownerHint }}</small>
+                      }
+                    </span>
+                  </td>
+                }
+                <td>
+                  <div class="top-issue-cell">
+                    <strong>{{ row.topIssue || '-' }}</strong>
+                    @if (row.topIssueTaxonomy) {
+                      <span>{{ row.topIssueTaxonomy.ownerHint }} · {{ getIssueTaxonomyConfidenceLabel(row.topIssueTaxonomy.confidence) }}</span>
+                    }
+                  </div>
+                </td>
+              </tr>
+            }
+          </tbody>
+        </table>
+      </app-data-table-shell>
+    </section>
+    }
+
+    <!-- AI分析 Tab:AI 设计师表现诊断 -->
+    @if (activeTab === 'ai') {
+    <app-content-card class="ai-panel">
+      <app-ai-analysis-panel
+        [prompt]="aiPrompt"
+        [systemPrompt]="aiSystemPrompt"
+        promptKey="dashboard.designerPerformance.system"
+        title="AI 设计师表现诊断"
+        [autoGenerate]="false">
+      </app-ai-analysis-panel>
+    </app-content-card>
+    }
+  }
+</div>

+ 529 - 0
src/modules/dashboard/pages/designer-performance/designer-performance.component.scss

@@ -0,0 +1,529 @@
+.designer-performance-page {
+  padding: 24px;
+  min-height: 100vh;
+  background: #f6f8fb;
+  color: #1f2937;
+}
+
+// #3 设计师表现看板:筛选栏滚动时固定,便于在长列表下随时改筛选
+:host ::ng-deep app-filter-bar {
+  position: sticky;
+  top: 0;
+  z-index: 30;
+  display: block;
+  background: #f6f8fb;
+  padding-top: 4px;
+  margin: 0 -24px;
+  padding-left: 24px;
+  padding-right: 24px;
+}
+
+.page-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.ghost-btn,
+.text-btn {
+  border: 1px solid #dbe3ef;
+  background: #fff;
+  color: #374151;
+  border-radius: 10px;
+  padding: 9px 14px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: #6366f1;
+    color: #4f46e5;
+    box-shadow: 0 8px 20px rgba(99, 102, 241, 0.12);
+  }
+}
+
+.text-btn {
+  border: 0;
+  color: #4f46e5;
+  background: #eef2ff;
+}
+
+.loading-card,
+.error-card,
+.empty-state,
+.empty-table {
+  padding: 24px;
+  border-radius: 16px;
+  background: #fff;
+  color: #6b7280;
+  text-align: center;
+}
+
+.error-card {
+  color: #b91c1c;
+  background: #fef2f2;
+}
+
+.summary-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 16px;
+  margin: 0 0 16px;
+}
+
+app-summary-metric-card {
+  display: block;
+  min-width: 0;
+}
+
+/* 设计师排行 + 商品下钻:主次分栏(split-panel 内壳) */
+.designer-ranking-split {
+  display: block;
+  margin-bottom: 18px;
+
+  app-content-card.ranking-panel,
+  app-data-table-shell.products-panel {
+    display: block;
+    height: 100%;
+  }
+}
+
+// 页内 Tab 切换栏
+.designer-board-tabs {
+  display: block;
+  margin-bottom: 18px;
+}
+
+// Tab 通用节区 / 无数据空态
+.tab-section { margin-bottom: 18px; }
+.tab-empty-section { margin: 40px 0; }
+
+/* .panel/.panel-header 外壳样式由 app-content-card 提供 */
+
+.ranking-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  max-height: 680px;
+  overflow: auto;
+  padding-right: 4px;
+}
+
+.ranking-item {
+  width: 100%;
+  display: block;
+  text-align: left;
+  border: 1px solid #eef2f7;
+  background: #fbfdff;
+  border-radius: 16px;
+  padding: 14px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover,
+  &.active {
+    border-color: #818cf8;
+    background: #f5f7ff;
+    transform: translateY(-1px);
+  }
+}
+
+.ranking-top {
+  display: flex;
+  justify-content: space-between;
+  gap: 12px;
+
+  strong {
+    display: block;
+    font-size: 16px;
+    color: #111827;
+  }
+
+  span {
+    display: block;
+    margin-top: 4px;
+    color: #6b7280;
+    font-size: 12px;
+  }
+}
+
+.risk-pill,
+.product-risk {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 999px;
+  padding: 4px 10px;
+  font-size: 12px;
+  font-style: normal;
+  font-weight: 700;
+  white-space: nowrap;
+
+  &.high,
+  &.critical {
+    color: #b91c1c;
+    background: #fee2e2;
+  }
+
+  &.medium,
+  &.warning {
+    color: #b45309;
+    background: #fef3c7;
+  }
+
+  &.low,
+  &.normal {
+    color: #047857;
+    background: #d1fae5;
+  }
+}
+
+.health-line {
+  height: 8px;
+  background: #eef2f7;
+  border-radius: 999px;
+  overflow: hidden;
+  margin: 12px 0;
+
+  span {
+    display: block;
+    height: 100%;
+    border-radius: inherit;
+    background: linear-gradient(90deg, #ef4444 0%, #f59e0b 45%, #10b981 100%);
+  }
+}
+
+.ranking-metrics,
+.reason-tags,
+.owner-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.ranking-metrics span,
+.reason-tags span,
+.owner-tags span {
+  border-radius: 999px;
+  padding: 4px 8px;
+  background: #f3f4f6;
+  color: #4b5563;
+  font-size: 12px;
+}
+
+.reason-tags {
+  margin-top: 10px;
+
+  span {
+    background: #fff7ed;
+    color: #c2410c;
+  }
+}
+
+.matrix-table-wrap,
+.product-table-wrap {
+  overflow: auto;
+}
+
+.matrix-table,
+.product-table {
+  width: 100%;
+  border-collapse: separate;
+  border-spacing: 0;
+  min-width: 920px;
+
+  th,
+  td {
+    padding: 12px;
+    border-bottom: 1px solid #edf2f7;
+    vertical-align: middle;
+    text-align: left;
+  }
+
+  th {
+    color: #6b7280;
+    font-size: 12px;
+    font-weight: 700;
+    background: #f8fafc;
+  }
+
+  td {
+    color: #374151;
+    font-size: 13px;
+  }
+}
+
+.designer-cell {
+  font-weight: 700;
+  color: #111827 !important;
+}
+
+.matrix-cell {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  min-width: 48px;
+  border-radius: 12px;
+  padding: 6px 8px;
+  background: #f3f4f6;
+  font-weight: 800;
+
+  small {
+    margin-top: 2px;
+    font-size: 10px;
+    font-weight: 600;
+  }
+
+  .taxonomy-owner {
+    max-width: 56px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    opacity: 0.8;
+  }
+
+  &.high {
+    color: #b91c1c;
+    background: #fee2e2;
+  }
+
+  &.medium {
+    color: #b45309;
+    background: #fef3c7;
+  }
+
+  &.low {
+    color: #047857;
+    background: #d1fae5;
+  }
+}
+
+.top-issue-cell {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+
+  strong {
+    color: #111827;
+    font-size: 13px;
+  }
+
+  span {
+    color: #64748b;
+    font-size: 11px;
+  }
+}
+
+.products-panel,
+.ai-panel {
+  margin-top: 18px;
+}
+
+.product-header {
+  align-items: center;
+}
+
+.search-input {
+  min-width: 280px;
+  border: 1px solid #dbe3ef;
+  border-radius: 12px;
+  padding: 10px 12px;
+  outline: none;
+
+  &:focus {
+    border-color: #6366f1;
+    box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
+  }
+}
+
+.product-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 300px;
+
+  img {
+    width: 54px;
+    height: 54px;
+    border-radius: 12px;
+    object-fit: cover;
+    background: #f3f4f6;
+  }
+
+  strong,
+  span,
+  small {
+    display: block;
+  }
+
+  strong {
+    max-width: 420px;
+    overflow: hidden;
+    color: #111827;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  span,
+  small {
+    margin-top: 3px;
+    color: #6b7280;
+  }
+}
+
+.product-table td strong {
+  display: block;
+  color: #111827;
+}
+
+.product-table td small {
+  display: block;
+  margin-top: 4px;
+  color: #6b7280;
+}
+
+.sort-th {
+  border: 0;
+  background: transparent;
+  color: #475569;
+  font: inherit;
+  font-weight: 800;
+  padding: 0;
+  cursor: pointer;
+}
+
+.sort-th.active,
+.sort-th:hover {
+  color: #1d4ed8;
+}
+
+.rating-structure-cell {
+  min-width: 190px;
+}
+
+.rating-score {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  margin-bottom: 7px;
+}
+
+.rating-score strong {
+  font-size: 16px;
+}
+
+.mini-star-bars {
+  display: grid;
+  gap: 4px;
+}
+
+.mini-star-bars span {
+  display: grid;
+  grid-template-columns: 26px minmax(70px, 1fr) 36px;
+  align-items: center;
+  gap: 6px;
+  color: #64748b;
+  font-size: 10px;
+  font-weight: 700;
+}
+
+.mini-star-bars em {
+  font-style: normal;
+}
+
+.mini-star-bars i {
+  height: 6px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: #edf2f7;
+}
+
+.mini-star-bars b {
+  display: block;
+  height: 100%;
+  min-width: 2px;
+  border-radius: inherit;
+  background: #2563eb;
+}
+
+.product-pagination {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  width: 100%;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.pagination-size,
+.pagination-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.pagination-size select {
+  border: 1px solid #dbe3ef;
+  border-radius: 8px;
+  padding: 5px 8px;
+  background: #fff;
+  color: #374151;
+  outline: none;
+}
+
+.page-btn {
+  border: 1px solid #dbe3ef;
+  border-radius: 8px;
+  padding: 6px 10px;
+  background: #fff;
+  color: #374151;
+  font-size: 12px;
+  cursor: pointer;
+
+  &:hover:not(:disabled) {
+    border-color: #6366f1;
+    color: #4f46e5;
+  }
+
+  &:disabled {
+    cursor: not-allowed;
+    opacity: 0.45;
+  }
+}
+
+@media (max-width: 1200px) {
+  .summary-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 768px) {
+  .designer-performance-page {
+    padding: 16px;
+  }
+
+  .product-header {
+    flex-direction: column;
+  }
+
+  .summary-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .search-input {
+    min-width: 0;
+    width: 100%;
+  }
+
+  .product-pagination {
+    align-items: flex-start;
+    flex-direction: column;
+  }
+
+  .pagination-actions {
+    width: 100%;
+    justify-content: space-between;
+  }
+}

+ 543 - 0
src/modules/dashboard/pages/designer-performance/designer-performance.component.ts

@@ -0,0 +1,543 @@
+import { CommonModule } from '@angular/common';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { FilterOptions } from '../../../shared/interfaces/voc-data.interface';
+import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
+import { AiAnalysisPanelComponent } from '../../../shared/components/ai-analysis-panel/ai-analysis-panel.component';
+import { DesignerPerformanceService } from '../../../shared/services/designer-performance.service';
+import {
+  DesignerIssueMatrixRow,
+  DesignerPerformanceDashboardData,
+  DesignerPerformanceFilters,
+  DesignerPerformanceRankingItem,
+  DesignerProductDrilldownItem,
+  DesignerStarDistributionPoint
+} from '../../../shared/interfaces/designer-performance.interface';
+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';
+import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
+import { DataTableShellComponent } from '../../../shared/components/data-table-shell/data-table-shell.component';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
+import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component';
+import { AlertCardComponent } from '../../../shared/components/alert-card/alert-card.component';
+import { SplitPanelComponent } from '../../../shared/components/split-panel/split-panel.component';
+import { ProductDimensionService } from '../../../shared/services/product-dimension.service';
+import { Subscription } from 'rxjs';
+
+type DesignerPerformanceTabKey = 'ranking' | 'trend' | 'issues' | 'ai';
+type DesignerProductSortKey = 'risk' | 'rating' | 'negative' | 'return';
+type DesignerRankingSortKey = 'health' | 'rating' | 'negative';
+
+@Component({
+  selector: 'app-designer-performance',
+  standalone: true,
+  imports: [CommonModule, FormsModule, FilterBarComponent, AiAnalysisPanelComponent, SummaryMetricCardComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent, DataTableShellComponent, ContentCardComponent, BoardTabsComponent, ProductIdentityComponent, AlertCardComponent, SplitPanelComponent],
+  templateUrl: './designer-performance.component.html',
+  styleUrls: ['./designer-performance.component.scss']
+})
+export class DesignerPerformanceComponent implements OnInit, OnDestroy {
+  currentFilters: DesignerPerformanceFilters = { timeRange: '30days' };
+  data?: DesignerPerformanceDashboardData;
+  private dataRevision = 0;
+  private highRiskRankingsCacheKey = '';
+  private highRiskRankingsCache: DesignerPerformanceRankingItem[] = [];
+  private highRiskHeadlineCacheKey = '';
+  private highRiskHeadlineCache = '';
+  private selectedDesignerSummaryCacheKey = '';
+  private selectedDesignerSummaryCache: DesignerPerformanceRankingItem | undefined;
+  private pageTabsCacheKey = '';
+  private pageTabsCache: BoardTabItem[] = [];
+  private designerFilterOptionsCacheKey = '';
+  private designerFilterOptionsCache: string[] = [];
+  private dimensionDesignerOptions: string[] = [];
+  private designerOptionsSub?: Subscription;
+  private lastRequestedFilterKey = '';
+  private loadRequestSeq = 0;
+  private initialFilterApplied = false;
+  private initialLoadFallbackId: number | null = null;
+  loading = false;
+  error = '';
+  selectedDesigner = '';
+  designerFilterOptions: string[] = [];
+  productSearch = '';
+  productCurrentPage = 1;
+  productPageSize = 10;
+  productSortKey: DesignerProductSortKey = 'risk';
+  productSortDir: 'asc' | 'desc' = 'desc';
+  readonly productPageSizeOptions = [10, 20, 50];
+  filteredProducts: DesignerProductDrilldownItem[] = [];
+  visibleProducts: DesignerProductDrilldownItem[] = [];
+  productTotalPages = 1;
+  productDisplayPage = 1;
+  productPageStart = 0;
+  productPageEnd = 0;
+  private readonly starDistributionCache = new WeakMap<object, DesignerProductDrilldownItem['starDistribution']>();
+  private readonly starCountCache = new WeakMap<object, number>();
+  lastUpdateTime = new Date();
+
+  constructor(
+    private designerPerformanceService: DesignerPerformanceService,
+    private productDimensionService: ProductDimensionService,
+    private router: Router
+  ) {}
+
+  ngOnInit(): void {
+    this.loadDesignerOptions();
+  }
+
+  ngOnDestroy(): void {
+    if (this.initialLoadFallbackId !== null) {
+      window.clearTimeout(this.initialLoadFallbackId);
+      this.initialLoadFallbackId = null;
+    }
+    this.designerOptionsSub?.unsubscribe();
+  }
+
+  onFilterChange(filters: FilterOptions): void {
+    this.initialFilterApplied = true;
+    if (this.initialLoadFallbackId !== null) {
+      window.clearTimeout(this.initialLoadFallbackId);
+      this.initialLoadFallbackId = null;
+    }
+    const nextFilters = this.normalizeFilters(filters);
+    const nextFilterKey = this.getFilterKey(nextFilters);
+    if (nextFilterKey === this.lastRequestedFilterKey && (this.loading || this.data)) return;
+    this.currentFilters = nextFilters;
+    this.selectedDesigner = this.currentFilters.designerName?.[0] || '';
+    this.productCurrentPage = 1;
+    this.loadData();
+  }
+
+  loadData(): void {
+    if (!this.hasStoreScope(this.currentFilters)) {
+      this.loading = false;
+      this.error = '';
+      this.data = undefined;
+      this.refreshProductView();
+      return;
+    }
+
+    this.lastRequestedFilterKey = this.getFilterKey(this.currentFilters);
+    const requestSeq = ++this.loadRequestSeq;
+    this.loading = true;
+    this.error = '';
+    this.designerPerformanceService.getDashboardData(this.currentFilters).subscribe({
+      next: data => {
+        if (requestSeq !== this.loadRequestSeq) return;
+        this.data = data;
+        this.dataRevision++;
+        this.designerFilterOptions = this.buildDesignerFilterOptions();
+        this.refreshProductView();
+        this.lastUpdateTime = new Date();
+        this.loading = false;
+      },
+      error: err => {
+        if (requestSeq !== this.loadRequestSeq) return;
+        this.error = err?.message || '设计师表现看板加载失败';
+        this.loading = false;
+      }
+    });
+  }
+
+  get rankings(): DesignerPerformanceRankingItem[] {
+    return this.data?.rankings || [];
+  }
+
+  // #23 设计师健康排行:评分/差评独立可排序
+  rankingSortKey: DesignerRankingSortKey = 'health';
+  rankingSortDir: 'asc' | 'desc' = 'asc';
+  setRankingSort(key: DesignerRankingSortKey): void {
+    if (this.rankingSortKey === key) {
+      this.rankingSortDir = this.rankingSortDir === 'desc' ? 'asc' : 'desc';
+      return;
+    }
+    this.rankingSortKey = key;
+    // 默认:健康分升序(差的在前);评分升序;差评率降序
+    this.rankingSortDir = key === 'negative' ? 'desc' : 'asc';
+  }
+  getRankingSortIndicator(key: DesignerRankingSortKey): string {
+    if (this.rankingSortKey !== key) return '';
+    return this.rankingSortDir === 'desc' ? '↓' : '↑';
+  }
+  sortedRankings(): DesignerPerformanceRankingItem[] {
+    const list = [...this.rankings];
+    const val = (it: DesignerPerformanceRankingItem): number =>
+      this.rankingSortKey === 'rating' ? Number(it.avgRating || 0)
+      : this.rankingSortKey === 'negative' ? Number(it.negativeRate || 0)
+      : Number(it.healthScore || 0);
+    const dir = this.rankingSortDir === 'desc' ? -1 : 1;
+    return list.sort((a, b) => (val(a) - val(b)) * dir);
+  }
+
+  get matrixRows(): DesignerIssueMatrixRow[] {
+    return this.data?.issueMatrix.rows || [];
+  }
+
+  // 高风险设计师摘要(预警条用)
+  get highRiskRankings(): DesignerPerformanceRankingItem[] {
+    const key = String(this.dataRevision);
+    if (key === this.highRiskRankingsCacheKey) return this.highRiskRankingsCache;
+    this.highRiskRankingsCacheKey = key;
+    this.highRiskRankingsCache = this.rankings.filter(r => r.riskLevel === 'high');
+    return this.highRiskRankingsCache;
+  }
+  get highRiskCount(): number {
+    return this.highRiskRankings.length;
+  }
+  get highRiskHeadline(): string {
+    const key = String(this.dataRevision);
+    if (key === this.highRiskHeadlineCacheKey) return this.highRiskHeadlineCache;
+    const head = this.highRiskRankings.slice(0, 2)
+      .map(r => `${r.designerName} ${r.negativeRate}%`)
+      .join('、');
+    this.highRiskHeadlineCacheKey = key;
+    this.highRiskHeadlineCache = head || '暂无高风险';
+    return this.highRiskHeadlineCache;
+  }
+
+  private getFilteredProducts(): DesignerProductDrilldownItem[] {
+    const keyword = this.productSearch.trim().toLowerCase();
+    const items = (this.data?.products || []).filter(product => {
+      const matchDesigner = !this.selectedDesigner || product.designerName === this.selectedDesigner;
+      const matchKeyword = !keyword || [
+        product.asin,
+        product.title,
+        product.sellerSku,
+        product.productSku,
+        product.operatorName,
+        product.developerName,
+        product.fulfillmentType
+      ].some(value => String(value || '').toLowerCase().includes(keyword));
+      return matchDesigner && matchKeyword;
+    });
+    return this.sortProducts(items);
+  }
+
+  get selectedDesignerSummary(): DesignerPerformanceRankingItem | undefined {
+    const key = `${this.dataRevision}|${this.selectedDesigner}`;
+    if (key === this.selectedDesignerSummaryCacheKey) return this.selectedDesignerSummaryCache;
+    this.selectedDesignerSummaryCacheKey = key;
+    this.selectedDesignerSummaryCache = this.rankings.find(item => item.designerName === this.selectedDesigner);
+    return this.selectedDesignerSummaryCache;
+  }
+
+  get aiPrompt(): string {
+    if (!this.data) return '';
+    const data = this.data;
+    const summary = data.summary;
+
+    const starDesc = (distribution: DesignerStarDistributionPoint[]): string =>
+      distribution?.length
+        ? [...distribution].sort((a, b) => b.star - a.star).map(point => `${point.star}★${point.count}(${point.pct}%)`).join(' ')
+        : '-';
+
+    const rankingLines = this.rankings.map((item, index) =>
+      `[D${index + 1}] ${item.designerName}: 健康分${item.healthScore}, 风险${item.riskLevel}, 运营${item.operatorNames.join('/') || '-'}, 产品${item.productCount}(在售${item.activeProductCount}), VOC${item.vocCount}条, 平均评分${item.avgRating}, 差评${item.negativeReviewCount}条/${item.negativeRate}%, 退货${item.returnCount}件/${item.returnPct}%, 严重风险${item.criticalRiskCount}个, 预警${item.warningRiskCount}个, 配送${item.fulfillmentTypes.join('/') || '-'}, 星级分布${starDesc(item.starDistribution)}, 主因${item.topReasons.join('/') || '暂无'}`
+    ).join('\n');
+
+    const issueMatrix = data.issueMatrix;
+    const issueHeader = issueMatrix.issues?.length ? `问题维度:${issueMatrix.issues.join('、')}` : '';
+    const matrixLines = issueMatrix.rows.map((row, index) => {
+      const taxonomy = row.topIssueTaxonomy;
+      const confidenceLabel = this.getIssueTaxonomyConfidenceLabel(taxonomy?.confidence);
+      const keywords = taxonomy?.matchedKeywords?.length ? `,命中关键词${taxonomy.matchedKeywords.join('/')}` : '';
+      const cells = row.cells.filter(cell => cell.count > 0)
+        .map(cell => `${cell.issue}:${cell.count}(${cell.pct}%/${cell.riskLevel})`).join(',') || '暂无';
+      return `[M${index + 1}] ${row.designerName}: 问题总数${row.totalIssueCount}, TOP问题${row.topIssue || '暂无'}, 责任角色${taxonomy?.ownerHint || '未识别'}, 置信度${confidenceLabel}${keywords};各问题分布:${cells}`;
+    }).join('\n');
+
+    const reasonMatrix = data.returnReasonMatrix;
+    const reasonHeader = reasonMatrix?.reasons?.length ? `退货原因维度:${reasonMatrix.reasons.join('、')}` : '';
+    const reasonLines = (reasonMatrix?.rows || []).map((row, index) => {
+      const cells = row.cells.filter(cell => cell.count > 0)
+        .map(cell => `${cell.reason}:${cell.count}(${cell.pct}%/${cell.riskLevel})`).join(',') || '暂无';
+      return `[RM${index + 1}] ${row.designerName}: 退货总数${row.totalReturnCount}, TOP原因${row.topReason || '暂无'};各原因分布:${cells}`;
+    }).join('\n');
+
+    const returnRankLines = (data.returnRanking || []).map((item, index) => {
+      const skus = (item.highRiskSkus || []).slice(0, 3)
+        .map(sku => `${sku.asin || sku.sku}(${sku.productName || '-'},退${sku.returnCount ?? '-'}/${sku.returnRate}%,主因${sku.mainReason || '-'})`).join(';') || '暂无';
+      return `[RR${index + 1}] ${item.designerName}: 运营${item.operatorNames.join('/') || '-'}, 产品${item.productCount}, SKU${item.skuCount}, 退货${item.returnCount}件/${item.returnPct}%, 配送${item.fulfillmentTypes.join('/') || '-'}, 主因${item.topReasons.join('/') || '暂无'};高风险SKU:${skus}`;
+    }).join('\n');
+
+    const productScope = this.filteredProducts;
+    const scopeLabel = this.selectedDesigner ? `已下钻设计师「${this.selectedDesigner}」` : '全部设计师';
+    const searchLabel = this.productSearch.trim() ? `,搜索「${this.productSearch.trim()}」` : '';
+    const productLines = productScope.map((product, index) => {
+      const alerts = product.alerts?.length
+        ? product.alerts.map(alert => `${alert.severity}:${alert.type}-${alert.message}`).join(';')
+        : '';
+      return `[P${index + 1}] ${product.asin}/${product.productSku || product.sellerSku || '-'}: 标题${product.title || '-'}, 设计师${product.designerName}, 负责人${product.operatorName || '-'}, 开发${product.developerName || '-'}, 状态${product.itemStatus ?? '-'}, 配送${product.fulfillmentType || '-'}, 月销${product.monthlySales}, 评分${product.rating}(${product.ratingsCount}评), 差评${product.negativeReviewCount}条/${product.negativeRate}%, VOC${product.vocCount}条, 退货${product.returnCount}件/${product.returnRate}%, 风险${product.riskLevel}, 星级分布${starDesc(product.starDistribution)}${alerts ? `, 预警${alerts}` : ''}`;
+    }).join('\n');
+
+    const sampleWarnings: string[] = [];
+    if (summary.totalVoc < 30) sampleWarnings.push('VOC样本少于30条,差评率结论需降级为趋势判断');
+    if (summary.totalReturns < 10) sampleWarnings.push('退货样本少于10件,退货主因仅作线索');
+    if (issueMatrix.rows.length < 3) sampleWarnings.push('问题矩阵覆盖设计师少于3人,避免过度横向排名');
+
+    return [
+      `【设计师表现总览】设计师${summary.designerCount}人,产品${summary.totalProducts}个,在售${summary.activeProducts}个,VOC${summary.totalVoc}条,退货${summary.totalReturns}件,严重风险${summary.criticalRiskCount}个,平均差评率${summary.avgNegativeRate}%`,
+      `【健康排行·全部${this.rankings.length}人】\n${rankingLines || '暂无'}`,
+      `【统一问题归因矩阵·全部${issueMatrix.rows.length}行】${issueHeader ? '\n' + issueHeader : ''}\n${matrixLines || '暂无'}`,
+      `【退货原因矩阵·全部${reasonMatrix?.rows?.length || 0}行】${reasonHeader ? '\n' + reasonHeader : ''}\n${reasonLines || '暂无'}`,
+      `【退货风险排行·全部${(data.returnRanking || []).length}人】\n${returnRankLines || '暂无'}`,
+      `【下钻商品证据·${scopeLabel}${searchLabel},共${productScope.length}个】\n${productLines || '暂无'}`,
+      `【证据链约束】输出结论必须引用[D]/[M]/[RM]/[RR]/[P]证据编号;优先使用统一问题归因与退货原因矩阵中的责任角色与置信度;${sampleWarnings.length ? sampleWarnings.join(';') : '当前样本量可支持方向性判断'}`
+    ].join('\n') + '\n\n请基于以上完整数据输出设计师表现诊断:①高风险设计师及核心原因 ②优先处理的商品/问题 ③设计、运营、开发协同建议。控制在400字内。';
+  }
+
+  get aiSystemPrompt(): string {
+    return '你是一名亚马逊跨境电商VOC与退货风险分析专家,擅长从设计师、运营负责人和商品维度定位产品问题。请用中文、Markdown格式输出,结论要可执行,并引用用户提供的证据编号。';
+  }
+
+  selectDesigner(designerName: string): void {
+    this.selectedDesigner = this.selectedDesigner === designerName ? '' : designerName;
+    this.productCurrentPage = 1;
+    this.refreshProductView();
+  }
+
+  clearDesignerSelection(): void {
+    this.selectedDesigner = '';
+    this.productCurrentPage = 1;
+    this.refreshProductView();
+  }
+
+  onProductSearchChange(value: string): void {
+    this.productSearch = value;
+    this.productCurrentPage = 1;
+    this.refreshProductView();
+  }
+
+  onProductPageSizeChange(value: string): void {
+    const nextSize = Number(value);
+    if (!Number.isFinite(nextSize) || nextSize <= 0) return;
+    this.productPageSize = nextSize;
+    this.productCurrentPage = 1;
+    this.refreshProductView();
+  }
+
+  onProductPageChange(page: number): void {
+    const nextPage = Math.max(1, Math.min(this.productTotalPages, page));
+    if (nextPage === this.productCurrentPage) return;
+    this.productCurrentPage = nextPage;
+    this.refreshProductView();
+  }
+
+  setProductSort(key: DesignerProductSortKey): void {
+    if (this.productSortKey === key) {
+      this.productSortDir = this.productSortDir === 'desc' ? 'asc' : 'desc';
+      this.productCurrentPage = 1;
+      this.refreshProductView();
+      return;
+    }
+    this.productSortKey = key;
+    this.productSortDir = key === 'rating' ? 'asc' : 'desc';
+    this.productCurrentPage = 1;
+    this.refreshProductView();
+  }
+
+  getProductSortIndicator(key: DesignerProductSortKey): string {
+    if (this.productSortKey !== key) return '';
+    return this.productSortDir === 'desc' ? '↓' : '↑';
+  }
+
+  getStarDistributionDesc(distribution: DesignerProductDrilldownItem['starDistribution']): DesignerProductDrilldownItem['starDistribution'] {
+    if (!distribution?.length) return [];
+    const cached = this.starDistributionCache.get(distribution);
+    if (cached) return cached;
+    const sorted = [...distribution].sort((a, b) => b.star - a.star);
+    this.starDistributionCache.set(distribution, sorted);
+    return sorted;
+  }
+
+  getTotalStarCount(distribution: DesignerProductDrilldownItem['starDistribution']): number {
+    if (!distribution?.length) return 0;
+    const cached = this.starCountCache.get(distribution);
+    if (cached !== undefined) return cached;
+    const total = distribution.reduce((sum, item) => sum + item.count, 0);
+    this.starCountCache.set(distribution, total);
+    return total;
+  }
+
+  // ─── 页内 Tab 切换 ───
+  activeTab: DesignerPerformanceTabKey = 'ranking';
+  get pageTabs(): BoardTabItem[] {
+    const critical = this.data?.summary.criticalRiskCount || 0;
+    const key = `${this.dataRevision}|${critical}`;
+    if (key === this.pageTabsCacheKey) return this.pageTabsCache;
+    this.pageTabsCacheKey = key;
+    this.pageTabsCache = [
+      {
+        key: 'ranking',
+        label: '风险排行',
+        icon: '📉',
+        badges: critical ? [{ label: critical, tone: 'red' }] : undefined
+      },
+      { key: 'trend', label: '趋势图', icon: '📈' },
+      { key: 'issues', label: '问题分布', icon: '📊' },
+      { key: 'ai', label: 'AI分析', icon: '🤖' }
+    ];
+    return this.pageTabsCache;
+  }
+  setActiveTab(key: string): void {
+    this.activeTab = key as DesignerPerformanceTabKey;
+  }
+
+  openRiskAlert(): void {
+    this.router.navigate(['/monitoring/risk-alert']);
+  }
+
+  openReturnAnalysis(): void {
+    this.router.navigate(['/return-analysis/overview']);
+  }
+
+  getHealthBarWidth(score: number): number {
+    return Math.max(4, Math.min(100, score));
+  }
+
+  getRiskLevelLabel(level: 'high' | 'medium' | 'low'): string {
+    const map = { high: '高风险', medium: '中风险', low: '低风险' };
+    return map[level];
+  }
+
+  getIssueTaxonomyConfidenceLabel(confidence?: 'high' | 'medium' | 'low'): string {
+    const map = { high: '高置信', medium: '中置信', low: '低置信' };
+    return confidence ? map[confidence] : '未识别';
+  }
+
+  getProductRiskLabel(level: 'critical' | 'warning' | 'normal'): string {
+    const map = { critical: '严重', warning: '预警', normal: '正常' };
+    return map[level];
+  }
+
+  trackByDesigner(_: number, item: DesignerPerformanceRankingItem | DesignerIssueMatrixRow): string {
+    return item.designerName;
+  }
+
+  trackByProduct(_: number, item: DesignerProductDrilldownItem): string {
+    return item.asin;
+  }
+
+  private refreshProductView(): void {
+    const products = this.getFilteredProducts();
+    this.filteredProducts = products;
+    this.productTotalPages = Math.max(1, Math.ceil(products.length / this.productPageSize));
+    this.productDisplayPage = Math.max(1, Math.min(this.productCurrentPage, this.productTotalPages));
+    if (this.productCurrentPage !== this.productDisplayPage) {
+      this.productCurrentPage = this.productDisplayPage;
+    }
+    const start = (this.productDisplayPage - 1) * this.productPageSize;
+    this.visibleProducts = products.slice(start, start + this.productPageSize);
+    this.productPageStart = products.length ? start + 1 : 0;
+    this.productPageEnd = Math.min(start + this.productPageSize, products.length);
+  }
+
+  private sortProducts(products: DesignerProductDrilldownItem[]): DesignerProductDrilldownItem[] {
+    const factor = this.productSortDir === 'desc' ? -1 : 1;
+    const riskScore = { critical: 3, warning: 2, normal: 1 };
+    const valueOf = (product: DesignerProductDrilldownItem): number => {
+      if (this.productSortKey === 'rating') return Number(product.rating || 0);
+      if (this.productSortKey === 'negative') return Number(product.negativeRate || 0);
+      if (this.productSortKey === 'return') return Number(product.returnRate || 0);
+      return riskScore[product.riskLevel] || 0;
+    };
+    return [...products].sort((a, b) => {
+      const diff = valueOf(a) - valueOf(b);
+      if (diff !== 0) return diff * factor;
+      return b.negativeRate - a.negativeRate;
+    });
+  }
+
+  private normalizeFilters(filters: FilterOptions): DesignerPerformanceFilters {
+    return {
+      timeRange: filters.timeRange || '30days',
+      category: filters.category,
+      sku: filters.sku,
+      store: filters.store,
+      storeNames: filters.storeNames,
+      site: filters.site,
+      designerName: this.cleanDesignerFilterValues(filters.designerName),
+      operatorName: filters.operatorName,
+      developerName: filters.developerName,
+      productSku: filters.productSku,
+      sellerSku: filters.sellerSku,
+      itemStatus: filters.itemStatus,
+      fulfillmentType: filters.fulfillmentType,
+      customDateRange: filters.customDateRange
+    };
+  }
+
+  private getFilterKey(filters: DesignerPerformanceFilters): string {
+    const list = (values?: Array<string | number>) => (values || [])
+      .map(value => String(value || '').trim())
+      .filter(Boolean)
+      .sort()
+      .join('|');
+    const range = filters.customDateRange;
+    return [
+      filters.timeRange || '30days',
+      range?.start || '',
+      range?.end || '',
+      list(filters.category),
+      list(filters.sku),
+      list(filters.store),
+      list(filters.storeNames),
+      list(filters.site),
+      list(filters.designerName),
+      list(filters.operatorName),
+      list(filters.developerName),
+      list(filters.productSku),
+      list(filters.sellerSku),
+      list(filters.itemStatus),
+      list(filters.fulfillmentType)
+    ].join('::');
+  }
+
+  private hasStoreScope(filters: DesignerPerformanceFilters): boolean {
+    return !!(filters.store?.length || filters.storeNames?.length);
+  }
+
+  private buildDesignerFilterOptions(): string[] {
+    const key = `${this.dataRevision}|${this.dimensionDesignerOptions.join('|')}`;
+    if (key === this.designerFilterOptionsCacheKey) return this.designerFilterOptionsCache;
+    this.designerFilterOptionsCacheKey = key;
+    const scopedDesignerOptions = [
+      ...this.rankings.map(item => item.designerName),
+      ...(this.data?.products || []).map(item => item.designerName),
+      ...(this.data?.returnRanking || []).map(item => item.designerName)
+    ].filter(name => this.isValidDesignerName(name));
+    const fallbackDesignerOptions = scopedDesignerOptions.length ? [] : this.dimensionDesignerOptions;
+    this.designerFilterOptionsCache = Array.from(new Set([
+      ...scopedDesignerOptions,
+      ...fallbackDesignerOptions,
+      ...(this.currentFilters.designerName || [])
+    ].filter(name => this.isValidDesignerName(name))))
+      .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
+    return this.designerFilterOptionsCache;
+  }
+
+  private loadDesignerOptions(): void {
+    this.designerOptionsSub?.unsubscribe();
+    this.designerOptionsSub = this.productDimensionService.getDesignerOptions().subscribe(options => {
+      this.dimensionDesignerOptions = options.filter(name => this.isValidDesignerName(name));
+      this.designerFilterOptionsCacheKey = '';
+      this.designerFilterOptions = this.buildDesignerFilterOptions();
+    });
+  }
+
+  private cleanDesignerFilterValues(values?: string[]): string[] | undefined {
+    if (!values?.length) return values;
+    const cleaned = values.map(value => String(value || '').trim()).filter(value => this.isValidDesignerName(value));
+    return cleaned.length ? cleaned : undefined;
+  }
+
+  private isValidDesignerName(value: any): boolean {
+    const text = String(value || '').trim();
+    if (!text) return false;
+    const normalized = text.toLowerCase();
+    return !['-', '—', '–', '未分配', '未分配设计师', '未归属', '未归属设计师', 'unknown', 'null', 'undefined', 'n/a', 'na'].includes(normalized);
+  }
+}

+ 151 - 0
src/modules/dashboard/pages/order-list/order-list.component.html

@@ -0,0 +1,151 @@
+<div class="order-list-container">
+  <div class="header">
+    <h1>订单列表</h1>
+    <div class="sub">
+      店铺: {{ shopId }} | 共 {{ total }} 条
+    </div>
+  </div>
+
+  <div class="table-wrapper" [class.loading]="loading">
+    @if (loading) {
+      <app-loading-spinner text="加载中..." size="md"></app-loading-spinner>
+    } @else {
+      <table class="order-table">
+        <thead>
+          <tr>
+            <th>订单ID</th>
+            <th>购买国家</th>
+            <th>购买城市</th>
+            <th>金额</th>
+            <th>订单时间</th>
+            <th>订单状态</th>
+            <th>操作</th>
+          </tr>
+        </thead>
+        <tbody>
+          @for (item of orders; track item.id) {
+            <tr>
+              <td>{{ item.platformOrderId }}</td>
+              <td>{{ item.country }}</td>
+              <td>{{ item.city }}</td>
+              <td>{{ item.amount | number:'1.2-2' }}</td>
+              <td>{{ item.orderDate }}</td>
+              <td>{{ item.status }}</td>
+              <td>
+                <button class="btn btn-sm" (click)="viewDetail(item.platformOrderId)">查看详情</button>
+              </td>
+            </tr>
+          }
+        </tbody>
+      </table>
+    }
+  </div>
+
+  <div class="pagination">
+    <button (click)="onPageChange(currentPage - 1)" [disabled]="currentPage === 1">上一页</button>
+    <span>第 {{ currentPage }} / {{ totalPages }} 页</span>
+    <button (click)="onPageChange(currentPage + 1)" [disabled]="currentPage === totalPages">下一页</button>
+  </div>
+
+  @if (showDetailModal) {
+    <div class="modal-backdrop"></div>
+    <div class="modal">
+      <div class="modal-header">
+        <h3>订单详情 - {{ selectedOrderId }}</h3>
+        <button class="close" (click)="closeDetail()">×</button>
+      </div>
+      <div class="modal-body">
+        @if (detailLoading) {
+          <app-loading-spinner text="加载中..." size="md"></app-loading-spinner>
+        } @else {
+          <div class="detail-grid">
+            <div class="card core">
+              <div class="card-title">核心信息</div>
+              <div class="card-content">
+                <div class="field"><span class="label">订单号</span><span class="value">{{ detailData?.core?.orderId }}</span></div>
+                <div class="field"><span class="label">状态</span><span class="tag">{{ detailData?.core?.status }}</span></div>
+                <div class="field"><span class="label">金额</span><span class="value amount">{{ detailData?.core?.amount }}</span></div>
+                <div class="field"><span class="label">下单时间</span><span class="value">{{ detailData?.core?.purchaseDate }}</span></div>
+              </div>
+            </div>
+
+            <div class="card">
+              <div class="card-title">基础信息</div>
+              <div class="card-content">
+                <div class="field"><span class="label">销售渠道</span><span class="value">{{ detailData?.basics?.salesChannel }}</span></div>
+                <div class="field"><span class="label">订单类型</span><span class="value">{{ detailData?.basics?.orderType }}</span></div>
+                <div class="field"><span class="label">支付方式</span><span class="value">{{ detailData?.basics?.paymentMethod }}</span></div>
+              </div>
+            </div>
+
+            <div class="card">
+              <div class="card-title">物流信息</div>
+              <div class="card-content">
+                <div class="field"><span class="label">最早发货</span><span class="value">{{ detailData?.logistics?.earliestShipDate }}</span></div>
+                <div class="field"><span class="label">最晚发货</span><span class="value">{{ detailData?.logistics?.latestShipDate }}</span></div>
+                <div class="field"><span class="label">商品数量</span><span class="value">{{ detailData?.logistics?.numberOfItems }}</span></div>
+                <div class="field"><span class="label">物流渠道</span><span class="value">{{ detailData?.logistics?.fulfillmentChannel }}</span></div>
+              </div>
+            </div>
+
+            <div class="card">
+              <div class="card-title">地址信息</div>
+              <div class="card-content">
+                <div class="field"><span class="label">国家</span><span class="value">{{ detailData?.address?.country }}</span></div>
+                <div class="field"><span class="label">州/省</span><span class="value">{{ detailData?.address?.state }}</span></div>
+                <div class="field"><span class="label">城市</span><span class="value">{{ detailData?.address?.city }}</span></div>
+              </div>
+            </div>
+
+            <div class="card">
+              <div class="card-title">标识信息</div>
+              <div class="card-content">
+                <div class="field"><span class="label">Prime订单</span><span class="value">{{ detailData?.identifiers?.isPrime }}</span></div>
+                <div class="field"><span class="label">企业订单</span><span class="value">{{ detailData?.identifiers?.isBusinessOrder }}</span></div>
+                <div class="field"><span class="label">Premium订单</span><span class="value">{{ detailData?.identifiers?.isPremiumOrder }}</span></div>
+              </div>
+            </div>
+
+            <div class="card full-row">
+              <div class="card-title">商品明细</div>
+              <div class="card-content">
+                @if ((detailData?.items?.length || 0) === 0) {
+                  <app-empty-state title="无商品数据" size="sm" icon="📦"></app-empty-state>
+                } @else {
+                  <table class="items-table">
+                    <thead>
+                      <tr>
+                        <th>ASIN</th>
+                        <th>SKU</th>
+                        <th>标题</th>
+                        <th>数量</th>
+                        <th>商品金额</th>
+                        <th>运费</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      @for (it of detailData?.items; track it.orderItemId) {
+                        <tr>
+                          <td>{{ it.asin }}</td>
+                          <td>{{ it.sku }}</td>
+                          <td>{{ it.title }}</td>
+                          <td>{{ it.qtyOrdered }}</td>
+                          <td>{{ it.itemCurrency }} {{ it.itemPrice | number:'1.2-2' }}</td>
+                          <td>{{ it.shippingCurrency }} {{ it.shippingPrice | number:'1.2-2' }}</td>
+                        </tr>
+                      }
+                    </tbody>
+                  </table>
+                }
+              </div>
+            </div>
+          </div>
+
+          <div class="detail-footer">
+            <app-neu-button size="sm" color="primary" (click)="exportDetail()">导出详情(JSON)</app-neu-button>
+          </div>
+        }
+      </div>
+    </div>
+  }
+</div>

+ 182 - 0
src/modules/dashboard/pages/order-list/order-list.component.scss

@@ -0,0 +1,182 @@
+:host {
+  display: block;
+  padding: 24px;
+}
+
+.header {
+  display: flex;
+  align-items: baseline;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.sub {
+  color: #666;
+  font-size: 13px;
+}
+
+.table-wrapper {
+  background: white;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
+  border: 1px solid #eee;
+  overflow: auto;
+}
+
+.order-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+.order-table th, .order-table td {
+  padding: 12px 16px;
+  border-bottom: 1px solid #f0f0f0;
+  text-align: left;
+}
+.order-table thead th {
+  background: #fafafa;
+  font-weight: 600;
+  color: #333;
+}
+
+.pagination {
+  margin-top: 12px;
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.btn {
+  padding: 6px 10px;
+  border: 1px solid #ddd;
+  border-radius: 6px;
+  background: white;
+  cursor: pointer;
+}
+.btn:hover {
+  background: #f5f5f5;
+}
+.btn[disabled] {
+  cursor: not-allowed;
+  opacity: 0.6;
+}
+.btn-sm {
+  padding: 4px 8px;
+  font-size: 12px;
+}
+
+.modal-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,0.3);
+  z-index: 1000;
+}
+.modal {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+  width: min(800px, 90vw);
+  background: white;
+  border-radius: 12px;
+  box-shadow: 0 6px 24px rgba(0,0,0,0.2);
+  z-index: 1001;
+  max-height: 80vh;
+  display: flex;
+  flex-direction: column;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 16px;
+  border-bottom: 1px solid #eee;
+}
+.modal-body {
+  padding: 16px;
+  overflow-y: auto;
+}
+.detail-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+  gap: 12px;
+}
+.card {
+  background: #fff;
+  border: 1px solid #eee;
+  border-radius: 10px;
+  box-shadow: 0 1px 6px rgba(0,0,0,0.06);
+}
+.card.core {
+  background: #f7f7f7;
+}
+.card.full-row {
+  grid-column: 1 / -1;
+}
+.card-title {
+  font-weight: 600;
+  padding: 10px 12px;
+  border-bottom: 1px solid #eee;
+}
+.card-content {
+  padding: 10px 12px;
+  display: grid;
+  gap: 8px;
+}
+.items-table {
+  width: 100%;
+  border-collapse: collapse;
+  overflow-x: auto;
+  display: block;
+}
+.items-table th, .items-table td {
+  padding: 8px 10px;
+  border-bottom: 1px solid #f0f0f0;
+  text-align: left;
+}
+.items-table thead th {
+  background: #fafafa;
+  font-weight: 600;
+}
+.field {
+  display: grid;
+  grid-template-columns: minmax(90px, 120px) 1fr;
+  align-items: center;
+  gap: 8px;
+}
+.label {
+  color: #666;
+}
+.value {
+  color: #333;
+  word-break: break-word;
+}
+.amount {
+  color: #f59e0b;
+  font-weight: 700;
+}
+.tag {
+  display: inline-block;
+  padding: 2px 8px;
+  border-radius: 999px;
+  background: #e6f4ff;
+  color: #2563eb;
+  font-size: 12px;
+  border: 1px solid #cde1ff;
+}
+.detail-footer {
+  margin-top: 12px;
+  display: flex;
+  justify-content: flex-end;
+}
+.close {
+  border: 1px solid #ddd;
+  background: #fff;
+  font-size: 16px;
+  cursor: pointer;
+  border-radius: 999px;
+  width: 28px;
+  height: 28px;
+  line-height: 26px;
+  text-align: center;
+  color: #333;
+}

+ 186 - 0
src/modules/dashboard/pages/order-list/order-list.component.ts

@@ -0,0 +1,186 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterModule, ActivatedRoute } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { HttpClient } from '@angular/common/http';
+import { MSQParse } from '../../../../app/parse';
+import { NeuButtonComponent } from '../../../shared/components/neu-button/neu-button.component';
+import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+
+interface OrderRow {
+  id: string;
+  platformOrderId: string;
+  country: string;
+  city: string;
+  amount: number;
+  orderDate: string;
+  status: string;
+}
+
+@Component({
+  selector: 'app-order-list',
+  standalone: true,
+  imports: [CommonModule, RouterModule, FormsModule, NeuButtonComponent, LoadingSpinnerComponent, EmptyStateComponent],
+  templateUrl: './order-list.component.html',
+  styleUrls: ['./order-list.component.scss']
+})
+export class OrderListComponent implements OnInit {
+  shopId = '';
+  loading = false;
+  orders: OrderRow[] = [];
+  currentPage = 1;
+  pageSize = 20;
+  total = 0;
+  showDetailModal = false;
+  detailLoading = false;
+  selectedOrderId: string | null = null;
+  detailData: any = null;
+  orderItems: any[] = [];
+
+  constructor(private route: ActivatedRoute, private http: HttpClient) {}
+
+  ngOnInit() {
+    this.shopId = this.route.snapshot.queryParamMap.get('shopId') || '';
+    this.loadOrders();
+  }
+
+  async loadOrders() {
+    this.loading = true;
+    try {
+      const query = new MSQParse.Query('Order');
+      if (this.shopId) {
+        const shopPtr: any = { __type: 'Pointer', className: 'Shop', objectId: this.shopId };
+        query.equalTo('shop', shopPtr);
+      }
+      this.total = await query.count();
+      query.descending('orderDate');
+      query.skip((this.currentPage - 1) * this.pageSize);
+      query.limit(this.pageSize);
+      const results = await query.find();
+      this.orders = results.map((o: any) => ({
+        id: o.id,
+        platformOrderId: o.get('platformOrderId') || o.get('platform_order_id') || o.id,
+        country: o.get('shippingCountry') || (o.get('shippingAddress')?.country || ''),
+        city: o.get('shippingCity') || (o.get('shippingAddress')?.city || ''),
+        amount: Number(o.get('totalAmount') ?? o.get('total_amount') ?? 0),
+        orderDate: (o.get('orderDate') || o.createdAt)?.toISOString?.().split('T')[0] || '',
+        status: o.get('status') || ''
+      }));
+    } catch (e) {
+      this.orders = [];
+      this.total = 0;
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  get totalPages(): number {
+    return Math.ceil(this.total / this.pageSize);
+  }
+
+  get pages(): number[] {
+    return Array.from({ length: this.totalPages }, (_, i) => i + 1);
+  }
+
+  onPageChange(page: number) {
+    if (page < 1 || page > this.totalPages) return;
+    this.currentPage = page;
+    this.loadOrders();
+  }
+
+  async viewDetail(orderId: string) {
+    this.selectedOrderId = orderId;
+    this.showDetailModal = true;
+    this.detailLoading = true;
+    this.detailData = null;
+    this.orderItems = [];
+    try {
+      const itemsResp = await this.http.post('/api/amazon/forward', { path: `/orders/v0/orders/${orderId}/orderItems` }, {
+        headers: { 'shop-objectId': this.shopId }
+      }).toPromise();
+      const itemsPayload: any = (itemsResp as any)?.payload || (itemsResp as any)?.data?.payload || itemsResp;
+      const items = Array.isArray(itemsPayload?.OrderItems) ? itemsPayload.OrderItems : [];
+      this.orderItems = this.normalizeItems(items);
+      this.detailData = { ...(this.detailData || {}), items: this.orderItems };
+    } catch (e) {
+      this.detailData = { error: (e as any)?.message || '请求失败' };
+    } finally {
+      this.detailLoading = false;
+    }
+  }
+
+  closeDetail() {
+    this.showDetailModal = false;
+    this.selectedOrderId = null;
+    this.detailData = null;
+  }
+
+  exportDetail() {
+    if (!this.detailData) return;
+    const blob = new Blob([JSON.stringify(this.detailData, null, 2)], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `order_${this.selectedOrderId || 'detail'}.json`;
+    a.click();
+    URL.revokeObjectURL(url);
+  }
+
+  private normalizeDetail(obj: any) {
+    const o = obj?.payload || obj?.order || obj;
+    const formatDate = (d: any) => {
+      try {
+        const date = new Date(d);
+        const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
+        return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} (UTC)`;
+      } catch {
+        return d;
+      }
+    };
+    const yesNo = (b: any) => (String(b) === 'true' || b === true ? '是' : '否');
+    const core = {
+      orderId: o?.AmazonOrderId || o?.orderId || this.selectedOrderId,
+      status: o?.OrderStatus || o?.status || '',
+      amount: o?.OrderTotal?.Amount ?? o?.totalAmount ?? '',
+      purchaseDate: formatDate(o?.PurchaseDate || o?.orderDate || o?.purchaseDate)
+    };
+    const basics = {
+      salesChannel: o?.SalesChannel || o?.salesChannel || '',
+      orderType: o?.OrderType || o?.orderType || '',
+      paymentMethod: o?.PaymentMethod || o?.paymentMethod || ''
+    };
+    const logistics = {
+      earliestShipDate: formatDate(o?.EarliestShipDate || o?.earliestShipDate),
+      latestShipDate: formatDate(o?.LatestShipDate || o?.latestShipDate),
+      numberOfItems: o?.NumberOfItemsShipped ?? o?.NumberOfItemsUnshipped ?? o?.items?.length ?? '',
+      fulfillmentChannel: o?.FulfillmentChannel || o?.fulfillmentChannel || ''
+    };
+    const address = {
+      country: o?.ShippingAddress?.CountryCode || o?.shippingAddress?.country || '',
+      state: o?.ShippingAddress?.StateOrRegion || o?.shippingAddress?.state || '',
+      city: o?.ShippingAddress?.City || o?.shippingAddress?.city || ''
+    };
+    const identifiers = {
+      isPrime: yesNo(o?.IsPrime),
+      isBusinessOrder: yesNo(o?.IsBusinessOrder),
+      isPremiumOrder: yesNo(o?.IsPremiumOrder)
+    };
+    return { core, basics, logistics, address, identifiers, raw: o };
+  }
+
+  private normalizeItems(items: any[]) {
+    return items.map(it => ({
+      asin: it?.ASIN || '',
+      orderItemId: it?.OrderItemId || '',
+      sku: it?.SellerSKU || '',
+      title: it?.Title || '',
+      qtyOrdered: Number(it?.QuantityOrdered ?? 0),
+      qtyShipped: Number(it?.QuantityShipped ?? 0),
+      itemPrice: (it?.ItemPrice?.Amount != null ? Number(it.ItemPrice.Amount) : null),
+      itemCurrency: it?.ItemPrice?.CurrencyCode || '',
+      shippingPrice: (it?.ShippingPrice?.Amount != null ? Number(it.ShippingPrice.Amount) : null),
+      shippingCurrency: it?.ShippingPrice?.CurrencyCode || ''
+    }));
+  }
+}

+ 109 - 0
src/modules/domestic-voc/data-status.component.ts

@@ -0,0 +1,109 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
+import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
+import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
+
+@Component({
+  selector: 'app-domestic-data-status',
+  standalone: true,
+  imports: [CommonModule, ContentCardComponent, DataTableShellComponent, SummaryMetricCardComponent],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <section class="status-page">
+      <header class="status-header">
+        <div>
+          <p>Domestic Ecommerce</p>
+          <h1>数据接入状态</h1>
+          <span>德玛仕案例的导入结果、质量问题与外部依赖</span>
+        </div>
+      </header>
+
+      @if (dataset$ | async; as data) {
+        <div class="metric-grid">
+          <app-summary-metric-card label="日指标记录" [value]="data.summary.metricRows" icon="▤" tone="blue"></app-summary-metric-card>
+          <app-summary-metric-card label="经营商品" [value]="data.summary.metricProducts" icon="□" tone="green"></app-summary-metric-card>
+          <app-summary-metric-card label="竞品关系" [value]="data.summary.relations" icon="⇄" tone="amber"></app-summary-metric-card>
+          <app-summary-metric-card label="评论样本" [value]="data.summary.reviewCount" icon="◇" tone="red" description="等待 JustOneAPI"></app-summary-metric-card>
+        </div>
+
+        <div class="status-grid">
+          <app-content-card title="当前数据源" subtitle="规范化文件已就绪" tone="green">
+            <dl class="source-list">
+              <div><dt>案例</dt><dd>{{ data.caseName }}</dd></div>
+              <div><dt>平台</dt><dd>{{ data.platform }}</dd></div>
+              <div><dt>源文件</dt><dd>{{ data.source.sourceFile }}</dd></div>
+              <div><dt>日期范围</dt><dd>{{ data.source.dateRange.start }} 至 {{ data.source.dateRange.end }}</dd></div>
+            </dl>
+          </app-content-card>
+          <app-content-card title="待接通依赖" subtitle="不会访问原跨境线上数据库" tone="amber">
+            <ol class="dependency-list">
+              <li><span class="done">已完成</span> 原 VOC 前端框架迁入</li>
+              <li><span class="done">已完成</span> 商品 ID 兼容层与本地案例数据</li>
+              <li><span class="waiting">待处理</span> 独立 PostgreSQL / Parse 应用</li>
+              <li><span class="waiting">待处理</span> Fmode / JustOneAPI 评论采集</li>
+            </ol>
+          </app-content-card>
+        </div>
+
+        <app-data-table-shell title="数据质量问题" [count]="issueCount(data.quality)" variant="admin" [scrollable]="true">
+          <table class="quality-table">
+            <thead><tr><th>类型</th><th>商品ID</th><th>详情</th></tr></thead>
+            <tbody>
+              @for (item of data.quality.orphanMappings; track item.ownProductId) {
+                <tr><td><span class="issue warning">经营数据缺失</span></td><td>{{ item.ownProductId }}</td><td>{{ item.model }} · {{ item.category }}</td></tr>
+              }
+              @for (item of data.quality.mappingsWithoutCompetitor; track item.ownProductId) {
+                <tr><td><span class="issue warning">未配置竞品</span></td><td>{{ item.ownProductId }}</td><td>{{ item.model }} · {{ item.category }}</td></tr>
+              }
+              @for (item of data.quality.brandWithoutProductId; track item.ownProductId + item.slot) {
+                <tr><td><span class="issue info">竞品编码缺失</span></td><td>{{ item.ownProductId }}</td><td>{{ item.brand }} · 第 {{ item.slot }} 个竞品位</td></tr>
+              }
+            </tbody>
+          </table>
+        </app-data-table-shell>
+      }
+    </section>
+  `,
+  styles: [`
+    :host { display: block; }
+    .status-page { display: grid; gap: 20px; padding: 28px; }
+    .status-header p { margin: 0 0 4px; color: #64748b; font-size: 12px; font-weight: 700; text-transform: uppercase; }
+    .status-header h1 { margin: 0; color: #111827; font-size: 28px; line-height: 1.25; }
+    .status-header span { display: block; margin-top: 7px; color: #64748b; font-size: 14px; }
+    .metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
+    .status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+    .source-list { display: grid; gap: 12px; margin: 0; }
+    .source-list div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; }
+    .source-list dt { color: #64748b; }
+    .source-list dd { margin: 0; overflow: hidden; color: #1f2937; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
+    .dependency-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
+    .dependency-list li { display: flex; align-items: center; gap: 10px; color: #374151; }
+    .dependency-list span { min-width: 54px; padding: 3px 7px; border-radius: 5px; font-size: 11px; font-weight: 700; text-align: center; }
+    .done { color: #166534; background: #dcfce7; }
+    .waiting { color: #92400e; background: #fef3c7; }
+    .quality-table { width: 100%; border-collapse: collapse; }
+    .quality-table th, .quality-table td { height: 46px; padding: 0 16px; border-bottom: 1px solid #e5e7eb; text-align: left; }
+    .quality-table th { color: #64748b; background: #f8fafc; font-size: 12px; }
+    .quality-table td { color: #374151; font-size: 13px; }
+    .issue { display: inline-flex; padding: 3px 8px; border-radius: 5px; font-size: 11px; font-weight: 700; }
+    .issue.warning { color: #92400e; background: #fef3c7; }
+    .issue.info { color: #1d4ed8; background: #dbeafe; }
+    @media (max-width: 960px) { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+    @media (max-width: 720px) {
+      .status-page { padding: 18px 14px; }
+      .metric-grid, .status-grid { grid-template-columns: 1fr; }
+      .status-header h1 { font-size: 23px; }
+      .quality-table { min-width: 640px; }
+    }
+  `],
+})
+export class DomesticDataStatusComponent {
+  private readonly dataset = inject(DomesticDatasetService);
+  readonly dataset$ = this.dataset.dataset$;
+
+  issueCount(quality: { orphanMappings: unknown[]; mappingsWithoutCompetitor: unknown[]; brandWithoutProductId: unknown[] }): number {
+    return quality.orphanMappings.length + quality.mappingsWithoutCompetitor.length + quality.brandWithoutProductId.length;
+  }
+}

+ 311 - 0
src/modules/knowledge-center/competitor-backfill-dialog/competitor-backfill-dialog.component.ts

@@ -0,0 +1,311 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { NeuButtonComponent } from '../../shared/components/neu-button/neu-button.component';
+
+export interface BackfillScope {
+  all: boolean;
+  brands: string[];
+  categories: string[];
+  limit: number;
+}
+
+export interface BackfillPreview {
+  scopeTotal: number;
+  incomplete: number;
+  willProcess: number;
+}
+
+export interface BackfillRunResult {
+  requested: number;
+  succeeded: number;
+  failed: number;
+  filled: number;
+  noData: number;
+}
+
+/**
+ * 竞品「补齐数据」弹窗:选择补齐范围(全库 / 按品牌 / 按类目多选),
+ * 先「试算」(dryRun) 预览范围内未补齐条数,再「开始补齐」触发云函数逐个请求详情接口回填。
+ */
+@Component({
+  selector: 'app-competitor-backfill-dialog',
+  standalone: true,
+  imports: [CommonModule, FormsModule, NeuButtonComponent],
+  template: `
+    <div class="dialog-overlay" (click)="onOverlayClick($event)">
+      <div class="dialog-container">
+        <div class="dialog-header">
+          <h2>补齐竞品数据</h2>
+          <button class="close-btn" (click)="cancel()">&times;</button>
+        </div>
+        <div class="dialog-body">
+          <p class="intro">
+            选择要补齐的范围,系统会对范围内「未补齐」的竞品逐个请求商品详情接口,
+            回填价格 / 月销量 / 评分等字段。已下架或详情无数据的产品会被如实跳过。
+          </p>
+
+          <!-- 全库 -->
+          <label class="all-toggle">
+            <input type="checkbox" [(ngModel)]="all" (ngModelChange)="onScopeChange()">
+            <span>全库补齐(忽略下方品牌 / 类目筛选)</span>
+          </label>
+
+          @if (!all) {
+            <!-- 品牌多选 -->
+            <div class="form-section">
+              <label>按品牌(多选,不选=不限)@if (selectedBrands.size) {<span class="sel-count">已选 {{ selectedBrands.size }}</span>}</label>
+              @if (brandOptions.length) {
+                <input type="text" class="chip-search" [(ngModel)]="brandSearch" placeholder="搜索品牌…">
+                @if (filteredBrandOptions.length) {
+                  <div class="chips">
+                    @for (b of filteredBrandOptions; track b) {
+                      <button type="button" class="chip" [class.active]="selectedBrands.has(b)" (click)="toggleBrand(b)">{{ b }}</button>
+                    }
+                  </div>
+                } @else {
+                  <div class="empty-hint">无匹配品牌</div>
+                }
+              } @else {
+                <div class="empty-hint">当前列表无可选品牌</div>
+              }
+            </div>
+
+            <!-- 类目多选 -->
+            <div class="form-section">
+              <label>按类目(多选,不选=不限)@if (selectedCategories.size) {<span class="sel-count">已选 {{ selectedCategories.size }}</span>}</label>
+              @if (categoryOptions.length) {
+                <input type="text" class="chip-search" [(ngModel)]="categorySearch" placeholder="搜索类目…">
+                @if (filteredCategoryOptions.length) {
+                  <div class="chips">
+                    @for (c of filteredCategoryOptions; track c) {
+                      <button type="button" class="chip" [class.active]="selectedCategories.has(c)" (click)="toggleCategory(c)">{{ c }}</button>
+                    }
+                  </div>
+                } @else {
+                  <div class="empty-hint">无匹配类目</div>
+                }
+              } @else {
+                <div class="empty-hint">当前列表无可选类目</div>
+              }
+            </div>
+          }
+
+          <!-- 单次上限 -->
+          <div class="form-section">
+            <label>单次补齐上限(保护 API 配额,每条消耗 1 次详情请求)</label>
+            <div class="limit-row">
+              <div class="preset-options">
+                @for (opt of limitPresets; track opt) {
+                  <button type="button" class="preset-btn" [class.active]="limit === opt" (click)="limit = opt">{{ opt }}</button>
+                }
+              </div>
+              <input type="number" [(ngModel)]="limit" [min]="1" [max]="500" class="count-input">
+            </div>
+          </div>
+
+          <!-- 试算结果 -->
+          @if (preview) {
+            <div class="preview-box">
+              <span>范围内共 <strong>{{ preview.scopeTotal }}</strong> 条,</span>
+              <span>未补齐 <strong>{{ preview.incomplete }}</strong> 条,</span>
+              <span>本次将补齐 <strong>{{ preview.willProcess }}</strong> 条</span>
+            </div>
+          }
+
+          <!-- 补齐结果 -->
+          @if (runResult) {
+            <div class="result-box">
+              <span>本次处理 <strong>{{ runResult.requested }}</strong> 条:</span>
+              <span class="ok">成功补齐 {{ runResult.filled }}</span>
+              <span class="muted">无数据(已下架) {{ runResult.noData }}</span>
+              <span class="bad">失败 {{ runResult.failed }}</span>
+            </div>
+          }
+
+          @if (running) {
+            <div class="info-box"><p>处理中,请稍候…(数据量大时可能需要一些时间)</p></div>
+          }
+        </div>
+        <div class="dialog-footer">
+          <app-neu-button size="sm" (click)="cancel()">{{ runResult ? '关闭' : '取消' }}</app-neu-button>
+          <app-neu-button size="sm" (click)="preview$()" [disabled]="running">试算</app-neu-button>
+          <app-neu-button size="sm" color="primary" (click)="confirm()" [disabled]="running">
+            {{ running ? '补齐中...' : '开始补齐' }}
+          </app-neu-button>
+        </div>
+      </div>
+    </div>
+  `,
+  styles: [`
+    .dialog-overlay {
+      position: fixed; top: 0; left: 0; right: 0; bottom: 0;
+      background: rgba(0, 0, 0, 0.45);
+      display: flex; align-items: center; justify-content: center;
+      z-index: 1000;
+    }
+    .dialog-container {
+      background: #fff; border-radius: 12px;
+      width: 680px; max-width: 94%;
+      max-height: 90vh; overflow-y: auto;
+      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
+      animation: dialogIn 0.2s ease-out;
+    }
+    @keyframes dialogIn {
+      from { opacity: 0; transform: translateY(-20px) scale(0.96); }
+      to { opacity: 1; transform: translateY(0) scale(1); }
+    }
+    .dialog-header {
+      display: flex; justify-content: space-between; align-items: center;
+      padding: 18px 24px; border-bottom: 1px solid #f0f0f0;
+    }
+    .dialog-header h2 { margin: 0; font-size: 17px; font-weight: 600; color: #1a1a2e; }
+    .close-btn {
+      background: none; border: none; font-size: 22px; cursor: pointer;
+      color: #999; padding: 0; line-height: 1; transition: color 0.2s;
+    }
+    .close-btn:hover { color: #333; }
+    .dialog-body { padding: 20px 24px; }
+    .intro { margin: 0 0 16px; font-size: 13px; color: #666; line-height: 1.6; }
+
+    .all-toggle {
+      display: flex; align-items: center; gap: 8px;
+      font-size: 13px; color: #444; cursor: pointer; margin-bottom: 16px;
+    }
+    .all-toggle input { width: 15px; height: 15px; cursor: pointer; }
+
+    .form-section { margin-bottom: 16px; }
+    .form-section > label {
+      display: block; margin-bottom: 8px; font-size: 13px; font-weight: 500; color: #444;
+    }
+    .sel-count { margin-left: 8px; font-size: 12px; font-weight: 500; color: #667eea; }
+    .chip-search {
+      width: 100%; padding: 8px 12px; margin-bottom: 10px;
+      border: 1px solid #e0e0e0; border-radius: 8px; font-size: 13px; box-sizing: border-box;
+      transition: border-color 0.2s;
+    }
+    .chip-search:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); }
+    .chips { display: flex; flex-wrap: wrap; gap: 8px; max-height: 168px; overflow-y: auto; }
+    .chip {
+      padding: 6px 12px; border: 1px solid #e0e0e0; background: #fff;
+      border-radius: 16px; font-size: 12px; color: #555; cursor: pointer; transition: all 0.2s;
+    }
+    .chip:hover { border-color: #667eea; color: #667eea; }
+    .chip.active { background: #667eea; border-color: #667eea; color: #fff; }
+    .empty-hint { font-size: 12px; color: #aaa; }
+
+    .limit-row { display: flex; align-items: center; gap: 12px; }
+    .preset-options { display: flex; gap: 6px; flex: 1; }
+    .preset-btn {
+      flex: 1; padding: 7px 0; border: 1px solid #e0e0e0; background: #fff;
+      border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; color: #555; transition: all 0.2s;
+    }
+    .preset-btn:hover { border-color: #667eea; color: #667eea; }
+    .preset-btn.active { background: #667eea; border-color: #667eea; color: #fff; }
+    .count-input {
+      width: 64px; padding: 7px 8px; border: 1px solid #e0e0e0;
+      border-radius: 6px; font-size: 14px; text-align: center;
+    }
+    .count-input:focus { outline: none; border-color: #667eea; }
+
+    .preview-box {
+      margin-top: 4px; padding: 10px 12px; background: #f0f4ff;
+      border: 1px solid #d6e0ff; border-radius: 8px; font-size: 13px; color: #444;
+    }
+    .preview-box strong { color: #667eea; }
+
+    .result-box {
+      margin-top: 12px; padding: 10px 12px; background: #f6fbf6;
+      border: 1px solid #cfe8cf; border-radius: 8px; font-size: 13px; color: #444;
+      display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
+    }
+    .result-box .ok { color: #2e7d32; font-weight: 600; }
+    .result-box .muted { color: #888; }
+    .result-box .bad { color: #c0392b; }
+
+    .info-box { margin-top: 12px; background: #fffbf0; padding: 10px 12px; border-radius: 6px; border: 1px solid #ffeeba; }
+    .info-box p { margin: 0; font-size: 12px; color: #856404; }
+
+    .dialog-footer {
+      display: flex; justify-content: flex-end; gap: 10px;
+      padding: 14px 24px; border-top: 1px solid #f0f0f0;
+    }
+  `]
+})
+export class CompetitorBackfillDialogComponent {
+  @Input() brandOptions: string[] = [];
+  @Input() categoryOptions: string[] = [];
+  @Input() running = false;
+  @Input() preview: BackfillPreview | null = null;
+  @Input() runResult: BackfillRunResult | null = null;
+
+  @Output() previewBackfill = new EventEmitter<BackfillScope>();
+  @Output() confirmBackfill = new EventEmitter<BackfillScope>();
+  @Output() cancelBackfill = new EventEmitter<void>();
+
+  all = false;
+  limit = 50;
+  readonly limitPresets = [20, 50, 100, 200];
+  selectedBrands = new Set<string>();
+  selectedCategories = new Set<string>();
+  brandSearch = '';
+  categorySearch = '';
+
+  get filteredBrandOptions(): string[] {
+    const q = this.brandSearch.trim().toLowerCase();
+    if (!q) return this.brandOptions;
+    return this.brandOptions.filter(b => b.toLowerCase().includes(q));
+  }
+
+  get filteredCategoryOptions(): string[] {
+    const q = this.categorySearch.trim().toLowerCase();
+    if (!q) return this.categoryOptions;
+    return this.categoryOptions.filter(c => c.toLowerCase().includes(q));
+  }
+
+  toggleBrand(b: string): void {
+    if (this.selectedBrands.has(b)) { this.selectedBrands.delete(b); } else { this.selectedBrands.add(b); }
+    this.onScopeChange();
+  }
+
+  toggleCategory(c: string): void {
+    if (this.selectedCategories.has(c)) { this.selectedCategories.delete(c); } else { this.selectedCategories.add(c); }
+    this.onScopeChange();
+  }
+
+  onScopeChange(): void {
+    // 范围变化后旧的试算/结果失效
+    this.preview = null;
+    this.runResult = null;
+  }
+
+  private buildScope(): BackfillScope {
+    const lim = Math.max(1, Math.min(Number(this.limit) || 50, 500));
+    return {
+      all: this.all,
+      brands: this.all ? [] : Array.from(this.selectedBrands),
+      categories: this.all ? [] : Array.from(this.selectedCategories),
+      limit: lim
+    };
+  }
+
+  preview$(): void {
+    if (this.running) return;
+    this.previewBackfill.emit(this.buildScope());
+  }
+
+  confirm(): void {
+    if (this.running) return;
+    this.confirmBackfill.emit(this.buildScope());
+  }
+
+  cancel(): void {
+    this.cancelBackfill.emit();
+  }
+
+  onOverlayClick(event: MouseEvent): void {
+    if ((event.target as HTMLElement).classList.contains('dialog-overlay') && !this.running) {
+      this.cancel();
+    }
+  }
+}

+ 1593 - 0
src/modules/knowledge-center/competitor-detail/competitor-detail.component.scss

@@ -0,0 +1,1593 @@
+@use '../../shared/styles/modal' as m;
+
+.competitor-detail-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 24px;
+}
+
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.back-btn {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 16px;
+  background: #fff;
+  border: 1px solid #e0e0e0;
+  border-radius: 8px;
+  cursor: pointer;
+  font-size: 14px;
+  color: #333;
+  transition: all 0.2s;
+
+  &:hover {
+    background: #f5f5f5;
+    border-color: #ccc;
+  }
+
+  .back-icon {
+    font-size: 16px;
+  }
+}
+
+.review-dashboard {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.review-summary-strip {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 18px;
+  align-items: center;
+  padding: 16px;
+  background:
+    radial-gradient(circle at 92% 12%, rgba(245, 158, 11, 0.14), transparent 28%),
+    #fff;
+  border: 1px solid #e8eef6;
+  border-radius: 16px;
+  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
+}
+
+.review-summary-copy {
+  min-width: 0;
+
+  h3 {
+    margin: 4px 0 6px;
+    color: #111827;
+    font-size: 18px;
+    line-height: 1.3;
+  }
+
+  p {
+    margin: 0;
+    color: #64748b;
+    font-size: 13px;
+  }
+}
+
+.review-section-eyebrow {
+  color: #f59e0b;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.review-summary-metrics {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(92px, 1fr));
+  gap: 8px;
+}
+
+.review-summary-metric {
+  min-width: 92px;
+  padding: 10px 12px;
+  border: 1px solid #eef2f7;
+  border-radius: 12px;
+  background: #f8fafc;
+  text-align: left;
+
+  span {
+    display: block;
+    color: #64748b;
+    font-size: 12px;
+    margin-bottom: 4px;
+  }
+
+  strong {
+    color: #111827;
+    font-size: 17px;
+    font-weight: 800;
+
+    &.rating {
+      color: #f59e0b;
+    }
+
+    &.positive {
+      color: #16a34a;
+    }
+
+    &.negative {
+      color: #dc2626;
+    }
+  }
+
+  &.clickable {
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      border-color: #f59e0b;
+      background: #fffbeb;
+      transform: translateY(-1px);
+    }
+  }
+}
+
+.review-insight-card {
+  border: 1px solid #e8eef6;
+  box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05);
+
+  .review-card-title-row {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 12px;
+    margin-bottom: 12px;
+
+    h3 {
+      margin: 0;
+      padding: 0;
+      border: 0;
+      color: #111827;
+      font-size: 15px;
+      line-height: 1.3;
+    }
+
+    span {
+      color: #94a3b8;
+      font-size: 12px;
+      white-space: nowrap;
+    }
+  }
+
+  .review-overview {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 10px;
+    margin-bottom: 12px;
+
+    .overview-item {
+      background: #f8f9fa;
+      border-radius: 8px;
+      padding: 10px;
+      text-align: center;
+
+      .label {
+        display: block;
+        font-size: 12px;
+        color: #8c8c8c;
+      }
+
+      .value {
+        display: block;
+        margin-top: 4px;
+        font-size: 16px;
+        font-weight: 600;
+        color: #262626;
+
+        &.positive { color: #389e0d; }
+        &.negative { color: #cf1322; }
+      }
+    }
+  }
+
+  .theme-block,
+  .phrase-block {
+    margin-bottom: 0;
+  }
+
+  .theme-title {
+    display: inline-block;
+    margin-bottom: 8px;
+    font-size: 13px;
+    color: #595959;
+    font-weight: 600;
+  }
+
+  .theme-list,
+  .phrase-list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  .theme-tag {
+    display: inline-flex;
+    align-items: center;
+    min-height: 24px;
+    padding: 4px 10px;
+    border-radius: 12px;
+    font-size: 12px;
+    line-height: 1.25;
+
+    &.positive {
+      color: #389e0d;
+      background: rgba(82, 196, 26, 0.12);
+    }
+
+    &.negative {
+      color: #cf1322;
+      background: rgba(255, 77, 79, 0.12);
+    }
+  }
+
+  .phrase-link {
+    border: 1px dashed #91caff;
+    background: #e6f4ff;
+    color: #0958d9;
+    border-radius: 12px;
+    padding: 7px 10px;
+    font-size: 12px;
+    line-height: 1.35;
+    text-align: left;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background: #bae0ff;
+      border-color: #69b1ff;
+    }
+  }
+
+  .phrase-variant {
+    display: inline-block;
+    font-size: 10px;
+    background: #fff0f6;
+    color: #c41d7f;
+    border: 1px solid #ffadd2;
+    border-radius: 8px;
+    padding: 1px 6px;
+    margin-right: 4px;
+    font-weight: 500;
+    white-space: nowrap;
+    max-width: 120px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    vertical-align: middle;
+  }
+
+  .variant-breakdown-block {
+    margin-bottom: 12px;
+    margin-top: 4px;
+  }
+
+  .variant-breakdown-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-top: 8px;
+  }
+
+  .variant-breakdown-item {
+    display: grid;
+    grid-template-columns: minmax(110px, 0.9fr) auto minmax(0, 1.2fr);
+    align-items: center;
+    gap: 8px;
+    padding: 9px 10px;
+    background: #f8fafc;
+    border: 1px solid #eef2f7;
+    border-left: 3px solid #ff4d4f;
+    border-radius: 10px;
+    font-size: 13px;
+
+    .variant-name {
+      font-weight: 600;
+      color: #262626;
+      min-width: 80px;
+      max-width: 180px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .variant-count {
+      color: #cf1322;
+      font-weight: 600;
+      white-space: nowrap;
+    }
+
+    .variant-issues {
+      color: #8c8c8c;
+      font-size: 12px;
+      flex: 1;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+}
+
+.review-theme-columns {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.positive-theme-block,
+.negative-theme-block {
+  padding: 12px;
+  border-radius: 12px;
+}
+
+.positive-theme-block {
+  background: #f0fdf4;
+  border: 1px solid #dcfce7;
+}
+
+.negative-theme-block {
+  background: #fef2f2;
+  border: 1px solid #fee2e2;
+}
+
+.review-ai-panel {
+  display: block;
+}
+
+.header-actions {
+  display: flex;
+  gap: 12px;
+}
+
+.btn-refresh {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 10px 20px;
+  background: #007bff;
+  color: #fff;
+  border: none;
+  border-radius: 8px;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 500;
+  transition: all 0.2s;
+
+  &:hover:not(:disabled) {
+    background: #0056b3;
+  }
+
+  &:disabled {
+    background: #a0c4e8;
+    cursor: not-allowed;
+  }
+
+  .refresh-icon {
+    display: inline-block;
+    font-size: 16px;
+
+    &.spinning {
+      animation: spin 1s linear infinite;
+    }
+  }
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 80px 0;
+
+  .spinner {
+    width: 48px;
+    height: 48px;
+    border: 4px solid #e0e0e0;
+    border-top-color: #007bff;
+    border-radius: 50%;
+    animation: spin 1s linear infinite;
+  }
+
+  p {
+    margin-top: 16px;
+    color: #666;
+    font-size: 14px;
+  }
+}
+
+.detail-layout {
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr);
+  gap: 18px;
+  align-items: start;
+}
+
+.info-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 20px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+
+  h3 {
+    font-size: 16px;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 16px;
+    padding-bottom: 12px;
+    border-bottom: 1px solid #f0f0f0;
+  }
+}
+
+.basic-info {
+  &.product-rail {
+    position: sticky;
+    top: 18px;
+    padding: 14px;
+    border: 1px solid #e9eef5;
+    box-shadow: 0 14px 36px rgba(15, 23, 42, 0.08);
+  }
+
+  .product-image {
+    width: 100%;
+    height: 178px;
+    background: linear-gradient(145deg, #f8fafc 0%, #edf2f7 100%);
+    border: 1px solid #eef2f7;
+    border-radius: 12px;
+    overflow: hidden;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-bottom: 12px;
+
+    .main-image {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+    }
+
+    .image-placeholder {
+      font-size: 48px;
+      color: #ccc;
+    }
+  }
+
+  .product-title {
+    font-size: 14px;
+    font-weight: 600;
+    color: #1f2937;
+    margin-bottom: 7px;
+    line-height: 1.4;
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .product-asin {
+    font-size: 12px;
+    color: #666;
+    margin-bottom: 4px;
+  }
+
+  .product-brand {
+    font-size: 13px;
+    color: #2563eb;
+    margin-bottom: 10px;
+    font-weight: 600;
+  }
+
+  .product-badges {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin-bottom: 12px;
+
+    .badge {
+      padding: 4px 8px;
+      background: #fffbeb;
+      color: #b45309;
+      border: 1px solid #fde68a;
+      border-radius: 999px;
+      font-size: 11px;
+      font-weight: 600;
+    }
+  }
+
+  .rail-meta-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    padding: 12px 0;
+    border-top: 1px solid #eef2f7;
+    border-bottom: 1px solid #eef2f7;
+    margin-bottom: 12px;
+  }
+
+  .rail-meta-row {
+    display: grid;
+    grid-template-columns: 64px minmax(0, 1fr);
+    gap: 8px;
+    align-items: start;
+    font-size: 12px;
+
+    span {
+      color: #64748b;
+    }
+
+    strong {
+      min-width: 0;
+      color: #1f2937;
+      font-weight: 600;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+
+  .rail-actions {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+    margin-bottom: 10px;
+  }
+
+  .rail-action {
+    border: 1px solid #dbe3ef;
+    border-radius: 10px;
+    padding: 8px 10px;
+    background: #f8fafc;
+    color: #475569;
+    font-size: 12px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover:not(:disabled) {
+      border-color: #2563eb;
+      color: #2563eb;
+      background: #eff6ff;
+    }
+
+    &:disabled {
+      cursor: not-allowed;
+      opacity: 0.65;
+    }
+
+    &.primary {
+      background: #2563eb;
+      border-color: #2563eb;
+      color: #fff;
+
+      &:hover:not(:disabled) {
+        background: #1d4ed8;
+        color: #fff;
+      }
+    }
+  }
+
+  .rail-external {
+    display: block;
+    text-align: center;
+    color: #2563eb;
+    font-size: 12px;
+    font-weight: 600;
+    text-decoration: none;
+    padding: 7px 8px;
+    border-radius: 9px;
+    background: #eff6ff;
+
+    &:hover {
+      background: #dbeafe;
+    }
+  }
+}
+
+.details-section {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  min-width: 0;
+}
+
+.detail-overview-card {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 16px;
+  align-items: center;
+  padding: 18px;
+  background:
+    radial-gradient(circle at 92% 18%, rgba(37, 99, 235, 0.12), transparent 28%),
+    #fff;
+  border: 1px solid #e6edf6;
+  border-radius: 16px;
+  box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
+}
+
+.overview-copy {
+  min-width: 0;
+
+  h2 {
+    margin: 4px 0 8px;
+    color: #111827;
+    font-size: 20px;
+    line-height: 1.35;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+}
+
+.overview-eyebrow {
+  display: inline-flex;
+  color: #2563eb;
+  font-size: 11px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.overview-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+
+  span {
+    display: inline-flex;
+    align-items: center;
+    min-height: 24px;
+    padding: 4px 9px;
+    border-radius: 999px;
+    background: #f1f5f9;
+    color: #475569;
+    font-size: 12px;
+    font-weight: 600;
+  }
+}
+
+.detail-tabs {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 6px;
+  padding: 4px;
+  background: #f1f5f9;
+  border: 1px solid #e2e8f0;
+  border-radius: 999px;
+
+  button {
+    border: 0;
+    color: #64748b;
+    background: transparent;
+    font-size: 12px;
+    font-weight: 700;
+    padding: 7px 11px;
+    border-radius: 999px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover,
+    &.active {
+      color: #2563eb;
+      background: #fff;
+      box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
+    }
+  }
+}
+
+.detail-card-grid {
+  display: grid;
+  gap: 14px;
+}
+
+.detail-tab-panels,
+.detail-tab-panel {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  min-width: 0;
+}
+
+.detail-tab-panel[hidden] {
+  display: none !important;
+}
+
+.overview-info-grid {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.review-overview-grid {
+  grid-template-columns: minmax(300px, 0.72fr) minmax(0, 1.28fr);
+  align-items: stretch;
+}
+
+.review-detail-grid {
+  grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
+  align-items: start;
+}
+
+.feature-tab-grid {
+  grid-template-columns: minmax(260px, 0.55fr) minmax(0, 1.45fr);
+  align-items: start;
+}
+
+.overview-info-grid > app-content-card,
+.review-overview-grid > app-content-card,
+.review-overview-grid > .review-insight-card,
+.review-detail-grid > .review-insight-card,
+.feature-tab-grid > app-content-card,
+.feature-tab-grid > app-product-feature-analysis {
+  min-width: 0;
+  margin-bottom: 0;
+}
+
+.review-overview-grid > :only-child,
+.review-detail-grid > :only-child,
+.feature-tab-grid > :only-child {
+  grid-column: 1 / -1;
+}
+
+.metrics-card {
+  .metrics-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 16px;
+  }
+
+  .metric-item {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    min-height: 76px;
+    padding: 12px 14px;
+    background: #f8fafc;
+    border: 1px solid #eef2f7;
+    border-radius: 12px;
+
+    .metric-label {
+      font-size: 12px;
+      color: #64748b;
+      margin-bottom: 4px;
+    }
+
+    .metric-value {
+      font-size: 17px;
+      font-weight: 600;
+      color: #1f2937;
+
+      &.price {
+        color: #e74c3c;
+      }
+
+      &.rating {
+        color: #f39c12;
+      }
+    }
+  }
+}
+
+.profit-card {
+  .profit-grid {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 10px;
+  }
+
+  .profit-item {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    min-height: 64px;
+    padding: 10px 12px;
+    background: #f8fafc;
+    border: 1px solid #eef2f7;
+    border-radius: 12px;
+
+    .profit-label {
+      font-size: 12px;
+      color: #666;
+      margin-bottom: 4px;
+    }
+
+    .profit-value {
+      font-size: 15px;
+      font-weight: 600;
+      color: #333;
+    }
+
+    &.highlight {
+      background: #ecfdf5;
+      border-color: #bbf7d0;
+
+      .profit-value {
+        color: #27ae60;
+      }
+    }
+  }
+}
+
+.category-card {
+  .category-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .category-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+
+    .category-label {
+      font-size: 13px;
+      color: #666;
+      min-width: 80px;
+    }
+
+    .category-value {
+      font-size: 14px;
+      color: #333;
+      font-weight: 500;
+    }
+  }
+}
+
+.seller-card {
+  .seller-info {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .seller-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+
+    .seller-label {
+      font-size: 13px;
+      color: #666;
+      min-width: 80px;
+    }
+
+    .seller-value {
+      font-size: 14px;
+      color: #333;
+    }
+  }
+}
+
+.features-card {
+  .feature-list {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+
+    li {
+      position: relative;
+      padding-left: 16px;
+      font-size: 14px;
+      color: #555;
+      line-height: 1.5;
+
+      &::before {
+        content: '•';
+        position: absolute;
+        left: 0;
+        color: #007bff;
+      }
+    }
+  }
+}
+
+.rating-dist-card {
+  .rating-insight {
+    font-size: 12px;
+    color: #b45309;
+    background: #fffbeb;
+    border: 1px solid #fde68a;
+    border-radius: 6px;
+    padding: 8px 10px;
+    margin: 0 0 10px 0;
+  }
+
+  .rating-dist {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  .rating-bar {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+
+    .rating-label {
+      font-size: 13px;
+      color: #666;
+      min-width: 32px;
+    }
+
+    .bar-container {
+      flex: 1;
+      height: 8px;
+      background: #e9ecef;
+      border-radius: 4px;
+      overflow: hidden;
+
+      .bar-fill {
+        height: 100%;
+        background: #f39c12;
+        border-radius: 4px;
+        transition: width 0.3s ease;
+      }
+    }
+
+    .rating-count {
+      font-size: 13px;
+      color: #666;
+      min-width: 50px;
+      text-align: right;
+    }
+  }
+}
+
+.update-card {
+  .update-info {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+
+    .update-label {
+      font-size: 13px;
+      color: #666;
+    }
+
+    .update-value {
+      font-size: 14px;
+      color: #333;
+    }
+  }
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 80px 0;
+  background: #fff;
+  border-radius: 12px;
+
+  p {
+    font-size: 16px;
+    color: #666;
+    margin-bottom: 20px;
+  }
+
+  .btn-refresh {
+    padding: 12px 24px;
+    font-size: 14px;
+  }
+}
+
+@media (max-width: 1180px) {
+  .overview-info-grid,
+  .review-overview-grid,
+  .review-detail-grid,
+  .feature-tab-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .review-summary-strip {
+    grid-template-columns: 1fr;
+  }
+
+  .review-summary-metrics {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+  }
+
+  .detail-overview-card {
+    grid-template-columns: 1fr;
+  }
+
+  .detail-tabs {
+    justify-content: flex-start;
+    width: fit-content;
+  }
+}
+
+// Responsive
+@media (max-width: 1024px) {
+  .detail-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .basic-info.product-rail {
+    position: static;
+  }
+
+  .metrics-card .metrics-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .profit-card .profit-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+
+  .review-summary-metrics {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .review-theme-columns {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 768px) {
+  .competitor-detail-container {
+    padding: 16px;
+  }
+
+  .detail-header {
+    flex-direction: column;
+    gap: 12px;
+    align-items: flex-start;
+  }
+
+  .metrics-card .metrics-grid,
+  .profit-card .profit-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .review-summary-strip {
+    padding: 14px;
+  }
+
+  .review-summary-metrics {
+    grid-template-columns: 1fr;
+  }
+
+  .review-insight-card {
+    .review-card-title-row {
+      flex-direction: column;
+      gap: 4px;
+
+      span {
+        white-space: normal;
+      }
+    }
+
+    .variant-breakdown-item {
+      grid-template-columns: 1fr;
+      align-items: start;
+
+      .variant-name,
+      .variant-issues {
+        max-width: none;
+        white-space: normal;
+      }
+    }
+  }
+
+  .detail-overview-card {
+    padding: 14px;
+  }
+
+  .overview-copy h2 {
+    font-size: 18px;
+  }
+
+  .detail-tabs {
+    width: 100%;
+    border-radius: 14px;
+
+    button {
+      flex: 1 1 auto;
+      text-align: center;
+    }
+  }
+
+  .basic-info {
+    .product-image {
+      height: 220px;
+    }
+
+    .rail-actions {
+      grid-template-columns: 1fr;
+    }
+  }
+}
+
+// ---- 可点击指标项 ----
+.metric-item.clickable {
+  cursor: pointer;
+  transition: background 0.2s;
+  border-radius: 8px;
+  &:hover { background: rgba(52, 58, 64, 0.04); }
+}
+.metric-hint {
+  display: block;
+  font-size: 11px;
+  color: #343A40;
+  margin-top: 2px;
+}
+
+// ==================== 评论弹窗 ====================
+.modal-overlay {
+  @include m.overlay(rgba(0, 0, 0, 0.45), 1000);
+}
+
+.reviews-modal {
+  @include m.dialog(720px, 12px);
+  max-height: 85vh;
+}
+
+.modal-header {
+  @include m.dialog-header(16px 24px, white);
+  border-color: #f0f0f0;
+}
+
+.modal-title {
+  font-size: 18px;
+  font-weight: 600;
+  margin: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.modal-subtitle {
+  font-size: 13px;
+  color: #8c8c8c;
+  font-weight: 400;
+}
+
+.modal-header-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.modal-close {
+  background: none;
+  border: none;
+  font-size: 24px;
+  color: #8c8c8c;
+  cursor: pointer;
+  padding: 4px 8px;
+  line-height: 1;
+  &:hover { color: #333; }
+}
+
+@include m.modal-keyframes;
+
+.ai-analysis-btn {
+  padding: 6px 14px;
+  border: 1px solid #E9ECEF;
+  border-radius: 6px;
+  background: linear-gradient(135deg, #F1F3F5 0%, #e8efff 100%);
+  font-size: 13px;
+  font-weight: 500;
+  color: #343A40;
+  cursor: pointer;
+  transition: all 0.2s;
+  &:hover:not(:disabled) {
+    background: #343A40;
+    border-color: #343A40;
+    color: #fff;
+  }
+  &:disabled { opacity: 0.6; cursor: wait; }
+}
+
+.translate-btn {
+  padding: 6px 14px;
+  border: 1px solid #d9d9d9;
+  border-radius: 6px;
+  background: #fff;
+  font-size: 13px;
+  cursor: pointer;
+  transition: all 0.2s;
+  &.active {
+    background: #343A40;
+    border-color: #343A40;
+    color: #fff;
+  }
+  &:disabled { opacity: 0.6; cursor: wait; }
+  &:hover:not(:disabled):not(.active) { border-color: #343A40; color: #343A40; }
+}
+
+// ---- 筛选栏 ----
+.reviews-filters {
+  padding: 12px 24px;
+  border-bottom: 1px solid #f0f0f0;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.star-filters {
+  display: flex;
+  gap: 6px;
+
+  button {
+    padding: 4px 12px;
+    border: 1px solid #d9d9d9;
+    border-radius: 16px;
+    background: #fff;
+    font-size: 12px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &.active {
+      background: #faad14;
+      border-color: #faad14;
+      color: #fff;
+    }
+    &:hover:not(.active) { border-color: #faad14; color: #faad14; }
+  }
+}
+
+.extra-filters {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  button {
+    padding: 4px 12px;
+    border: 1px solid #d9d9d9;
+    border-radius: 6px;
+    background: #fff;
+    font-size: 12px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &.active {
+      background: #52c41a;
+      border-color: #52c41a;
+      color: #fff;
+    }
+    &:hover:not(.active):not(:disabled) { border-color: #343A40; }
+    &:disabled { opacity: 0.5; cursor: not-allowed; }
+  }
+}
+
+.data-source-tag {
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 10px;
+  &.db {
+    background: rgba(52, 58, 64, 0.08);
+    color: #343A40;
+  }
+}
+
+.keyword-indicator {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+
+  .keyword-label {
+    color: #8c8c8c;
+  }
+
+  .keyword-text {
+    background: #fff7e6;
+    color: #d46b08;
+    border: 1px solid #ffd591;
+    border-radius: 10px;
+    padding: 2px 8px;
+  }
+
+  .keyword-clear {
+    border: none;
+    background: transparent;
+    cursor: pointer;
+    color: #8c8c8c;
+    font-size: 14px;
+    line-height: 1;
+  }
+}
+
+// ---- 评论列表 ----
+.reviews-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0 24px;
+  min-height: 200px;
+  max-height: 55vh;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding-top: 10px;
+  padding-bottom: 10px;
+}
+
+.review-item {
+  padding: 16px 0;
+  border-bottom: 1px solid #f5f5f5;
+
+  &.keyword-match {
+    background: rgba(255, 247, 230, 0.7);
+    border-left: 3px solid #fa8c16;
+    padding-left: 10px;
+    border-radius: 4px;
+  }
+
+  &:last-child { border-bottom: none; }
+}
+
+.review-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 6px;
+}
+
+.review-stars {
+  color: #faad14;
+  font-size: 14px;
+  letter-spacing: 1px;
+}
+
+.review-date {
+  font-size: 12px;
+  color: #8c8c8c;
+}
+
+.review-title {
+  font-size: 14px;
+  font-weight: 600;
+  margin: 0 0 6px;
+  color: #262626;
+}
+
+.review-content {
+  font-size: 13px;
+  color: #595959;
+  line-height: 1.6;
+  margin: 0 0 8px;
+}
+
+.review-footer {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  font-size: 12px;
+  color: #8c8c8c;
+}
+
+.verified-badge {
+  color: #52c41a;
+  background: rgba(82, 196, 26, 0.08);
+  padding: 1px 8px;
+  border-radius: 10px;
+  font-size: 11px;
+}
+
+.helpful-count {
+  color: #343A40;
+}
+
+// ---- 分页 ----
+.reviews-pagination {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 16px;
+  padding: 12px 24px;
+  border-top: 1px solid #f0f0f0;
+
+  button {
+    padding: 6px 16px;
+    border: 1px solid #d9d9d9;
+    border-radius: 6px;
+    background: #fff;
+    font-size: 13px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover:not(:disabled) { border-color: #343A40; color: #343A40; }
+    &:disabled { opacity: 0.4; cursor: not-allowed; }
+  }
+
+  span {
+    font-size: 13px;
+    color: #595959;
+  }
+}
+
+// ==================== 变体信息与相似产品 ====================
+.loading-card {
+  .loading-small {
+    text-align: center;
+    color: #8c8c8c;
+    font-size: 14px;
+    padding: 20px;
+  }
+}
+
+.variations-card {
+  .variations-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+  }
+
+  .variation-item {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    background: #f8f9fa;
+    border-radius: 8px;
+    transition: background 0.2s;
+
+    &:hover {
+      background: #f0f4ff;
+    }
+  }
+
+  .variation-image {
+    width: 60px;
+    height: 60px;
+    object-fit: cover;
+    border-radius: 6px;
+    background: #fff;
+  }
+
+  .variation-image-placeholder {
+    width: 60px;
+    height: 60px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #e9ecef;
+    border-radius: 6px;
+    font-size: 24px;
+    color: #adb5bd;
+  }
+
+  .variation-info {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .variation-asin {
+    font-size: 12px;
+    color: #343A40;
+    font-family: monospace;
+    margin-bottom: 4px;
+  }
+
+  .variation-title {
+    font-size: 13px;
+    color: #333;
+    margin-bottom: 2px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .variation-attr {
+    font-size: 12px;
+    color: #8c8c8c;
+  }
+
+  .variation-price {
+    font-size: 15px;
+    font-weight: 600;
+    color: #e74c3c;
+  }
+}
+
+.similar-card {
+  .similar-list {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 12px;
+  }
+
+  .similar-item {
+    display: flex;
+    gap: 10px;
+    padding: 10px;
+    background: #f8f9fa;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      background: #f0f4ff;
+      transform: translateY(-2px);
+      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+    }
+  }
+
+  .similar-image {
+    width: 70px;
+    height: 70px;
+    object-fit: cover;
+    border-radius: 6px;
+    background: #fff;
+    flex-shrink: 0;
+  }
+
+  .similar-image-placeholder {
+    width: 70px;
+    height: 70px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #e9ecef;
+    border-radius: 6px;
+    font-size: 24px;
+    color: #adb5bd;
+    flex-shrink: 0;
+  }
+
+  .similar-info {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+  }
+
+  .similar-title {
+    font-size: 12px;
+    color: #333;
+    margin-bottom: 6px;
+    line-height: 1.4;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .similar-meta {
+    display: flex;
+    gap: 8px;
+    font-size: 11px;
+  }
+
+  .similar-price {
+    color: #e74c3c;
+    font-weight: 600;
+  }
+
+  .similar-rating {
+    color: #faad14;
+  }
+
+  .similar-rank {
+    color: #8c8c8c;
+  }
+}
+
+@media (max-width: 768px) {
+  .similar-card .similar-list {
+    grid-template-columns: 1fr;
+  }
+}

+ 1771 - 0
src/modules/knowledge-center/competitor-detail/competitor-detail.component.ts

@@ -0,0 +1,1771 @@
+import { Component, OnInit, ViewChild, signal, computed } from '@angular/core';
+import { CommonModule, Location } from '@angular/common';
+import { ActivatedRoute } from '@angular/router';
+import { FormsModule } from '@angular/forms';
+import { DataStoreService } from '../../../app/core/services/data-store.service';
+import { SorftimeApiService } from '../../../app/core/services/sorftime-api.service';
+import { catchError, of, forkJoin } from 'rxjs';
+import { ProductVariationsComponent } from './product-variations/product-variations.component';
+import { ProductVariationHistoryComponent } from './product-variation-history/product-variation-history.component';
+import { ProductFeatureAnalysisComponent } from './product-feature-analysis/product-feature-analysis.component';
+import { ProductSpecsComponent } from './product-specs/product-specs.component';
+import { AiAnalysisPanelComponent } from '../../shared/components/ai-analysis-panel/ai-analysis-panel.component';
+import { ReviewCardComponent } from '../../shared/components/review-card/review-card.component';
+import { ReviewDetailModalComponent } from '../../shared/components/review-detail-modal/review-detail-modal.component';
+import { AiChatModalComponent } from '../../shared/components/ai-chat-modal/ai-chat-modal.component';
+import { MonitoringDataService } from '../../shared/services/monitoring-data.service';
+import { ToastService } from '../../shared/services/toast.service';
+import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
+import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
+import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
+import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
+
+@Component({
+  selector: 'app-competitor-detail',
+  standalone: true,
+  imports: [CommonModule, FormsModule, ProductVariationsComponent, ProductVariationHistoryComponent, ProductFeatureAnalysisComponent, ProductSpecsComponent, AiAnalysisPanelComponent, ReviewCardComponent, ReviewDetailModalComponent, AiChatModalComponent, PageHeaderComponent, LoadingSpinnerComponent, EmptyStateComponent, ContentCardComponent],
+  template: `
+    <div class="competitor-detail-container">
+      <!-- Header -->
+      <app-page-header
+        title="竞品详情分析"
+        eyebrow="Knowledge Center"
+        description="竞品产品详细数据、变体、特征与评论分析"
+        icon="🔍">
+        <div pageHeaderActions class="header-actions">
+          <button class="back-btn" (click)="goBack()">
+            <span class="back-icon">&larr;</span> 返回库
+          </button>
+          <button class="btn-refresh" (click)="refreshData()" [disabled]="isRefreshing">
+            <span class="refresh-icon" [class.spinning]="isRefreshing">&#8635;</span>
+            {{ isRefreshing ? '刷新中...' : '刷新数据' }}
+          </button>
+        </div>
+      </app-page-header>
+
+      @if (loading) {
+        <app-loading-spinner text="正在加载竞品数据..."></app-loading-spinner>
+      }
+
+      @if (!loading && product) {
+        <div class="detail-layout">
+          <!-- Left: Product Basic Info -->
+          <div class="info-card basic-info product-rail">
+            <div class="product-image rail-image">
+              @if (product.photo && product.photo.length) {
+                <img [src]="product.photo[0]" [alt]="product.title" class="main-image">
+              } @else {
+                <div class="image-placeholder">&#128247;</div>
+              }
+            </div>
+            <div class="product-title">{{ product.title || '未知产品' }}</div>
+            <div class="product-asin">ASIN: {{ product.asin }}</div>
+            <div class="product-brand">{{ product.brand || '未知品牌' }}</div>
+            <div class="product-badges">
+              @for (badge of product.productBadge; track badge) {
+                <span class="badge">{{ badge }}</span>
+              }
+            </div>
+            <div class="rail-meta-list">
+              <div class="rail-meta-row">
+                <span>BSR类目</span>
+                <strong>{{ getBsrCategoryName(product.bsrCategory) }}</strong>
+              </div>
+              <div class="rail-meta-row">
+                <span>BuyBox</span>
+                <strong>{{ product.buyboxSeller || '-' }}</strong>
+              </div>
+              <div class="rail-meta-row">
+                <span>发货地</span>
+                <strong>{{ product.shipsFrom || '-' }}</strong>
+              </div>
+              <div class="rail-meta-row">
+                <span>最后更新</span>
+                <strong>{{ formatDateTime(product.dataUpdatedAt) || product.updateDate || '-' }}</strong>
+              </div>
+            </div>
+            <div class="rail-actions">
+              <button type="button" class="rail-action primary" (click)="openReviewsModal()">查看评论</button>
+              <button type="button" class="rail-action" (click)="refreshData()" [disabled]="isRefreshing">
+                {{ isRefreshing ? '刷新中' : '刷新数据' }}
+              </button>
+            </div>
+            <a class="rail-external" [href]="'https://www.amazon.com/dp/' + asin" target="_blank" rel="noopener">
+              打开 Amazon 商品页 ↗
+            </a>
+          </div>
+
+          <!-- Right: Product Details -->
+          <div class="details-section">
+            <div class="detail-overview-card" id="detail-overview">
+              <div class="overview-copy">
+                <span class="overview-eyebrow">Competitor Overview</span>
+                <h2>{{ product.title || '未知产品' }}</h2>
+                <div class="overview-meta">
+                  <span>{{ product.brand || '未知品牌' }}</span>
+                  <span>ASIN: {{ product.asin }}</span>
+                  <span>{{ getBsrCategoryName(product.bsrCategory) }}</span>
+                </div>
+              </div>
+              <nav class="detail-tabs" aria-label="竞品详情导航">
+                <button type="button" [class.active]="activeDetailSection() === 'detail-overview'" (click)="selectDetailSection('detail-overview')">概览</button>
+                <button type="button" [class.active]="activeDetailSection() === 'detail-profit'" (click)="selectDetailSection('detail-profit')">利润</button>
+                <button type="button" [class.active]="activeDetailSection() === 'detail-reviews'" (click)="selectDetailSection('detail-reviews')">评价</button>
+                <button type="button" [class.active]="activeDetailSection() === 'detail-features'" (click)="selectDetailSection('detail-features')">特征</button>
+                <button type="button" [class.active]="activeDetailSection() === 'detail-variants'" (click)="selectDetailSection('detail-variants')">变体</button>
+                <button type="button" [class.active]="activeDetailSection() === 'detail-specs'" (click)="selectDetailSection('detail-specs')">规格</button>
+              </nav>
+            </div>
+            <div class="detail-tab-panels">
+              <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-overview'">
+                <!-- Key Metrics -->
+                <app-content-card class="metrics-card" title="关键指标">
+                  <div class="metrics-grid">
+                    <div class="metric-item">
+                      <span class="metric-label">价格</span>
+                      <span class="metric-value price">\${{ formatPrice(product.salesPrice || product.price) }}</span>
+                    </div>
+                    <div class="metric-item clickable" (click)="openReviewsModal()">
+                      <span class="metric-label">评分</span>
+                      <span class="metric-value rating">&#9733; {{ product.ratings || 0 }}</span>
+                    </div>
+                    <div class="metric-item clickable" (click)="openReviewsModal()">
+                      <span class="metric-label">评价数</span>
+                      <span class="metric-value">{{ formatNumber(product.ratingsCount || 0) }}</span>
+                      <span class="metric-hint">点击查看评论</span>
+                    </div>
+                    <div class="metric-item">
+                      <span class="metric-label">月销量</span>
+                      <span class="metric-value">{{ formatNumber(product.listingSalesVolumeOfMonth || 0) }}</span>
+                    </div>
+                    <div class="metric-item">
+                      <span class="metric-label">BSR排名</span>
+                      <span class="metric-value">{{ product.rank > 0 ? '#' + product.rank : '-' }}</span>
+                    </div>
+                    <div class="metric-item">
+                      <span class="metric-label">上架日期</span>
+                      <span class="metric-value">{{ product.onlineDate || '-' }}</span>
+                    </div>
+                    <div class="metric-item">
+                      <span class="metric-label">FBA</span>
+                      <span class="metric-value">{{ product.isFBA ? '是' : '否' }}</span>
+                    </div>
+                    <div class="metric-item">
+                      <span class="metric-label">卖家数</span>
+                      <span class="metric-value">{{ product.sellerCount || 0 }}</span>
+                    </div>
+                  </div>
+                </app-content-card>
+
+                <div class="detail-card-grid overview-info-grid">
+                  <!-- Category Info -->
+                  <app-content-card class="category-card" title="类目信息" density="compact">
+                    <div class="category-list">
+                      @if (product.category && product.category.length) {
+                        <div class="category-item">
+                          <span class="category-label">产品类目</span>
+                          <span class="category-value">{{ getCategoryName(product.category) }}</span>
+                        </div>
+                      }
+                      @if (product.bsrCategory && product.bsrCategory.length) {
+                        <div class="category-item">
+                          <span class="category-label">BSR类目</span>
+                          <span class="category-value">{{ getBsrCategoryName(product.bsrCategory) }}</span>
+                        </div>
+                      }
+                    </div>
+                  </app-content-card>
+
+                  <!-- Seller Info -->
+                  <app-content-card class="seller-card" title="卖家信息" density="compact">
+                    <div class="seller-info">
+                      <div class="seller-item">
+                        <span class="seller-label">BuyBox卖家</span>
+                        <span class="seller-value">{{ product.buyboxSeller || '-' }}</span>
+                      </div>
+                      <div class="seller-item">
+                        <span class="seller-label">发货地</span>
+                        <span class="seller-value">{{ product.shipsFrom || '-' }}</span>
+                      </div>
+                      <div class="seller-item">
+                        <span class="seller-label">店铺名称</span>
+                        <span class="seller-value">{{ product.storeName || '-' }}</span>
+                      </div>
+                    </div>
+                  </app-content-card>
+
+                  <!-- Last Update -->
+                  <app-content-card class="update-card" title="数据更新" density="compact">
+                    <div class="update-info">
+                      <span class="update-label">最后更新:</span>
+                      <span class="update-value">{{ formatDateTime(product.dataUpdatedAt) || product.updateDate || '-' }}</span>
+                    </div>
+                  </app-content-card>
+                </div>
+              </section>
+
+              <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-profit'">
+                <!-- Sales & Profit -->
+                <app-content-card class="profit-card" title="销售利润分析">
+                  <div class="profit-grid">
+                    <div class="profit-item">
+                      <span class="profit-label">售价</span>
+                      <span class="profit-value">\${{ formatPrice(product.salesPrice) }}</span>
+                    </div>
+                    <div class="profit-item">
+                      <span class="profit-label">平台费</span>
+                      <span class="profit-value">\${{ formatPrice(product.platformFee) }}</span>
+                    </div>
+                    <div class="profit-item">
+                      <span class="profit-label">FBA费用</span>
+                      <span class="profit-value">\${{ formatPrice(product.fbaFee) }}</span>
+                    </div>
+                    <div class="profit-item">
+                      <span class="profit-label">物流成本</span>
+                      <span class="profit-value">\${{ formatPrice(product.shipCost) }}</span>
+                    </div>
+                    <div class="profit-item highlight">
+                      <span class="profit-label">预估利润</span>
+                      <span class="profit-value">\${{ formatPrice(product.profit) }}</span>
+                    </div>
+                    <div class="profit-item highlight">
+                      <span class="profit-label">利润率</span>
+                      <span class="profit-value">{{ (product.profitRate || 0).toFixed(1) }}%</span>
+                    </div>
+                  </div>
+                </app-content-card>
+              </section>
+
+              <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-reviews'">
+                <div class="review-dashboard">
+                  <div class="review-summary-strip">
+                    <div class="review-summary-copy">
+                      <span class="review-section-eyebrow">Review Intelligence</span>
+                      <h3>评价健康度</h3>
+                      <p>聚合评分分布、好差评主题与变体风险,快速定位 VOC 优先级。</p>
+                    </div>
+                    <div class="review-summary-metrics">
+                      <button type="button" class="review-summary-metric clickable" (click)="openReviewsModal()">
+                        <span>评分</span>
+                        <strong class="rating">&#9733; {{ product.ratings || 0 }}</strong>
+                      </button>
+                      <button type="button" class="review-summary-metric clickable" (click)="openReviewsModal()">
+                        <span>评价数</span>
+                        <strong>{{ formatNumber(product.ratingsCount || reviewInsight()?.totalReviews || 0) }}</strong>
+                      </button>
+                      @if (reviewInsight()) {
+                        <div class="review-summary-metric">
+                          <span>正向率</span>
+                          <strong class="positive">{{ reviewInsight()!.positiveRate.toFixed(1) }}%</strong>
+                        </div>
+                        <div class="review-summary-metric">
+                          <span>负向率</span>
+                          <strong class="negative">{{ reviewInsight()!.negativeRate.toFixed(1) }}%</strong>
+                        </div>
+                      }
+                    </div>
+                  </div>
+
+                  <div class="detail-card-grid review-overview-grid">
+                    <!-- Rating Distribution -->
+                    @if (hasRatingDistribution()) {
+                      <app-content-card class="rating-dist-card review-rating-card" title="评分分布" density="compact">
+                        @if (hasLowStarAnomaly()) {
+                          <p class="rating-insight">低星评价集中(1星多于2星),建议结合「产品VOC深度洞察」排查原因。</p>
+                        }
+                        <div class="rating-dist">
+                          <div class="rating-bar">
+                            <span class="rating-label">5星</span>
+                            <div class="bar-container">
+                              <div class="bar-fill" [style.width.%]="getRatingPercent(5)"></div>
+                            </div>
+                            <span class="rating-count">{{ product.fiveStartRatings || 0 }}</span>
+                          </div>
+                          <div class="rating-bar">
+                            <span class="rating-label">4星</span>
+                            <div class="bar-container">
+                              <div class="bar-fill" [style.width.%]="getRatingPercent(4)"></div>
+                            </div>
+                            <span class="rating-count">{{ product.fourStartRatings || 0 }}</span>
+                          </div>
+                          <div class="rating-bar">
+                            <span class="rating-label">3星</span>
+                            <div class="bar-container">
+                              <div class="bar-fill" [style.width.%]="getRatingPercent(3)"></div>
+                            </div>
+                            <span class="rating-count">{{ product.threeStartRatings || 0 }}</span>
+                          </div>
+                          <div class="rating-bar">
+                            <span class="rating-label">2星</span>
+                            <div class="bar-container">
+                              <div class="bar-fill" [style.width.%]="getRatingPercent(2)"></div>
+                            </div>
+                            <span class="rating-count">{{ product.twoStartRatings || 0 }}</span>
+                          </div>
+                          <div class="rating-bar">
+                            <span class="rating-label">1星</span>
+                            <div class="bar-container">
+                              <div class="bar-fill" [style.width.%]="getRatingPercent(1)"></div>
+                            </div>
+                            <span class="rating-count">{{ product.oneStartRatings || 0 }}</span>
+                          </div>
+                        </div>
+                      </app-content-card>
+                    }
+
+                    @if (reviewInsight()) {
+                      <div class="info-card review-insight-card review-theme-card">
+                        <div class="review-card-title-row">
+                          <h3>好差评主题</h3>
+                          <span>复用竞品分析活动</span>
+                        </div>
+                        <div class="review-theme-columns">
+                          @if (reviewInsight()!.positiveThemes.length > 0) {
+                            <div class="theme-block positive-theme-block">
+                              <span class="theme-title">好评主题</span>
+                              <div class="theme-list">
+                                @for (t of reviewInsight()!.positiveThemes; track t) {
+                                  <span class="theme-tag positive">{{ t }}</span>
+                                }
+                              </div>
+                            </div>
+                          }
+
+                          @if (reviewInsight()!.negativeThemes.length > 0) {
+                            <div class="theme-block negative-theme-block">
+                              <span class="theme-title">差评主题</span>
+                              <div class="theme-list">
+                                @for (t of reviewInsight()!.negativeThemes; track t) {
+                                  <span class="theme-tag negative">{{ t }}</span>
+                                }
+                              </div>
+                            </div>
+                          }
+                        </div>
+                      </div>
+                    }
+                  </div>
+
+                  @if (reviewInsight()) {
+                    <div class="detail-card-grid review-detail-grid">
+                      @if (reviewInsight()!.negativeDetails.length > 0) {
+                        <div class="info-card review-insight-card review-phrase-card">
+                          <div class="review-card-title-row">
+                            <h3>典型差评短句</h3>
+                            <span>点击短句查看原始评论</span>
+                          </div>
+                          <div class="phrase-list">
+                            @for (d of reviewInsight()!.negativeDetails.slice(0, 10); track $index) {
+                              <button class="phrase-link" (click)="openReviewsModalWithKeyword(d.snippet || d.issue)">
+                                @if (d.variant) {
+                                  <span class="phrase-variant">{{ formatVariant(d.variant) }}</span>
+                                }
+                                「{{ (d.snippet || d.issue).slice(0, 42) }}{{ (d.snippet || d.issue).length > 42 ? '...' : '' }}」
+                              </button>
+                            }
+                          </div>
+                        </div>
+                      }
+
+                      @if (variantBreakdown().length > 0) {
+                        <div class="info-card review-insight-card review-variant-card">
+                          <div class="review-card-title-row">
+                            <h3>变体维度差评分布</h3>
+                            <span>SKU / 颜色 / 尺码</span>
+                          </div>
+                          <div class="variant-breakdown-list">
+                            @for (v of variantBreakdown(); track v.variant) {
+                              <div class="variant-breakdown-item">
+                                <span class="variant-name">{{ formatVariant(v.variant) }}</span>
+                                <span class="variant-count">{{ v.count }}条差评</span>
+                                <span class="variant-issues">{{ v.issues.join(' / ') }}</span>
+                              </div>
+                            }
+                          </div>
+                        </div>
+                      }
+                    </div>
+
+                    @if (aiReviewPrompt) {
+                      <div class="review-ai-panel">
+                        <app-ai-analysis-panel
+                          [prompt]="aiReviewPrompt"
+                          promptKey="shared.analysisPanel.defaultSystem"
+                          [title]="'AI 评论分析报告'"
+                          [autoGenerate]="true"
+                          [enablePhraseLinks]="true"
+                          (phraseClick)="openReviewsModalWithKeyword($event)"
+                          (reviewClick)="onReviewNameClick($event)"
+                          (openChat)="onOpenAiChat($event)"
+                        ></app-ai-analysis-panel>
+                      </div>
+                    }
+                  }
+                </div>
+              </section>
+
+              <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-features'">
+                <div class="detail-card-grid feature-tab-grid">
+                  <!-- Features -->
+                  @if (product.feature && product.feature.length) {
+                    <app-content-card class="features-card" title="产品特点">
+                      <ul class="feature-list">
+                        @for (feature of product.feature; track feature) {
+                          <li>{{ feature }}</li>
+                        }
+                      </ul>
+                    </app-content-card>
+                  }
+
+                  <!-- 产品特征结构化分析 -->
+                  <app-product-feature-analysis
+                    [feature]="product.feature"
+                    [description]="product.description"
+                    [title]="product.title">
+                  </app-product-feature-analysis>
+                </div>
+              </section>
+
+              <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-variants'">
+                <!-- 变体概要(数据直接来自 ProductRequest 结果,无额外请求) -->
+                <app-product-variations
+                  [asinList]="getVariationASINs()"
+                  [currentAsin]="asin"
+                  [variationCount]="product.variationASINCount"
+                  [attributes]="product.attribute"
+                  [sizes]="product.size"
+                  [parentAsin]="product.parentAsin">
+                </app-product-variations>
+
+                <!-- 变体历史(调用 ProductVariationHistory API,点刷新按钮触发) -->
+                <app-product-variation-history #variationHistory
+                  [asin]="asin"
+                  [currentAsin]="asin">
+                </app-product-variation-history>
+              </section>
+
+              <section class="detail-tab-panel" [hidden]="activeDetailSection() !== 'detail-specs'">
+                <!-- 产品结构化规格(Property / ProductInfo) -->
+                <app-product-specs
+                  [property]="product.property"
+                  [productInfo]="product.productInfo"
+                  [offSale]="product.offSale">
+                </app-product-specs>
+              </section>
+            </div>
+
+          </div>
+        </div>
+      }
+
+      @if (!loading && !product) {
+        <app-empty-state title="未找到该竞品数据" icon="🔍" actionText="点击刷新数据" (action)="refreshData()"></app-empty-state>
+      }
+
+      <!-- 评论弹窗 -->
+      @if (showReviewsModal()) {
+        <div class="modal-overlay" (click)="closeReviewsModal()">
+          <div class="modal-container reviews-modal" (click)="$event.stopPropagation()">
+            <div class="modal-header">
+              <h2 class="modal-title">产品评论 <span class="modal-subtitle">{{ asin }}</span></h2>
+              <div class="modal-header-actions">
+                <button class="ai-analysis-btn" (click)="openAiReviewAnalysis()" [disabled]="aiAnalysisLoading()">
+                  @if (aiAnalysisLoading()) { 分析中... }
+                  @else { 🤖 AI分析 }
+                </button>
+                <button class="translate-btn" [class.active]="isTranslated()"
+                        [disabled]="isTranslating()" (click)="toggleTranslation()">
+                  @if (isTranslating()) { 翻译中... }
+                  @else if (isTranslated()) { 显示原文 }
+                  @else { 翻译为中文 }
+                </button>
+                <button class="modal-close" (click)="closeReviewsModal()">&times;</button>
+              </div>
+            </div>
+
+            <div class="reviews-filters">
+              <div class="star-filters">
+                <button [class.active]="reviewsStarFilter() === ''" (click)="onStarFilterChange('')">全部</button>
+                @for (s of starOptions; track s) {
+                  <button [class.active]="reviewsStarFilter() === s" (click)="onStarFilterChange(s)">{{ s }}星</button>
+                }
+              </div>
+              <div class="extra-filters">
+                <button [class.active]="reviewsOnlyPurchase() === 1" (click)="togglePurchaseFilter()">仅已购买</button>
+                @if (reviewsFromDb()) {
+                  <span class="data-source-tag db">缓存 ({{ reviewDbCount() }}条)</span>
+                }
+                <button (click)="refreshReviewsFromApi()" [disabled]="reviewsLoading()">从API获取最新</button>
+              </div>
+              @if (reviewSearchKeyword()) {
+                <div class="keyword-indicator">
+                  <span class="keyword-label">关键词匹配:</span>
+                  <span class="keyword-text">{{ reviewSearchKeyword() }}</span>
+                  <button class="keyword-clear" (click)="clearReviewKeyword()">×</button>
+                </div>
+              }
+            </div>
+
+            <div class="reviews-list">
+              @if (reviewsLoading()) {
+                <app-loading-spinner text="加载评论中..." size="sm"></app-loading-spinner>
+              } @else if (reviews().length === 0) {
+                <app-empty-state title="暂无评论数据" size="sm" icon="💬"></app-empty-state>
+              } @else {
+                @for (review of reviews(); track $index) {
+                  <app-review-card
+                    [review]="review"
+                    [translatedTitle]="translatedMap().get($index)?.title || ''"
+                    [translatedContent]="translatedMap().get($index)?.content || ''"
+                    [showTranslation]="isTranslated()"
+                    (cardClick)="openReviewDetail($event)">
+                  </app-review-card>
+                }
+              }
+            </div>
+
+            <div class="reviews-pagination" *ngIf="!reviewSearchKeyword()">
+              <button [disabled]="reviewsPageIndex() <= 1" (click)="reviewsGoPage(-1)">上一页</button>
+              <span>第 {{ reviewsPageIndex() }} 页</span>
+              <button [disabled]="!reviewsHasMore()" (click)="reviewsGoPage(1)">下一页</button>
+            </div>
+          </div>
+        </div>
+      }
+      <!-- AI 聊天弹窗 -->
+      <app-ai-chat-modal
+        [visible]="showAiChat()"
+        [contextPrompt]="aiChatContext()"
+        [reportText]="aiChatReportText()"
+        [autoSend]="aiAutoSend()"
+        [title]="'AI 评论深度分析 - ' + asin"
+        (reviewClick)="onReviewNameClick($event)"
+        (close)="showAiChat.set(false)">
+      </app-ai-chat-modal>
+
+      <!-- 评论详情弹窗 -->
+      <app-review-detail-modal
+        [visible]="showReviewDetail()"
+        [review]="reviewDetailData()"
+        (close)="showReviewDetail.set(false)">
+      </app-review-detail-modal>
+    </div>
+  `,
+  styleUrls: ['./competitor-detail.component.scss']
+})
+export class CompetitorDetailComponent implements OnInit {
+  asin = '';
+  loading = true;
+  isRefreshing = false;
+  product: any = null;
+
+  // ---- AI 聊天弹窗 ----
+  showAiChat = signal<boolean>(false);
+  aiChatContext = signal<string>('');
+  aiChatReportText = signal<string>('');
+  aiAutoSend = signal<string>('');
+
+  // ---- 评论详情弹窗 ----
+  showReviewDetail = signal<boolean>(false);
+  reviewDetailData = signal<any>(null);
+
+  // ---- 评论相关信号 ----
+  showReviewsModal = signal<boolean>(false);
+  reviews = signal<any[]>([]);
+  reviewsLoading = signal<boolean>(false);
+  reviewsPageIndex = signal<number>(1);
+  reviewsStarFilter = signal<string>('');
+  reviewsOnlyPurchase = signal<number>(0);
+  reviewsTotal = signal<number>(0);
+  reviewsHasMore = signal<boolean>(false);
+  reviewsFromDb = signal<boolean>(false);
+  reviewDbCount = signal<number>(0);
+  isTranslated = signal<boolean>(false);
+  isTranslating = signal<boolean>(false);
+  translatedMap = signal<Map<number, { title: string; content: string }>>(new Map());
+  starOptions = ['5', '4', '3', '2', '1'];
+  reviewSearchKeyword = signal<string>('');
+  reviewMatchTerms = signal<string[]>([]);
+  aiAnalysisLoading = signal<boolean>(false);
+  activeDetailSection = signal<string>('detail-overview');
+
+  reviewInsight = signal<{
+    positiveRate: number;
+    negativeRate: number;
+    totalReviews: number;
+    positiveThemes: string[];
+    negativeThemes: string[];
+    negativeDetails: Array<{ issue: string; variant: string; star: number; snippet: string }>;
+  } | null>(null);
+  aiReviewPrompt = '';
+
+  /** 将 variant JSON 字符串转为可读格式,如 "Color: Black, Size: M" */
+  formatVariant(raw: string): string {
+    if (!raw) return '';
+    try {
+      const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
+      if (Array.isArray(arr) && arr.length > 0 && arr[0].name) {
+        return arr.map((v: any) => `${v.name}: ${v.detail}`).join(', ');
+      }
+    } catch { /* not JSON, return as-is */ }
+    return raw;
+  }
+
+  // ---- 变体维度差评分布 ----
+  variantBreakdown = computed(() => {
+    const insight = this.reviewInsight();
+    if (!insight?.negativeDetails?.length) return [];
+    const map = new Map<string, { count: number; issues: string[] }>();
+    insight.negativeDetails.forEach(d => {
+      const v = this.formatVariant((d.variant || '').trim());
+      if (!v) return;
+      const entry = map.get(v) || { count: 0, issues: [] };
+      entry.count++;
+      if (entry.issues.length < 3) entry.issues.push(d.issue || d.snippet);
+      map.set(v, entry);
+    });
+    return Array.from(map.entries())
+      .map(([variant, data]) => ({ variant, ...data }))
+      .sort((a, b) => b.count - a.count)
+      .slice(0, 10);
+  });
+
+  constructor(
+    private route: ActivatedRoute,
+    private location: Location,
+    private dataStore: DataStoreService,
+    private sorftimeApi: SorftimeApiService,
+    private monitoringDataService: MonitoringDataService,
+    private toast: ToastService
+  ) {}
+
+  ngOnInit(): void {
+    // 订阅 paramMap 而非使用 snapshot,确保同一组件不同 ASIN 之间跳转时页面能正确刷新
+    this.route.paramMap.subscribe(params => {
+      const newAsin = params.get('asin') || '';
+      if (newAsin && newAsin !== this.asin) {
+        this.asin = newAsin;
+        this.product = null;
+        this.loading = true;
+        this.isRefreshing = false;
+        this.activeDetailSection.set('detail-overview');
+        this.loadProductFromDB();
+        this.loadReviewInsightData();
+      } else if (!newAsin) {
+        this.loading = false;
+      }
+    });
+  }
+
+  private loadProductFromDB(): void {
+    // 从 AmazonProduct 表查询数据
+    this.dataStore.query('AmazonProduct', { asin: this.asin }, { limit: 1 }).subscribe({
+      next: (rows) => {
+        if (rows?.length) {
+          const vm = this.toViewModel(rows[0]);
+          // 避免“刷新后先展示接口数据,随后 DB 记录把字段覆盖回空/0”
+          this.product = this.mergePreferMeaningful(this.product, vm);
+        }
+        this.loading = false;
+      },
+      error: (err) => {
+        console.warn('[CompetitorDetail] Load from DB failed:', err?.message);
+        this.loading = false;
+      }
+    });
+  }
+
+  @ViewChild('variationHistory') variationHistoryRef?: ProductVariationHistoryComponent;
+
+  refreshData(): void {
+    if (!this.asin || this.isRefreshing) return;
+    
+    this.isRefreshing = true;
+
+    // 同步刷新变体历史数据
+    this.variationHistoryRef?.load();
+    
+    // 调用 Sorftime API 获取最新数据
+    this.sorftimeApi.getProductByAsin(this.asin).subscribe({
+      next: (data) => {
+        const productData = data && data.length ? data[0] : null;
+        if (productData && this.isMeaningfulProduct(productData)) {
+          // 先用接口数据更新展示(避免等 DB 回写)
+          this.product = this.toViewModel(productData);
+
+          // 调用云函数保存到 AmazonProduct 表
+          this.saveToCloudFunction(productData);
+        } else {
+          // Sorftime 未收录该 ASIN 时会返回空壳(Asin 形如 "#xxx"、字段全空),
+          // 直接保存会抹掉已有数据,这里保留现有数据并提示
+          this.isRefreshing = false;
+          console.warn('[CompetitorDetail] Sorftime returned empty/placeholder data for', this.asin);
+          this.toast.warning('Sorftime 暂无该商品的最新数据(可能尚未采集或正在采集中),已保留现有数据');
+        }
+      },
+      error: (err) => {
+        this.isRefreshing = false;
+        const msg = err?.message || '';
+        if (/694|请求次数不足|配额/.test(msg)) {
+          this.toast.error('Sorftime 数据源请求次数不足(配额已耗尽,错误码 694),请联系管理员充值/续费后再重试');
+        } else {
+          this.toast.error('刷新失败:' + (msg || 'Sorftime 上游接口暂时不可用'));
+        }
+        console.warn('[CompetitorDetail] Refresh failed:', msg);
+      }
+    });
+  }
+
+  /** 判断 Sorftime 返回的产品数据是否有效(非空壳/未收录占位) */
+  private isMeaningfulProduct(p: any): boolean {
+    if (!p || typeof p !== 'object') return false;
+    // Sorftime 对未收录的 ASIN 会返回形如 "#B0XXXX" 的占位,且字段全空
+    const asin = String(p.Asin ?? p.asin ?? '');
+    if (asin.startsWith('#')) return false;
+    const hasTitle = !!(p.Title || p.title);
+    const hasPrice = Number(p.Price ?? p.SalesPrice ?? p.price ?? 0) > 0;
+    const hasRatings = Number(p.RatingsCount ?? p.ratingsCount ?? 0) > 0;
+    return hasTitle || hasPrice || hasRatings;
+  }
+
+  private saveToCloudFunction(productData: any): void {
+    // 标准化处理:确保字段类型符合云函数 BdZWIv7cqu 的 AmazonProduct schema
+    // Object 字段(Feature/Property 等)→ 包装为 Object;String 字段 → 强制 String
+    const normalizedData = this.normalizeProductData(productData);
+
+    // 调用云函数保存(sorftime-ProductRequest)
+    this.dataStore.cloudFunction({
+      id: 'BdZWIv7cqu', // 云函数ID: sorftime-ProductRequest
+      data: {
+        Code: 200,
+        Data: normalizedData
+      },
+      context: {
+        query: { domain: 1 },
+        domain: 1
+      }
+    }).subscribe({
+      next: (result) => {
+        console.log('[CompetitorDetail] Cloud function saved:', result);
+        // 重新从数据库加载
+        this.loadProductFromDB();
+        this.isRefreshing = false;
+        this.toast.success('已从 Sorftime 刷新并保存最新数据');
+      },
+      error: (err) => {
+        console.warn('[CompetitorDetail] Cloud function save failed:', err);
+        this.isRefreshing = false;
+        this.toast.error('已获取 Sorftime 数据,但保存到数据库失败:' + (err?.message || '请稍后重试'));
+      }
+    });
+  }
+
+  /** 竞品详情页展示用:兼容不同字段命名/趋势数组 */
+  private toViewModel(raw: any): any {
+    const src = raw || {};
+
+    // 兼容 PascalCase(API)/ camelCase(DB)/ rawData 嵌套
+    const get = (pascal: string, ...alts: string[]): any => {
+      const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
+      for (const key of [pascal, camel, ...alts]) {
+        if (src[key] !== undefined && src[key] !== null) return src[key];
+      }
+      for (const key of [pascal, camel, ...alts]) {
+        const v = src.rawData?.[key] ?? src.data?.[key];
+        if (v !== undefined && v !== null) return v;
+      }
+      return undefined;
+    };
+
+    // API 价格单位为"分",转为"元"
+    const centsToDollars = (v: any): number => {
+      const n = this.toNumber(v);
+      return n != null ? n / 100 : 0;
+    };
+
+    // ---- 月销量 ----
+    const monthlySalesExplicit = this.toNumber(get('ListingSalesVolumeOfMonth'));
+    const monthlySalesFromTrend = this.pickLatestTrendValue(
+      get('ListingSalesVolumeOfMonthTrend', 'salesVolumeMonthTrend')
+    );
+    const monthlySalesFromAsin = this.toNumber(get('AsinSalesCount'));
+    const monthlySales =
+      monthlySalesExplicit ??
+      monthlySalesFromTrend ??
+      (monthlySalesFromAsin && monthlySalesFromAsin > 0 ? monthlySalesFromAsin : null);
+
+    // ---- BSR 排名:优先从 BsrCategory[0][2] 取子类目排名 ----
+    const rawRank = this.toNumber(get('Rank'));
+    const bsrCategory = get('BsrCategory') || [];
+    let bsrRank = rawRank;
+    if (Array.isArray(bsrCategory) && bsrCategory.length) {
+      const firstCat = bsrCategory[0];
+      if (Array.isArray(firstCat) && firstCat.length >= 3) {
+        const catRank = this.toNumber(firstCat[2]);
+        if (catRank != null && catRank > 0) {
+          bsrRank = catRank;
+        }
+      }
+    }
+
+    // ---- 上架日期:兼容 Parse Date 对象 / 字符串 ----
+    const rawOnlineDate = get('OnlineDate');
+    let onlineDate = '';
+    if (rawOnlineDate) {
+      if (typeof rawOnlineDate === 'string') {
+        onlineDate = rawOnlineDate;
+      } else if (rawOnlineDate?.iso) {
+        onlineDate = rawOnlineDate.iso.substring(0, 10);
+      } else if (rawOnlineDate instanceof Date) {
+        onlineDate = rawOnlineDate.toISOString().substring(0, 10);
+      }
+    }
+
+    return {
+      ...src,
+      // 基本信息
+      title: get('Title') || '',
+      asin: get('Asin') || '',
+      brand: get('Brand') || '',
+      photo: get('Photo') || [],
+      productBadge: get('ProductBadge') || [],
+      description: get('Description') || '',
+
+      // 价格(分→元)
+      salesPrice: centsToDollars(get('SalesPrice')),
+      price: centsToDollars(get('Price')),
+      listPrice: centsToDollars(get('ListPrice')),
+      platformFee: centsToDollars(get('PlatformFee')),
+      fbaFee: centsToDollars(get('FbaFee')),
+      shipCost: centsToDollars(get('ShipCost')),
+      profit: centsToDollars(get('Profit')),
+      profitRate: this.toNumber(get('ProfitRate')) ?? 0,
+      coupon: centsToDollars(get('Coupon')),
+
+      // 核心指标
+      ratings: this.toNumber(get('Ratings')) ?? 0,
+      ratingsCount: this.toNumber(get('RatingsCount')) ?? 0,
+      listingSalesVolumeOfMonth: monthlySales ?? 0,
+      __hasMonthlySales: monthlySales != null,
+      rank: bsrRank ?? -1,
+      onlineDate: onlineDate || '-',
+      onlineDays: this.toNumber(get('OnlineDays')) ?? 0,
+      isFBA: get('IsFBA') ?? false,
+      sellerCount: this.toNumber(get('SellerCount')) ?? 0,
+
+      // 分类
+      category: get('Category') || [],
+      bsrCategory,
+      productType: get('ProductType') || '',
+
+      // 卖家
+      buyboxSeller: get('BuyboxSeller') || '',
+      shipsFrom: get('ShipsFrom') || '',
+      storeName: get('StoreName') || '',
+      buyboxSellerAddress: get('BuyboxSellerAddress') || '',
+
+      // 评价分布(百分比)
+      fiveStartRatings: this.toNumber(get('FiveStartRatings')) ?? 0,
+      fourStartRatings: this.toNumber(get('FourStartRatings')) ?? 0,
+      threeStartRatings: this.toNumber(get('ThreeStartRatings')) ?? 0,
+      twoStartRatings: this.toNumber(get('TwoStartRatings')) ?? 0,
+      oneStartRatings: this.toNumber(get('OneStartRatings')) ?? 0,
+
+      // 其他
+      feature: get('Feature') || [],
+      updateDate: get('UpdateDate') || '',
+      dataUpdatedAt: new Date().toISOString(),
+      hasVideo: get('HasVideo') ?? false,
+      aPlus: get('APlus') ?? false,
+      hasBrandStore: get('HasBrandStore') ?? false,
+      dealType: get('DealType') || '',
+
+      // 结构化规格(来自 Property / ProductInfo)
+      property: get('Property') || [],
+      productInfo: get('ProductInfo') || [],
+      offSale: get('OffSale') ?? 0,
+
+      // 变体相关
+      variationASIN: get('VariationASIN') || [],
+      variationASINCount: this.toNumber(get('VariationASINCount')) ?? 0,
+      parentAsin: get('ParentAsin') || '',
+      attribute: get('Attribute') || [],
+      size: get('Size') || [],
+    };
+  }
+
+  /**
+   * 合并两个视图模型:当 incoming(通常来自 DB)缺少关键字段时,
+   * 保留 current(通常来自接口实时返回)中更“有意义”的值。
+   */
+  private mergePreferMeaningful(current: any, incoming: any): any {
+    if (!current) return incoming;
+    if (!incoming) return current;
+
+    const merged = { ...current, ...incoming };
+
+    // 月销量:DB 回来如果没有有效来源(__hasMonthlySales=false),则保留当前值
+    if (incoming.__hasMonthlySales === false && current.listingSalesVolumeOfMonth > 0) {
+      merged.listingSalesVolumeOfMonth = current.listingSalesVolumeOfMonth;
+    }
+
+    // 清理内部标记,避免污染模板/保存
+    delete merged.__hasMonthlySales;
+    return merged;
+  }
+
+  /** 从趋势数组中取最新有效值:格式 [date, value, date, value, ...],末尾可能 -1 */
+  private pickLatestTrendValue(trend: any): number | null {
+    if (!Array.isArray(trend) || trend.length < 2) return null;
+    // 以 (date,value) 为对,取最后一个 value != -1 的值
+    for (let i = trend.length - 1; i >= 1; i -= 2) {
+      const v = this.toNumber(trend[i]);
+      if (v == null) continue;
+      if (v === -1) continue;
+      return v;
+    }
+    return null;
+  }
+
+  private toNumber(v: any): number | null {
+    if (v == null) return null;
+    const n = typeof v === 'number' ? v : Number(v);
+    return Number.isFinite(n) ? n : null;
+  }
+
+  /**
+   * 标准化 Sorftime 产品数据,使其符合云函数 AmazonProduct schema。
+   * 参照《竞品获取接口规范》:
+   *  - Array 字段:强制转 Array(null → [],非数组 → [value])
+   *  - String 字段:强制转 String(Array/Object → JSON.stringify)
+   *  - 其余字段按原值复制
+   */
+  private normalizeProductData(data: any): any {
+    const pick = (key: string): any => {
+      const lower = key.charAt(0).toLowerCase() + key.slice(1);
+      return data[key] ?? data[lower];
+    };
+
+    // ============================================================
+    // 字段分类基于云函数 BdZWIv7cqu 源码 + 实际 Parse DB 列类型
+    // 4 个字段的 DB 类型与云函数默认值不匹配(标 ⚠️):
+    //   feature:     DB=Object, 云函数 || []  ⚠️
+    //   attribute:   DB=Array,  云函数 || ''  ⚠️
+    //   property:    DB=String, 云函数 || []  ⚠️
+    //   productInfo: DB=String, 云函数 || []  ⚠️
+    // ============================================================
+
+    // ---- 1. Array 字段(DB=Array)----
+    const arrayFields = [
+      'Photo', 'EBCPhoto', 'ExtraSavings',
+      'Category', 'BsrCategory', 'Size',
+      'VariationASIN', 'FbaDetail',
+      'ProductBadge', 'Attribute'
+    ];
+
+    // ---- 2. Object 字段(DB=Object,仅 Feature)----
+    const objectFields = ['Feature'];
+
+    // ---- 3a. String 字段-安全(DB=String,云函数默认 '',匹配)----
+    const safeStringFields = [
+      'Description', 'BrandPromotion', 'DealType',
+      'ProductType', 'StoreName', 'UpdateDate', 'ShipsFrom'
+    ];
+
+    // ---- 3b. String 字段-不安全(DB=String,云函数默认 [] 不匹配)----
+    const unsafeStringFields = ['Property', 'ProductInfo'];
+
+    // ---- 4. 直接复制字段 ----
+    const passthroughFields = [
+      'Asin', 'ParentAsin', 'Title', 'Brand',
+      'Price', 'ListPrice', 'SalesPrice', 'Coupon',
+      'Rank', 'RatingsCount', 'Ratings',
+      'FiveStartRatings', 'FourStartRatings', 'ThreeStartRatings',
+      'TwoStartRatings', 'OneStartRatings',
+      'OnlineDate', 'OnlineDays', 'OffSale',
+      'BuyboxSeller', 'BuyboxSellerId', 'BuyboxSellerAddress',
+      'IsFBA', 'FbaFee', 'PlatformFee', 'Profit', 'ProfitRate',
+      'ShipCost', 'Weight',
+      'SellerCount', 'HasVideo', 'APlus', 'HasBrandStore',
+      'VariationASINCount',
+      'ListingSalesVolumeOfDaily', 'ListingSalesVolumeOfMonth',
+      'ListingSalesOfDaily', 'ListingSalesOfMonth',
+      'AsinSalesCount',
+      'PriceTrend', 'ListPriceTrend',
+      'SalesVolumeDailyTrend', 'SalesDailyTrend',
+      'SalesVolumeMonthTrend', 'SalesMonthTrend',
+      'RankTrend', 'BsrRankTrend', 'DealTrend'
+    ];
+
+    const normalized: any = {};
+
+    // 1. 直接复制
+    passthroughFields.forEach(key => {
+      const val = pick(key);
+      if (val !== undefined && val !== null) {
+        normalized[key] = val;
+      }
+    });
+
+    // 2. Array 字段:强制 Array(null → [])
+    arrayFields.forEach(key => {
+      const val = pick(key);
+      if (Array.isArray(val)) {
+        normalized[key] = val;
+      } else if (val !== undefined && val !== null) {
+        normalized[key] = [val];
+      } else {
+        normalized[key] = [];
+      }
+    });
+
+    // 3. Object 字段:强制 Object(null → {})
+    objectFields.forEach(key => {
+      const val = pick(key);
+      if (val === undefined || val === null) {
+        normalized[key] = {};
+      } else if (Array.isArray(val)) {
+        normalized[key] = { items: val };
+      } else if (typeof val === 'object') {
+        normalized[key] = val;
+      } else {
+        normalized[key] = { value: val };
+      }
+    });
+
+    // 4a. 安全 String 字段(null OK,云函数 || '' 正确兜底)
+    safeStringFields.forEach(key => {
+      const val = pick(key);
+      if (val === undefined || val === null) {
+        // 不设置,让云函数默认为 ''
+      } else if (Array.isArray(val) || (typeof val === 'object')) {
+        normalized[key] = JSON.stringify(val);
+      } else {
+        normalized[key] = String(val);
+      }
+    });
+
+    // 4b. 不安全 String 字段(null → "[]",防止云函数 || [] 变成 Array)
+    unsafeStringFields.forEach(key => {
+      const val = pick(key);
+      if (val === undefined || val === null) {
+        normalized[key] = '[]';
+      } else if (Array.isArray(val) || (typeof val === 'object')) {
+        normalized[key] = JSON.stringify(val);
+      } else {
+        normalized[key] = String(val) || '[]';
+      }
+    });
+
+    return normalized;
+  }
+
+  // ==================== 评论功能 ====================
+
+  private loadReviewInsightData(): void {
+    if (!this.asin) return;
+    this.monitoringDataService.fetchAndAnalyzeReviewsForAsins([this.asin]).subscribe({
+      next: (map) => {
+        const insight = map?.[this.asin];
+        if (!insight) return;
+        this.reviewInsight.set(insight);
+        this.aiReviewPrompt = this.buildAiReviewPrompt(insight);
+      },
+      error: () => {
+        this.reviewInsight.set(null);
+        this.aiReviewPrompt = '';
+      }
+    });
+  }
+
+  private buildAiReviewPrompt(insight: {
+    positiveRate: number;
+    negativeRate: number;
+    totalReviews: number;
+    positiveThemes: string[];
+    negativeThemes: string[];
+    negativeDetails: Array<{ issue: string; variant: string; star: number; snippet: string }>;
+    fullReviews?: Array<{ star: number; title: string; content: string; asin: string; variant: string; consumerName: string; date: string; helpful: number }>;
+  }): string {
+    // 按变体聚合差评分布
+    const variantMap = new Map<string, { count: number; issues: string[] }>();
+    insight.negativeDetails.forEach(d => {
+      const v = this.formatVariant((d.variant || '').trim());
+      if (!v) return;
+      const entry = variantMap.get(v) || { count: 0, issues: [] };
+      entry.count++;
+      if (entry.issues.length < 3) entry.issues.push(d.issue || d.snippet);
+      variantMap.set(v, entry);
+    });
+    const variantLines = Array.from(variantMap.entries())
+      .sort((a, b) => b[1].count - a[1].count)
+      .map(([v, data]) => `- ${v}: ${data.count}条差评,问题:${data.issues.join(' / ')}`)
+      .join('\n');
+
+    // 完整评论数据(差评+好评+中评样本)— 用评论者+变体作为标识,不用数字编号
+    const fullReviewLines = (insight.fullReviews || []).map((r) => {
+      const label = r.consumerName || '匿名用户';
+      const variantTag = r.variant ? `[${this.formatVariant(r.variant)}]` : '';
+      const starTag = '★'.repeat(Math.max(1, r.star)) + '☆'.repeat(Math.max(0, 5 - r.star));
+      const meta: string[] = [];
+      if (r.asin) meta.push(`ASIN:${r.asin}`);
+      if (r.date) meta.push(`日期:${r.date}`);
+      if (r.helpful > 0) meta.push(`${r.helpful}人觉得有用`);
+      const metaStr = meta.length ? ` (${meta.join(', ')})` : '';
+      return `- 【${label}${variantTag}】${starTag}${metaStr}\n  标题: ${r.title || '(无标题)'}\n  内容: ${r.content || '(无内容)'}`;
+    }).join('\n');
+
+    return [
+      `【注意:以下是一款竞品的完整评论数据,非本店产品。请从竞品分析视角出发,基于每条评论的具体ASIN、变体(尺码/颜色)信息进行细致分析。】`,
+      `竞品主ASIN: ${this.asin}`,
+      `总评论: ${insight.totalReviews}`,
+      `正向率: ${insight.positiveRate.toFixed(1)}%`,
+      `负向率: ${insight.negativeRate.toFixed(1)}%`,
+      `好评主题: ${insight.positiveThemes.join(' | ') || '无'}`,
+      `差评主题: ${insight.negativeThemes.join(' | ') || '无'}`,
+      '',
+      '### 变体/SKU维度差评分布:',
+      variantLines || '(无变体信息)',
+      '',
+      '### 完整评论样本(含ASIN、变体、完整原文):',
+      fullReviewLines || '无',
+      '',
+      '【重要格式要求】引用评论时,必须使用【评论者姓名[变体信息]】格式标注评论者,例如【John[Size:M/Color:Black]】。禁止使用"差评1""差评9"等数字编号。引用原文时用「」括起来。',
+      '',
+      '请基于以上完整评论数据,从竞品分析角度输出,分析颗粒度需细致到具体尺码/颜色/SKU:',
+      '1) 竞品各变体/尺码的痛点分布——哪些尺码/颜色差评集中?具体是什么问题?引用具体评论者和原文佐证。这些是我方可以差异化的切入点;',
+      '2) 竞品好评亮点——消费者具体认可什么?按变体维度分析,引用具体评论原文,我方产品需对标或超越的点;',
+      '3) 我方产品的差异化切入建议——基于竞品各变体的弱点,给出具体到尺码/颜色的产品开发和改良建议;',
+      '4) Listing优化参考——从评论原文中提炼消费者在不同变体上关注的卖点关键词,用于优化标题、五点和A+页面。'
+    ].join('\n');
+  }
+
+  openReviewsModal(): void {
+    this.showReviewsModal.set(true);
+    this.reviewsPageIndex.set(1);
+    this.reviewsStarFilter.set('');
+    this.reviewsOnlyPurchase.set(0);
+    this.reviewSearchKeyword.set('');
+    this.reviewMatchTerms.set([]);
+    this.isTranslated.set(false);
+    this.translatedMap.set(new Map());
+    this.loadReviews();
+  }
+
+  onReviewNameClick(reviewer: string): void {
+    // 从【用户名[变体]】格式中提取用户名
+    const name = reviewer.replace(/\[.*?\]/g, '').trim();
+    if (name) {
+      this.openReviewsModalWithKeyword(name);
+    }
+  }
+
+  openReviewsModalWithKeyword(keyword: string): void {
+    this.showReviewsModal.set(true);
+    this.reviewsPageIndex.set(1);
+    this.reviewsStarFilter.set('');
+    this.reviewsOnlyPurchase.set(0);
+    this.reviewSearchKeyword.set((keyword || '').trim());
+    this.reviewMatchTerms.set(this.extractKeywordTerms(this.reviewSearchKeyword()));
+    this.isTranslated.set(false);
+    this.translatedMap.set(new Map());
+    this.loadReviews();
+  }
+
+  clearReviewKeyword(): void {
+    this.reviewSearchKeyword.set('');
+    this.reviewMatchTerms.set([]);
+    this.reviewsPageIndex.set(1);
+    this.loadReviews();
+  }
+
+  closeReviewsModal(): void {
+    this.showReviewsModal.set(false);
+    this.reviewSearchKeyword.set('');
+    this.reviewMatchTerms.set([]);
+  }
+
+  onStarFilterChange(star: string): void {
+    this.reviewsStarFilter.set(this.reviewsStarFilter() === star ? '' : star);
+    this.reviewsPageIndex.set(1);
+    this.loadReviews();
+  }
+
+  togglePurchaseFilter(): void {
+    this.reviewsOnlyPurchase.set(this.reviewsOnlyPurchase() ? 0 : 1);
+    this.reviewsPageIndex.set(1);
+    this.loadReviews();
+  }
+
+  reviewsGoPage(delta: number): void {
+    const newPage = this.reviewsPageIndex() + delta;
+    if (newPage < 1 || newPage > 100) return;
+    this.reviewsPageIndex.set(newPage);
+    this.loadReviews();
+  }
+
+  refreshReviewsFromApi(): void {
+    this.fetchAndSaveFromApi();
+  }
+
+  private loadReviews(): void {
+    this.reviewsLoading.set(true);
+    this.isTranslated.set(false);
+    this.translatedMap.set(new Map());
+
+    const star = this.reviewsStarFilter();
+    const page = this.reviewsPageIndex();
+    const onlyPurchase = this.reviewsOnlyPurchase();
+    const limit = 20;
+    const skip = (page - 1) * limit;
+
+    // 1. 查 DB 缓存数量
+    const filters: any = { asin: this.asin };
+    if (star) filters.star = Number(star);
+    if (onlyPurchase) filters.ivp = true;
+
+    this.dataStore.query('SorftimeReviews', { asin: this.asin }, { limit: 1 }).subscribe({
+      next: (countCheck) => {
+        if (countCheck?.length) {
+          // DB 有数据,从 DB 加载
+          this.loadReviewsFromDb(filters, limit, skip);
+        } else {
+          // DB 无数据,从 API 获取
+          this.fetchAndSaveFromApi();
+        }
+      },
+      error: () => {
+        this.fetchAndSaveFromApi();
+      }
+    });
+  }
+
+  private loadReviewsFromDb(filters: any, limit: number, skip: number): void {
+    const hasKeyword = !!this.reviewSearchKeyword();
+    this.dataStore.query('SorftimeReviews', filters, {
+      limit: hasKeyword ? 200 : limit,
+      skip: hasKeyword ? 0 : skip,
+      orderBy: 'reviewDate',
+      descending: true
+    }).subscribe({
+      next: (rows) => {
+        let mapped = (rows || []).map((r: any) => ({
+          Star: r.star,
+          Title: r.title,
+          Content: r.content,
+          ConsumerName: r.consumerName,
+          ReviewDate: r.reviewDate,
+          UpdateTime: r.updateTime,
+          Helpful: r.helpful,
+          Ivp: r.ivp,
+          AsinProperty: r.asinProperty || r.rawData?.AsinProperty || '',
+          ReviewsCountry: r.reviewsCountry,
+          ReviewLink: r.reviewLink,
+          Resource: r.resource || '',
+          Videos: r.videos || '',
+          rawData: r.rawData,
+          _objectId: r.objectId,
+          _translatedTitle: r.translatedTitle,
+          _translatedContent: r.translatedContent
+        }));
+
+        mapped = this.applyKeywordSort(mapped);
+
+        this.reviews.set(mapped);
+        this.reviewsFromDb.set(true);
+        this.reviewsHasMore.set(!hasKeyword && mapped.length >= limit);
+        this.reviewsLoading.set(false);
+
+        // 加载已保存的翻译
+        const tMap = new Map<number, { title: string; content: string }>();
+        mapped.forEach((r: any, i: number) => {
+          if (r._translatedTitle || r._translatedContent) {
+            tMap.set(i, { title: r._translatedTitle || '', content: r._translatedContent || '' });
+          }
+        });
+        this.translatedMap.set(tMap);
+
+        // 获取 DB 总数
+        this.dataStore.query('SorftimeReviews', { asin: this.asin }).subscribe({
+          next: (all) => this.reviewDbCount.set(all?.length || 0)
+        });
+      },
+      error: () => {
+        this.reviewsLoading.set(false);
+        this.reviews.set([]);
+      }
+    });
+  }
+
+  private fetchAndSaveFromApi(): void {
+    this.reviewsLoading.set(true);
+    this.reviewsFromDb.set(false);
+    const hasKeyword = !!this.reviewSearchKeyword();
+
+    this.sorftimeApi.getProductReviews(this.asin, {
+      star: this.reviewsStarFilter() || undefined,
+      pageIndex: this.reviewsPageIndex(),
+      onlyPurchase: this.reviewsOnlyPurchase() || undefined
+    }).subscribe({
+      next: (resp) => {
+        const data = resp?.Data || resp?.Reviews || resp?.data || resp;
+        const reviewList = Array.isArray(data) ? data : [];
+
+        // 提取总数(从 ItemIndex "14/122")
+        if (reviewList.length && reviewList[0]?.ItemIndex) {
+          const parts = String(reviewList[0].ItemIndex).split('/');
+          if (parts.length === 2) {
+            this.reviewsTotal.set(Number(parts[1]) || 0);
+          }
+        }
+
+        // Debug: 打印第一条评论的字段名
+        if (reviewList.length) {
+          console.log('[Reviews] 第一条评论字段:', Object.keys(reviewList[0]));
+          console.log('[Reviews] ReviewsDate:', reviewList[0].ReviewsDate, 'UpdateTime:', reviewList[0].UpdateTime);
+        }
+
+        const sorted = this.applyKeywordSort(reviewList);
+        this.reviews.set(sorted);
+        this.reviewsHasMore.set(!hasKeyword && reviewList.length >= 20);
+        this.reviewsLoading.set(false);
+
+        // 后台保存到 DB(不阻塞 UI)
+        if (reviewList.length) {
+          this.saveReviewsToDb(reviewList);
+        }
+      },
+      error: (err) => {
+        console.warn('[Reviews] API fetch failed:', err?.message);
+        this.reviewsLoading.set(false);
+        this.reviews.set([]);
+      }
+    });
+  }
+
+  private saveReviewsToDb(reviewList: any[]): void {
+    const items = reviewList.map(r => ({
+      reviewId: r.ReviewsLink || r.ReviewLink || `${this.asin}_${r.ConsumerName}_${r.ReviewsDate || r.ReviewDate}`,
+      asin: this.asin,
+      star: Number(r.Star) || 0,
+      title: r.Title || '',
+      content: r.Content || '',
+      consumerName: r.ConsumerName || '',
+      reviewDate: r.ReviewsDate || r.ReviewDate || r.CreateTime || '',
+      updateTime: r.UpdateTime || '',
+      helpful: Number(r.Helpful) || 0,
+      ivp: !!(r.IsVP || r.Ivp),
+      asinProperty: r.AsinProperty || '',
+      reviewsCountry: r.ReviewedCountry || r.ReviewsCountry || '',
+      reviewLink: r.ReviewsLink || r.ReviewLink || '',
+      consumerUrl: r.ConsumerURL || r.ConsumerUrl || '',
+      itemIndex: r.ItemIndex || '',
+      resource: r.Resource || '',
+      videos: r.Videos || '',
+      rawData: r
+    }));
+
+    this.dataStore.saveBatch('SorftimeReviews', items, 'reviewId').subscribe({
+      next: () => console.log('[Reviews] Saved', items.length, 'reviews to DB'),
+      error: (err: any) => console.warn('[Reviews] DB save failed:', err?.message)
+    });
+  }
+
+  openReviewDetail(review: any): void {
+    this.reviewDetailData.set(review);
+    this.showReviewDetail.set(true);
+  }
+
+  onOpenAiChat(event: { prompt: string; report: string }): void {
+    this.aiChatContext.set(event.prompt);
+    this.aiChatReportText.set(event.report);
+    this.aiAutoSend.set('');
+    this.showAiChat.set(true);
+  }
+
+  /** 从评论弹窗打开 AI 全量评论分析 */
+  openAiReviewAnalysis(): void {
+    this.aiAnalysisLoading.set(true);
+
+    // 从 DB 加载全量评论用于 AI 分析
+    this.dataStore.query('SorftimeReviews', { asin: this.asin }, {
+      limit: 500, orderBy: 'reviewDate', descending: true
+    }).subscribe({
+      next: (rows: any[]) => {
+        const allReviews = (rows || []).map((r: any) => {
+          // variant: 优先从 asinProperty 字段取,降级从 rawData 取
+          const variant = r.asinProperty || r.rawData?.AsinProperty || '';
+          return {
+            star: Number(r.star) || 0,
+            title: r.title || '',
+            content: r.content || '',
+            consumerName: r.consumerName || '',
+            variant,
+            date: r.reviewDate || '',
+            helpful: Number(r.helpful) || 0,
+            ivp: !!r.ivp
+          };
+        });
+
+        if (allReviews.length === 0 && this.aiReviewPrompt) {
+          // 无 DB 数据但有 insight 数据,直接用已有 prompt
+          this.aiChatContext.set(this.aiReviewPrompt);
+          this.aiChatReportText.set('');
+          this.aiAutoSend.set('请基于以上评论数据,进行完整的竞品评论分析');
+          this.closeReviewsModal();
+          this.showAiChat.set(true);
+          this.aiAnalysisLoading.set(false);
+          return;
+        }
+
+        // 构建全量评论分析 prompt
+        const fullPrompt = this.buildFullReviewAiPrompt(allReviews);
+        this.aiReviewPrompt = fullPrompt;
+        this.aiChatContext.set(fullPrompt);
+        this.aiChatReportText.set('');
+        this.aiAutoSend.set('请基于以上全量评论数据,进行完整的竞品评论分析');
+        this.closeReviewsModal();
+        this.showAiChat.set(true);
+        this.aiAnalysisLoading.set(false);
+      },
+      error: () => {
+        // 降级:使用已有的 aiReviewPrompt
+        if (this.aiReviewPrompt) {
+          this.aiChatContext.set(this.aiReviewPrompt);
+          this.aiChatReportText.set('');
+          this.aiAutoSend.set('请基于以上评论数据,进行竞品评论分析');
+          this.closeReviewsModal();
+          this.showAiChat.set(true);
+        }
+        this.aiAnalysisLoading.set(false);
+      }
+    });
+  }
+
+  /** 构建全量评论 AI 分析 prompt */
+  private buildFullReviewAiPrompt(reviews: Array<{
+    star: number; title: string; content: string;
+    consumerName: string; variant: string; date: string;
+    helpful: number; ivp: boolean;
+  }>): string {
+    const total = reviews.length;
+    const starDist: Record<number, number> = { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 };
+    reviews.forEach(r => { if (r.star >= 1 && r.star <= 5) starDist[r.star]++; });
+    const negCount = starDist[1] + starDist[2];
+    const posCount = starDist[4] + starDist[5];
+    const negRate = total > 0 ? ((negCount / total) * 100).toFixed(1) : '0';
+    const posRate = total > 0 ? ((posCount / total) * 100).toFixed(1) : '0';
+
+    // 按变体聚合
+    const variantMap = new Map<string, { pos: number; neg: number; issues: string[] }>();
+    reviews.forEach(r => {
+      const v = (r.variant || '').trim();
+      if (!v) return;
+      const entry = variantMap.get(v) || { pos: 0, neg: 0, issues: [] };
+      if (r.star <= 2) {
+        entry.neg++;
+        if (entry.issues.length < 3) entry.issues.push(r.title || r.content.slice(0, 60));
+      } else if (r.star >= 4) {
+        entry.pos++;
+      }
+      variantMap.set(v, entry);
+    });
+    const variantLines = Array.from(variantMap.entries())
+      .sort((a, b) => b[1].neg - a[1].neg)
+      .slice(0, 15)
+      .map(([v, d]) => `- ${v}: 好评${d.pos}条, 差评${d.neg}条${d.issues.length ? ', 问题: ' + d.issues.join(' / ') : ''}`)
+      .join('\n');
+
+    // 完整评论样本(最多100条,优先差评)
+    const sorted = [...reviews].sort((a, b) => a.star - b.star);
+    const sampleReviews = sorted.slice(0, 100);
+    const reviewLines = sampleReviews.map(r => {
+      const label = r.consumerName || '匿名用户';
+      const variantTag = r.variant ? `[${r.variant}]` : '';
+      const starTag = '★'.repeat(Math.max(1, r.star)) + '☆'.repeat(Math.max(0, 5 - r.star));
+      const meta: string[] = [];
+      if (r.date) meta.push(`日期:${r.date}`);
+      if (r.helpful > 0) meta.push(`${r.helpful}人觉得有用`);
+      if (r.ivp) meta.push('已验证购买');
+      const metaStr = meta.length ? ` (${meta.join(', ')})` : '';
+      return `- 【${label}${variantTag}】${starTag}${metaStr}\n  标题: ${r.title || '(无标题)'}\n  内容: ${(r.content || '(无内容)').slice(0, 300)}`;
+    }).join('\n');
+
+    return [
+      `【竞品全量评论AI分析】`,
+      `竞品ASIN: ${this.asin}`,
+      `产品: ${this.product?.title || ''}`,
+      `品牌: ${this.product?.brand || ''}`,
+      `价格: $${this.product?.salesPrice || this.product?.price || 0}`,
+      `评分: ${this.product?.ratings || 0} (${this.product?.ratingsCount || 0}条评价)`,
+      '',
+      `## 评论概览`,
+      `- 总评论数: ${total}`,
+      `- 星级分布: 5★=${starDist[5]}, 4★=${starDist[4]}, 3★=${starDist[3]}, 2★=${starDist[2]}, 1★=${starDist[1]}`,
+      `- 好评率: ${posRate}% | 差评率: ${negRate}%`,
+      '',
+      `## 变体/SKU维度分布:`,
+      variantLines || '(无变体信息)',
+      '',
+      `## 评论样本(${sampleReviews.length}条,优先展示差评):`,
+      reviewLines || '无',
+      '',
+      '【重要格式要求】引用评论时,必须使用【评论者姓名[变体信息]】格式标注评论者。引用原文时用「」括起来。',
+      '',
+      '请基于以上全量评论数据,进行完整的竞品评论分析,输出以下内容:',
+      '1) **差评痛点深度分析** - 按问题类型分类(质量/尺码/材质/物流/描述不符等),标注严重程度和出现频率,引用具体评论佐证;',
+      '2) **好评亮点提炼** - 消费者最认可的产品优势,按变体维度分析差异,引用具体评论;',
+      '3) **变体/SKU差异分析** - 哪些尺码/颜色差评集中?哪些变体口碑最好?给出变体优化建议;',
+      '4) **我方差异化切入点** - 基于竞品痛点,给出具体可落地的产品开发和改良建议;',
+      '5) **Listing优化参考** - 从评论中提炼高频消费者语言和卖点关键词,用于优化标题、五点描述和A+页面;',
+      '6) **风险预警** - 评估该竞品的整体风险等级,识别可能导致退货/差评的关键问题。'
+    ].join('\n');
+  }
+
+  // ---- 翻译 ----
+
+  toggleTranslation(): void {
+    if (this.isTranslated()) {
+      this.isTranslated.set(false);
+      return;
+    }
+    // 检查是否已有翻译缓存
+    const hasContent = Array.from(this.translatedMap().values()).some(t => t.title || t.content);
+    if (hasContent) {
+      this.isTranslated.set(true);
+      return;
+    }
+    this.translateReviews();
+  }
+
+  private async translateReviews(): Promise<void> {
+    this.isTranslating.set(true);
+    const currentReviews = this.reviews();
+    const tMap = new Map<number, { title: string; content: string }>();
+
+    for (let i = 0; i < currentReviews.length; i++) {
+      const r = currentReviews[i];
+      const tTitle = await this.translateText(r.Title || '');
+      const tContent = await this.translateText(r.Content || '');
+      tMap.set(i, { title: tTitle, content: tContent });
+
+      // 保存翻译到 DB
+      if (r._objectId && (tTitle || tContent)) {
+        this.dataStore.save('SorftimeReviews', {
+          objectId: r._objectId,
+          translatedTitle: tTitle,
+          translatedContent: tContent
+        }).subscribe();
+      }
+    }
+
+    this.translatedMap.set(tMap);
+    this.isTranslated.set(true);
+    this.isTranslating.set(false);
+  }
+
+  private async translateText(text: string): Promise<string> {
+    if (!text || text.length < 2) return text;
+    const truncated = text.length > 500 ? text.substring(0, 500) + '...' : text;
+    try {
+      const resp = await fetch(
+        `https://api.mymemory.translated.net/get?q=${encodeURIComponent(truncated)}&langpair=en|zh-CN`
+      );
+      const json = await resp.json();
+      if (json?.responseData?.translatedText) {
+        return json.responseData.translatedText;
+      }
+    } catch { }
+    // 降级:Google Translate
+    try {
+      const resp = await fetch(
+        `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=zh-CN&dt=t&q=${encodeURIComponent(truncated)}`
+      );
+      const json = await resp.json();
+      if (Array.isArray(json?.[0])) {
+        return json[0].map((s: any) => s[0]).join('');
+      }
+    } catch { }
+    return text;
+  }
+
+  // ---- 评论显示辅助 ----
+
+  getStars(rating: number): string {
+    const r = Math.round(rating);
+    return '★'.repeat(r) + '☆'.repeat(5 - r);
+  }
+
+  getReviewDate(review: any): string {
+    // 使用 ReviewsDate(实际评论日期),UpdateTime 是系统更新时间不准确
+    const direct = review.ReviewsDate || review.ReviewDate || review.reviewDate || '';
+    if (direct) return direct;
+
+    // 从 rawData 提取(DB 缓存的原始 Sorftime 数据)
+    const raw = review.rawData || review._rawData;
+    if (raw) {
+      const rawDate = raw.ReviewsDate || raw.ReviewDate || '';
+      if (rawDate) return rawDate;
+    }
+    return '';
+  }
+
+  formatReviewDate(dateStr: string): string {
+    if (!dateStr) return '-';
+    const s = String(dateStr).trim();
+
+    // "2026-02-06 17:39" 格式 — 已精确到分钟,直接返回
+    if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(s)) {
+      return s;
+    }
+
+    // "20260204" 格式 — 转为 YYYY-MM-DD
+    if (s.length === 8 && /^\d{8}$/.test(s)) {
+      return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
+    }
+
+    try {
+      const d = new Date(s);
+      if (!isNaN(d.getTime())) {
+        return d.toLocaleString('zh-CN', {
+          year: 'numeric', month: '2-digit', day: '2-digit',
+          hour: '2-digit', minute: '2-digit'
+        });
+      }
+    } catch { }
+    return s;
+  }
+
+  getReviewTitle(review: any, index: number): string {
+    if (this.isTranslated()) {
+      const t = this.translatedMap().get(index);
+      if (t?.title) return t.title;
+    }
+    return review.Title || '';
+  }
+
+  getReviewContent(review: any, index: number): string {
+    if (this.isTranslated()) {
+      const t = this.translatedMap().get(index);
+      if (t?.content) return t.content;
+    }
+    return review.Content || '';
+  }
+
+  isReviewMatchingKeyword(review: any): boolean {
+    const terms = this.reviewMatchTerms();
+    if (!terms.length) return false;
+    const text = `${review.Title || ''} ${review.Content || ''}`.toLowerCase();
+    return terms.some(t => text.includes(t));
+  }
+
+  private applyKeywordSort(reviews: any[]): any[] {
+    const terms = this.reviewMatchTerms();
+    if (!terms.length) return reviews;
+    return [...reviews].sort((a, b) => {
+      const aText = `${a.Title || a.title || ''} ${a.Content || a.content || ''}`.toLowerCase();
+      const bText = `${b.Title || b.title || ''} ${b.Content || b.content || ''}`.toLowerCase();
+      const aScore = terms.reduce((s, t) => s + (aText.includes(t) ? 1 : 0), 0);
+      const bScore = terms.reduce((s, t) => s + (bText.includes(t) ? 1 : 0), 0);
+      if (aScore !== bScore) return bScore - aScore;
+      return (a.Star || 5) - (b.Star || 5);
+    });
+  }
+
+  private extractKeywordTerms(keyword: string): string[] {
+    const details = this.reviewInsight()?.negativeDetails;
+    return MonitoringDataService.buildSearchTerms(keyword, details);
+  }
+
+  goBack(): void {
+    this.location.back();
+  }
+
+  selectDetailSection(sectionId: string): void {
+    this.activeDetailSection.set(sectionId);
+  }
+
+  formatNumber(num: number): string {
+    if (!num) return '0';
+    if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
+    if (num >= 1000) return (num / 1000).toFixed(1) + 'k';
+    return num.toLocaleString();
+  }
+
+  formatPrice(val: number): string {
+    return (val || 0).toFixed(2);
+  }
+
+  formatDateTime(isoStr: string): string {
+    if (!isoStr) return '';
+    try {
+      const d = new Date(isoStr);
+      if (isNaN(d.getTime())) return '';
+      const pad = (n: number) => n.toString().padStart(2, '0');
+      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+    } catch {
+      return '';
+    }
+  }
+
+  getCategoryName(category: any[]): string {
+    if (!category || !category.length) return '-';
+    const cat = category[0];
+    if (Array.isArray(cat)) return cat[0] || '-';
+    return String(cat);
+  }
+
+  /** 从 product 对象中提取 VariationASIN 数组 */
+  getVariationASINs(): string[] {
+    const raw = this.product?.variationASIN ?? [];
+    if (Array.isArray(raw)) return raw.filter((v: any) => typeof v === 'string' && v.length > 0);
+    if (typeof raw === 'string') {
+      try { return JSON.parse(raw); } catch { return []; }
+    }
+    return [];
+  }
+
+  getBsrCategoryName(bsrCategory: any[]): string {
+    if (!bsrCategory || !bsrCategory.length) return '-';
+    const cat = bsrCategory[0];
+    if (Array.isArray(cat)) {
+      const name = cat[0] || '-';
+      const rank = cat[2] ? `#${cat[2]}` : '';
+      return rank ? `${name} ${rank}` : name;
+    }
+    return String(cat);
+  }
+
+  hasRatingDistribution(): boolean {
+    return !!(this.product?.fiveStartRatings || this.product?.fourStartRatings || 
+              this.product?.threeStartRatings || this.product?.twoStartRatings || 
+              this.product?.oneStartRatings);
+  }
+
+  /** 1星数量多于2星时提示关注 VOC */
+  hasLowStarAnomaly(): boolean {
+    const one = this.product?.oneStartRatings ?? 0;
+    const two = this.product?.twoStartRatings ?? 0;
+    return one > two && one > 0;
+  }
+
+  getRatingPercent(stars: number): number {
+    const total = (this.product?.fiveStartRatings || 0) +
+                   (this.product?.fourStartRatings || 0) +
+                   (this.product?.threeStartRatings || 0) +
+                   (this.product?.twoStartRatings || 0) +
+                   (this.product?.oneStartRatings || 0);
+    
+    if (!total) return 0;
+    
+    const counts: Record<number, number> = {
+      5: this.product?.fiveStartRatings || 0,
+      4: this.product?.fourStartRatings || 0,
+      3: this.product?.threeStartRatings || 0,
+      2: this.product?.twoStartRatings || 0,
+      1: this.product?.oneStartRatings || 0
+    };
+    
+    return ((counts[stars] || 0) / total) * 100;
+  }
+}

+ 322 - 0
src/modules/knowledge-center/competitor-detail/product-feature-analysis/product-feature-analysis.component.ts

@@ -0,0 +1,322 @@
+import { Component, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges, computed, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+export interface FeatureDimension {
+  key: string;
+  label: string;
+  icon: string;
+  values: string[];
+  color: string;
+}
+
+/**
+ * 服装产品特征结构化解析组件
+ * 覆盖 15 个服装关键维度:面料、版型、领型、袖型、衣长、腰线、胸垫、
+ * 闭合方式、口袋、弹性、印花、场景、洗护、产地、其他工艺
+ */
+@Component({
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  selector: 'app-product-feature-analysis',
+  standalone: true,
+  imports: [CommonModule],
+  template: `
+    <div class="pfa-card" *ngIf="dimensions().length > 0">
+      <div class="pfa-header">
+        <h3 class="pfa-title">产品特征分析</h3>
+        <span class="pfa-badge">{{ dimensions().length }} 个维度</span>
+      </div>
+
+      <div class="pfa-grid">
+        @for (dim of dimensions(); track dim.key) {
+          <div class="pfa-dim" [style.--dim-color]="dim.color">
+            <div class="pfa-dim-header">
+              <span class="pfa-dim-icon">{{ dim.icon }}</span>
+              <span class="pfa-dim-label">{{ dim.label }}</span>
+            </div>
+            <div class="pfa-dim-values">
+              @for (val of dim.values; track val) {
+                <span class="pfa-val">{{ val }}</span>
+              }
+            </div>
+          </div>
+        }
+      </div>
+
+      <!-- 原始 Feature 折叠展示 -->
+      @if (featureLines().length > 0) {
+        <div class="pfa-raw-section">
+          <button class="pfa-raw-toggle" (click)="showRaw.set(!showRaw())">
+            {{ showRaw() ? '▲ 收起原文' : '▼ 展开原始特点 (' + featureLines().length + ' 条)' }}
+          </button>
+          @if (showRaw()) {
+            <ul class="pfa-raw-list">
+              @for (line of featureLines(); track line) {
+                <li>{{ line }}</li>
+              }
+            </ul>
+          }
+        </div>
+      }
+    </div>
+  `,
+  styles: [`
+    .pfa-card {
+      background: #fff;
+      border-radius: 12px;
+      padding: 20px;
+      box-shadow: 0 2px 12px rgba(0,0,0,.08);
+      margin-top: 16px;
+    }
+    .pfa-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 16px;
+    }
+    .pfa-title { margin: 0; font-size: 15px; font-weight: 600; color: #1a1a2e; }
+    .pfa-badge {
+      font-size: 12px; color: #059669;
+      background: #ecfdf5; padding: 2px 10px;
+      border-radius: 20px; font-weight: 500;
+    }
+    .pfa-grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+      gap: 12px;
+      margin-bottom: 14px;
+    }
+    .pfa-dim {
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+      padding: 12px;
+      border-left: 3px solid var(--dim-color, #6366f1);
+    }
+    .pfa-dim-header {
+      display: flex; align-items: center;
+      gap: 6px; margin-bottom: 8px;
+    }
+    .pfa-dim-icon { font-size: 15px; }
+    .pfa-dim-label {
+      font-size: 11px; font-weight: 700;
+      color: #6b7280; text-transform: uppercase; letter-spacing: .4px;
+    }
+    .pfa-dim-values { display: flex; flex-wrap: wrap; gap: 5px; }
+    .pfa-val {
+      font-size: 12px; background: #f8fafc;
+      border: 1px solid #e2e8f0; padding: 2px 8px;
+      border-radius: 5px; color: #374151;
+    }
+    .pfa-raw-section { border-top: 1px solid #f3f4f6; padding-top: 10px; }
+    .pfa-raw-toggle {
+      background: none; border: none; font-size: 12px;
+      color: #9ca3af; cursor: pointer; padding: 0;
+    }
+    .pfa-raw-toggle:hover { color: #6366f1; }
+    .pfa-raw-list {
+      margin: 8px 0 0; padding-left: 18px;
+      font-size: 12px; color: #6b7280; line-height: 1.9;
+    }
+  `]
+})
+export class ProductFeatureAnalysisComponent implements OnChanges {
+  @Input() feature: any = [];
+  @Input() description = '';
+  @Input() title = '';
+
+  showRaw = signal(false);
+  featureLines = signal<string[]>([]);
+  dimensions = computed(() => this.analyze(this.featureLines()));
+
+  ngOnChanges(_c: SimpleChanges): void {
+    this.featureLines.set(this.extractLines());
+  }
+
+  // ── 文本提取 ────────────────────────────────────────────────────────────────
+
+  private extractLines(): string[] {
+    const lines: string[] = [];
+    if (this.title) lines.push(this.title);
+
+    const f = this.feature;
+    const push = (v: any) => {
+      if (typeof v === 'string' && v.trim().length > 2) lines.push(v.trim());
+    };
+
+    if (Array.isArray(f)) {
+      f.forEach(item => {
+        if (typeof item === 'string') push(item);
+        else if (item && typeof item === 'object') Object.values(item).forEach(push);
+      });
+    } else if (f && typeof f === 'object') {
+      Object.values(f).forEach(v => {
+        if (typeof v === 'string') push(v);
+        else if (Array.isArray(v)) v.forEach(push);
+      });
+    } else if (typeof f === 'string') {
+      try { (JSON.parse(f) as any[]).forEach(push); } catch { push(f); }
+    }
+
+    if (this.description) lines.push(this.description);
+    return lines;
+  }
+
+  // ── 规则匹配引擎 ─────────────────────────────────────────────────────────────
+
+  private analyze(lines: string[]): FeatureDimension[] {
+    const text = lines.join(' ').toLowerCase();
+    const dims: FeatureDimension[] = [];
+
+    const rules: Array<{
+      key: string; label: string; icon: string; color: string;
+      patterns: RegExp[];
+      labelMap?: Record<string, string>;
+    }> = [
+      // 1. 面料成分
+      {
+        key: 'fabric', label: '面料成分', icon: '🧵', color: '#6366f1',
+        patterns: [
+          /(\d+\.?\d*\s*%?\s*(?:cotton|nylon|polyester|spandex|elastane|rayon|viscose|modal|silk|linen|wool|cashmere|bamboo|lycra|tencel|recycled\s+polyester|acrylic|hemp|fleece))/gi,
+        ]
+      },
+      // 2. 胸垫 / 内衬
+      {
+        key: 'padding', label: '内衬/胸垫', icon: '👙', color: '#ec4899',
+        patterns: [
+          /\b(removable\s+(?:pad|liner)|built[- ]in\s+(?:bra|pad)|shelf\s+bra|bra\s+pad|chest\s+pad|no\s+pad|padded|unpadded|with\s+pad|without\s+pad|lightly\s+lined|fully\s+lined|unlined|double\s+layered?|lined)\b/gi,
+        ]
+      },
+      // 3. 领型
+      {
+        key: 'neckline', label: '领型', icon: '👗', color: '#8b5cf6',
+        patterns: [
+          /\b(v[- ]?neck|crew\s*neck|round\s*neck|scoop\s*neck|square\s*neck|cowl\s*neck|turtleneck|mock\s*neck|funnel\s*neck|halter\s*neck|off[- ]shoulder|one[- ]shoulder|strapless|sweetheart|asymmetric\s*neck|boat\s*neck|bateau|surplice|henley|collar(?:ed)?|polo|keyhole)\b/gi,
+        ]
+      },
+      // 4. 袖型
+      {
+        key: 'sleeve', label: '袖型', icon: '👕', color: '#0ea5e9',
+        patterns: [
+          /\b(sleeveless|short[- ]sleeve[sd]?|long[- ]sleeve[sd]?|3\/4[- ]sleeve[sd]?|cap\s*sleeve[sd]?|flutter\s*sleeve[sd]?|bell\s*sleeve[sd]?|bishop\s*sleeve[sd]?|ruffle\s*sleeve[sd]?|puff\s*sleeve[sd]?|raglan|dolman|kimono\s*sleeve[sd]?|cold\s*shoulder|spaghetti\s*strap[sd]?|tank\s*top|racerback)\b/gi,
+        ]
+      },
+      // 5. 衣长 / 裤长
+      {
+        key: 'length', label: '衣长/裤长', icon: '📏', color: '#f59e0b',
+        patterns: [
+          /\b(mini|midi|maxi|micro\s*mini|knee[- ]?length|ankle[- ]?length|floor[- ]?length|above\s+knee|below\s+knee|cropped?|crop\s+top|longline|full[- ]?length|thigh[- ]?high|short(?:\s+length)?|long(?:\s+length)?)\b/gi,
+          /\b(\d{2}["″\s]*(?:inseam|length))\b/gi,
+        ]
+      },
+      // 6. 腰线 / 腰高
+      {
+        key: 'rise', label: '腰线/腰高', icon: '⬆️', color: '#f97316',
+        patterns: [
+          /\b(high[- ]?(?:rise|waist(?:ed)?)|mid[- ]?(?:rise|waist(?:ed)?)|low[- ]?(?:rise|waist(?:ed)?)|ultra[- ]?high[- ]?rise|empire\s*waist|natural\s*waist|drop\s*waist|elastic\s*waist(?:band)?|drawstring\s*waist|adjustable\s*waist)\b/gi,
+        ]
+      },
+      // 7. 版型 / 轮廓
+      {
+        key: 'fit', label: '版型/轮廓', icon: '✂️', color: '#10b981',
+        patterns: [
+          /\b(slim[- ]?fit|regular\s*fit|relaxed\s*fit|loose\s*fit|oversized|fitted|tailored|straight(?:[- ]leg)?|flared|bootcut|wide[- ]?leg|skinny(?:[- ]leg)?|tapered|a[- ]?line|bodycon|wrap|shift|sheath|peplum|trapeze|balloon|tiered|box(?:y)?[- ]?fit|athletic\s*fit)\b/gi,
+        ]
+      },
+      // 8. 闭合方式
+      {
+        key: 'closure', label: '开合方式', icon: '🔒', color: '#64748b',
+        patterns: [
+          /\b(zip(?:per)?[- ]?(?:fly|front|back|side|closure)?|button(?:[- ]down|[- ]fly|[- ]front|[- ]back|[- ]closure)?|pullover|pull[- ]?on|hook[- ]?(?:and[- ]?)?eye|lace[- ]?up|snap|velcro|tie[- ]?front|tie[- ]?back|wrap[- ]?around|magnetic|toggle)\b/gi,
+        ]
+      },
+      // 9. 口袋
+      {
+        key: 'pocket', label: '口袋', icon: '🎒', color: '#6b7280',
+        patterns: [
+          /\b(\d+[- ]pocket|five[- ]pocket|no\s+pocket|with\s+pocket|pocketless|front\s+pocket|back\s+pocket|side\s+pocket|hidden\s+pocket|cargo\s+pocket|flap\s+pocket|welt\s+pocket|slip\s+pocket|patch\s+pocket)\b/gi,
+        ]
+      },
+      // 10. 弹性 / 舒适度
+      {
+        key: 'stretch', label: '弹性/舒适', icon: '🤸', color: '#14b8a6',
+        patterns: [
+          /\b((?:\d+[- ]?way\s+)?stretch|non[- ]stretch|stretchy|flexible|rigid|stiff|comfortable|4[- ]way\s+stretch|2[- ]way\s+stretch|lightweight|heavyweight|medium\s+weight|breathable|moisture[- ]wicking|quick[- ]dry|wrinkle[- ](?:free|resistant)|easy\s+care|soft|smooth|silky)\b/gi,
+        ]
+      },
+      // 11. 印花 / 图案
+      {
+        key: 'pattern', label: '印花/图案', icon: '🎨', color: '#a855f7',
+        patterns: [
+          /\b(solid|plaid|stripe[sd]?|floral|leopard|animal\s*print|tie[- ]dye|camo|camouflage|graphic|logo|embroidered|lace|eyelet|burnout|jacquard|herringbone|houndstooth|checkered|polka\s*dot|abstract|geometric|tropical|paisley|tie\s*dye|color[- ]?block|ombre)\b/gi,
+        ]
+      },
+      // 12. 场合 / 风格
+      {
+        key: 'occasion', label: '适用场景', icon: '🎯', color: '#ef4444',
+        patterns: [
+          /\b(casual|formal|business|office|work|party|beach|vacation|resort|sports|athletic|yoga|gym|outdoor|hiking|running|everyday|date\s*night|wedding|cocktail|evening|swimwear|activewear|loungewear|sleepwear|maternity|plus\s*size)\b/gi,
+        ]
+      },
+      // 13. 工艺细节
+      {
+        key: 'detail', label: '工艺细节', icon: '💎', color: '#e879f9',
+        patterns: [
+          /\b(distressed|raw\s*hem|frayed|ripped|acid\s*wash|stone\s*wash|sand\s*wash|garment\s*dyed|overdyed|embellished|studded|beaded|sequin|ruched|gathered|smocked|pleated|shirred|quilted|faux\s*leather|vegan\s*leather|denim|twill|jersey|ribbed|waffle|terry|velvet|satin\s*finish|matte)\b/gi,
+        ]
+      },
+      // 14. 洗涤护理
+      {
+        key: 'care', label: '洗涤护理', icon: '🧺', color: '#94a3b8',
+        patterns: [
+          /\b(machine\s*wash(?:able)?|hand\s*wash(?:only)?|dry\s*clean(?:\s*only)?|tumble\s*dry|air\s*dry|do\s*not\s*bleach|cold\s*(?:water|wash)|warm\s*(?:water|wash)|gentle\s*cycle|low\s*heat|lay\s*flat\s*to\s*dry|iron(?:able)?|do\s*not\s*iron|delicate\s*cycle)\b/gi,
+        ]
+      },
+      // 15. 产地 / 认证
+      {
+        key: 'origin', label: '产地/认证', icon: '🌍', color: '#78716c',
+        patterns: [
+          /\b(?:imported|made\s+in\s+(?:usa|china|vietnam|bangladesh|india|cambodia|turkey|italy|portugal|indonesia|pakistan)|country\s+of\s+origin|fair\s+trade|gots\s+certified|oeko[- ]?tex|bluesign|sustainably\s+sourced|recycled\s+materials?|organic)\b/gi,
+        ]
+      },
+    ];
+
+    for (const rule of rules) {
+      const found = new Set<string>();
+      for (const re of rule.patterns) {
+        // 对每行单独匹配,避免跨行误匹配
+        for (const line of lines) {
+          const lower = line.toLowerCase();
+          const matches = lower.matchAll(re);
+          for (const m of matches) {
+            const val = this.normalize(m[0]);
+            if (val && val.length >= 2) found.add(val);
+          }
+        }
+      }
+      if (found.size > 0) {
+        dims.push({
+          key: rule.key,
+          label: rule.label,
+          icon: rule.icon,
+          color: rule.color,
+          values: this.dedupe([...found]).slice(0, 10),
+        });
+      }
+    }
+
+    return dims;
+  }
+
+  private normalize(raw: string): string {
+    return raw.trim()
+      .replace(/\s+/g, ' ')
+      .replace(/\b\w/g, c => c.toUpperCase());
+  }
+
+  /** 去掉包含关系的重复项,保留较长的 */
+  private dedupe(vals: string[]): string[] {
+    const lower = vals.map(v => v.toLowerCase());
+    return vals.filter((_, i) =>
+      !lower.some((l, j) => j !== i && l.includes(lower[i]) && l.length > lower[i].length)
+    );
+  }
+}

+ 406 - 0
src/modules/knowledge-center/competitor-detail/product-specs/product-specs.component.ts

@@ -0,0 +1,406 @@
+import { Component, Input, OnChanges, SimpleChanges, signal, computed } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+export interface SpecItem {
+  key: string;
+  label: string;
+  value: string;
+  category: string;
+  highlight?: boolean;
+}
+
+/**
+ * 产品结构化规格卡片
+ * 数据来自 Property(产品属性列表)和 ProductInfo(Product Information 表格)
+ * 这是亚马逊 Listing 页中的结构化 spec 数据,比 Feature 文本更准确
+ */
+@Component({
+  selector: 'app-product-specs',
+  standalone: true,
+  imports: [CommonModule],
+  template: `
+    <div class="ps-card" *ngIf="specs().length > 0 || offSale">
+
+      <!-- 断货警告 -->
+      @if (offSale) {
+        <div class="ps-offsale-banner">
+          <span class="ps-offsale-icon">⚠️</span>
+          <strong>该产品当前不可售(断货/下架)</strong>
+        </div>
+      }
+
+      <div class="ps-header">
+        <h3 class="ps-title">产品规格</h3>
+        <div class="ps-header-right">
+          @if (specs().length > 0) {
+            <span class="ps-badge">{{ specs().length }} 项规格</span>
+          }
+          <button type="button" class="ps-json-btn" (click)="openJsonModal()" title="查看原始数据">原始 JSON</button>
+          <!-- 视图切换 -->
+          <div class="ps-view-tabs">
+            <button [class.active]="view() === 'grid'" (click)="view.set('grid')">网格</button>
+            <button [class.active]="view() === 'table'" (click)="view.set('table')">列表</button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 网格视图 -->
+      @if (view() === 'grid') {
+        @for (cat of categories(); track cat) {
+          <div class="ps-category">
+            <div class="ps-cat-title">{{ cat }}</div>
+            <div class="ps-grid">
+              @for (spec of specsByCategory()[cat]; track spec.key) {
+                <div class="ps-item" [class.ps-highlight]="spec.highlight">
+                  <div class="ps-item-key">{{ spec.label }}</div>
+                  <div class="ps-item-val" [title]="isPlaceholder(spec.value) ? '数据源未返回具体值' : null">
+                    {{ isPlaceholder(spec.value) ? '—' : spec.value }}
+                  </div>
+                </div>
+              }
+            </div>
+          </div>
+        }
+      }
+
+      <!-- 列表视图 -->
+      @if (view() === 'table') {
+        <div class="ps-table-wrap">
+          <table class="ps-table">
+            <thead>
+              <tr><th>属性</th><th>值</th></tr>
+            </thead>
+            <tbody>
+              @for (spec of specs(); track spec.key) {
+                <tr [class.ps-row-highlight]="spec.highlight">
+                  <td class="ps-td-key">{{ spec.label }}</td>
+                  <td class="ps-td-val" [title]="isPlaceholder(spec.value) ? '数据源未返回具体值' : null">
+                    {{ isPlaceholder(spec.value) ? '—' : spec.value }}
+                  </td>
+                </tr>
+              }
+            </tbody>
+          </table>
+        </div>
+      }
+
+      <!-- 原始 JSON 弹窗 -->
+      @if (showJsonModal()) {
+        <div class="ps-modal-overlay" (click)="closeJsonModal()">
+          <div class="ps-modal" (click)="$event.stopPropagation()">
+            <div class="ps-modal-header">
+              <h4 class="ps-modal-title">产品规格原始数据 (Property / ProductInfo)</h4>
+              <button type="button" class="ps-modal-close" (click)="closeJsonModal()">&times;</button>
+            </div>
+            <div class="ps-modal-body">
+              <pre class="ps-json-pre">{{ rawJsonText() }}</pre>
+            </div>
+            <div class="ps-modal-footer">
+              <button type="button" class="ps-copy-btn" (click)="copyJson()">复制</button>
+              <button type="button" class="ps-modal-close-btn" (click)="closeJsonModal()">关闭</button>
+            </div>
+          </div>
+        </div>
+      }
+    </div>
+  `,
+  styles: [`
+    .ps-card {
+      background: #fff;
+      border-radius: 12px;
+      padding: 20px;
+      box-shadow: 0 2px 12px rgba(0,0,0,.08);
+      margin-top: 16px;
+    }
+    .ps-offsale-banner {
+      display: flex; align-items: center; gap: 8px;
+      background: #fef2f2; border: 1px solid #fca5a5;
+      border-radius: 8px; padding: 10px 14px;
+      color: #dc2626; font-size: 13px;
+      margin-bottom: 14px;
+    }
+    .ps-offsale-icon { font-size: 16px; }
+    .ps-header {
+      display: flex; align-items: center;
+      justify-content: space-between; margin-bottom: 16px;
+    }
+    .ps-title { margin: 0; font-size: 15px; font-weight: 600; color: #1a1a2e; }
+    .ps-header-right { display: flex; align-items: center; gap: 10px; }
+    .ps-badge {
+      font-size: 12px; color: #0369a1;
+      background: #e0f2fe; padding: 2px 10px;
+      border-radius: 20px; font-weight: 500;
+    }
+    .ps-view-tabs {
+      display: flex; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;
+    }
+    .ps-view-tabs button {
+      padding: 4px 10px; font-size: 12px; border: none;
+      background: #fff; color: #6b7280; cursor: pointer;
+    }
+    .ps-view-tabs button.active { background: #6366f1; color: #fff; }
+    .ps-json-btn {
+      padding: 4px 10px; font-size: 12px; border: 1px solid #e2e8f0;
+      background: #fff; color: #64748b; border-radius: 6px;
+      cursor: pointer; white-space: nowrap;
+    }
+    .ps-json-btn:hover { background: #f1f5f9; border-color: #cbd5e1; color: #475569; }
+
+    /* 弹窗 */
+    .ps-modal-overlay {
+      position: fixed; inset: 0; background: rgba(0,0,0,.4);
+      display: flex; align-items: center; justify-content: center;
+      z-index: 1000; padding: 20px;
+    }
+    .ps-modal {
+      background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
+      max-width: 90vw; max-height: 85vh; display: flex; flex-direction: column;
+    }
+    .ps-modal-header {
+      display: flex; align-items: center; justify-content: space-between;
+      padding: 14px 18px; border-bottom: 1px solid #e2e8f0;
+    }
+    .ps-modal-title { margin: 0; font-size: 14px; font-weight: 600; color: #1e293b; }
+    .ps-modal-close {
+      width: 28px; height: 28px; border: none; background: none;
+      font-size: 22px; line-height: 1; color: #94a3b8; cursor: pointer;
+    }
+    .ps-modal-close:hover { color: #64748b; }
+    .ps-modal-body {
+      padding: 16px; overflow: auto; flex: 1;
+      min-height: 200px; max-height: 60vh;
+    }
+    .ps-json-pre {
+      margin: 0; font-family: ui-monospace, monospace; font-size: 12px;
+      line-height: 1.5; color: #334155; white-space: pre-wrap; word-break: break-all;
+    }
+    .ps-modal-footer {
+      display: flex; justify-content: flex-end; gap: 8px;
+      padding: 12px 18px; border-top: 1px solid #e2e8f0;
+    }
+    .ps-copy-btn, .ps-modal-close-btn {
+      padding: 6px 14px; font-size: 13px; border-radius: 6px; cursor: pointer;
+    }
+    .ps-copy-btn {
+      border: 1px solid #6366f1; background: #ede9fe; color: #4f46e5;
+    }
+    .ps-copy-btn:hover { background: #ddd6fe; }
+    .ps-modal-close-btn {
+      border: 1px solid #e2e8f0; background: #fff; color: #475569;
+    }
+    .ps-modal-close-btn:hover { background: #f8fafc; }
+
+    /* 网格 */
+    .ps-category { margin-bottom: 16px; }
+    .ps-cat-title {
+      font-size: 11px; font-weight: 700; color: #9ca3af;
+      text-transform: uppercase; letter-spacing: .5px;
+      margin-bottom: 8px; padding-bottom: 4px;
+      border-bottom: 1px solid #f3f4f6;
+    }
+    .ps-grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+      gap: 8px;
+    }
+    .ps-item {
+      background: #f8fafc; border: 1px solid #e2e8f0;
+      border-radius: 8px; padding: 10px 12px;
+    }
+    .ps-highlight { background: #eff6ff; border-color: #bfdbfe; }
+    .ps-item-key {
+      font-size: 11px; color: #9ca3af; font-weight: 600;
+      margin-bottom: 4px; text-transform: uppercase; letter-spacing: .3px;
+    }
+    .ps-item-val { font-size: 13px; color: #1f2937; font-weight: 500; }
+
+    /* 列表 */
+    .ps-table-wrap { overflow-x: auto; }
+    .ps-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    .ps-table th {
+      background: #f8fafc; font-weight: 600; color: #6b7280;
+      padding: 8px 12px; text-align: left;
+      border-bottom: 2px solid #e5e7eb;
+    }
+    .ps-table td { padding: 8px 12px; border-bottom: 1px solid #f3f4f6; }
+    .ps-table tr:last-child td { border-bottom: none; }
+    .ps-row-highlight { background: #eff6ff; }
+    .ps-td-key { color: #6b7280; white-space: nowrap; min-width: 140px; }
+    .ps-td-val { color: #1f2937; }
+  `]
+})
+export class ProductSpecsComponent implements OnChanges {
+  /** product.property (Array | JSON String) */
+  @Input() property: any = [];
+  /** product.productInfo (Array | JSON String) */
+  @Input() productInfo: any = [];
+  /** product.offSale (0 | 1) */
+  @Input() offSale = 0;
+
+  view = signal<'grid' | 'table'>('grid');
+  showJsonModal = signal(false);
+
+  specs = signal<SpecItem[]>([]);
+
+  /** 弹窗中展示的原始 JSON 文本 */
+  rawJsonText = computed(() => {
+    const payload = { property: this.property, productInfo: this.productInfo };
+    try {
+      return JSON.stringify(payload, null, 2);
+    } catch {
+      return String(payload);
+    }
+  });
+
+  categories = computed(() => [...new Set(this.specs().map(s => s.category))]);
+
+  specsByCategory = computed(() => {
+    const map: Record<string, SpecItem[]> = {};
+    this.specs().forEach(s => {
+      if (!map[s.category]) map[s.category] = [];
+      map[s.category].push(s);
+    });
+    return map;
+  });
+
+  openJsonModal(): void {
+    this.showJsonModal.set(true);
+  }
+
+  closeJsonModal(): void {
+    this.showJsonModal.set(false);
+  }
+
+  copyJson(): void {
+    const text = this.rawJsonText();
+    if (navigator.clipboard?.writeText) {
+      navigator.clipboard.writeText(text).then(() => {
+        const btn = document.querySelector('.ps-copy-btn');
+        if (btn) { (btn as HTMLButtonElement).textContent = '已复制'; setTimeout(() => { (btn as HTMLButtonElement).textContent = '复制'; }, 1500); }
+      });
+    }
+  }
+
+  /** 常见占位符/键名误作值:展示为 — 并提示「数据源未返回具体值」 */
+  isPlaceholder(val: string): boolean {
+    if (!val || typeof val !== 'string') return false;
+    const v = val.trim().toLowerCase();
+    const placeholders = [
+      'fabric type', 'care instructions', 'origin', 'closure type',
+      'closure', 'weight', 'dimensions', 'material', 'composition',
+      'product dimensions', 'item weight', 'department', 'brand'
+    ];
+    return placeholders.includes(v) || /^[\w\s]+ type$/.test(v);
+  }
+
+  ngOnChanges(_c: SimpleChanges): void {
+    this.specs.set(this.parse());
+  }
+
+  private parse(): SpecItem[] {
+    const items: SpecItem[] = [];
+    const seen = new Set<string>();
+
+    const add = (rawKey: string, value: string) => {
+      const key = rawKey.trim();
+      const val = String(value ?? '').trim();
+      if (!key || !val || val === '-' || seen.has(key.toLowerCase())) return;
+      seen.add(key.toLowerCase());
+      const mapped = this.mapKey(key);
+      items.push({ key, label: mapped.label, value: val, category: mapped.category, highlight: mapped.highlight });
+    };
+
+    // 解析 Property
+    const props = this.toArray(this.property);
+    props.forEach(p => {
+      if (typeof p === 'string') {
+        // "Key: Value" 或 "Key:Value"
+        const m = p.match(/^(.+?)[::]\s*(.+)$/);
+        if (m) add(m[1].trim(), m[2].trim());
+        else add(p, p);
+      } else if (Array.isArray(p) && p.length >= 2) {
+        add(String(p[0]), String(p[1]));
+      } else if (p && typeof p === 'object') {
+        Object.entries(p).forEach(([k, v]) => add(k, String(v)));
+      }
+    });
+
+    // 解析 ProductInfo
+    const info = this.toArray(this.productInfo);
+    info.forEach(p => {
+      if (typeof p === 'string') {
+        const m = p.match(/^(.+?)[::]\s*(.+)$/);
+        if (m) add(m[1].trim(), m[2].trim());
+      } else if (Array.isArray(p) && p.length >= 2) {
+        add(String(p[0]), String(p[1]));
+      } else if (p && typeof p === 'object') {
+        Object.entries(p).forEach(([k, v]) => add(k, String(v)));
+      }
+    });
+
+    return items;
+  }
+
+  private toArray(raw: any): any[] {
+    if (!raw) return [];
+    if (Array.isArray(raw)) return raw;
+    if (typeof raw === 'string') {
+      try {
+        const p = JSON.parse(raw);
+        return Array.isArray(p) ? p : typeof p === 'object' ?
+          Object.entries(p).map(([k, v]) => [k, v]) : [];
+      } catch { return []; }
+    }
+    if (typeof raw === 'object') return Object.entries(raw).map(([k, v]) => [k, v]);
+    return [];
+  }
+
+  // ─── 属性 key 映射表 ─────────────────────────────────────────────────────────
+
+  private mapKey(key: string): { label: string; category: string; highlight: boolean } {
+    const k = key.toLowerCase().replace(/[\s_-]+/g, ' ').trim();
+    const table: Array<[string | RegExp, string, string, boolean]> = [
+      // [匹配, 中文标签, 分类, 高亮]
+      // ── 面料 ──────────────────────────────────────────────────
+      [/fabric|material|composition|content|fiber/,   '面料成分',     '面料与材质', true],
+      [/care|wash|laundry|cleaning/,                   '洗涤方式',     '面料与材质', false],
+      [/lining/,                                        '内衬',         '面料与材质', false],
+      [/weight class|fabric weight/,                   '面料厚度',     '面料与材质', false],
+      // ── 款式 ──────────────────────────────────────────────────
+      [/neck|collar/,                                   '领型',         '款式设计',  true],
+      [/sleeve/,                                        '袖型',         '款式设计',  true],
+      [/length|item length/,                            '衣长',         '款式设计',  true],
+      [/rise/,                                          '腰线',         '款式设计',  true],
+      [/fit|cut/,                                       '版型',         '款式设计',  true],
+      [/closure|fastening/,                             '开合方式',     '款式设计',  false],
+      [/waist|waistband/,                               '腰部设计',     '款式设计',  false],
+      [/pocket/,                                        '口袋',         '款式设计',  false],
+      [/pattern|print/,                                 '印花/图案',    '款式设计',  true],
+      [/style|silhouette/,                              '款式风格',     '款式设计',  false],
+      [/occasion|use/,                                  '适用场合',     '款式设计',  false],
+      // ── 颜色规格 ──────────────────────────────────────────────
+      [/color|colour/,                                  '颜色',         '颜色与尺码', true],
+      [/size/,                                          '尺码',         '颜色与尺码', true],
+      [/number of items|variation|count/,               '变体数量',     '颜色与尺码', false],
+      // ── 商品信息 ──────────────────────────────────────────────
+      [/brand/,                                         '品牌',         '商品信息',  false],
+      [/asin/,                                          'ASIN',         '商品信息',  false],
+      [/department|gender|target|audience/,             '适用人群',     '商品信息',  false],
+      [/date first available|first available|list date/, '首次上架',    '商品信息',  false],
+      [/rank|best seller/,                              '销售排名',     '商品信息',  false],
+      [/manufacturer|made by/,                          '制造商',       '商品信息',  false],
+      [/origin|country/,                                '产地',         '商品信息',  false],
+      [/model|item model/,                              '型号',         '商品信息',  false],
+      // ── 尺寸重量 ──────────────────────────────────────────────
+      [/dimension|package size/,                        '包装尺寸',     '尺寸重量',  false],
+      [/weight/,                                        '重量',         '尺寸重量',  false],
+    ];
+
+    for (const [pattern, label, category, highlight] of table) {
+      if (typeof pattern === 'string' ? k.includes(pattern) : pattern.test(k)) {
+        return { label, category, highlight };
+      }
+    }
+    return { label: key, category: '其他', highlight: false };
+  }
+}

+ 629 - 0
src/modules/knowledge-center/competitor-detail/product-variation-history/product-variation-history.component.ts

@@ -0,0 +1,629 @@
+import { Component, Input, OnChanges, SimpleChanges, signal, computed } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { SorftimeApiService } from '../../../../app/core/services/sorftime-api.service';
+import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
+import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
+import { catchError, of } from 'rxjs';
+
+export interface VariantItem {
+  asin: string;
+  title: string;
+  price: number;
+  priceFormatted: string;
+  ratings: number;
+  ratingsCount: number;
+  image: string;
+  color: string;
+  size: string;
+  otherAttrs: Record<string, string>;
+  offSale: boolean;
+}
+
+const PAGE_SIZE = 20;
+
+/**
+ * 变体详情仪表盘
+ * 调用 ProductVariationHistory API,展示:
+ * - 统计概览(颜色数、尺码数、价格区间、断货占比)
+ * - 颜色色板(每种颜色对应的尺码可用情况)
+ * - 可筛选 + 分页的变体明细表格
+ */
+@Component({
+  selector: 'app-product-variation-history',
+  standalone: true,
+  imports: [CommonModule, FormsModule, MsqDropdownComponent, EmptyStateComponent, ErrorStateComponent],
+  template: `
+    <div class="pvh-card">
+      <div class="pvh-header">
+        <h3 class="pvh-title">变体仪表盘</h3>
+        @if (!loading() && allItems().length) {
+          <span class="pvh-total">{{ allItems().length }} 个变体</span>
+        }
+        @if (loading()) {
+          <span class="pvh-loading-badge">
+            <span class="pvh-spinner-sm"></span> 加载中...
+          </span>
+        }
+      </div>
+
+      @if (loading()) {
+        <div class="pvh-skeleton">
+          <div class="pvh-skel-row"></div>
+          <div class="pvh-skel-row short"></div>
+          <div class="pvh-skel-row"></div>
+        </div>
+      } @else if (error()) {
+        <app-error-state
+          [description]="error() || ''"
+          [suggestions]="['可能该 ASIN 暂无变体历史数据,或 Sorftime 接口暂不可用']"
+          (retry)="retry()"></app-error-state>
+      } @else if (!allItems().length) {
+        <app-empty-state title="暂无变体数据" size="sm" icon="📦"></app-empty-state>
+      } @else {
+
+        <!-- ── 统计概览 ──────────────────────────────────── -->
+        <div class="pvh-stats">
+          <div class="pvh-stat">
+            <span class="pvh-stat-num">{{ stats().totalColors }}</span>
+            <span class="pvh-stat-label">颜色款式</span>
+          </div>
+          <div class="pvh-stat">
+            <span class="pvh-stat-num">{{ stats().totalSizes }}</span>
+            <span class="pvh-stat-label">尺码规格</span>
+          </div>
+          <div class="pvh-stat">
+            <span class="pvh-stat-num">{{ stats().priceRange }}</span>
+            <span class="pvh-stat-label">价格区间</span>
+          </div>
+          <div class="pvh-stat" [class.pvh-stat-warn]="stats().offSaleCount > 0">
+            <span class="pvh-stat-num">{{ stats().offSaleCount }}</span>
+            <span class="pvh-stat-label">断货变体</span>
+          </div>
+          <div class="pvh-stat">
+            <span class="pvh-stat-num">{{ stats().inStockRate }}%</span>
+            <span class="pvh-stat-label">在售率</span>
+          </div>
+        </div>
+
+        <!-- ── 颜色色板 ─────────────────────────────────── -->
+        @if (stats().totalColors > 0 && stats().totalColors <= 40) {
+          <div class="pvh-palette-section">
+            <div class="pvh-section-label">颜色可用尺码</div>
+            <div class="pvh-palette">
+              @for (c of colorGroups(); track c.color) {
+                <div class="pvh-color-group"
+                     [class.pvh-color-active]="filterColor() === c.color"
+                     (click)="toggleColorFilter(c.color)">
+                  <div class="pvh-color-name">{{ c.color }}</div>
+                  <div class="pvh-color-sizes">
+                    @for (s of c.sizes; track s.size) {
+                      <span class="pvh-size-dot" [class.pvh-size-oos]="s.offSale"
+                            [title]="s.size + (s.offSale ? ' (断货)' : '')">
+                        {{ s.size }}
+                      </span>
+                    }
+                  </div>
+                  <div class="pvh-color-meta">
+                    <span class="pvh-color-price">{{ c.priceRange }}</span>
+                    @if (c.offSaleCount > 0) {
+                      <span class="pvh-color-oos-badge">{{ c.offSaleCount }}断</span>
+                    }
+                  </div>
+                </div>
+              }
+            </div>
+          </div>
+        }
+
+        <!-- ── 筛选栏 ──────────────────────────────────── -->
+        <div class="pvh-filters">
+          @if (colorOptions().length > 1) {
+            <div class="pvh-filter-group">
+              <label>颜色</label>
+              <msq-dropdown [options]="colorDropdownOpts()" [placeholder]="'全部颜色 (' + colorOptions().length + ')'" (valueChange)="setColorFilter($event)"></msq-dropdown>
+            </div>
+          }
+          @if (sizeOptions().length > 1) {
+            <div class="pvh-filter-group">
+              <label>尺码</label>
+              <msq-dropdown [options]="sizeDropdownOpts()" [placeholder]="'全部尺码 (' + sizeOptions().length + ')'" (valueChange)="setSize($event)"></msq-dropdown>
+            </div>
+          }
+          <div class="pvh-filter-group">
+            <label>状态</label>
+            <msq-dropdown [options]="pvhStatusOpts" placeholder="全部" (valueChange)="filterStatus.set($event); page.set(1)"></msq-dropdown>
+          </div>
+          @if (hasFilter()) {
+            <button class="pvh-clear" (click)="clearFilters()">✕ 清除</button>
+          }
+          <span class="pvh-filter-count">
+            显示 {{ filteredItems().length }} / {{ allItems().length }} 个
+          </span>
+        </div>
+
+        <!-- ── 变体明细表 ───────────────────────────────── -->
+        <div class="pvh-table-wrap">
+          <table class="pvh-table">
+            <thead>
+              <tr>
+                <th>ASIN</th>
+                @if (hasColor()) { <th>颜色</th> }
+                @if (hasSize()) { <th>尺码</th> }
+                @for (k of otherAttrKeys(); track k) { <th>{{ k }}</th> }
+                <th class="tr">价格</th>
+                <th class="tr">评分</th>
+                <th class="tr">评价数</th>
+                <th>状态</th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              @for (item of pagedItems(); track item.asin) {
+                <tr [class.pvh-cur]="item.asin === currentAsin"
+                    [class.pvh-oos]="item.offSale">
+                  <td><code class="pvh-asin">{{ item.asin }}</code></td>
+                  @if (hasColor()) {
+                    <td class="pvh-color-cell">
+                      <span class="pvh-color-tag">{{ item.color || '-' }}</span>
+                    </td>
+                  }
+                  @if (hasSize()) {
+                    <td><span class="pvh-size-tag">{{ item.size || '-' }}</span></td>
+                  }
+                  @for (k of otherAttrKeys(); track k) {
+                    <td>{{ item.otherAttrs[k] || '-' }}</td>
+                  }
+                  <td class="tr pvh-price">{{ item.priceFormatted }}</td>
+                  <td class="tr">
+                    @if (item.ratings) {
+                      <span class="pvh-star">★ {{ item.ratings.toFixed(1) }}</span>
+                    } @else { <span class="pvh-na">-</span> }
+                  </td>
+                  <td class="tr">
+                    {{ item.ratingsCount ? item.ratingsCount.toLocaleString() : '-' }}
+                  </td>
+                  <td>
+                    @if (item.offSale) {
+                      <span class="pvh-badge-oos">断货</span>
+                    } @else {
+                      <span class="pvh-badge-ok">在售</span>
+                    }
+                  </td>
+                  <td>
+                    @if (item.asin !== currentAsin) {
+                      <button class="pvh-goto" (click)="navigate(item.asin)">查看</button>
+                    } @else {
+                      <span class="pvh-badge-cur">当前</span>
+                    }
+                  </td>
+                </tr>
+              }
+            </tbody>
+          </table>
+        </div>
+
+        <!-- ── 分页 ───────────────────────────────────── -->
+        @if (totalPages() > 1) {
+          <div class="pvh-pagination">
+            <button [disabled]="page() === 1" (click)="goPage(page()-1)">‹</button>
+            @for (p of pageNums(); track p) {
+              @if (p === -1) { <span class="pvh-ellip">…</span> }
+              @else {
+                <button [class.pvh-pg-active]="p === page()" (click)="goPage(p)">{{ p }}</button>
+              }
+            }
+            <button [disabled]="page() === totalPages()" (click)="goPage(page()+1)">›</button>
+            <span class="pvh-pg-info">{{ page() }}/{{ totalPages() }} 页</span>
+          </div>
+        }
+      }
+    </div>
+  `,
+  styles: [`
+    .pvh-card {
+      background: #fff; border-radius: 12px;
+      padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,.08); margin-top: 16px;
+    }
+    .pvh-header {
+      display: flex; align-items: center;
+      justify-content: space-between; margin-bottom: 14px;
+    }
+    .pvh-title { margin: 0; font-size: 15px; font-weight: 600; color: #1a1a2e; }
+    .pvh-total {
+      font-size: 12px; color: #6366f1; background: #ede9fe;
+      padding: 2px 10px; border-radius: 20px; font-weight: 500;
+    }
+    .pvh-loading-badge {
+      display: flex; align-items: center; gap: 6px;
+      font-size: 12px; color: #9ca3af;
+    }
+    .pvh-spinner-sm {
+      width: 14px; height: 14px; border: 2px solid #e5e7eb;
+      border-top-color: #6366f1; border-radius: 50%;
+      animation: pvh-spin .7s linear infinite;
+    }
+    @keyframes pvh-spin { to { transform: rotate(360deg); } }
+
+    /* 骨架屏 */
+    .pvh-skeleton { display: flex; flex-direction: column; gap: 10px; }
+    .pvh-skel-row {
+      height: 16px; background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
+      background-size: 200% 100%; border-radius: 6px;
+      animation: pvh-shimmer 1.5s infinite;
+    }
+    .pvh-skel-row.short { width: 60%; }
+    @keyframes pvh-shimmer { to { background-position: -200% 0; } }
+
+    /* 统计概览 */
+    .pvh-stats {
+      display: flex; flex-wrap: wrap; gap: 8px;
+      margin-bottom: 14px; padding: 12px;
+      background: #fafbff; border-radius: 10px; border: 1px solid #e8edf5;
+    }
+    .pvh-stat {
+      display: flex; flex-direction: column; align-items: center;
+      min-width: 80px; padding: 8px 16px;
+      background: #fff; border-radius: 8px;
+      border: 1px solid #e2e8f0; flex: 1;
+    }
+    .pvh-stat-warn { border-color: #fca5a5; background: #fef2f2; }
+    .pvh-stat-num { font-size: 20px; font-weight: 700; color: #1a1a2e; }
+    .pvh-stat-warn .pvh-stat-num { color: #dc2626; }
+    .pvh-stat-label { font-size: 11px; color: #9ca3af; margin-top: 2px; }
+
+    /* 颜色色板 */
+    .pvh-palette-section { margin-bottom: 14px; }
+    .pvh-section-label {
+      font-size: 11px; font-weight: 700; color: #9ca3af;
+      text-transform: uppercase; letter-spacing: .4px; margin-bottom: 8px;
+    }
+    .pvh-palette { display: flex; flex-wrap: wrap; gap: 8px; }
+    .pvh-color-group {
+      border: 1px solid #e2e8f0; border-radius: 10px;
+      padding: 10px 12px; cursor: pointer;
+      transition: border-color .15s, background .15s;
+      min-width: 120px;
+    }
+    .pvh-color-group:hover { border-color: #a5b4fc; background: #fafbff; }
+    .pvh-color-active { border-color: #6366f1 !important; background: #ede9fe !important; }
+    .pvh-color-name { font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 6px; }
+    .pvh-color-sizes { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 6px; }
+    .pvh-size-dot {
+      font-size: 10px; padding: 2px 6px;
+      background: #f0fdf4; color: #15803d;
+      border: 1px solid #bbf7d0; border-radius: 4px;
+    }
+    .pvh-size-oos {
+      background: #fef2f2 !important; color: #dc2626 !important;
+      border-color: #fca5a5 !important; text-decoration: line-through;
+    }
+    .pvh-color-meta { display: flex; align-items: center; gap: 6px; }
+    .pvh-color-price { font-size: 11px; color: #059669; font-weight: 600; }
+    .pvh-color-oos-badge {
+      font-size: 10px; background: #fef2f2; color: #dc2626;
+      border: 1px solid #fca5a5; padding: 1px 5px; border-radius: 4px;
+    }
+
+    /* 筛选栏 */
+    .pvh-filters {
+      display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px;
+      margin-bottom: 12px; padding: 10px 12px;
+      background: #fafbff; border-radius: 8px; border: 1px solid #e8edf5;
+    }
+    .pvh-filter-group { display: flex; flex-direction: column; gap: 3px; }
+    .pvh-filter-group label { font-size: 11px; font-weight: 600; color: #9ca3af; }
+    .pvh-filter-group select {
+      border: 1px solid #d1d5db; border-radius: 6px;
+      padding: 5px 8px; font-size: 12px; color: #374151;
+      background: #fff; cursor: pointer; min-width: 120px;
+    }
+    .pvh-filter-group select:focus { outline: none; border-color: #6366f1; }
+    .pvh-clear {
+      align-self: flex-end; background: none;
+      border: 1px solid #fca5a5; color: #ef4444;
+      border-radius: 6px; padding: 5px 10px; font-size: 12px; cursor: pointer;
+    }
+    .pvh-filter-count { align-self: flex-end; font-size: 12px; color: #9ca3af; }
+
+    /* 表格 */
+    .pvh-table-wrap { overflow-x: auto; }
+    .pvh-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    .pvh-table th {
+      background: #f8fafc; font-weight: 600; color: #6b7280;
+      padding: 8px 12px; text-align: left;
+      border-bottom: 2px solid #e5e7eb; white-space: nowrap;
+    }
+    .pvh-table td { padding: 8px 12px; border-bottom: 1px solid #f3f4f6; vertical-align: middle; }
+    .pvh-table tr:last-child td { border-bottom: none; }
+    .pvh-cur { background: #eff6ff; }
+    .pvh-oos { opacity: .7; }
+    .tr { text-align: right; }
+    .pvh-asin {
+      font-family: monospace; font-size: 11px;
+      background: #f1f5f9; padding: 2px 5px; border-radius: 3px; color: #334155;
+    }
+    .pvh-color-cell { max-width: 140px; }
+    .pvh-color-tag {
+      display: inline-block; font-size: 12px; color: #374151;
+      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 130px;
+    }
+    .pvh-size-tag {
+      font-size: 12px; background: #f0fdf4; color: #15803d;
+      padding: 2px 7px; border-radius: 4px; border: 1px solid #bbf7d0;
+    }
+    .pvh-price { font-weight: 600; color: #059669; }
+    .pvh-star { color: #f59e0b; }
+    .pvh-na { color: #d1d5db; }
+    .pvh-badge-oos {
+      font-size: 11px; background: #fef2f2; color: #dc2626;
+      border: 1px solid #fca5a5; padding: 2px 7px; border-radius: 10px;
+    }
+    .pvh-badge-ok {
+      font-size: 11px; background: #f0fdf4; color: #15803d;
+      border: 1px solid #bbf7d0; padding: 2px 7px; border-radius: 10px;
+    }
+    .pvh-badge-cur {
+      font-size: 11px; background: #3b82f6; color: #fff;
+      padding: 2px 7px; border-radius: 10px;
+    }
+    .pvh-goto {
+      padding: 3px 10px; border: 1px solid #6366f1; color: #6366f1;
+      background: transparent; border-radius: 6px; cursor: pointer; font-size: 12px;
+    }
+    .pvh-goto:hover { background: #ede9fe; }
+
+    /* 分页 */
+    .pvh-pagination {
+      display: flex; align-items: center; gap: 4px;
+      padding-top: 12px; flex-wrap: wrap;
+    }
+    .pvh-pagination button {
+      min-width: 30px; height: 30px; padding: 0 8px;
+      border: 1px solid #e2e8f0; border-radius: 6px;
+      background: #fff; color: #374151; font-size: 13px; cursor: pointer;
+    }
+    .pvh-pagination button:hover:not(:disabled) { background: #ede9fe; border-color: #a5b4fc; }
+    .pvh-pagination button:disabled { opacity: .4; cursor: default; }
+    .pvh-pg-active { background: #6366f1 !important; color: #fff !important; border-color: #6366f1 !important; }
+    .pvh-ellip { padding: 0 4px; color: #9ca3af; }
+    .pvh-pg-info { margin-left: 6px; font-size: 12px; color: #9ca3af; }
+  `]
+})
+export class ProductVariationHistoryComponent implements OnChanges {
+  @Input() asin = '';
+  @Input() currentAsin = '';
+
+  allItems = signal<VariantItem[]>([]);
+  loading = signal(false);
+  error = signal('');
+  filterColor = signal('');
+  filterSize = signal('');
+  filterStatus = signal('');
+  page = signal(1);
+
+  // ── 派生数据 ──────────────────────────────────────────────────────────────
+
+  colorOptions = computed(() => [...new Set(this.allItems().map(i => i.color).filter(Boolean))].sort());
+  sizeOptions  = computed(() => this.sortSizes([...new Set(this.allItems().map(i => i.size).filter(Boolean))]));
+  hasColor     = computed(() => this.allItems().some(i => i.color));
+  hasSize      = computed(() => this.allItems().some(i => i.size));
+
+  colorDropdownOpts = computed<MsqDropdownOption[]>(() => this.colorOptions().map(c => ({ value: c, label: c })));
+  sizeDropdownOpts  = computed<MsqDropdownOption[]>(() => this.sizeOptions().map(s => ({ value: s, label: s })));
+  readonly pvhStatusOpts: MsqDropdownOption[] = [
+    { value: 'instock', label: '在售' },
+    { value: 'oos', label: '断货' }
+  ];
+  otherAttrKeys = computed(() => {
+    const keys = new Set<string>();
+    this.allItems().forEach(i => Object.keys(i.otherAttrs).forEach(k => keys.add(k)));
+    return [...keys];
+  });
+
+  stats = computed(() => {
+    const items = this.allItems();
+    if (!items.length) return { totalColors: 0, totalSizes: 0, priceRange: '-', offSaleCount: 0, inStockRate: 0 };
+    const colors = new Set(items.map(i => i.color).filter(Boolean));
+    const sizes  = new Set(items.map(i => i.size).filter(Boolean));
+    const prices = items.filter(i => i.price > 0).map(i => i.price);
+    const offSaleCount = items.filter(i => i.offSale).length;
+    const priceRange = prices.length
+      ? prices.length === 1 ? `$${prices[0].toFixed(2)}`
+        : `$${Math.min(...prices).toFixed(2)}–$${Math.max(...prices).toFixed(2)}`
+      : '-';
+    const inStockRate = Math.round(((items.length - offSaleCount) / items.length) * 100);
+    return { totalColors: colors.size, totalSizes: sizes.size, priceRange, offSaleCount, inStockRate };
+  });
+
+  colorGroups = computed(() => {
+    const map: Record<string, { items: VariantItem[] }> = {};
+    this.allItems().forEach(item => {
+      const c = item.color || '(未知颜色)';
+      if (!map[c]) map[c] = { items: [] };
+      map[c].items.push(item);
+    });
+    return Object.entries(map).map(([color, { items }]) => {
+      const sizes = this.sortSizes([...new Set(items.map(i => i.size).filter(Boolean))])
+        .map(size => ({ size, offSale: items.find(i => i.size === size)?.offSale ?? false }));
+      const prices = items.filter(i => i.price > 0).map(i => i.price);
+      const priceRange = prices.length
+        ? prices.length === 1 ? `$${prices[0].toFixed(2)}`
+          : `$${Math.min(...prices).toFixed(2)}–$${Math.max(...prices).toFixed(2)}`
+        : '';
+      const offSaleCount = items.filter(i => i.offSale).length;
+      return { color, sizes, priceRange, offSaleCount };
+    }).sort((a, b) => a.color.localeCompare(b.color));
+  });
+
+  filteredItems = computed(() => {
+    let items = this.allItems();
+    const fc = this.filterColor(), fs = this.filterSize(), fst = this.filterStatus();
+    if (fc) items = items.filter(i => i.color === fc);
+    if (fs) items = items.filter(i => i.size === fs);
+    if (fst === 'instock') items = items.filter(i => !i.offSale);
+    if (fst === 'oos')     items = items.filter(i => i.offSale);
+    return items;
+  });
+
+  totalPages   = computed(() => Math.max(1, Math.ceil(this.filteredItems().length / PAGE_SIZE)));
+  pagedItems   = computed(() => this.filteredItems().slice((this.page()-1)*PAGE_SIZE, this.page()*PAGE_SIZE));
+  hasFilter    = computed(() => !!(this.filterColor() || this.filterSize() || this.filterStatus()));
+  pageNums     = computed(() => this.buildPageNums(this.page(), this.totalPages()));
+
+  constructor(private sorftimeApi: SorftimeApiService, private router: Router) {}
+
+  ngOnChanges(_changes: SimpleChanges): void {
+    // 不再自动请求 API,由父组件刷新按钮触发 load()
+  }
+
+  setColorFilter(v: string): void { this.filterColor.set(v); this.page.set(1); }
+  setSize(v: string):        void { this.filterSize.set(v);  this.page.set(1); }
+  clearFilters():            void { this.filterColor.set(''); this.filterSize.set(''); this.filterStatus.set(''); this.page.set(1); }
+  goPage(p: number):         void { if (p >= 1 && p <= this.totalPages()) this.page.set(p); }
+
+  toggleColorFilter(color: string): void {
+    this.filterColor.set(this.filterColor() === color ? '' : color);
+    this.page.set(1);
+  }
+
+  navigate(asin: string): void {
+    if (asin) this.router.navigate(['/knowledge-center/competitor', asin]);
+  }
+
+  retry(): void {
+    this.error.set('');
+    this.load();
+  }
+
+  // ── API 调用 ──────────────────────────────────────────────────────────────
+
+  load(): void {
+    this.loading.set(true);
+    this.error.set('');
+    this.allItems.set([]);
+
+    this.sorftimeApi.getProductVariationHistory(this.asin).pipe(
+      catchError(err => {
+        const msg = err?.message || '接口请求失败';
+        const friendly = msg.includes('Error 0') || msg.includes('API Error 0')
+          ? 'Sorftime 变体历史接口暂未返回数据 (Code 0)'
+          : msg;
+        this.error.set(friendly);
+        return of(null);
+      })
+    ).subscribe(data => {
+      this.loading.set(false);
+      if (!data) return;
+      this.allItems.set(this.parse(data));
+    });
+  }
+
+  private parse(data: any): VariantItem[] {
+    let list: any[] = [];
+    if (Array.isArray(data)) list = data;
+    else {
+      for (const key of ['VariationList', 'variationList', 'Data', 'data', 'Items', 'items']) {
+        if (Array.isArray(data?.[key])) { list = data[key]; break; }
+      }
+    }
+    return list.slice(0, 500)
+      .map((item: any) => this.mapItem(item))
+      .filter((i): i is VariantItem => !!i?.asin);
+  }
+
+  private mapItem(item: any): VariantItem | null {
+    const asin = item.ASIN || item.Asin || item.asin || '';
+    if (!asin) return null;
+
+    // 解析属性(颜色/尺码/其他)
+    const { color, size, otherAttrs } = this.parseAttrs(item.Attribute || item.attribute);
+
+    const rawPrice = item.SalesPrice || item.Price || item.price || 0;
+    const price = rawPrice > 200 ? rawPrice / 100 : rawPrice; // 单位:美分 or 美元
+    const offSale = item.OffSale === 1 || item.offSale === 1 || item.OffSale === true || false;
+
+    return {
+      asin,
+      title: item.Title || item.title || '',
+      price,
+      priceFormatted: price > 0 ? `$${price.toFixed(2)}` : '-',
+      ratings: parseFloat(item.Ratings || item.ratings || 0),
+      ratingsCount: parseInt(item.RatingsCount || item.ratingsCount || 0, 10),
+      image: item.Image || item.Photo?.[0] || '',
+      color, size, otherAttrs, offSale,
+    };
+  }
+
+  private parseAttrs(attr: any): { color: string; size: string; otherAttrs: Record<string, string> } {
+    let color = '', size = '';
+    const otherAttrs: Record<string, string> = {};
+
+    const add = (k: string, v: string) => {
+      const key = k.toLowerCase().trim();
+      if (key === 'color' || key === 'colour' || key === '颜色') color = v;
+      else if (key === 'size' || key === '尺码' || key === '尺寸') size = v;
+      else otherAttrs[k] = v;
+    };
+
+    if (!attr) return { color, size, otherAttrs };
+
+    if (typeof attr === 'string') {
+      // "Color:Soft Black|Size:32 Long" 或 "Color / Soft Black / Size / 32 Long"
+      if (attr.includes('|')) {
+        attr.split('|').forEach((p: string) => {
+          const m = p.match(/^(.+?)[::]\s*(.+)$/);
+          if (m) add(m[1], m[2]);
+        });
+      } else if (attr.includes('/')) {
+        const parts = attr.split(/\s*\/\s*/);
+        for (let i = 0; i + 1 < parts.length; i += 2) add(parts[i], parts[i+1]);
+      } else {
+        try {
+          const p = JSON.parse(attr);
+          return this.parseAttrs(p);
+        } catch { /* ignore */ }
+      }
+    } else if (Array.isArray(attr)) {
+      if (attr.length === 2 && typeof attr[0] === 'string' && typeof attr[1] === 'string') {
+        add(attr[0], attr[1]);
+      } else {
+        attr.forEach((a: any) => {
+          if (Array.isArray(a) && a.length >= 2) add(String(a[0]), String(a[1]));
+          else if (a && typeof a === 'object') Object.entries(a).forEach(([k, v]) => add(k, String(v)));
+          else if (typeof a === 'string') {
+            const m = a.match(/^(.+?)[::]\s*(.+)$/);
+            if (m) add(m[1], m[2]);
+          }
+        });
+      }
+    } else if (attr && typeof attr === 'object') {
+      Object.entries(attr).forEach(([k, v]) => add(k, String(v)));
+    }
+
+    return { color, size, otherAttrs };
+  }
+
+  private sortSizes(sizes: string[]): string[] {
+    const order = ['XS','S','M','L','XL','XXL','XXXL','2XL','3XL','4XL','5XL','6XL'];
+    return [...sizes].sort((a, b) => {
+      const ia = order.indexOf(a.toUpperCase()), ib = order.indexOf(b.toUpperCase());
+      if (ia !== -1 && ib !== -1) return ia - ib;
+      const na = parseInt(a, 10), nb = parseInt(b, 10);
+      if (!isNaN(na) && !isNaN(nb)) return na !== nb ? na - nb : a.localeCompare(b);
+      return a.localeCompare(b);
+    });
+  }
+
+  private buildPageNums(cur: number, total: number): number[] {
+    if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
+    const pages: number[] = [1];
+    if (cur > 3) pages.push(-1);
+    for (let p = Math.max(2, cur - 1); p <= Math.min(total - 1, cur + 1); p++) pages.push(p);
+    if (cur < total - 2) pages.push(-1);
+    pages.push(total);
+    return pages;
+  }
+}

+ 441 - 0
src/modules/knowledge-center/competitor-detail/product-variations/product-variations.component.ts

@@ -0,0 +1,441 @@
+import { Component, ChangeDetectionStrategy, Input, OnChanges, SimpleChanges, signal, computed } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { MsqDropdownComponent, MsqDropdownOption } from '../../../shared/components/msq-dropdown/msq-dropdown.component';
+
+export interface VariationEntry {
+  asin: string;
+  attrs: Record<string, string>;
+}
+
+const PAGE_SIZE = 20;
+
+/**
+ * 变体概要卡片(带筛选 + 分页)
+ * Attribute 字段格式:["ASIN / Size / 32 Long / Color / Soft Black", ...]
+ */
+@Component({
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  selector: 'app-product-variations',
+  standalone: true,
+  imports: [CommonModule, FormsModule, MsqDropdownComponent],
+  template: `
+    <div class="pv-card" *ngIf="totalCount() > 0">
+
+      <!-- ── Header ─────────────────────────────────── -->
+      <div class="pv-header">
+        <div class="pv-title-row">
+          <h3 class="pv-title">变体列表</h3>
+          @if (parentAsin) {
+            <code class="pv-parent">父 ASIN: {{ parentAsin }}</code>
+          }
+        </div>
+        <span class="pv-count">
+          @if (filteredEntries().length < totalCount()) {
+            筛选 {{ filteredEntries().length }} / 共 {{ totalCount() }}
+          } @else {
+            共 {{ totalCount() }} 个变体
+          }
+        </span>
+      </div>
+
+      <!-- ── 属性统计摘要 ─────────────────────────────── -->
+      <div class="pv-summary">
+        @for (kv of attrSummary(); track kv.key) {
+          <div class="pv-sum-item">
+            <span class="pv-sum-key">{{ kv.key }}</span>
+            <span class="pv-sum-val">{{ kv.count }} 种</span>
+          </div>
+        }
+      </div>
+
+      <!-- ── 筛选栏 ───────────────────────────────────── -->
+      @if (attrKeys().length > 0) {
+        <div class="pv-filters">
+          @for (key of attrKeys(); track key) {
+            <div class="pv-filter-group">
+              <label class="pv-filter-label">{{ key }}</label>
+              <msq-dropdown [options]="getAttrDropdownOpts(key)" placeholder="全部" (valueChange)="setFilter(key, $event)"></msq-dropdown>
+            </div>
+          }
+          @if (hasActiveFilter()) {
+            <button class="pv-clear-btn" (click)="clearFilters()">✕ 清除筛选</button>
+          }
+        </div>
+      }
+
+      <!-- ── 变体表格 ─────────────────────────────────── -->
+      <div class="pv-table-wrap">
+        <table class="pv-table">
+          <thead>
+            <tr>
+              <th>ASIN</th>
+              @for (key of attrKeys(); track key) {
+                <th>{{ key }}</th>
+              }
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            @for (entry of pagedEntries(); track entry.asin) {
+              <tr [class.pv-current-row]="entry.asin === currentAsin">
+                <td>
+                  <code class="pv-asin-code">{{ entry.asin }}</code>
+                </td>
+                @for (key of attrKeys(); track key) {
+                  <td class="pv-attr-cell">{{ entry.attrs[key] || '-' }}</td>
+                }
+                <td class="pv-action-cell">
+                  @if (entry.asin === currentAsin) {
+                    <span class="pv-badge-cur">当前</span>
+                  } @else {
+                    <button class="pv-goto" (click)="navigate(entry.asin)">查看</button>
+                  }
+                </td>
+              </tr>
+            }
+          </tbody>
+        </table>
+      </div>
+
+      <!-- ── 分页 ────────────────────────────────────── -->
+      @if (totalPages() > 1) {
+        <div class="pv-pagination">
+          <button class="pv-pg-btn" [disabled]="page() === 1" (click)="goPage(1)">«</button>
+          <button class="pv-pg-btn" [disabled]="page() === 1" (click)="goPage(page() - 1)">‹</button>
+
+          @for (p of pageNumbers(); track p) {
+            @if (p === -1) {
+              <span class="pv-pg-ellipsis">…</span>
+            } @else {
+              <button class="pv-pg-btn" [class.pv-pg-active]="p === page()" (click)="goPage(p)">
+                {{ p }}
+              </button>
+            }
+          }
+
+          <button class="pv-pg-btn" [disabled]="page() === totalPages()" (click)="goPage(page() + 1)">›</button>
+          <button class="pv-pg-btn" [disabled]="page() === totalPages()" (click)="goPage(totalPages())">»</button>
+          <span class="pv-pg-info">第 {{ page() }} / {{ totalPages() }} 页,每页 {{ pageSize }} 条</span>
+        </div>
+      }
+
+    </div>
+  `,
+  styles: [`
+    .pv-card {
+      background: #fff;
+      border-radius: 12px;
+      padding: 20px;
+      box-shadow: 0 2px 12px rgba(0,0,0,.08);
+      margin-top: 16px;
+    }
+    .pv-header {
+      display: flex;
+      align-items: flex-start;
+      justify-content: space-between;
+      margin-bottom: 12px;
+      gap: 12px;
+    }
+    .pv-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+    .pv-title { margin: 0; font-size: 15px; font-weight: 600; color: #1a1a2e; }
+    .pv-parent {
+      font-family: monospace; font-size: 11px;
+      background: #f1f5f9; padding: 2px 7px;
+      border-radius: 4px; color: #64748b;
+    }
+    .pv-count {
+      font-size: 12px; white-space: nowrap;
+      color: #6366f1; background: #ede9fe;
+      padding: 2px 10px; border-radius: 20px; font-weight: 500;
+    }
+
+    /* 摘要 */
+    .pv-summary {
+      display: flex; flex-wrap: wrap; gap: 8px;
+      margin-bottom: 12px; padding-bottom: 12px;
+      border-bottom: 1px solid #f3f4f6;
+    }
+    .pv-sum-item {
+      display: flex; align-items: center; gap: 4px;
+      background: #f8fafc; border: 1px solid #e2e8f0;
+      border-radius: 6px; padding: 4px 10px;
+    }
+    .pv-sum-key { font-size: 12px; color: #6b7280; font-weight: 600; }
+    .pv-sum-val { font-size: 12px; color: #6366f1; font-weight: 700; }
+
+    /* 筛选栏 */
+    .pv-filters {
+      display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px;
+      margin-bottom: 12px; padding: 12px;
+      background: #fafbff; border-radius: 8px;
+      border: 1px solid #e8edf5;
+    }
+    .pv-filter-group { display: flex; flex-direction: column; gap: 4px; }
+    .pv-filter-label { font-size: 11px; font-weight: 600; color: #9ca3af; }
+    .pv-select {
+      border: 1px solid #d1d5db; border-radius: 6px;
+      padding: 5px 8px; font-size: 12px; color: #374151;
+      background: #fff; cursor: pointer; min-width: 120px;
+    }
+    .pv-select:focus { outline: none; border-color: #6366f1; }
+    .pv-clear-btn {
+      align-self: flex-end;
+      background: none; border: 1px solid #fca5a5;
+      color: #ef4444; border-radius: 6px;
+      padding: 5px 10px; font-size: 12px; cursor: pointer;
+    }
+    .pv-clear-btn:hover { background: #fef2f2; }
+
+    /* 表格 */
+    .pv-table-wrap { overflow-x: auto; }
+    .pv-table {
+      width: 100%; border-collapse: collapse; font-size: 13px;
+    }
+    .pv-table th {
+      background: #f8fafc; font-weight: 600; color: #6b7280;
+      padding: 8px 12px; text-align: left;
+      border-bottom: 2px solid #e5e7eb; white-space: nowrap;
+    }
+    .pv-table td {
+      padding: 8px 12px; border-bottom: 1px solid #f3f4f6;
+      vertical-align: middle;
+    }
+    .pv-table tr:last-child td { border-bottom: none; }
+    .pv-current-row { background: #eff6ff; }
+    .pv-asin-code {
+      font-family: monospace; font-size: 12px;
+      background: #f1f5f9; padding: 2px 6px;
+      border-radius: 4px; color: #334155;
+    }
+    .pv-attr-cell { color: #374151; }
+    .pv-action-cell { text-align: center; width: 60px; }
+    .pv-goto {
+      padding: 3px 10px; border: 1px solid #6366f1;
+      color: #6366f1; background: transparent;
+      border-radius: 6px; cursor: pointer; font-size: 12px;
+    }
+    .pv-goto:hover { background: #ede9fe; }
+    .pv-badge-cur {
+      font-size: 10px; background: #3b82f6;
+      color: #fff; padding: 2px 7px; border-radius: 10px;
+    }
+
+    /* 分页 */
+    .pv-pagination {
+      display: flex; align-items: center; gap: 4px;
+      padding-top: 12px; flex-wrap: wrap;
+    }
+    .pv-pg-btn {
+      min-width: 32px; height: 32px; padding: 0 8px;
+      border: 1px solid #e2e8f0; border-radius: 6px;
+      background: #fff; color: #374151; font-size: 13px;
+      cursor: pointer; transition: background .12s;
+    }
+    .pv-pg-btn:hover:not(:disabled) { background: #ede9fe; border-color: #a5b4fc; color: #4f46e5; }
+    .pv-pg-btn:disabled { opacity: .4; cursor: default; }
+    .pv-pg-active { background: #6366f1 !important; color: #fff !important; border-color: #6366f1 !important; font-weight: 700; }
+    .pv-pg-ellipsis { padding: 0 4px; color: #9ca3af; }
+    .pv-pg-info { margin-left: 8px; font-size: 12px; color: #9ca3af; }
+  `]
+})
+export class ProductVariationsComponent implements OnChanges {
+  @Input() asinList: string[] = [];
+  @Input() currentAsin = '';
+  @Input() variationCount = 0;
+  @Input() attributes: any = [];
+  @Input() sizes: any = [];
+  @Input() parentAsin = '';
+
+  readonly pageSize = PAGE_SIZE;
+
+  // ── 核心数据 ──────────────────────────────────────────────────────────────
+  allEntries = signal<VariationEntry[]>([]);
+  filterValues = signal<Record<string, string>>({});
+  page = signal(1);
+
+  // ── 计算属性 ──────────────────────────────────────────────────────────────
+  attrKeys = computed<string[]>(() => {
+    const keys = new Set<string>();
+    this.allEntries().forEach(e => Object.keys(e.attrs).forEach(k => keys.add(k)));
+    return [...keys];
+  });
+
+  attrOptions = computed<Record<string, string[]>>(() => {
+    const map: Record<string, Set<string>> = {};
+    this.allEntries().forEach(e => {
+      Object.entries(e.attrs).forEach(([k, v]) => {
+        if (!map[k]) map[k] = new Set();
+        map[k].add(v);
+      });
+    });
+    const result: Record<string, string[]> = {};
+    Object.keys(map).forEach(k => {
+      result[k] = this.sortAttrValues(k, [...map[k]]);
+    });
+    return result;
+  });
+
+  attrSummary = computed(() =>
+    this.attrKeys().map(key => ({
+      key,
+      count: (this.attrOptions()[key] || []).length
+    }))
+  );
+
+  filteredEntries = computed<VariationEntry[]>(() => {
+    const filters = this.filterValues();
+    return this.allEntries().filter(entry =>
+      Object.entries(filters).every(([k, v]) => !v || entry.attrs[k] === v)
+    );
+  });
+
+  totalCount = computed(() => this.allEntries().length);
+  totalPages = computed(() => Math.ceil(this.filteredEntries().length / PAGE_SIZE));
+
+  pagedEntries = computed<VariationEntry[]>(() => {
+    const start = (this.page() - 1) * PAGE_SIZE;
+    return this.filteredEntries().slice(start, start + PAGE_SIZE);
+  });
+
+  pageNumbers = computed<number[]>(() => {
+    const total = this.totalPages();
+    const cur = this.page();
+    if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
+
+    const pages: number[] = [1];
+    if (cur > 3) pages.push(-1);
+    for (let p = Math.max(2, cur - 1); p <= Math.min(total - 1, cur + 1); p++) pages.push(p);
+    if (cur < total - 2) pages.push(-1);
+    pages.push(total);
+    return pages;
+  });
+
+  hasActiveFilter = computed(() => Object.values(this.filterValues()).some(v => !!v));
+
+  getAttrDropdownOpts(key: string): MsqDropdownOption[] {
+    return (this.attrOptions()[key] || []).map(v => ({ value: v, label: v }));
+  }
+
+  constructor(private router: Router) {}
+
+  ngOnChanges(_c: SimpleChanges): void {
+    this.allEntries.set(this.parseEntries());
+    this.filterValues.set({});
+    this.page.set(1);
+  }
+
+  setFilter(key: string, value: string): void {
+    this.filterValues.update(prev => ({ ...prev, [key]: value }));
+    this.page.set(1);
+  }
+
+  clearFilters(): void {
+    this.filterValues.set({});
+    this.page.set(1);
+  }
+
+  goPage(p: number): void {
+    if (p < 1 || p > this.totalPages()) return;
+    this.page.set(p);
+  }
+
+  navigate(asin: string): void {
+    if (asin && asin !== this.currentAsin) {
+      this.router.navigate(['/knowledge-center/competitor', asin]);
+    }
+  }
+
+  // ── 数据解析 ──────────────────────────────────────────────────────────────
+
+  /** 解析 Attribute 数组,格式:"ASIN / Key1 / Val1 / Key2 / Val2 / ..." */
+  private parseEntries(): VariationEntry[] {
+    const raw = this.normalizeArray(this.attributes);
+
+    if (raw.length > 0) {
+      const parsed = raw
+        .map(s => this.parseAttrString(String(s)))
+        .filter((e): e is VariationEntry => !!e && !!e.asin);
+      if (parsed.length > 0) return parsed;
+    }
+
+    // 回退:只有 ASIN 列表,无属性
+    return this.normalizeArray(this.asinList).map(asin => ({ asin: String(asin), attrs: {} }));
+  }
+
+  /**
+   * 解析形如 "B07KMTRT62 / Size / 32 Long / Color / Soft Black" 的字符串
+   * 或嵌套数组 [["ASIN", "B07KMTRT62"], ["Size", "32 Long"], ...]
+   */
+  private parseAttrString(raw: any): VariationEntry | null {
+    if (Array.isArray(raw)) {
+      // [[key, val], ...] 或 [key, val, key, val, ...]
+      const flat: string[] = raw.flat().map(String);
+      return this.pairsToEntry(flat);
+    }
+    if (typeof raw !== 'string') return null;
+
+    const parts = raw.split(/\s*\/\s*/).map(p => p.trim()).filter(Boolean);
+    if (parts.length === 0) return null;
+
+    // 第一段是否像 ASIN(10位大写字母数字)
+    const asinRe = /^[A-Z0-9]{10}$/;
+    let asin = '';
+    let rest = parts;
+
+    if (asinRe.test(parts[0])) {
+      asin = parts[0];
+      rest = parts.slice(1);
+    }
+
+    const attrs: Record<string, string> = {};
+    for (let i = 0; i + 1 < rest.length; i += 2) {
+      attrs[rest[i]] = rest[i + 1];
+    }
+
+    // 如果没解析到 ASIN,尝试从 attrs 里找
+    if (!asin && attrs['ASIN']) {
+      asin = attrs['ASIN'];
+      delete attrs['ASIN'];
+    }
+
+    return asin ? { asin, attrs } : null;
+  }
+
+  private pairsToEntry(flat: string[]): VariationEntry | null {
+    const asinRe = /^[A-Z0-9]{10}$/;
+    let asin = '';
+    const attrs: Record<string, string> = {};
+
+    for (let i = 0; i < flat.length - 1; i += 2) {
+      const k = flat[i], v = flat[i + 1];
+      if (k === 'ASIN' || asinRe.test(v)) { asin = v; }
+      else { attrs[k] = v; }
+    }
+    return asin ? { asin, attrs } : null;
+  }
+
+  private normalizeArray(raw: any): any[] {
+    if (!raw) return [];
+    if (Array.isArray(raw)) return raw;
+    if (typeof raw === 'string') {
+      try { const p = JSON.parse(raw); return Array.isArray(p) ? p : []; }
+      catch { return []; }
+    }
+    return [];
+  }
+
+  /** 对 Size 值做数字优先排序,其他字母排序 */
+  private sortAttrValues(key: string, values: string[]): string[] {
+    const numRe = /(\d+)/;
+    if (key.toLowerCase() === 'size') {
+      return [...values].sort((a, b) => {
+        const na = parseInt((a.match(numRe) || ['0'])[0], 10);
+        const nb = parseInt((b.match(numRe) || ['0'])[0], 10);
+        return na !== nb ? na - nb : a.localeCompare(b);
+      });
+    }
+    return [...values].sort((a, b) => a.localeCompare(b));
+  }
+}

+ 777 - 0
src/modules/knowledge-center/export-reviews-dialog/export-reviews-dialog.component.ts

@@ -0,0 +1,777 @@
+import { Component, EventEmitter, Input, Output, OnInit, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { DataStoreService } from '../../../app/core/services/data-store.service';
+import { SorftimeApiService } from '../../../app/core/services/sorftime-api.service';
+import { MsqSearchBoxComponent } from '../../shared/components/msq-search-box/msq-search-box.component';
+import { saveAs } from 'file-saver';
+import { NeuButtonComponent } from '../../shared/components/neu-button/neu-button.component';
+import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
+import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
+
+export interface ExportCompetitorItem {
+  asin: string;
+  name: string;
+  selected: boolean;
+  reviewCount?: number;
+}
+
+@Component({
+  selector: 'app-export-reviews-dialog',
+  standalone: true,
+  imports: [CommonModule, FormsModule, MsqSearchBoxComponent, NeuButtonComponent, EmptyStateComponent, LoadingSpinnerComponent],
+  template: `
+    <div class="dialog-overlay" (click)="onOverlayClick($event)">
+      <div class="dialog-container">
+        <div class="dialog-header">
+          <h2>批量导出竞品评论</h2>
+          <button class="close-btn" (click)="cancel()">&times;</button>
+        </div>
+
+        <div class="dialog-body">
+          <!-- 搜索框 -->
+          <div class="search-row">
+            <msq-search-box
+              placeholder="搜索 ASIN 或竞品名称..."
+              [(ngModel)]="searchText"
+              (valueChange)="currentPage = 1"></msq-search-box>
+            <div class="select-actions">
+              <button class="btn-select-all" (click)="toggleSelectAll()">
+                {{ isAllSelected ? '取消全选' : '全选' }}
+              </button>
+              <button class="btn-select-page" (click)="toggleSelectPage()">
+                {{ isPageAllSelected ? '取消本页' : '选本页' }}
+              </button>
+              <span class="selected-count">已选 {{ selectedCount }} / {{ filteredItems.length }}</span>
+            </div>
+          </div>
+
+          <!-- 竞品列表 -->
+          <div class="competitor-list">
+            @if (isLoadingList) {
+              <app-loading-spinner text="加载竞品列表中..." size="sm"></app-loading-spinner>
+            } @else if (filteredItems.length === 0) {
+              <app-empty-state title="暂无竞品数据" icon="📭" size="sm"></app-empty-state>
+            } @else {
+              @for (item of pagedItems; track item.asin) {
+                <label class="competitor-row" [class.selected]="item.selected">
+                  <input type="checkbox" [(ngModel)]="item.selected" (change)="onSelectionChange()">
+                  <span class="item-asin">{{ item.asin }}</span>
+                  <span class="item-name" [title]="item.name">{{ item.name || '-' }}</span>
+                  @if (item.reviewCount !== undefined) {
+                    <span class="item-review-count">{{ item.reviewCount }} 条评论</span>
+                  }
+                </label>
+              }
+            }
+          </div>
+
+          <!-- 分页 -->
+          @if (totalPages > 1) {
+            <div class="pagination">
+              <button class="page-btn" [disabled]="currentPage <= 1" (click)="currentPage = currentPage - 1">上一页</button>
+              @for (p of pageNumbers; track p) {
+                <button class="page-num" [class.active]="p === currentPage" (click)="currentPage = p">{{ p }}</button>
+              }
+              <button class="page-btn" [disabled]="currentPage >= totalPages" (click)="currentPage = currentPage + 1">下一页</button>
+              <span class="page-info">共 {{ filteredItems.length }} 条</span>
+            </div>
+          }
+
+          <!-- 导出设置 -->
+          <div class="export-settings">
+            <span class="settings-label">导出格式:Excel (.xlsx)</span>
+          </div>
+        </div>
+
+        <!-- 进度条(导出中显示) -->
+        @if (isExporting) {
+          <div class="progress-bar-container">
+            <div class="progress-bar">
+              <div class="progress-fill" [style.width.%]="exportProgress"></div>
+            </div>
+            <span class="progress-text">{{ exportStatusText }}</span>
+          </div>
+        }
+
+        <div class="dialog-footer">
+          <app-neu-button size="sm" (click)="cancel()" [disabled]="isExporting">取消</app-neu-button>
+          <app-neu-button
+            size="sm"
+            color="primary"
+            (click)="startExport()"
+            [disabled]="selectedCount === 0 || isExporting">
+            {{ isExporting ? '导出中...' : '一键导出' }}
+          </app-neu-button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 悬浮进度条(导出过程中始终可见) -->
+    @if (isExporting) {
+      <div class="floating-progress">
+        <div class="floating-progress-inner">
+          <div class="floating-bar">
+            <div class="floating-fill" [style.width.%]="exportProgress"></div>
+          </div>
+          <span class="floating-text">{{ exportStatusText }}</span>
+        </div>
+      </div>
+    }
+  `,
+  styles: [`
+    .dialog-overlay {
+      position: fixed;
+      inset: 0;
+      background: rgba(0, 0, 0, 0.45);
+      z-index: 1000;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .dialog-container {
+      background: #fff;
+      border-radius: 12px;
+      box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18);
+      width: 640px;
+      max-width: 90vw;
+      max-height: 80vh;
+      display: flex;
+      flex-direction: column;
+    }
+
+    .dialog-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 20px 24px 16px;
+      border-bottom: 1px solid #f0f0f0;
+
+      h2 {
+        margin: 0;
+        font-size: 18px;
+        font-weight: 600;
+        color: #1a1a2e;
+      }
+
+      .close-btn {
+        background: none;
+        border: none;
+        font-size: 22px;
+        color: #8c8c8c;
+        cursor: pointer;
+        padding: 4px 8px;
+        border-radius: 4px;
+
+        &:hover { background: #f5f5f5; color: #333; }
+      }
+    }
+
+    .dialog-body {
+      padding: 16px 24px;
+      overflow-y: auto;
+      flex: 1;
+    }
+
+    .search-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      margin-bottom: 12px;
+
+      .search-input {
+        flex: 1;
+        padding: 8px 12px;
+        border: 1px solid #d9d9d9;
+        border-radius: 6px;
+        font-size: 13px;
+        outline: none;
+
+        &:focus { border-color: #667eea; }
+      }
+
+      .select-actions {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        white-space: nowrap;
+      }
+
+      .btn-select-all, .btn-select-page {
+        padding: 6px 12px;
+        border: 1px solid #667eea;
+        border-radius: 6px;
+        background: #fff;
+        color: #667eea;
+        font-size: 12px;
+        cursor: pointer;
+
+        &:hover { background: #667eea; color: #fff; }
+      }
+
+      .selected-count {
+        font-size: 12px;
+        color: #8c8c8c;
+      }
+    }
+
+    .competitor-list {
+      max-height: 360px;
+      overflow-y: auto;
+      border: 1px solid #f0f0f0;
+      border-radius: 8px;
+      margin-bottom: 12px;
+    }
+
+    .competitor-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      padding: 10px 16px;
+      border-bottom: 1px solid #fafafa;
+      cursor: pointer;
+      transition: background 0.15s;
+
+      &:hover { background: #f8f9ff; }
+      &.selected { background: #f0f4ff; }
+      &:last-child { border-bottom: none; }
+
+      input[type="checkbox"] {
+        width: 16px;
+        height: 16px;
+        accent-color: #667eea;
+        cursor: pointer;
+      }
+
+      .item-asin {
+        font-family: 'SF Mono', 'Fira Code', monospace;
+        font-size: 13px;
+        font-weight: 600;
+        color: #1a1a2e;
+        min-width: 110px;
+      }
+
+      .item-name {
+        flex: 1;
+        font-size: 13px;
+        color: #595959;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+
+      .item-review-count {
+        font-size: 12px;
+        color: #8c8c8c;
+        white-space: nowrap;
+      }
+    }
+
+    .pagination {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      padding: 10px 0 4px;
+
+      .page-btn {
+        padding: 4px 10px;
+        border: 1px solid #d9d9d9;
+        border-radius: 4px;
+        background: #fff;
+        font-size: 12px;
+        cursor: pointer;
+
+        &:hover:not(:disabled) { border-color: #667eea; color: #667eea; }
+        &:disabled { opacity: 0.4; cursor: not-allowed; }
+      }
+
+      .page-num {
+        min-width: 28px;
+        height: 28px;
+        padding: 0 6px;
+        border: 1px solid #d9d9d9;
+        border-radius: 4px;
+        background: #fff;
+        font-size: 12px;
+        cursor: pointer;
+
+        &:hover { border-color: #667eea; color: #667eea; }
+        &.active { background: #667eea; color: #fff; border-color: #667eea; }
+      }
+
+      .page-info {
+        font-size: 12px;
+        color: #8c8c8c;
+        margin-left: 8px;
+      }
+    }
+
+    .export-settings {
+      padding: 8px 0;
+
+      .settings-label {
+        font-size: 13px;
+        color: #8c8c8c;
+      }
+    }
+
+    .progress-bar-container {
+      padding: 0 24px 12px;
+
+      .progress-bar {
+        height: 6px;
+        background: #f0f0f0;
+        border-radius: 3px;
+        overflow: hidden;
+
+        .progress-fill {
+          height: 100%;
+          background: linear-gradient(90deg, #667eea, #764ba2);
+          border-radius: 3px;
+          transition: width 0.3s ease;
+        }
+      }
+
+      .progress-text {
+        display: block;
+        margin-top: 6px;
+        font-size: 12px;
+        color: #8c8c8c;
+        text-align: center;
+      }
+    }
+
+    .dialog-footer {
+      display: flex;
+      justify-content: flex-end;
+      gap: 12px;
+      padding: 16px 24px;
+      border-top: 1px solid #f0f0f0;
+
+      .btn-cancel {
+        padding: 8px 20px;
+        border: 1px solid #d9d9d9;
+        border-radius: 6px;
+        background: #fff;
+        color: #595959;
+        font-size: 14px;
+        cursor: pointer;
+
+        &:hover { background: #f5f5f5; }
+        &:disabled { opacity: 0.5; cursor: not-allowed; }
+      }
+
+      .btn-export {
+        padding: 8px 24px;
+        border: none;
+        border-radius: 6px;
+        background: linear-gradient(135deg, #667eea, #764ba2);
+        color: #fff;
+        font-size: 14px;
+        font-weight: 600;
+        cursor: pointer;
+
+        &:hover { opacity: 0.9; }
+        &:disabled { opacity: 0.5; cursor: not-allowed; }
+      }
+    }
+
+    /* 悬浮进度条 */
+    .floating-progress {
+      position: fixed;
+      bottom: 24px;
+      left: 50%;
+      transform: translateX(-50%);
+      z-index: 2000;
+
+      .floating-progress-inner {
+        background: #fff;
+        border-radius: 12px;
+        box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
+        padding: 12px 24px;
+        min-width: 320px;
+        display: flex;
+        align-items: center;
+        gap: 12px;
+      }
+
+      .floating-bar {
+        flex: 1;
+        height: 8px;
+        background: #f0f0f0;
+        border-radius: 4px;
+        overflow: hidden;
+
+        .floating-fill {
+          height: 100%;
+          background: linear-gradient(90deg, #667eea, #764ba2);
+          border-radius: 4px;
+          transition: width 0.3s ease;
+        }
+      }
+
+      .floating-text {
+        font-size: 13px;
+        color: #595959;
+        white-space: nowrap;
+      }
+    }
+  `]
+})
+export class ExportReviewsDialogComponent implements OnInit {
+  @Input() competitors: { asin: string; name: string }[] = [];
+  @Output() cancelExport = new EventEmitter<void>();
+  @Output() exportComplete = new EventEmitter<void>();
+
+  private dataStore = inject(DataStoreService);
+  private sorftimeApi = inject(SorftimeApiService);
+
+  items: ExportCompetitorItem[] = [];
+  searchText = '';
+  currentPage = 1;
+  pageSize = 20;
+  isLoadingList = false;
+  isExporting = false;
+  exportProgress = 0;
+  exportStatusText = '';
+
+  ngOnInit(): void {
+    this.loadCompetitorList();
+  }
+
+  private loadCompetitorList(): void {
+    if (this.competitors?.length) {
+      this.items = this.competitors
+        .map(c => ({ asin: c.asin, name: c.name, selected: false }))
+        .sort((a, b) => a.asin.localeCompare(b.asin) || a.name.localeCompare(b.name));
+
+      // 查询每个 ASIN 的评论数
+      this.loadReviewCounts();
+      return;
+    }
+
+    // 如果没有传入竞品列表,从 DB 加载
+    this.isLoadingList = true;
+    this.dataStore.query('SorftimeProducts', undefined, { limit: 1000 }).subscribe({
+      next: (rows) => {
+        this.items = (rows || [])
+          .filter((r: any) => r.asin)
+          .map((r: any) => ({
+            asin: r.asin,
+            name: r.title || r.name || '-',
+            selected: false
+          }))
+          .sort((a: ExportCompetitorItem, b: ExportCompetitorItem) =>
+            a.asin.localeCompare(b.asin) || a.name.localeCompare(b.name));
+
+        this.isLoadingList = false;
+        this.loadReviewCounts();
+      },
+      error: () => {
+        this.isLoadingList = false;
+      }
+    });
+  }
+
+  private loadReviewCounts(): void {
+    for (const item of this.items) {
+      this.dataStore.count('SorftimeReviews', { asin: item.asin }).subscribe({
+        next: (count) => { item.reviewCount = count; }
+      });
+    }
+  }
+
+  get filteredItems(): ExportCompetitorItem[] {
+    if (!this.searchText.trim()) return this.items;
+    const q = this.searchText.trim().toLowerCase();
+    return this.items.filter(item =>
+      item.asin.toLowerCase().includes(q) ||
+      (item.name || '').toLowerCase().includes(q)
+    );
+  }
+
+  get pagedItems(): ExportCompetitorItem[] {
+    const start = (this.currentPage - 1) * this.pageSize;
+    return this.filteredItems.slice(start, start + this.pageSize);
+  }
+
+  get totalPages(): number {
+    return Math.max(1, Math.ceil(this.filteredItems.length / this.pageSize));
+  }
+
+  get pageNumbers(): number[] {
+    const total = this.totalPages;
+    if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
+    // Show first, last, and around current
+    const pages = new Set<number>();
+    pages.add(1);
+    pages.add(total);
+    for (let i = Math.max(1, this.currentPage - 1); i <= Math.min(total, this.currentPage + 1); i++) {
+      pages.add(i);
+    }
+    return Array.from(pages).sort((a, b) => a - b);
+  }
+
+  get selectedCount(): number {
+    return this.items.filter(i => i.selected).length;
+  }
+
+  get isAllSelected(): boolean {
+    const visible = this.filteredItems;
+    return visible.length > 0 && visible.every(i => i.selected);
+  }
+
+  get isPageAllSelected(): boolean {
+    const page = this.pagedItems;
+    return page.length > 0 && page.every(i => i.selected);
+  }
+
+  toggleSelectAll(): void {
+    const target = !this.isAllSelected;
+    for (const item of this.filteredItems) {
+      item.selected = target;
+    }
+  }
+
+  toggleSelectPage(): void {
+    const target = !this.isPageAllSelected;
+    for (const item of this.pagedItems) {
+      item.selected = target;
+    }
+  }
+
+  onSelectionChange(): void {
+    // Trigger change detection
+  }
+
+  onOverlayClick(event: MouseEvent): void {
+    if ((event.target as HTMLElement).classList.contains('dialog-overlay') && !this.isExporting) {
+      this.cancel();
+    }
+  }
+
+  cancel(): void {
+    if (!this.isExporting) {
+      this.cancelExport.emit();
+    }
+  }
+
+  async startExport(): Promise<void> {
+    const selectedItems = this.items.filter(i => i.selected);
+    if (!selectedItems.length) return;
+
+    this.isExporting = true;
+    this.exportProgress = 0;
+    this.exportStatusText = '准备导出...';
+
+    const allRows: any[] = [];
+    const totalAsins = selectedItems.length;
+
+    for (let i = 0; i < totalAsins; i++) {
+      const item = selectedItems[i];
+      this.exportStatusText = `正在获取 ${item.asin} 的评论 (${i + 1}/${totalAsins})`;
+      this.exportProgress = Math.round((i / totalAsins) * 80);
+
+      try {
+        // 先从 DB 查询评论
+        const reviews = await this.fetchReviewsForAsin(item.asin);
+
+        // Debug: 打印第一条评论的字段,帮助定位日期字段名
+        if (reviews.length && i === 0) {
+          const first = reviews[0];
+          console.log('[Export] DB评论字段:', Object.keys(first));
+          console.log('[Export] rawData字段:', first.rawData ? Object.keys(first.rawData) : 'N/A');
+          console.log('[Export] 日期候选值:', {
+            reviewDate: first.reviewDate,
+            ReviewDate: first.ReviewDate,
+            rawData_ReviewDate: first.rawData?.ReviewDate,
+            rawData_CreateTime: first.rawData?.CreateTime,
+            rawData_Date: first.rawData?.Date,
+            rawData_date: first.rawData?.date
+          });
+        }
+
+        for (const r of reviews) {
+          allRows.push({
+            'ASIN': item.asin,
+            '竞品名称': item.name || '-',
+            '星级': r.star || r.Star || 0,
+            '短评(标题)': r.title || r.Title || '',
+            '长评(内容)': r.content || r.Content || '',
+            '评论时间': this.extractReviewDate(r),
+            '评论者': r.consumerName || r.ConsumerName || '',
+            '国家': r.reviewsCountry || r.ReviewsCountry || '',
+            '已验证购买': (r.ivp || r.Ivp) ? '是' : '否'
+          });
+        }
+      } catch (err) {
+        console.warn(`[Export] 获取 ${item.asin} 评论失败:`, err);
+      }
+    }
+
+    this.exportProgress = 85;
+    this.exportStatusText = `正在生成 Excel 文件 (共 ${allRows.length} 条评论)...`;
+
+    // 生成 Excel
+    try {
+      await this.generateExcel(allRows);
+      this.exportProgress = 100;
+      this.exportStatusText = `导出完成!共 ${allRows.length} 条评论`;
+
+      setTimeout(() => {
+        this.isExporting = false;
+        this.exportComplete.emit();
+      }, 1500);
+    } catch (err) {
+      console.error('[Export] Excel 生成失败:', err);
+      this.exportStatusText = '导出失败,请重试';
+      setTimeout(() => { this.isExporting = false; }, 2000);
+    }
+  }
+
+  private fetchReviewsForAsin(asin: string): Promise<any[]> {
+    return new Promise((resolve) => {
+      // 从 DB 查询所有评论(不分页)
+      this.dataStore.query('SorftimeReviews', { asin }, {
+        limit: 1000,
+        orderBy: 'reviewDate',
+        descending: true
+      }).subscribe({
+        next: (rows) => {
+          if (rows?.length) {
+            resolve(rows);
+          } else {
+            // DB 无数据 → 调 API 获取 → 保存到 DB → 返回
+            this.fetchAndSaveFromApi(asin).then(resolve);
+          }
+        },
+        error: () => {
+          this.fetchAndSaveFromApi(asin).then(resolve);
+        }
+      });
+    });
+  }
+
+  private fetchAndSaveFromApi(asin: string): Promise<any[]> {
+    return new Promise((resolve) => {
+      this.sorftimeApi.getProductReviews(asin, { pageIndex: 1 }).subscribe({
+        next: (resp: any) => {
+          const data = resp?.Data || resp?.data || resp;
+          const reviewList = Array.isArray(data) ? data : [];
+
+          if (reviewList.length) {
+            // 保存到 DB
+            const items = reviewList.map((r: any) => ({
+              reviewId: r.ReviewsLink || r.ReviewLink || `${asin}_${r.ConsumerName}_${r.ReviewsDate || r.ReviewDate}`,
+              asin,
+              star: Number(r.Star) || 0,
+              title: r.Title || '',
+              content: r.Content || '',
+              consumerName: r.ConsumerName || '',
+              reviewDate: r.ReviewsDate || r.ReviewDate || '',
+              updateTime: r.UpdateTime || '',
+              helpful: Number(r.Helpful) || 0,
+              ivp: !!(r.IsVP || r.Ivp),
+              reviewsCountry: r.ReviewedCountry || r.ReviewsCountry || '',
+              reviewLink: r.ReviewsLink || r.ReviewLink || '',
+              consumerUrl: r.ConsumerURL || r.ConsumerUrl || '',
+              itemIndex: r.ItemIndex || '',
+              rawData: r
+            }));
+
+            this.dataStore.saveBatch('SorftimeReviews', items, 'reviewId').subscribe({
+              next: () => {
+                console.log(`[Export] Saved ${items.length} reviews for ${asin} to DB`);
+                // 返回保存后的标准化数据(和 DB 字段一致)
+                resolve(items);
+              },
+              error: () => {
+                // 保存失败仍返回 API 原始数据
+                resolve(reviewList);
+              }
+            });
+          } else {
+            resolve([]);
+          }
+        },
+        error: () => resolve([])
+      });
+    });
+  }
+
+  private async generateExcel(rows: any[]): Promise<void> {
+    const XLSX = await import('xlsx');
+    if (!rows.length) {
+      rows = [{ 'ASIN': '(无评论数据)', '竞品名称': '', '星级': '', '短评(标题)': '', '长评(内容)': '', '评论时间': '', '评论者': '', '国家': '', '已验证购买': '' }];
+    }
+
+    const ws = XLSX.utils.json_to_sheet(rows);
+
+    // 设置列宽
+    ws['!cols'] = [
+      { wch: 14 },  // ASIN
+      { wch: 30 },  // 竞品名称
+      { wch: 6 },   // 星级
+      { wch: 40 },  // 短评
+      { wch: 80 },  // 长评
+      { wch: 14 },  // 评论时间
+      { wch: 16 },  // 评论者
+      { wch: 14 },  // 国家
+      { wch: 10 },  // 已验证购买
+    ];
+
+    const wb = XLSX.utils.book_new();
+    XLSX.utils.book_append_sheet(wb, ws, '竞品评论');
+
+    const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
+    const blob = new Blob([buf], { type: 'application/octet-stream' });
+
+    const now = new Date();
+    const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
+    const timeStr = `${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}`;
+    saveAs(blob, `${dateStr}_${timeStr}_竞品评论导出.xlsx`);
+  }
+
+  private extractReviewDate(r: any): string {
+    // 使用 ReviewsDate(实际评论日期),UpdateTime 是系统更新时间不准确
+    const direct = r.ReviewsDate || r.reviewDate || r.ReviewDate || '';
+    if (direct) return this.formatDate(direct);
+
+    // 从 rawData(原始 Sorftime 响应)中提取
+    const raw = r.rawData;
+    if (raw) {
+      const rawDate = raw.ReviewsDate || raw.ReviewDate || '';
+      if (rawDate) return this.formatDate(rawDate);
+    }
+
+    return '';
+  }
+
+  private formatDate(dateStr: string): string {
+    if (!dateStr) return '';
+    const s = String(dateStr).trim();
+
+    // "2026-02-06 17:39" — 已精确到分钟
+    if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(s)) {
+      return s;
+    }
+
+    // "20260204" — 转为 YYYY-MM-DD
+    if (s.length === 8 && /^\d{8}$/.test(s)) {
+      return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
+    }
+
+    try {
+      const d = new Date(s);
+      if (!isNaN(d.getTime())) {
+        return d.toLocaleString('zh-CN', {
+          year: 'numeric', month: '2-digit', day: '2-digit',
+          hour: '2-digit', minute: '2-digit'
+        });
+      }
+    } catch { }
+    return s;
+  }
+}

+ 492 - 0
src/modules/knowledge-center/fetch-count-dialog/fetch-count-dialog.component.ts

@@ -0,0 +1,492 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { NeuButtonComponent } from '../../shared/components/neu-button/neu-button.component';
+
+export interface FetchDialogResult {
+  mode: 'asin' | 'keyword';
+  asins?: string[];
+  keyword?: string;
+  pageCount?: number;
+}
+
+@Component({
+  selector: 'app-fetch-count-dialog',
+  standalone: true,
+  imports: [CommonModule, FormsModule, NeuButtonComponent],
+  template: `
+    <div class="dialog-overlay" (click)="onOverlayClick($event)">
+      <div class="dialog-container">
+        <div class="dialog-header">
+          <h2>添加竞品数据</h2>
+          <button class="close-btn" (click)="cancel()">&times;</button>
+        </div>
+        <div class="dialog-body">
+          <!-- Mode Tabs -->
+          <div class="mode-tabs">
+            <button
+              class="mode-tab"
+              [class.active]="mode === 'asin'"
+              (click)="mode = 'asin'">
+              按 ASIN 查询
+            </button>
+            <button
+              class="mode-tab"
+              [class.active]="mode === 'keyword'"
+              (click)="mode = 'keyword'">
+              按关键词搜索
+            </button>
+          </div>
+
+          <!-- ASIN Mode -->
+          @if (mode === 'asin') {
+            <div class="form-section">
+              <label>输入 ASIN 或 Amazon 商品链接(每行一个或用逗号分隔)</label>
+              <textarea
+                [(ngModel)]="asinInput"
+                placeholder="例如:&#10;B0CSVRWJ1H&#10;https://www.amazon.com/dp/B0DGY7DNTL&#10;https://www.amazon.com/gp/aw/d/B0DJLKFKML/?..."
+                class="asin-textarea"
+                rows="5"></textarea>
+              <div class="input-stats">
+                已输入 <strong>{{ parsedAsins.length }}</strong> 个有效 ASIN
+                <span class="cost-hint">(每个消耗 1 次 API 请求)</span>
+              </div>
+            </div>
+            @if (fetchType === 'competitor') {
+              <div class="search-helper">
+                <div class="helper-title">🔍 如何找到竞品 ASIN?</div>
+                <div class="helper-steps">
+                  <span>1. 输入关键词点击“去 Amazon 搜索”</span>
+                  <span>2. 从搜索结果中复制竞品的 ASIN</span>
+                  <span>3. 粘贴到上方输入框</span>
+                </div>
+                <div class="search-row">
+                  <input
+                    type="text"
+                    [(ngModel)]="keyword"
+                    placeholder="输入关键词,如 High Waisted Swimsuit"
+                    class="keyword-input search-keyword"
+                    (keydown.enter)="openAmazonSearch()">
+                  <button class="btn-amazon" (click)="openAmazonSearch()" [disabled]="!keyword.trim()">
+                    去 Amazon 搜索
+                  </button>
+                </div>
+              </div>
+            }
+          }
+
+          <!-- Keyword Mode -->
+          @if (mode === 'keyword') {
+            <div class="form-section">
+              <label>搜索关键词</label>
+              <input
+                type="text"
+                [(ngModel)]="keyword"
+                placeholder="输入产品关键词,如 High Waisted Swimsuit"
+                class="keyword-input">
+            </div>
+            <div class="form-section">
+              <label>请求次数</label>
+              <div class="page-count-row">
+                <div class="preset-options">
+                  <button
+                    *ngFor="let opt of presetOptions"
+                    class="preset-btn"
+                    [class.active]="pageCount === opt"
+                    (click)="pageCount = opt">
+                    {{ opt }}
+                  </button>
+                </div>
+                <input
+                  type="number"
+                  [(ngModel)]="pageCount"
+                  [min]="1"
+                  [max]="10"
+                  class="count-input">
+              </div>
+              <div class="input-stats">
+                将获取约 <strong>{{ pageCount * 20 }}</strong> 条数据
+                <span class="cost-hint">(消耗 {{ pageCount }} 次 API 请求)</span>
+              </div>
+            </div>
+          }
+
+          <div class="info-box">
+            <p class="warning">⚠ 每次请求消耗 API 配额,请合理使用</p>
+          </div>
+        </div>
+        <div class="dialog-footer">
+          <app-neu-button size="sm" (click)="cancel()">取消</app-neu-button>
+          <app-neu-button
+            size="sm"
+            color="primary"
+            (click)="confirm()"
+            [disabled]="!canConfirm">
+            {{ mode === 'asin' ? '查询 ' + parsedAsins.length + ' 个ASIN' : '开始搜索' }}
+          </app-neu-button>
+        </div>
+      </div>
+    </div>
+  `,
+  styles: [`
+    .dialog-overlay {
+      position: fixed;
+      top: 0; left: 0; right: 0; bottom: 0;
+      background: rgba(0, 0, 0, 0.45);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      z-index: 1000;
+    }
+    .dialog-container {
+      background: #fff;
+      border-radius: 12px;
+      width: 480px;
+      max-width: 92%;
+      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
+      animation: dialogIn 0.2s ease-out;
+    }
+    @keyframes dialogIn {
+      from { opacity: 0; transform: translateY(-20px) scale(0.96); }
+      to { opacity: 1; transform: translateY(0) scale(1); }
+    }
+    .dialog-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 18px 24px;
+      border-bottom: 1px solid #f0f0f0;
+    }
+    .dialog-header h2 {
+      margin: 0;
+      font-size: 17px;
+      font-weight: 600;
+      color: #1a1a2e;
+    }
+    .close-btn {
+      background: none;
+      border: none;
+      font-size: 22px;
+      cursor: pointer;
+      color: #999;
+      padding: 0;
+      line-height: 1;
+      transition: color 0.2s;
+    }
+    .close-btn:hover { color: #333; }
+    .dialog-body {
+      padding: 20px 24px;
+    }
+
+    /* Mode Tabs */
+    .mode-tabs {
+      display: flex;
+      gap: 0;
+      margin-bottom: 20px;
+      background: #f5f7fa;
+      border-radius: 8px;
+      padding: 3px;
+    }
+    .mode-tab {
+      flex: 1;
+      padding: 9px 16px;
+      border: none;
+      background: transparent;
+      border-radius: 6px;
+      font-size: 13px;
+      font-weight: 500;
+      color: #666;
+      cursor: pointer;
+      transition: all 0.2s;
+    }
+    .mode-tab:hover { color: #333; }
+    .mode-tab.active {
+      background: #fff;
+      color: #667eea;
+      font-weight: 600;
+      box-shadow: 0 1px 4px rgba(0,0,0,0.08);
+    }
+
+    /* Form */
+    .form-section {
+      margin-bottom: 16px;
+    }
+    .form-section label {
+      display: block;
+      margin-bottom: 8px;
+      font-size: 13px;
+      font-weight: 500;
+      color: #444;
+    }
+    .asin-textarea {
+      width: 100%;
+      padding: 10px 12px;
+      border: 1px solid #e0e0e0;
+      border-radius: 8px;
+      font-size: 13px;
+      font-family: 'SF Mono', 'Consolas', monospace;
+      resize: vertical;
+      line-height: 1.6;
+      box-sizing: border-box;
+      transition: border-color 0.2s;
+    }
+    .asin-textarea:focus {
+      outline: none;
+      border-color: #667eea;
+      box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+    }
+    .keyword-input {
+      width: 100%;
+      padding: 10px 12px;
+      border: 1px solid #e0e0e0;
+      border-radius: 8px;
+      font-size: 13px;
+      box-sizing: border-box;
+      transition: border-color 0.2s;
+    }
+    .keyword-input:focus {
+      outline: none;
+      border-color: #667eea;
+      box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
+    }
+    .page-count-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+    .preset-options {
+      display: flex;
+      gap: 6px;
+      flex: 1;
+    }
+    .preset-btn {
+      flex: 1;
+      padding: 7px 0;
+      border: 1px solid #e0e0e0;
+      background: #fff;
+      border-radius: 6px;
+      cursor: pointer;
+      font-size: 13px;
+      font-weight: 500;
+      color: #555;
+      transition: all 0.2s;
+    }
+    .preset-btn:hover {
+      border-color: #667eea;
+      color: #667eea;
+    }
+    .preset-btn.active {
+      background: #667eea;
+      border-color: #667eea;
+      color: #fff;
+    }
+    .count-input {
+      width: 56px;
+      padding: 7px 8px;
+      border: 1px solid #e0e0e0;
+      border-radius: 6px;
+      font-size: 14px;
+      text-align: center;
+    }
+    .count-input:focus {
+      outline: none;
+      border-color: #667eea;
+    }
+    .input-stats {
+      margin-top: 8px;
+      font-size: 12px;
+      color: #666;
+    }
+    .input-stats strong {
+      color: #667eea;
+    }
+    .cost-hint {
+      color: #999;
+    }
+
+    /* Info */
+    .info-box {
+      background: #fffbf0;
+      padding: 10px 12px;
+      border-radius: 6px;
+      border: 1px solid #ffeeba;
+    }
+    .info-box p {
+      margin: 0;
+      font-size: 12px;
+    }
+    .info-box .warning {
+      color: #856404;
+    }
+
+    /* Search Helper */
+    .search-helper {
+      background: #f0f4ff;
+      border: 1px solid #d6e0ff;
+      border-radius: 8px;
+      padding: 14px 16px;
+      margin-bottom: 16px;
+    }
+    .helper-title {
+      font-size: 13px;
+      font-weight: 600;
+      color: #4a5568;
+      margin-bottom: 8px;
+    }
+    .helper-steps {
+      display: flex;
+      flex-direction: column;
+      gap: 4px;
+      margin-bottom: 12px;
+    }
+    .helper-steps span {
+      font-size: 12px;
+      color: #667;
+    }
+    .search-row {
+      display: flex;
+      gap: 8px;
+    }
+    .search-keyword {
+      flex: 1;
+    }
+    .btn-amazon {
+      padding: 8px 16px;
+      background: #ff9900;
+      color: #fff;
+      border: none;
+      border-radius: 8px;
+      font-size: 13px;
+      font-weight: 600;
+      cursor: pointer;
+      white-space: nowrap;
+      transition: all 0.2s;
+    }
+    .btn-amazon:hover:not(:disabled) {
+      background: #e68a00;
+      box-shadow: 0 2px 8px rgba(255, 153, 0, 0.3);
+    }
+    .btn-amazon:disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+
+    /* Footer */
+    .dialog-footer {
+      display: flex;
+      justify-content: flex-end;
+      gap: 10px;
+      padding: 14px 24px;
+      border-top: 1px solid #f0f0f0;
+    }
+    .btn-cancel, .btn-confirm {
+      padding: 9px 22px;
+      border-radius: 8px;
+      font-size: 13px;
+      font-weight: 500;
+      cursor: pointer;
+      transition: all 0.2s;
+    }
+    .btn-cancel {
+      background: #fff;
+      border: 1px solid #ddd;
+      color: #666;
+    }
+    .btn-cancel:hover {
+      border-color: #999;
+      color: #333;
+    }
+    .btn-confirm {
+      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+      border: none;
+      color: #fff;
+    }
+    .btn-confirm:hover:not(:disabled) {
+      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+    }
+    .btn-confirm:disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+  `]
+})
+export class FetchCountDialogComponent {
+  @Input() fetchType: 'competitor' | 'influencer' = 'competitor';
+  @Output() confirmFetch = new EventEmitter<FetchDialogResult>();
+  @Output() cancelFetch = new EventEmitter<void>();
+
+  mode: 'asin' | 'keyword' = 'asin';
+  asinInput = '';
+  keyword = '';
+  pageCount = 1;
+  presetOptions = [1, 2, 3, 5, 10];
+
+  get parsedAsins(): string[] {
+    const lines = this.asinInput.split(/[\n,;]+/).map(s => s.trim()).filter(Boolean);
+    const asins: string[] = [];
+    const seen = new Set<string>();
+    for (const line of lines) {
+      const asin = this.extractAsin(line);
+      if (asin && !seen.has(asin)) {
+        seen.add(asin);
+        asins.push(asin);
+      }
+    }
+    return asins;
+  }
+
+  private extractAsin(input: string): string | null {
+    const trimmed = input.trim();
+    // 1. 直接是 ASIN
+    const upper = trimmed.toUpperCase();
+    if (/^B0[A-Z0-9]{8,}$/.test(upper)) return upper;
+    // 2. 从 Amazon URL 提取: /dp/ASIN, /gp/product/ASIN, /gp/aw/d/ASIN
+    const urlMatch = trimmed.match(/\/(?:dp|gp\/product|gp\/aw\/d)\/([A-Za-z0-9]{10})/);
+    if (urlMatch) return urlMatch[1].toUpperCase();
+    // 3. 宽松匹配:URL 中任何看起来像 ASIN 的片段
+    const looseMatch = trimmed.match(/\b(B0[A-Z0-9]{8,})\b/i);
+    if (looseMatch) return looseMatch[1].toUpperCase();
+    return null;
+  }
+
+  get canConfirm(): boolean {
+    if (this.mode === 'asin') return this.parsedAsins.length > 0;
+    return this.keyword.trim().length > 0;
+  }
+
+  confirm(): void {
+    if (!this.canConfirm) return;
+
+    if (this.mode === 'asin') {
+      this.confirmFetch.emit({
+        mode: 'asin',
+        asins: this.parsedAsins
+      });
+    } else {
+      if (this.pageCount < 1) this.pageCount = 1;
+      if (this.pageCount > 10) this.pageCount = 10;
+      this.confirmFetch.emit({
+        mode: 'keyword',
+        keyword: this.keyword.trim(),
+        pageCount: this.pageCount
+      });
+    }
+  }
+
+  cancel(): void {
+    this.cancelFetch.emit();
+  }
+
+  openAmazonSearch(): void {
+    const kw = this.keyword.trim();
+    if (!kw) return;
+    const url = 'https://www.amazon.com/s?k=' + encodeURIComponent(kw);
+    window.open(url, '_blank');
+  }
+
+  onOverlayClick(event: Event): void {
+    if ((event.target as HTMLElement).classList.contains('dialog-overlay')) {
+      this.cancel();
+    }
+  }
+}

+ 533 - 0
src/modules/knowledge-center/influencer-detail/influencer-detail.component.scss

@@ -0,0 +1,533 @@
+.influencer-detail-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 24px 32px;
+}
+
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+
+  .last-updated {
+    font-size: 13px;
+    color: #888;
+  }
+
+  .refresh-btn-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .save-status {
+    position: absolute;
+    right: 100%;
+    margin-right: 12px;
+    padding: 4px 10px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 500;
+    white-space: nowrap;
+
+    &.saving {
+      background: #fff3e0;
+      color: #f57c00;
+    }
+
+    &.saved {
+      background: #e8f5e9;
+      color: #4caf50;
+    }
+
+    &.error {
+      background: #ffebee;
+      color: #f44336;
+    }
+  }
+
+  .refresh-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 8px 16px;
+    background: #4a6cf7;
+    color: #fff;
+    border: none;
+    border-radius: 8px;
+    font-size: 14px;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover:not(:disabled) {
+      background: #3a5ce5;
+    }
+
+    &:disabled {
+      opacity: 0.7;
+      cursor: not-allowed;
+    }
+
+    .spinner-small {
+      width: 14px;
+      height: 14px;
+      border: 2px solid rgba(255, 255, 255, 0.3);
+      border-top-color: #fff;
+      border-radius: 50%;
+      animation: spin 0.8s linear infinite;
+    }
+  }
+}
+
+.back-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  background: none;
+  border: none;
+  color: #333;
+  font-size: 16px;
+  cursor: pointer;
+  padding: 8px 16px;
+  border-radius: 8px;
+  transition: background 0.2s;
+
+  &:hover {
+    background: #e8eaed;
+  }
+
+  .back-icon {
+    font-size: 20px;
+  }
+}
+
+// Loading
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120px 0;
+
+  .spinner {
+    width: 40px;
+    height: 40px;
+    border: 3px solid #e0e0e0;
+    border-top-color: #4a6cf7;
+    border-radius: 50%;
+    animation: spin 0.8s linear infinite;
+  }
+
+  p {
+    margin-top: 16px;
+    color: #888;
+    font-size: 14px;
+  }
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+// Layout
+.detail-layout {
+  display: flex;
+  gap: 32px;
+  align-items: flex-start;
+}
+
+// Profile Card
+.profile-card {
+  width: 280px;
+  min-width: 280px;
+  background: #fff;
+  border-radius: 16px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+}
+
+.profile-banner {
+  height: 100px;
+  background: linear-gradient(135deg, #4a6cf7 0%, #6a8cff 100%);
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  padding-bottom: 0;
+  position: relative;
+}
+
+.avatar {
+  width: 80px;
+  height: 80px;
+  border-radius: 50%;
+  border: 4px solid #fff;
+  object-fit: cover;
+  position: absolute;
+  bottom: -40px;
+  background: #fff;
+}
+
+.avatar-placeholder {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 32px;
+  font-weight: 700;
+  color: #4a6cf7;
+  background: #e8eeff;
+  position: absolute;
+  bottom: -40px;
+}
+
+.profile-info {
+  padding: 48px 20px 16px;
+  text-align: center;
+
+  .verified-badge {
+    display: inline-block;
+    width: 20px;
+    height: 20px;
+    background: #4a6cf7;
+    color: #fff;
+    border-radius: 50%;
+    font-size: 12px;
+    line-height: 20px;
+    text-align: center;
+    margin-bottom: 4px;
+  }
+
+  .nickname {
+    font-size: 20px;
+    font-weight: 700;
+    color: #1a1a1a;
+    margin: 4px 0;
+  }
+
+  .username {
+    font-size: 14px;
+    color: #888;
+    margin: 0 0 8px;
+  }
+
+  .location {
+    font-size: 13px;
+    color: #666;
+    margin: 4px 0;
+
+    .location-icon {
+      font-size: 14px;
+    }
+  }
+
+  .bio {
+    font-size: 13px;
+    color: #555;
+    line-height: 1.5;
+    margin: 10px 0;
+    word-break: break-word;
+  }
+
+  .contact {
+    font-size: 12px;
+    color: #4a6cf7;
+    margin: 4px 0 12px;
+  }
+}
+
+.tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  justify-content: center;
+  margin-top: 8px;
+
+  .tag {
+    padding: 4px 12px;
+    background: #f0f4ff;
+    color: #4a6cf7;
+    border-radius: 16px;
+    font-size: 12px;
+    font-weight: 500;
+    border: 1px solid #d4e0ff;
+  }
+}
+
+// Stats
+.stats-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 0;
+  padding: 16px 20px;
+  border-top: 1px solid #f0f0f0;
+  border-bottom: 1px solid #f0f0f0;
+
+  .stat-item {
+    text-align: center;
+    padding: 10px 0;
+
+    .stat-value {
+      display: block;
+      font-size: 18px;
+      font-weight: 700;
+      color: #1a1a1a;
+    }
+
+    .stat-label {
+      display: block;
+      font-size: 12px;
+      color: #999;
+      margin-top: 2px;
+    }
+  }
+}
+
+// Cooperation
+.cooperation-section {
+  padding: 16px 20px;
+
+  .cooperation-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 8px;
+
+    span:first-child {
+      font-size: 14px;
+      color: #555;
+      font-weight: 500;
+    }
+
+    .cooperation-score {
+      font-size: 20px;
+      font-weight: 700;
+      color: #4a6cf7;
+    }
+  }
+
+  .cooperation-bar {
+    height: 8px;
+    background: #e8eaed;
+    border-radius: 4px;
+    overflow: hidden;
+
+    .bar-fill {
+      height: 100%;
+      background: linear-gradient(90deg, #4a6cf7, #6dd5ed);
+      border-radius: 4px;
+      transition: width 0.6s ease;
+    }
+  }
+}
+
+.btn-primary,
+.btn-secondary {
+  display: block;
+  width: calc(100% - 40px);
+  margin: 8px 20px;
+  padding: 12px;
+  border: none;
+  border-radius: 10px;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s;
+  text-align: center;
+}
+
+.btn-primary {
+  background: #4a6cf7;
+  color: #fff;
+
+  &:hover {
+    background: #3a5ce5;
+  }
+}
+
+.btn-secondary {
+  background: #f0f4ff;
+  color: #4a6cf7;
+  margin-bottom: 20px;
+
+  &:hover {
+    background: #e0e8ff;
+  }
+}
+
+// Posts Section
+.posts-section {
+  flex: 1;
+  min-width: 0;
+}
+
+.posts-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+
+  h2 {
+    font-size: 22px;
+    font-weight: 700;
+    color: #1a1a1a;
+    margin: 0;
+  }
+
+  .sort-tabs {
+    display: flex;
+    background: #f0f0f0;
+    border-radius: 8px;
+    overflow: hidden;
+
+    .sort-tab {
+      padding: 8px 20px;
+      border: none;
+      background: transparent;
+      font-size: 14px;
+      cursor: pointer;
+      color: #666;
+      transition: all 0.2s;
+
+      &.active {
+        background: #fff;
+        color: #1a1a1a;
+        font-weight: 600;
+        box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+      }
+    }
+  }
+}
+
+.posts-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 20px;
+
+  @media (max-width: 1400px) {
+    grid-template-columns: repeat(3, 1fr);
+  }
+
+  @media (max-width: 1000px) {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+
+.post-card {
+  background: #fff;
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  transition: transform 0.2s, box-shadow 0.2s;
+  cursor: pointer;
+
+  &:hover {
+    transform: translateY(-4px);
+    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
+  }
+}
+
+.post-cover {
+  position: relative;
+  width: 100%;
+  padding-top: 125%;
+  background: #f0f0f0;
+  overflow: hidden;
+
+  .cover-img {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  .cover-placeholder {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: linear-gradient(135deg, #e8eaed, #d0d4da);
+    font-size: 40px;
+    color: #999;
+  }
+
+  .play-count {
+    position: absolute;
+    bottom: 10px;
+    left: 10px;
+    background: rgba(0, 0, 0, 0.6);
+    color: #fff;
+    padding: 4px 10px;
+    border-radius: 6px;
+    font-size: 13px;
+    font-weight: 500;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+
+    .play-icon {
+      font-size: 10px;
+    }
+  }
+}
+
+.post-info {
+  padding: 12px 14px;
+
+  .post-desc {
+    font-size: 13px;
+    color: #333;
+    line-height: 1.4;
+    margin: 0 0 8px;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    min-height: 36px;
+  }
+
+  .post-meta {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+
+    .post-date {
+      font-size: 12px;
+      color: #999;
+    }
+
+    .post-stats {
+      display: flex;
+      gap: 10px;
+
+      .like,
+      .comment {
+        font-size: 12px;
+        color: #888;
+      }
+
+      .clickable {
+        cursor: pointer;
+        transition: color 0.2s;
+
+        &:hover {
+          color: #4a6cf7;
+        }
+      }
+    }
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 80px 0;
+  color: #999;
+  font-size: 14px;
+}

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