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

feat: add competitor detail workflow

gangvy 1 месяц назад
Родитель
Сommit
b3a58491bb

+ 8 - 8
README.md

@@ -18,6 +18,7 @@
 - `/domestic/product`:单商品经营详情、流量转化、退款表现和商品档案
 - `/domestic/categories`:二级/三级类目经营对比
 - `/domestic/competitors`:竞品关系、映射覆盖率、搜索和本品跳转
+- `/domestic/competitors/:productId`:竞品实图、商品档案、关联本品、评分分布、评论证据趋势和评论明细
 - `/domestic/voc-overview`:VOC 核心指标、情绪健康度、问题与热词总览
 - `/domestic/category-voc`:类目 VOC 四象限、下钻维度与诊断摘要
 - `/domestic/risk-alerts`:商品风险、场景化投诉与异常激增预警框架
@@ -46,7 +47,7 @@
 3. 查看经营概览、商品经营详情、二级/三级类目分析和竞品关系。
 4. 按商品 ID、型号或名称搜索并选择商品,进入复用原系统布局的商品 VOC 详情。
 5. 在商品 VOC 详情、经营详情、竞品关系和深度洞察页面之间保持商品上下文。
-6. 对当前真实的 0 条评论展示空状态,不伪造情绪、痛点或分析结果
+6. 自有商品仍按真实的 0 条评论展示空状态;竞品详情使用已入库的 355 条评论样本,不伪造情绪、痛点或价格销量趋势
 7. 查看 VOC 趋势总览、类目 VOC 和风险预警的完整页面结构与真实空状态。
 8. 查看退款汇总、高退款商品和等待订单级原因的归因/关联空态。
 9. 查看趋势、需求、竞品、产品定义和生命周期五个新品开发环节。
@@ -59,7 +60,7 @@
 - 经营商品:2,817 个
 - 竞品关系:40 条
 - 唯一竞品商品:37 个
-- 评论正文:0 条
+- 评论正文:355 条竞品首批样本;自有商品评论 0 条
 - 指标日期:2026-07-14 至 2026-07-20
 
 源文件:
@@ -70,7 +71,7 @@ E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\2026-07\德玛仕产品及竟
 
 规范化结果位于 `src/assets/data/demashi-summary.json`。
 
-当前真实数据链为 `德玛仕 Excel -> Parse REST -> saas-voc-server -> 前端`。Parse 中已保存 2,817 个自有商品、37 个竞品商品、9,717 条日指标和 40 条竞品关系;37 个竞品商品详情均已通过公司电商中转完成回填。评论正文仍为真实的 0 条,因此 VOC 评论分析继续展示可解释空状态。
+当前真实数据链为 `德玛仕 Excel -> Parse REST -> saas-voc-server -> 前端`。Parse 中已保存 2,817 个自有商品、37 个竞品商品、9,717 条日指标、40 条竞品关系和 355 条竞品评论;37 个竞品商品详情及首批评论均已通过公司电商中转完成回填。自有商品评论仍为真实的 0 条,对应 VOC 页面继续展示可解释空状态。
 
 ## 复用策略
 
@@ -144,17 +145,16 @@ npm run import:demashi -- "E:\xwechat_files\wxid_ay08t6ugo2h922_df4f\msg\file\20
 - `npm run build`:通过
 - `npm test`:8 项 Jasmine 测试通过
 - `npm run verify:data`:通过
-- 桌面端:29 个启用入口的页面身份、主内容、导航、空状态和交互通过
-- 移动端:29 个启用入口无页面级横向溢出,导航抽屉展开/收起、标题和控件布局通过
+- 桌面端:30 个启用入口的页面身份、主内容、导航、空状态和交互通过
+- 移动端:30 个启用入口无页面级横向溢出,导航抽屉展开/收起、标题和控件布局通过
 - 浏览器控制台:启用入口无 Angular 运行时错误、警告或失败请求
-- 运行时请求:当前激活闭环请求同源 `/api/domestic-voc`,本地后端返回 2,817 个商品、9,717 条日指标和 40 条竞品关系,没有访问旧 Parse 或旧跨境域名
+- 运行时请求:当前激活闭环请求同源 `/api/domestic-voc`,本地后端返回 2,817 个自有商品、37 个竞品、9,717 条日指标、40 条竞品关系和 355 条竞品评论,没有访问旧跨境域名
 - `npm audit --omit=dev`:13 项原框架依赖公告(0 critical、10 high、3 moderate),正式公网部署前必须升级 Angular、ECharts 并替换无修复版本的 `xlsx`
 
 ## 尚未完成
 
 - 创建独立 PostgreSQL 数据库和独立 Parse 应用
-- 获取至少一页非空京东评论样本,完成评论字段契约确认
-- 将商品、日指标、竞品关系和评论幂等写入新 Parse 应用
+- 接通当前价格接口并开始积累价格/销量历史快照;当前价格接口 TLS 仍不可用
 - 将 `/api/domestic-voc` 从本地快照实现切换为新 PostgreSQL / Parse 持久化实现
 - 部署独立环境并完成端到端验收
 - 升级原框架的 Angular 17、ECharts、Parse 依赖基线,并替换无修复版本的 `xlsx`

+ 14 - 12
TASKS.md

@@ -34,28 +34,30 @@
 - [x] 完成构建、单测和数据完整性校验
 - [x] 将前端默认数据模式切换为国内 VOC 后端 API,静态案例仅保留为显式回退
 - [x] 从 Parse 快照读取 2,817 个自有商品和 37 个竞品商品,并展示真实竞品标题、型号及入库状态
+- [x] 拉取 37 个竞品的首批真实评论并幂等保存 355 条脱敏评论证据
+- [x] 复用原详情框架与共享组件,增加竞品实图、档案、关联本品、评分分布、评论趋势和评论明细页
 
 ## 周六前 P0
 
 - [ ] 创建全新的 PostgreSQL 数据库
 - [ ] 创建独立 Parse 应用,配置独立 `appId`、server URL 和 master key
-- [ ] 在服务端配置 Fmode API Key,不把 Key 下发到浏览器
+- [x] 在服务端以进程环境配置 Fmode API Key,不把 Key 下发到浏览器或 Git
 - [ ] 恢复或确认 Fmode 生产 TLS 入口可用
-- [ ] 用德玛仕京东商品 `11266507445` 验证商品详情接口
-- [ ] 用同一商品验证评论列表接口和分页字段
-- [ ] 固化公司电商数据中转响应到国内领域模型的字段映射
-- [ ] 实现商品、日指标、竞品关系和评论的幂等导入
+- [x] 用德玛仕京东商品 `11266507445` 验证商品详情接口
+- [x] 用真实竞品验证评论列表接口、分页字段、评分和评论日期映射
+- [x] 固化公司电商数据中转响应到国内领域模型的商品、档案与评论字段映射
+- [x] 实现商品、日指标、竞品关系和评论的幂等导入
 - [ ] 将前端读取切到独立 Parse 应用,并保留明确的本地案例模式
 - [ ] 部署独立前后端环境并跑一次端到端验收
 
 ## P0 验收标准
 
 - [ ] 新环境与原跨境数据库完全隔离
-- [ ] 输入一个国内平台商品 ID 后能获取并保存商品与评论
-- [ ] 选择商品后 VOC 页面展示真实评论统计,或显示可解释的真实空状态
-- [ ] 失败请求不写入脏数据,不伪造分析结果
-- [ ] 浏览器和 Git 中不存在 Fmode、供应商、Parse master key 或数据库密码
-- [ ] `npm run build`、`npm test`、`npm run verify:data` 全部通过
+- [x] 输入一个国内平台商品 ID 后能获取并保存商品与评论
+- [x] 选择竞品后详情页展示真实评论统计;自有商品无评论时显示可解释空状态
+- [x] 失败请求不写入脏数据,不伪造分析结果
+- [x] 浏览器和 Git 中不存在 Fmode、供应商、Parse master key 或数据库密码
+- [x] `npm run build`、`npm test`、`npm run verify:data` 全部通过
 
 ## 后续 P1
 
@@ -68,6 +70,6 @@
 
 ## 当前外部阻塞
 
-- Fmode 生产入口 TLS 握手在本机测试时被连接端重置,尚未完成真实公司中转字段验证
-- 德玛仕 Excel 不包含评论正文,当前评论样本数为 0
+- 德玛仕 Excel 不包含评论正文;当前 355 条评论均来自 37 个映射竞品的首批中转采样,自有商品评论仍为 0
+- 当前价格接口 TLS 连接失败,尚未形成价格或销量历史快照
 - 独立 PostgreSQL / Parse 的连接信息尚未提供或创建。

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

@@ -30,6 +30,11 @@ export const routes: Routes = [
         title: '品类分析',
         loadComponent: () => import('../modules/domestic-voc/category-analysis.component').then((m) => m.DomesticCategoryAnalysisComponent),
       },
+      {
+        path: 'competitors/:productId',
+        title: '竞品详情',
+        loadComponent: () => import('../modules/domestic-voc/competitor-detail.component').then((m) => m.DomesticCompetitorDetailComponent),
+      },
       {
         path: 'competitors',
         title: '竞品关系',

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

@@ -24,6 +24,28 @@ export interface DomesticDailyMetric extends DomesticMetricSummary {
   date: string;
 }
 
+export interface DomesticProductProfile {
+  imageUrl: string;
+  images: string[];
+  color: string;
+  specification: string;
+  origin: string;
+  weightKg: string;
+  dimensionsMm: {
+    length: string;
+    width: string;
+    height: string;
+  };
+  saleDate: string;
+  shopId: string;
+  sellerId: string;
+  skuStatus: string;
+  jdExpress: boolean;
+  localDelivery: boolean;
+  sellPoint: string;
+  collectedAt: string;
+}
+
 export interface DomesticProduct {
   platform: string;
   productId: string;
@@ -40,6 +62,7 @@ export interface DomesticProduct {
   relationCount: number;
   summary: DomesticMetricSummary;
   trend: Array<{ date: string; gmv: number; soldUnits: number; transactionOrders: number }>;
+  detail?: DomesticProductProfile;
 }
 
 export interface DomesticReview {

+ 6 - 0
src/app/core/services/domestic-analytics.service.ts

@@ -30,6 +30,7 @@ export interface DomesticCompetitorRelationRow extends DomesticProductRelation {
   competitorModel: string;
   competitorTitle: string;
   competitorProductAvailable: boolean;
+  competitorReviewCount: number;
 }
 
 export interface DomesticOverview {
@@ -149,6 +150,10 @@ export class DomesticAnalyticsService {
   readonly competitorRelations$ = this.dataset.dataset$.pipe(
     map((dataset): DomesticCompetitorRelationRow[] => {
       const products = new Map(dataset.products.map((product) => [product.productId, product]));
+      const reviewCounts = (dataset.reviews ?? []).reduce((counts, review) => {
+        counts.set(review.productId, (counts.get(review.productId) ?? 0) + 1);
+        return counts;
+      }, new Map<string, number>());
       const mappingGroups = new Map(dataset.mappingGroups.map((group) => [group.ownProductId, group]));
       return dataset.relations.map((relation) => {
         const ownProduct = products.get(relation.ownProductId);
@@ -164,6 +169,7 @@ export class DomesticAnalyticsService {
           competitorModel: competitorProduct?.model || relation.competitorProductId,
           competitorTitle: competitorProduct?.title || relation.competitorBrand || relation.competitorProductId,
           competitorProductAvailable: !!competitorProduct && competitorProduct.source === 'fmode_gateway' && Boolean(competitorProduct.title),
+          competitorReviewCount: reviewCounts.get(relation.competitorProductId) ?? 0,
         };
       });
     }),

+ 34 - 9
src/app/core/services/domestic-dataset.service.ts

@@ -43,28 +43,37 @@ export class DomesticDatasetService {
   }
 
   toProductInfo(product: DomesticProduct, dataset: DomesticDataset): ProductInfo {
+    const reviews = (dataset.reviews ?? []).filter((review) => review.productId === product.productId);
+    const ratedReviews = reviews.filter((review) => review.rating > 0);
+    const positive = ratedReviews.filter((review) => review.rating >= 4).length;
+    const neutral = ratedReviews.filter((review) => review.rating === 3).length;
+    const negative = ratedReviews.filter((review) => review.rating <= 2).length;
+    const averageRating = ratedReviews.length
+      ? ratedReviews.reduce((total, review) => total + review.rating, 0) / ratedReviews.length
+      : 0;
+    const launchDate = new Date(product.detail?.saleDate || dataset.source.dateRange.start || dataset.generatedAt);
     return {
       id: product.productKey,
       title: product.title,
-      photo: 'assets/images/placeholder.svg',
+      photo: product.detail?.imageUrl || 'assets/images/placeholder.svg',
       price: product.summary.averageUnitPrice,
       sales: product.summary.soldUnits,
       asin: product.productId,
-      launchDate: new Date(dataset.source.dateRange.start),
+      launchDate: Number.isNaN(launchDate.getTime()) ? new Date(dataset.generatedAt) : launchDate,
       category: product.category3 || product.category2 || product.category1,
       store: '京东',
       vocSummary: {
-        totalReviews: 0,
-        averageRating: 0,
-        positiveRate: 0,
-        negativeRate: 0,
-        neutralRate: 0,
+        totalReviews: reviews.length,
+        averageRating,
+        positiveRate: ratedReviews.length ? positive / ratedReviews.length * 100 : 0,
+        negativeRate: ratedReviews.length ? negative / ratedReviews.length * 100 : 0,
+        neutralRate: ratedReviews.length ? neutral / ratedReviews.length * 100 : 0,
       },
       competitorComparison: { rating: 0, price: 0, sales: 0 },
       analysisStatus: {
-        dataCompleteness: dataset.summary.reviewCount > 0 ? 100 : 35,
+        dataCompleteness: this.dataCompleteness(product, reviews.length),
         aiProgress: 0,
-        lastUpdate: new Date(dataset.generatedAt),
+        lastUpdate: new Date(product.detail?.collectedAt || dataset.generatedAt),
       },
       productSku: product.model || product.productId,
       sellerSku: product.productId,
@@ -74,4 +83,20 @@ export class DomesticDatasetService {
       salesDailyTrend: product.trend.map((item) => ({ date: item.date, value: item.soldUnits })),
     };
   }
+
+  private dataCompleteness(product: DomesticProduct, reviewCount: number): number {
+    const fields = [
+      product.title,
+      product.brand,
+      product.model,
+      product.category3 || product.category2 || product.category1,
+      product.detail?.imageUrl,
+      product.detail?.origin,
+      product.detail?.weightKg,
+      product.detail?.saleDate,
+      product.detail?.shopId,
+      reviewCount > 0 ? String(reviewCount) : '',
+    ];
+    return Math.round(fields.filter(Boolean).length / fields.length * 100);
+  }
 }

+ 194 - 0
src/modules/domestic-voc/competitor-detail.component.scss

@@ -0,0 +1,194 @@
+:host {
+  display: block;
+  min-width: 0;
+}
+
+.competitor-hero {
+  display: grid;
+  grid-template-columns: 220px minmax(0, 1fr);
+  gap: 28px;
+  align-items: center;
+  min-height: 250px;
+  padding: 24px 0;
+  border-top: 1px solid #e6eaf0;
+  border-bottom: 1px solid #e6eaf0;
+  background: #fff;
+}
+
+.competitor-media {
+  display: grid;
+  place-items: center;
+  width: 220px;
+  height: 220px;
+  overflow: hidden;
+  border: 1px solid #e6eaf0;
+  border-radius: 6px;
+  background: #f8fafc;
+}
+
+.competitor-media img {
+  display: block;
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+
+.competitor-hero-copy {
+  min-width: 0;
+}
+
+.competitor-kicker {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.competitor-kicker > span:first-child {
+  color: #1d4ed8;
+}
+
+.status-dot {
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: #16a34a;
+}
+
+.competitor-hero h2 {
+  max-width: 900px;
+  margin: 12px 0 8px;
+  color: #111827;
+  font-size: 24px;
+  line-height: 1.35;
+  letter-spacing: 0;
+}
+
+.competitor-hero p {
+  max-width: 860px;
+  margin: 0;
+  color: #64748b;
+  font-size: 13px;
+  line-height: 1.7;
+}
+
+.competitor-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px 18px;
+  margin-top: 20px;
+}
+
+.competitor-meta span {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.competitor-meta strong {
+  margin-left: 4px;
+  color: #1f2937;
+}
+
+.rating-list {
+  display: grid;
+  gap: 14px;
+}
+
+.rating-row {
+  display: grid;
+  grid-template-columns: 42px minmax(80px, 1fr) 28px;
+  gap: 10px;
+  align-items: center;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.rating-track {
+  height: 8px;
+  overflow: hidden;
+  border-radius: 4px;
+  background: #eef2f7;
+}
+
+.rating-fill {
+  display: block;
+  height: 100%;
+  border-radius: inherit;
+  background: #d69e2e;
+}
+
+.rating-row strong {
+  color: #334155;
+  text-align: right;
+}
+
+.review-table {
+  min-width: 700px;
+}
+
+.review-table .review-content {
+  min-width: 360px;
+  max-width: 720px;
+  padding-top: 12px;
+  padding-bottom: 12px;
+  line-height: 1.6;
+  white-space: normal;
+}
+
+.review-rating {
+  color: #a16207;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.boundary-list {
+  display: grid;
+  gap: 10px;
+  margin: 0;
+  padding-left: 18px;
+  color: #475569;
+  font-size: 13px;
+  line-height: 1.65;
+}
+
+.boundary-list strong {
+  color: #1f2937;
+}
+
+@media (max-width: 720px) {
+  .competitor-hero {
+    grid-template-columns: 96px minmax(0, 1fr);
+    gap: 14px;
+    min-height: 0;
+    padding: 18px 0;
+  }
+
+  .competitor-media {
+    width: 96px;
+    height: 96px;
+  }
+
+  .competitor-hero h2 {
+    display: -webkit-box;
+    margin-top: 8px;
+    overflow: hidden;
+    font-size: 16px;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 3;
+  }
+
+  .competitor-hero p {
+    display: none;
+  }
+
+  .competitor-meta {
+    grid-column: 1 / -1;
+    margin-top: 12px;
+  }
+
+  .competitor-meta span {
+    flex: 1 1 140px;
+  }
+}

+ 331 - 0
src/modules/domestic-voc/competitor-detail.component.ts

@@ -0,0 +1,331 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import type { EChartsOption } from 'echarts';
+import { NgxEchartsModule } from 'ngx-echarts';
+import { Subject, takeUntil } from 'rxjs';
+import { DomesticDataset, DomesticProduct, DomesticReview } from '../../app/core/models/domestic.models';
+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 { EmptyStateComponent } from '../shared/components/empty-state/empty-state.component';
+import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
+import { NeuButtonComponent } from '../shared/components/neu-button/neu-button.component';
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
+import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
+import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
+
+interface RatingRow {
+  rating: number;
+  count: number;
+  width: number;
+}
+
+@Component({
+  selector: 'app-domestic-competitor-detail',
+  standalone: true,
+  imports: [
+    CommonModule,
+    NgxEchartsModule,
+    PageHeaderComponent,
+    SummaryMetricCardComponent,
+    ContentCardComponent,
+    DataTableShellComponent,
+    EmptyStateComponent,
+    LoadingSpinnerComponent,
+    NeuButtonComponent,
+    ProductIdentityComponent,
+  ],
+  template: `
+    <section class="domestic-page competitor-detail-page">
+      <app-page-header
+        title="竞品详情"
+        eyebrow="Competitor Detail"
+        description="复用原竞品详情框架,展示国内平台商品档案、关联本品与真实评论证据。"
+        icon="◇">
+        <app-neu-button pageHeaderActions size="sm" (btnClick)="goBack()">返回竞品关系</app-neu-button>
+        <app-neu-button pageHeaderActions size="sm" color="primary" (btnClick)="goVoc()">查看竞品 VOC</app-neu-button>
+      </app-page-header>
+
+      @if (product && dataset) {
+        <section class="competitor-hero" aria-label="竞品商品信息">
+          <div class="competitor-media">
+            <img [src]="product.detail?.imageUrl || fallbackImage" [alt]="product.title" (error)="useFallback($event)" />
+          </div>
+          <div class="competitor-hero-copy">
+            <div class="competitor-kicker"><span>京东竞品</span><span class="status-dot"></span>{{ statusLabel }}</div>
+            <h2>{{ product.title }}</h2>
+            <p>{{ product.detail?.sellPoint || '商品详情、规格与评论均来自公司电商数据中转。' }}</p>
+            <div class="competitor-meta">
+              <span>品牌 <strong>{{ product.brand || '-' }}</strong></span>
+              <span>型号 <strong>{{ product.model || '-' }}</strong></span>
+              <span>商品ID <strong>{{ product.productId }}</strong></span>
+              <span>类目 <strong>{{ product.category3 || product.category2 || '-' }}</strong></span>
+            </div>
+          </div>
+        </section>
+
+        <div class="metric-grid">
+          <app-summary-metric-card label="已采评论" [value]="number(reviews.length)" icon="□" tone="blue" description="当前已入库真实评论样本"></app-summary-metric-card>
+          <app-summary-metric-card label="平均评分" [value]="averageRating ? averageRating.toFixed(1) : '-'" unit="/ 5" icon="★" tone="green" [sampleSize]="ratedReviews.length"></app-summary-metric-card>
+          <app-summary-metric-card label="正向评论" [value]="percent(positiveRate)" icon="↑" tone="purple" description="按 4-5 星样本计算"></app-summary-metric-card>
+          <app-summary-metric-card label="关联本品" [value]="number(ownProducts.length)" icon="⇄" tone="amber" [description]="number(relations.length) + ' 条映射关系'"></app-summary-metric-card>
+        </div>
+
+        <div class="detail-grid">
+          <app-content-card title="商品档案" subtitle="商品身份、类目与规格字段" tone="blue">
+            <dl class="info-list">
+              <div><dt>品牌 / 型号</dt><dd>{{ product.brand || '-' }} / {{ product.model || '-' }}</dd></div>
+              <div><dt>颜色规格</dt><dd>{{ product.detail?.color || product.detail?.specification || '-' }}</dd></div>
+              <div><dt>商品产地</dt><dd>{{ product.detail?.origin || '-' }}</dd></div>
+              <div><dt>重量</dt><dd>{{ weightLabel }}</dd></div>
+              <div><dt>包装尺寸</dt><dd>{{ dimensionsLabel }}</dd></div>
+              <div><dt>上架时间</dt><dd>{{ date(product.detail?.saleDate) }}</dd></div>
+            </dl>
+          </app-content-card>
+          <app-content-card title="店铺与履约" subtitle="平台商家和配送能力快照" tone="green">
+            <dl class="info-list">
+              <div><dt>店铺 ID</dt><dd>{{ product.detail?.shopId || '-' }}</dd></div>
+              <div><dt>商家 ID</dt><dd>{{ product.detail?.sellerId || '-' }}</dd></div>
+              <div><dt>商品状态</dt><dd>{{ statusLabel }}</dd></div>
+              <div><dt>京东配送</dt><dd>{{ product.detail?.jdExpress ? '支持' : '未标记' }}</dd></div>
+              <div><dt>即时零售</dt><dd>{{ product.detail?.localDelivery ? '支持' : '未标记' }}</dd></div>
+              <div><dt>详情采集</dt><dd>{{ dateTime(product.detail?.collectedAt) }}</dd></div>
+            </dl>
+          </app-content-card>
+        </div>
+
+        <div class="detail-grid">
+          <app-content-card title="评论证据趋势" subtitle="按评论日期聚合当前样本的平均评分和评论数" tone="purple">
+            @if (hasReviewTrend) {
+              <div echarts class="chart" [options]="reviewTrendOptions"></div>
+            } @else {
+              <app-empty-state size="sm" variant="plain" icon="⌁" title="暂无可绘制的评论趋势" description="评论日期不足时不生成推断曲线。"></app-empty-state>
+            }
+          </app-content-card>
+          <app-content-card title="评分分布" subtitle="仅统计已采评论样本" tone="amber">
+            @if (ratedReviews.length) {
+              <div class="rating-list">
+                @for (row of ratingRows; track row.rating) {
+                  <div class="rating-row">
+                    <span>{{ row.rating }} 星</span>
+                    <span class="rating-track"><span class="rating-fill" [style.width.%]="row.width"></span></span>
+                    <strong>{{ row.count }}</strong>
+                  </div>
+                }
+              </div>
+            } @else {
+              <app-empty-state size="sm" variant="plain" title="暂无评分样本"></app-empty-state>
+            }
+          </app-content-card>
+        </div>
+
+        <app-data-table-shell title="关联本品" subtitle="Excel 映射中与当前竞品对应的德玛仕商品" [count]="ownProducts.length" variant="admin" [empty]="ownProducts.length === 0" emptyTitle="暂无关联本品">
+          <table class="analytics-table">
+            <thead><tr><th>德玛仕商品</th><th>映射类目</th><th class="numeric">成交金额</th><th class="numeric">成交件数</th></tr></thead>
+            <tbody>
+              @for (own of ownProducts; track own.productId) {
+                <tr>
+                  <td>
+                    <app-product-identity
+                      [showImage]="false"
+                      [title]="own.title"
+                      [productSku]="own.model"
+                      [asin]="own.productId"
+                      size="xs"
+                      [detailRoute]="['/domestic/product']"
+                      [detailQueryParams]="{ productId: own.productId }">
+                    </app-product-identity>
+                  </td>
+                  <td>{{ relationCategory(own.productId) }}</td>
+                  <td class="numeric">{{ currency(own.summary.gmv) }}</td>
+                  <td class="numeric">{{ number(own.summary.soldUnits) }}</td>
+                </tr>
+              }
+            </tbody>
+          </table>
+        </app-data-table-shell>
+
+        <app-data-table-shell title="评论证据" subtitle="首批评论样本;不展示用户昵称、头像或其他身份字段" [count]="reviews.length" variant="admin" [empty]="reviews.length === 0" emptyTitle="暂无评论证据" emptyDescription="数据源返回评论后会在此展示。">
+          <table class="analytics-table review-table">
+            <thead><tr><th>评分</th><th>评论内容</th><th>评论时间</th></tr></thead>
+            <tbody>
+              @for (review of reviews; track review.reviewId) {
+                <tr>
+                  <td><span class="review-rating">{{ review.rating > 0 ? review.rating + ' 星' : '未评分' }}</span></td>
+                  <td class="review-content">{{ review.content }}</td>
+                  <td>{{ date(review.reviewDate) }}</td>
+                </tr>
+              }
+            </tbody>
+          </table>
+        </app-data-table-shell>
+
+        <app-content-card title="趋势与数据边界" subtitle="只展示接口已提供并已落库的数据" tone="amber" variant="dashed">
+          <ul class="boundary-list">
+            <li><strong>已有:</strong>商品档案、图片、规格、店铺履约、关联本品和首批评论。</li>
+            <li><strong>可计算:</strong>评论样本平均分、评分分布和按评论日期聚合的证据趋势。</li>
+            <li><strong>暂缺:</strong>价格接口当前不可用,销量与价格历史序列尚未入库,因此不展示价格或销量趋势。</li>
+          </ul>
+        </app-content-card>
+      } @else if (loaded) {
+        <app-empty-state variant="card" icon="◇" title="未找到竞品" description="该商品可能尚未完成竞品详情采集。" actionText="返回竞品关系" (action)="goBack()"></app-empty-state>
+      } @else {
+        <app-loading-spinner variant="card" tone="blue" text="正在加载竞品详情"></app-loading-spinner>
+      }
+    </section>
+  `,
+  styleUrls: ['./domestic-page.shared.scss', './competitor-detail.component.scss'],
+})
+export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
+  private readonly datasetService = inject(DomesticDatasetService);
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly cdr = inject(ChangeDetectorRef);
+  private readonly destroy$ = new Subject<void>();
+
+  readonly fallbackImage = 'assets/images/placeholder.svg';
+  dataset?: DomesticDataset;
+  product?: DomesticProduct;
+  reviews: DomesticReview[] = [];
+  ratedReviews: DomesticReview[] = [];
+  relations: DomesticDataset['relations'] = [];
+  ownProducts: DomesticProduct[] = [];
+  ratingRows: RatingRow[] = [];
+  reviewTrendOptions: EChartsOption = {};
+  averageRating = 0;
+  positiveRate = 0;
+  hasReviewTrend = false;
+  loaded = false;
+
+  ngOnInit(): void {
+    this.datasetService.dataset$.pipe(takeUntil(this.destroy$)).subscribe((dataset) => {
+      this.dataset = dataset;
+      const productId = this.route.snapshot.paramMap.get('productId') || '';
+      this.product = dataset.products.find((product) => product.role === 'competitor' && product.productId === productId);
+      this.loaded = true;
+      if (!this.product) {
+        this.cdr.markForCheck();
+        return;
+      }
+      this.relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
+      const ownIds = new Set(this.relations.map((relation) => relation.ownProductId));
+      this.ownProducts = dataset.products.filter((product) => product.role === 'own' && ownIds.has(product.productId));
+      this.reviews = (dataset.reviews ?? [])
+        .filter((review) => review.productId === productId)
+        .sort((left, right) => String(right.reviewDate ?? '').localeCompare(String(left.reviewDate ?? '')));
+      this.ratedReviews = this.reviews.filter((review) => review.rating > 0);
+      this.averageRating = this.ratedReviews.length
+        ? this.ratedReviews.reduce((total, review) => total + review.rating, 0) / this.ratedReviews.length
+        : 0;
+      this.positiveRate = this.ratedReviews.length
+        ? this.ratedReviews.filter((review) => review.rating >= 4).length / this.ratedReviews.length
+        : 0;
+      this.ratingRows = this.buildRatingRows();
+      this.reviewTrendOptions = this.buildReviewTrend();
+      this.cdr.markForCheck();
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  get statusLabel(): string {
+    return this.product?.detail?.skuStatus === '1' ? '在售' : this.product?.detail?.skuStatus ? `状态 ${this.product.detail.skuStatus}` : '状态待确认';
+  }
+
+  get weightLabel(): string {
+    return this.product?.detail?.weightKg ? `${this.product.detail.weightKg} kg` : '-';
+  }
+
+  get dimensionsLabel(): string {
+    const dimensions = this.product?.detail?.dimensionsMm;
+    if (!dimensions || !dimensions.length || !dimensions.width || !dimensions.height) return '-';
+    return `${dimensions.length} × ${dimensions.width} × ${dimensions.height} mm`;
+  }
+
+  goBack(): void {
+    this.router.navigate(['/domestic/competitors']);
+  }
+
+  goVoc(): void {
+    this.router.navigate(['/voc-insight/detail'], {
+      queryParams: { productId: this.product?.productId || null },
+    });
+  }
+
+  useFallback(event: Event): void {
+    const image = event.target as HTMLImageElement;
+    if (!image.src.endsWith(this.fallbackImage)) image.src = this.fallbackImage;
+  }
+
+  relationCategory(productId: string): string {
+    return this.relations.find((relation) => relation.ownProductId === productId)?.category || '-';
+  }
+
+  number(value: number): string {
+    return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
+  }
+
+  currency(value: number): string {
+    return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
+  }
+
+  percent(value: number): string {
+    return value ? `${(value * 100).toFixed(1)}%` : '-';
+  }
+
+  date(value?: string): string {
+    if (!value) return '-';
+    const parsed = new Date(value);
+    return Number.isNaN(parsed.getTime()) ? value.slice(0, 10) : parsed.toLocaleDateString('zh-CN');
+  }
+
+  dateTime(value?: string): string {
+    if (!value) return '-';
+    const parsed = new Date(value);
+    return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
+  }
+
+  private buildRatingRows(): RatingRow[] {
+    const counts = new Map<number, number>();
+    for (let rating = 1; rating <= 5; rating += 1) counts.set(rating, 0);
+    for (const review of this.ratedReviews) {
+      const rating = Math.max(1, Math.min(5, Math.round(review.rating)));
+      counts.set(rating, (counts.get(rating) ?? 0) + 1);
+    }
+    const max = Math.max(...counts.values(), 1);
+    return [5, 4, 3, 2, 1].map((rating) => ({ rating, count: counts.get(rating) ?? 0, width: (counts.get(rating) ?? 0) / max * 100 }));
+  }
+
+  private buildReviewTrend(): EChartsOption {
+    const daily = new Map<string, { total: number; count: number }>();
+    for (const review of this.ratedReviews) {
+      if (!review.reviewDate) continue;
+      const date = review.reviewDate.slice(0, 10);
+      const value = daily.get(date) ?? { total: 0, count: 0 };
+      value.total += review.rating;
+      value.count += 1;
+      daily.set(date, value);
+    }
+    const rows = [...daily.entries()].sort(([left], [right]) => left.localeCompare(right));
+    this.hasReviewTrend = rows.length > 0;
+    return {
+      color: ['#4f6bed', '#38a169'],
+      tooltip: { trigger: 'axis' },
+      legend: { data: ['平均评分', '评论数'], top: 0 },
+      grid: { left: 46, right: 44, top: 48, bottom: 34 },
+      xAxis: { type: 'category', data: rows.map(([date]) => date.slice(5)), axisTick: { show: false } },
+      yAxis: [
+        { type: 'value', min: 0, max: 5, name: '评分', splitLine: { lineStyle: { color: '#edf0f4' } } },
+        { type: 'value', minInterval: 1, name: '评论', splitLine: { show: false } },
+      ],
+      series: [
+        { name: '平均评分', type: 'line', smooth: true, data: rows.map(([, value]) => Number((value.total / value.count).toFixed(2))) },
+        { name: '评论数', type: 'bar', yAxisIndex: 1, barMaxWidth: 18, data: rows.map(([, value]) => value.count) },
+      ],
+    };
+  }
+}

+ 17 - 6
src/modules/domestic-voc/competitor-relations.component.ts

@@ -54,16 +54,22 @@ import { VocInsightStateService } from '../shared/services/voc-insight-state.ser
                   </td>
                   <td>{{ row.category }}</td>
                   <td>
-                    <div class="competitor-cell">
-                      <strong>{{ row.competitorBrand || row.competitorProduct?.brand || '未知品牌' }}</strong>
-                      <span>{{ row.competitorTitle }}</span>
-                      <small>{{ row.competitorModel }}</small>
-                    </div>
+                    <app-product-identity
+                      [imageUrl]="row.competitorProduct?.detail?.imageUrl || ''"
+                      [title]="row.competitorTitle"
+                      [productSku]="row.competitorModel"
+                      [asin]="row.competitorProductId"
+                      [subtitle]="row.competitorBrand || row.competitorProduct?.brand || '未知品牌'"
+                      [clickable]="row.competitorProductAvailable"
+                      [muted]="!row.competitorProductAvailable"
+                      size="xs"
+                      (identityClick)="openCompetitor(row)">
+                    </app-product-identity>
                   </td>
                   <td>{{ row.competitorProductId }}</td>
                   <td>
                     <span class="status-pill" [class.success]="row.competitorProductAvailable">
-                      {{ row.competitorProductAvailable ? '商品详情已入库' : '待采集商品详情' }}
+                      {{ row.competitorProductAvailable ? (row.competitorReviewCount > 0 ? '详情 · ' + row.competitorReviewCount + ' 条评论' : '商品详情已入库') : '待采集商品详情' }}
                     </span>
                   </td>
                 </tr>
@@ -140,6 +146,11 @@ export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
     this.router.navigate(['/domestic/product'], { queryParams: { productId: product.productId } });
   }
 
+  openCompetitor(row: DomesticCompetitorRelationRow): void {
+    if (!row.competitorProductAvailable) return;
+    this.router.navigate(['/domestic/competitors', row.competitorProductId]);
+  }
+
   number(value: number): string {
     return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
   }

+ 0 - 25
src/modules/domestic-voc/domestic-page.shared.scss

@@ -237,31 +237,6 @@
   color: #1d4ed8;
 }
 
-.competitor-cell {
-  display: grid;
-  min-width: 220px;
-  gap: 3px;
-}
-
-.competitor-cell strong {
-  color: #111827;
-  font-size: 12px;
-}
-
-.competitor-cell span {
-  overflow: hidden;
-  max-width: 360px;
-  color: #374151;
-  font-size: 12px;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.competitor-cell small {
-  color: #94a3b8;
-  font-size: 11px;
-}
-
 .dependency-list {
   display: grid;
   gap: 12px;