Эх сурвалжийг харах

feat: improve listing insights and competitor monitoring

Yi Jiarui 3 долоо хоног өмнө
parent
commit
6a877bb954
27 өөрчлөгдсөн 1525 нэмэгдсэн , 59 устгасан
  1. 6 0
      proxy.conf.json
  2. 5 0
      src/app/app.routes.ts
  3. 7 5
      src/modules/ai-voc-insight/analysis-runs/analysis-run-components.spec.ts
  4. 127 0
      src/modules/competitor-listing-monitor/models/competitor-listing.models.ts
  5. 114 0
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.html
  6. 0 0
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.scss
  7. 57 0
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.spec.ts
  8. 103 0
      src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.ts
  9. 40 0
      src/modules/competitor-listing-monitor/services/competitor-listing-api.service.spec.ts
  10. 34 0
      src/modules/competitor-listing-monitor/services/competitor-listing-api.service.ts
  11. 93 0
      src/modules/competitor-listing-monitor/services/competitor-listing-overview.store.spec.ts
  12. 258 0
      src/modules/competitor-listing-monitor/services/competitor-listing-overview.store.ts
  13. 62 19
      src/modules/domestic-voc/competitor-detail.component.ts
  14. 6 4
      src/modules/domestic-voc/operating-overview.component.scss
  15. 82 2
      src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts
  16. 16 5
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.html
  17. 0 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.scss
  18. 12 0
      src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.ts
  19. 154 2
      src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts
  20. 0 0
      src/modules/listing-ai/listing-ai.shared.scss
  21. 1 1
      src/modules/listing-ai/models/listing-ai.models.ts
  22. 121 8
      src/modules/listing-ai/pages/product/listing-ai-product.component.html
  23. 137 3
      src/modules/listing-ai/pages/product/listing-ai-product.component.ts
  24. 82 8
      src/modules/monitoring/pages/category-voc/category-voc.component.ts
  25. 1 0
      src/modules/shared/components/navigation/navigation.component.ts
  26. 6 1
      src/modules/shared/components/summary-metric-card/summary-metric-card.component.scss
  27. 1 1
      src/modules/voc-insight/shared/components/voc-insight-rail/voc-insight-rail.component.scss

+ 6 - 0
proxy.conf.json

@@ -35,6 +35,12 @@
     "changeOrigin": true,
     "logLevel": "debug"
   },
+  "/api/competitor-listings": {
+    "target": "http://127.0.0.1:4400",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
+  },
   "/parse": {
     "target": "http://127.0.0.1:4400",
     "secure": false,

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

@@ -35,6 +35,11 @@ export const routes: Routes = [
         title: '竞品详情',
         loadComponent: () => import('../modules/domestic-voc/competitor-detail.component').then((m) => m.DomesticCompetitorDetailComponent),
       },
+      {
+        path: 'competitor-listings',
+        title: '竞品 Listing 监控',
+        loadComponent: () => import('../modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component').then((m) => m.CompetitorListingOverviewComponent),
+      },
       {
         path: 'competitors',
         title: '竞品关系',

+ 7 - 5
src/modules/ai-voc-insight/analysis-runs/analysis-run-components.spec.ts

@@ -1,5 +1,5 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { Router } from '@angular/router';
+import { provideRouter, Router } from '@angular/router';
 import { of } from 'rxjs';
 import {
   SaasAnalysisRun,
@@ -73,13 +73,12 @@ describe('Analysis run center', () => {
   };
 
   let service: jasmine.SpyObj<SaasPlatformService>;
-  let router: jasmine.SpyObj<Router>;
+  let router: Router;
 
   beforeEach(() => {
     service = jasmine.createSpyObj<SaasPlatformService>('SaasPlatformService', ['analyses', 'insightDecisions']);
     service.analyses.and.returnValue(of({ items: [completedRun], nextCursor: null }));
     service.insightDecisions.and.returnValue(of({ items: [decision], nextCursor: null }));
-    router = jasmine.createSpyObj<Router>('Router', ['navigate']);
   });
 
   it('renders the real run fields and opens the detail view', async () => {
@@ -87,10 +86,13 @@ describe('Analysis run center', () => {
       imports: [AnalysisRunListComponent],
       providers: [
         { provide: SaasPlatformService, useValue: service },
-        { provide: Router, useValue: router },
+        provideRouter([]),
       ],
     }).compileComponents();
 
+    router = TestBed.inject(Router);
+    const navigate = spyOn(router, 'navigate').and.resolveTo(true);
+
     const fixture = TestBed.createComponent(AnalysisRunListComponent);
     fixture.detectChanges();
 
@@ -103,7 +105,7 @@ describe('Analysis run center', () => {
     const detailButton = fixture.nativeElement.querySelector('.row-action') as HTMLButtonElement;
     detailButton.click();
     fixture.detectChanges();
-    expect(router.navigate).toHaveBeenCalledWith(
+    expect(navigate).toHaveBeenCalledWith(
       ['/voc-insight/runs', completedRun.id],
       { queryParamsHandling: 'preserve' },
     );

+ 127 - 0
src/modules/competitor-listing-monitor/models/competitor-listing.models.ts

@@ -0,0 +1,127 @@
+export type CompetitorListingAvailability = 'online' | 'offline' | 'unknown';
+export type CompetitorListingObservedField = 'title' | 'price' | 'availability' | 'main_image' | 'key_specifications';
+export type CompetitorListingMonitorStatus = 'not_initialized' | 'awaiting_comparison' | 'changed' | 'unchanged' | 'failed';
+export type CompetitorListingRunStatus = 'queued' | 'running' | 'completed' | 'partial' | 'failed';
+
+export interface CompetitorListingKeySpecifications {
+  model: string | null;
+  color: string | null;
+  specification: string | null;
+  origin: string | null;
+  weightKg: string | null;
+  lengthMm: string | null;
+  widthMm: string | null;
+  heightMm: string | null;
+}
+
+export interface CompetitorListingSnapshot {
+  id: string;
+  naturalKey: string;
+  workspaceId: string;
+  platform: 'jd';
+  productId: string;
+  previousSnapshotId: string | null;
+  contentHash: string;
+  observedAt: string;
+  title: string | null;
+  priceCents: number | null;
+  currency: 'CNY';
+  availability: CompetitorListingAvailability;
+  mainImageUrl: string | null;
+  keySpecifications: CompetitorListingKeySpecifications;
+  observedFields: CompetitorListingObservedField[];
+  collectionStatus: 'succeeded' | 'partial';
+}
+
+export interface CompetitorListingChange {
+  id: string;
+  naturalKey: string;
+  workspaceId: string;
+  platform: 'jd';
+  productId: string;
+  previousSnapshotId: string;
+  currentSnapshotId: string;
+  detectedAt: string;
+  changeTypes: CompetitorListingObservedField[];
+  changes: Array<{ field: string; before: unknown; after: unknown }>;
+}
+
+export interface CompetitorListingRefreshItemResult {
+  productId: string;
+  status: 'baseline' | 'unchanged' | 'changed' | 'failed';
+  errorCode?: string;
+  observedAt: string;
+}
+
+export interface CompetitorListingRefreshRun {
+  id: string;
+  workspaceId: string;
+  platform: 'jd';
+  trigger: 'manual' | 'scheduled';
+  status: CompetitorListingRunStatus;
+  total: number;
+  completed: number;
+  baseline: number;
+  unchanged: number;
+  changed: number;
+  failed: number;
+  itemResults: CompetitorListingRefreshItemResult[];
+  requestedAt: string;
+  startedAt: string | null;
+  completedAt: string | null;
+}
+
+export interface CompetitorListingOverviewRow {
+  productId: string;
+  platform: 'jd';
+  title: string | null;
+  brand: string | null;
+  mainImageUrl: string | null;
+  category: string | null;
+  relatedProducts: Array<{ productId: string; title: string | null }>;
+  currentSnapshot: CompetitorListingSnapshot | null;
+  latestChange: CompetitorListingChange | null;
+  latestCollectionResult: CompetitorListingRefreshItemResult | null;
+  monitorStatus: CompetitorListingMonitorStatus;
+}
+
+export interface CompetitorListingOverview {
+  summary: {
+    targetTotal: number;
+    baselineTotal: number;
+    comparedTotal: number;
+    changedIn7d: number | null;
+    priceChangedIn7d: number | null;
+    contentChangedIn7d: number | null;
+    latestFailedTotal: number;
+    lastRefreshAt: string | null;
+    lastSuccessfulRefreshAt: string | null;
+    historyStatus: 'not_initialized' | 'baseline_only' | 'comparable';
+  };
+  items: CompetitorListingOverviewRow[];
+  facets: {
+    brands: Array<{ value: string; count: number }>;
+    categories: Array<{ value: string; count: number }>;
+  };
+}
+
+export type CompetitorListingSort = 'updatedAt' | 'price' | 'changePriority';
+export type CompetitorListingDirection = 'asc' | 'desc';
+
+export interface CompetitorListingQuery {
+  search?: string;
+  brand?: string;
+  category?: string;
+  status?: CompetitorListingMonitorStatus;
+  sort?: CompetitorListingSort;
+  direction?: CompetitorListingDirection;
+}
+
+export interface CompetitorListingQueryState {
+  search?: string;
+  brand?: string;
+  category?: string;
+  status?: CompetitorListingMonitorStatus;
+  sort: CompetitorListingSort;
+  direction: CompetitorListingDirection;
+}

+ 114 - 0
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.html

@@ -0,0 +1,114 @@
+<main class="monitor-page" data-testid="competitor-listing-overview">
+  <app-page-header
+    eyebrow="京东竞品监控"
+    title="竞品 Listing 监控"
+    description="查看已映射竞品的当前状态、真实采集结果与最近 7 日 Listing 变化"
+    groupBadge="37 个已映射竞品"
+    groupBadgeTone="blue"
+    [lastUpdated]="store.overview()?.summary?.lastRefreshAt || ''"
+    [sampleSize]="store.overview()?.summary?.targetTotal ?? null"
+  >
+    <div pageHeaderActions class="header-actions">
+      <button class="refresh-button" type="button" (click)="store.refresh()" [disabled]="store.refreshing()">
+        {{ store.refreshing() ? '刷新中…' : '立即刷新' }}
+      </button>
+    </div>
+  </app-page-header>
+
+  @if (store.refreshMessage()) {
+    <div class="run-banner" [class.error]="store.currentRun()?.status === 'failed'" role="status">
+      <span>{{ store.refreshMessage() }}</span>
+      @if (store.currentRun(); as run) {
+        <small>基线 {{ run.baseline }} · 无变化 {{ run.unchanged }} · 有变化 {{ run.changed }} · 失败 {{ run.failed }}</small>
+      }
+    </div>
+  }
+
+  @if (store.error()) {
+    <div class="page-error" role="alert">
+      <span>{{ store.error() }}</span>
+      <button type="button" (click)="store.retry()">重试</button>
+    </div>
+  }
+
+  @if (store.loading() && !store.overview()) {
+    <app-loading-spinner size="lg" variant="card" tone="blue" text="正在加载竞品 Listing 监控数据"></app-loading-spinner>
+  }
+
+  @if (store.overview(); as overview) {
+    @if (overview.summary.historyStatus === 'not_initialized') {
+      <section class="baseline-notice" role="note">
+        <strong>尚未建立监控基线</strong>
+        <span>当前仅展示已有竞品档案。首次成功采集只建立基线,不会生成变化、趋势或告警。</span>
+      </section>
+    } @else if (overview.summary.historyStatus === 'baseline_only') {
+      <section class="baseline-notice ready" role="note">
+        <strong>监控基线已建立</strong>
+        <span>等待下一次有效采集后进行比较;当前不展示虚构变化。</span>
+      </section>
+    }
+
+    <section class="metric-grid" aria-label="竞品 Listing 监控指标">
+      <app-summary-metric-card label="监控竞品数" [value]="overview.summary.targetTotal" description="来自现有映射关系" tone="blue" variant="gradient-soft"></app-summary-metric-card>
+      <app-summary-metric-card label="已建立基线" [value]="overview.summary.baselineTotal" description="有至少一次有效快照" tone="green"></app-summary-metric-card>
+      <app-summary-metric-card label="近 7 日变化" [value]="overview.summary.changedIn7d === null ? '—' : overview.summary.changedIn7d" description="无比较历史时显示 —" tone="purple"></app-summary-metric-card>
+      <app-summary-metric-card label="价格变化" [value]="overview.summary.priceChangedIn7d === null ? '—' : overview.summary.priceChangedIn7d" description="近 7 日真实事件" tone="amber"></app-summary-metric-card>
+      <app-summary-metric-card label="内容变化" [value]="overview.summary.contentChangedIn7d === null ? '—' : overview.summary.contentChangedIn7d" description="标题、主图、规格或状态" tone="blue"></app-summary-metric-card>
+      <app-summary-metric-card label="采集失败" [value]="overview.summary.latestFailedTotal" description="最近一次刷新" [tone]="overview.summary.latestFailedTotal ? 'red' : 'default'"></app-summary-metric-card>
+    </section>
+
+    <app-content-card title="竞品列表" subtitle="37 条以内在前端筛选和排序,不展示模拟数据" [hasHeaderActions]="true">
+      <div contentCardActions class="result-count">当前显示 {{ store.items().length }} / {{ overview.items.length }}</div>
+      <div class="filters" aria-label="竞品筛选">
+        <label class="search-field">
+          <span>搜索</span>
+          <input #searchInput type="search" [value]="store.query().search || ''" placeholder="标题或商品 ID" (keyup.enter)="applyQuery({ search: searchInput.value })" (blur)="applyQuery({ search: searchInput.value })" />
+        </label>
+        <label><span>品牌</span><select [ngModel]="store.query().brand || ''" (ngModelChange)="applyQuery({ brand: $event || undefined })"><option value="">全部品牌</option>@for (facet of overview.facets.brands; track facet.value) {<option [value]="facet.value">{{ facet.value }}({{ facet.count }})</option>}</select></label>
+        <label><span>类目</span><select [ngModel]="store.query().category || ''" (ngModelChange)="applyQuery({ category: $event || undefined })"><option value="">全部类目</option>@for (facet of overview.facets.categories; track facet.value) {<option [value]="facet.value">{{ facet.value }}({{ facet.count }})</option>}</select></label>
+        <label><span>数据状态</span><select [ngModel]="store.query().status || ''" (ngModelChange)="applyQuery({ status: $event || undefined })"><option value="">全部状态</option><option value="not_initialized">尚未建立基线</option><option value="awaiting_comparison">等待比较</option><option value="changed">有变化</option><option value="unchanged">无变化</option><option value="failed">最近采集失败</option></select></label>
+        <label><span>排序</span><select [ngModel]="store.query().sort" (ngModelChange)="applyQuery({ sort: $event })"><option value="updatedAt">更新时间</option><option value="price">当前价格</option><option value="changePriority">变化优先级</option></select></label>
+        <label><span>方向</span><select [ngModel]="store.query().direction" (ngModelChange)="applyQuery({ direction: $event })"><option value="desc">降序</option><option value="asc">升序</option></select></label>
+        <button class="clear-button" type="button" (click)="clearFilters()">清空筛选</button>
+      </div>
+
+      @if (store.loading()) { <div class="loading-strip" role="status">正在更新列表…</div> }
+
+      @if (!overview.items.length) {
+        <app-empty-state variant="card" icon="📡" title="暂无竞品监控目标" description="当前工作区没有可用的竞品映射关系。"></app-empty-state>
+      } @else if (!store.items().length) {
+        <app-empty-state variant="table" title="当前筛选条件下没有竞品" description="请调整搜索或筛选条件。" actionText="清空筛选" (action)="clearFilters()"></app-empty-state>
+      } @else {
+        <div class="desktop-table">
+          <table>
+            <thead><tr><th>竞品</th><th>品牌 / 类目</th><th>关联本品</th><th>当前价格</th><th>在售状态</th><th>最近变化</th><th>数据状态</th><th>操作</th></tr></thead>
+            <tbody>
+              <tr *ngFor="let row of store.items(); trackBy: trackByProductId">
+                <td class="product-cell"><app-product-identity [imageUrl]="row.mainImageUrl || ''" [title]="row.title || '未获取标题'" [asin]="row.productId" identifierLabel="商品ID" size="sm"></app-product-identity></td>
+                <td><strong>{{ row.brand || '—' }}</strong><small>{{ row.category || '未分类' }}</small></td>
+                <td><div class="related-list">@for (related of row.relatedProducts.slice(0, 2); track related.productId) {<span title="{{ related.title || related.productId }}">{{ related.title || related.productId }}</span>}@if (row.relatedProducts.length > 2) {<small>另 {{ row.relatedProducts.length - 2 }} 个</small>}</div></td>
+                <td class="price">{{ price(row) }}</td>
+                <td><span class="availability" [class.online]="row.currentSnapshot?.availability === 'online'" [class.offline]="row.currentSnapshot?.availability === 'offline'">{{ availability(row) }}</span></td>
+                <td><div class="change-cell"><strong>{{ changeSummary(row.latestChange) }}</strong><small>{{ changeEvidence(row.latestChange) }}</small></div></td>
+                <td><div class="status-cell"><span class="status-pill" [attr.data-status]="row.monitorStatus">{{ statusLabel(row.monitorStatus) }}</span><small>{{ updatedAt(row) ? (updatedAt(row) | date:'yyyy-MM-dd HH:mm') : '尚未采集' }}</small></div></td>
+                <td><a class="detail-link" [routerLink]="['/domestic/competitors', row.productId]">查看详情</a></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+
+        <div class="mobile-list">
+          @for (row of store.items(); track row.productId) {
+            <article class="competitor-card">
+              <app-product-identity [imageUrl]="row.mainImageUrl || ''" [title]="row.title || '未获取标题'" [asin]="row.productId" identifierLabel="商品ID" size="md"></app-product-identity>
+              <div class="mobile-meta"><span>{{ row.brand || '品牌未知' }}</span><span>{{ row.category || '未分类' }}</span></div>
+              <div class="mobile-grid"><div><small>当前价格</small><strong>{{ price(row) }}</strong></div><div><small>在售状态</small><strong>{{ availability(row) }}</strong></div><div><small>关联本品</small><strong>{{ row.relatedProducts.length }}</strong></div><div><small>更新时间</small><strong>{{ updatedAt(row) ? (updatedAt(row) | date:'MM-dd HH:mm') : '尚未采集' }}</strong></div></div>
+              <div class="mobile-change"><span class="status-pill" [attr.data-status]="row.monitorStatus">{{ statusLabel(row.monitorStatus) }}</span><p>{{ changeSummary(row.latestChange) }}</p><small>{{ changeEvidence(row.latestChange) }}</small></div>
+              <a class="detail-link" [routerLink]="['/domestic/competitors', row.productId]">查看竞品详情</a>
+            </article>
+          }
+        </div>
+      }
+    </app-content-card>
+  }
+</main>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.scss


+ 57 - 0
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.spec.ts

@@ -0,0 +1,57 @@
+import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { RouterTestingHarness } from '@angular/router/testing';
+import { of, throwError } from 'rxjs';
+import { routes } from '../../../../app/app.routes';
+import type { CompetitorListingOverview } from '../../models/competitor-listing.models';
+import { CompetitorListingApiService } from '../../services/competitor-listing-api.service';
+
+const EMPTY_OVERVIEW: CompetitorListingOverview = {
+  summary: { targetTotal: 37, baselineTotal: 0, comparedTotal: 0, changedIn7d: null, priceChangedIn7d: null, contentChangedIn7d: null, latestFailedTotal: 0, lastRefreshAt: null, lastSuccessfulRefreshAt: null, historyStatus: 'not_initialized' },
+  facets: { brands: [{ value: '品牌 A', count: 1 }], categories: [{ value: '冷柜', count: 1 }] },
+  items: [{ productId: '1001', platform: 'jd', title: '测试竞品冷柜', brand: '品牌 A', mainImageUrl: null, category: '冷柜', relatedProducts: [{ productId: 'own-1', title: '本品冷柜' }], currentSnapshot: null, latestChange: null, latestCollectionResult: null, monitorStatus: 'not_initialized' }],
+};
+
+describe('CompetitorListingOverviewComponent', () => {
+  async function open(response: CompetitorListingOverview = EMPTY_OVERVIEW) {
+    const api = jasmine.createSpyObj<CompetitorListingApiService>('CompetitorListingApiService', ['overview', 'refresh', 'run']);
+    api.overview.and.returnValue(of(response));
+    await TestBed.configureTestingModule({ providers: [provideRouter(routes), { provide: CompetitorListingApiService, useValue: api }] }).compileComponents();
+    const harness = await RouterTestingHarness.create('/domestic/competitor-listings?sort=updatedAt&direction=desc');
+    return { api, harness, element: harness.routeNativeElement as HTMLElement };
+  }
+
+  it('renders the baseline-safe metrics, desktop table and mobile cards without fake changes', async () => {
+    const { element } = await open();
+    const text = element.textContent ?? '';
+    expect(text).toContain('竞品 Listing 监控');
+    expect(text).toContain('尚未建立监控基线');
+    expect(text).toContain('首次成功采集只建立基线');
+    expect(text).toContain('测试竞品冷柜');
+    expect(text).toContain('本品冷柜');
+    expect(text).toContain('暂无真实变化记录');
+    expect(element.querySelector('.desktop-table')).toBeTruthy();
+    expect(element.querySelector('.mobile-list')).toBeTruthy();
+    expect(element.querySelector('a.detail-link')?.getAttribute('href')).toContain('/domestic/competitors/1001');
+    expect(text).not.toContain('模拟趋势');
+  });
+
+  it('shows API errors and retries without losing the page shell', async () => {
+    const api = jasmine.createSpyObj<CompetitorListingApiService>('CompetitorListingApiService', ['overview', 'refresh', 'run']);
+    api.overview.and.returnValues(throwError(() => new Error('offline')), of(EMPTY_OVERVIEW));
+    await TestBed.configureTestingModule({ providers: [provideRouter(routes), { provide: CompetitorListingApiService, useValue: api }] }).compileComponents();
+    const harness = await RouterTestingHarness.create('/domestic/competitor-listings?sort=updatedAt&direction=desc');
+    const element = harness.routeNativeElement as HTMLElement;
+    expect(element.textContent).toContain('加载失败');
+    (element.querySelector('.page-error button') as HTMLButtonElement).click();
+    harness.detectChanges();
+    expect(api.overview).toHaveBeenCalledTimes(2);
+    expect(element.textContent).toContain('测试竞品冷柜');
+  });
+
+  it('renders a true empty target state instead of fabricated rows', async () => {
+    const { element } = await open({ ...EMPTY_OVERVIEW, items: [], summary: { ...EMPTY_OVERVIEW.summary, targetTotal: 0 }, facets: { brands: [], categories: [] } });
+    expect(element.textContent).toContain('暂无竞品监控目标');
+    expect(element.querySelectorAll('tbody tr').length).toBe(0);
+  });
+});

+ 103 - 0
src/modules/competitor-listing-monitor/pages/overview/competitor-listing-overview.component.ts

@@ -0,0 +1,103 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
+import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component';
+import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
+import type {
+  CompetitorListingChange,
+  CompetitorListingMonitorStatus,
+  CompetitorListingOverviewRow,
+  CompetitorListingQuery,
+} from '../../models/competitor-listing.models';
+import { CompetitorListingOverviewStore } from '../../services/competitor-listing-overview.store';
+
+const STATUS_LABELS: Record<CompetitorListingMonitorStatus, string> = {
+  not_initialized: '尚未建立监控基线',
+  awaiting_comparison: '基线已建立,等待比较',
+  changed: '最近比较有变化',
+  unchanged: '最近比较无变化',
+  failed: '最近采集失败',
+};
+
+const CHANGE_LABELS: Record<string, string> = {
+  price: '价格',
+  availability: '在售状态',
+  title: '标题',
+  main_image: '主图',
+  key_specifications: '规格',
+};
+
+@Component({
+  selector: 'app-competitor-listing-overview',
+  standalone: true,
+  imports: [
+    CommonModule,
+    FormsModule,
+    RouterLink,
+    PageHeaderComponent,
+    ContentCardComponent,
+    SummaryMetricCardComponent,
+    LoadingSpinnerComponent,
+    EmptyStateComponent,
+    ProductIdentityComponent,
+  ],
+  providers: [CompetitorListingOverviewStore],
+  templateUrl: './competitor-listing-overview.component.html',
+  styleUrls: ['./competitor-listing-overview.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CompetitorListingOverviewComponent implements OnInit {
+  readonly store = inject(CompetitorListingOverviewStore);
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+
+  ngOnInit(): void { this.store.connect(this.workspaceId); }
+
+  applyQuery(patch: Partial<CompetitorListingQuery>): void { this.store.patchQuery(patch); }
+  clearFilters(): void { this.store.resetQuery(); }
+  statusLabel(status: CompetitorListingMonitorStatus): string { return STATUS_LABELS[status]; }
+
+  price(row: CompetitorListingOverviewRow): string {
+    const cents = row.currentSnapshot?.priceCents;
+    return cents == null ? '—' : `¥${(cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+  }
+
+  availability(row: CompetitorListingOverviewRow): string {
+    switch (row.currentSnapshot?.availability) {
+      case 'online': return '在售';
+      case 'offline': return '下架';
+      default: return '未知';
+    }
+  }
+
+  updatedAt(row: CompetitorListingOverviewRow): string | null {
+    return row.latestCollectionResult?.observedAt ?? row.currentSnapshot?.observedAt ?? null;
+  }
+
+  changeSummary(change: CompetitorListingChange | null): string {
+    if (!change) return '暂无真实变化记录';
+    return change.changeTypes.map((type) => CHANGE_LABELS[type] ?? type).join('、');
+  }
+
+  changeEvidence(change: CompetitorListingChange | null): string {
+    const first = change?.changes[0];
+    if (!first) return '';
+    const before = formatEvidence(first.before);
+    const after = formatEvidence(first.after);
+    return `${before} → ${after}`;
+  }
+
+  trackByProductId(_index: number, row: CompetitorListingOverviewRow): string { return row.productId; }
+}
+
+function formatEvidence(value: unknown): string {
+  if (value === null || value === undefined || value === '') return '—';
+  if (typeof value === 'number') return String(value);
+  if (typeof value === 'string') return value.length > 28 ? `${value.slice(0, 28)}…` : value;
+  return JSON.stringify(value);
+}

+ 40 - 0
src/modules/competitor-listing-monitor/services/competitor-listing-api.service.spec.ts

@@ -0,0 +1,40 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { CompetitorListingApiService } from './competitor-listing-api.service';
+
+describe('CompetitorListingApiService', () => {
+  let service: CompetitorListingApiService;
+  let http: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
+    service = TestBed.inject(CompetitorListingApiService);
+    http = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => http.verify());
+
+  it('calls the overview endpoint with workspace and exact platform', () => {
+    service.overview('demashi').subscribe();
+    const request = http.expectOne((candidate) => candidate.url === '/api/competitor-listings/overview');
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('workspaceId')).toBe('demashi');
+    expect(request.request.params.get('platform')).toBe('jd');
+    request.flush({ summary: {}, items: [], facets: { brands: [], categories: [] } });
+  });
+
+  it('creates a manual refresh and polls the returned run id', () => {
+    service.refresh('demashi').subscribe();
+    const refresh = http.expectOne('/api/competitor-listings/refresh');
+    expect(refresh.request.method).toBe('POST');
+    expect(refresh.request.body).toEqual({ workspaceId: 'demashi', platform: 'jd' });
+    refresh.flush({ run: { id: 'run-1', status: 'queued', total: 37, requestedAt: '2026-08-26T00:00:00.000Z' } });
+
+    service.run('demashi', 'run/1').subscribe();
+    const run = http.expectOne((candidate) => candidate.url === '/api/competitor-listings/runs/run%2F1');
+    expect(run.request.method).toBe('GET');
+    expect(run.request.params.get('workspaceId')).toBe('demashi');
+    expect(run.request.params.get('platform')).toBe('jd');
+    run.flush({ run: {} });
+  });
+});

+ 34 - 0
src/modules/competitor-listing-monitor/services/competitor-listing-api.service.ts

@@ -0,0 +1,34 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import type { Observable } from 'rxjs';
+import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
+import type {
+  CompetitorListingOverview,
+  CompetitorListingRefreshRun,
+} from '../models/competitor-listing.models';
+
+@Injectable({ providedIn: 'root' })
+export class CompetitorListingApiService {
+  private readonly http = inject(HttpClient);
+  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/api/competitor-listings`;
+
+  overview(workspaceId: string): Observable<CompetitorListingOverview> {
+    const params = new HttpParams().set('workspaceId', workspaceId).set('platform', 'jd');
+    return this.http.get<CompetitorListingOverview>(`${this.baseUrl}/overview`, { params });
+  }
+
+  refresh(workspaceId: string): Observable<{ run: Pick<CompetitorListingRefreshRun, 'id' | 'status' | 'total' | 'requestedAt'> }> {
+    return this.http.post<{ run: Pick<CompetitorListingRefreshRun, 'id' | 'status' | 'total' | 'requestedAt'> }>(
+      `${this.baseUrl}/refresh`,
+      { workspaceId, platform: 'jd' },
+    );
+  }
+
+  run(workspaceId: string, runId: string): Observable<{ run: CompetitorListingRefreshRun }> {
+    const params = new HttpParams().set('workspaceId', workspaceId).set('platform', 'jd');
+    return this.http.get<{ run: CompetitorListingRefreshRun }>(
+      `${this.baseUrl}/runs/${encodeURIComponent(runId)}`,
+      { params },
+    );
+  }
+}

+ 93 - 0
src/modules/competitor-listing-monitor/services/competitor-listing-overview.store.spec.ts

@@ -0,0 +1,93 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
+import { BehaviorSubject, of, throwError } from 'rxjs';
+import type { CompetitorListingOverview, CompetitorListingRefreshRun } from '../models/competitor-listing.models';
+import { CompetitorListingApiService } from './competitor-listing-api.service';
+import {
+  CompetitorListingOverviewStore,
+  competitorListingQueryFromParamMap,
+  competitorListingQueryToParams,
+} from './competitor-listing-overview.store';
+
+const OVERVIEW: CompetitorListingOverview = {
+  summary: { targetTotal: 2, baselineTotal: 1, comparedTotal: 1, changedIn7d: 1, priceChangedIn7d: 1, contentChangedIn7d: 0, latestFailedTotal: 0, lastRefreshAt: '2026-08-26T02:00:00.000Z', lastSuccessfulRefreshAt: '2026-08-26T02:00:00.000Z', historyStatus: 'comparable' },
+  facets: { brands: [{ value: '品牌 A', count: 1 }, { value: '品牌 B', count: 1 }], categories: [{ value: '冷柜', count: 2 }] },
+  items: [
+    { productId: '1001', platform: 'jd', title: '测试冷柜', brand: '品牌 A', mainImageUrl: null, category: '冷柜', relatedProducts: [], currentSnapshot: { id: 's1', naturalKey: 'n1', workspaceId: 'demashi', platform: 'jd', productId: '1001', previousSnapshotId: 's0', contentHash: 'a'.repeat(64), observedAt: '2026-08-26T02:00:00.000Z', title: '测试冷柜', priceCents: 199900, currency: 'CNY', availability: 'online', mainImageUrl: null, keySpecifications: { model: null, color: null, specification: null, origin: null, weightKg: null, lengthMm: null, widthMm: null, heightMm: null }, observedFields: ['title', 'price', 'availability'], collectionStatus: 'succeeded' }, latestChange: { id: 'c1', naturalKey: 'c1', workspaceId: 'demashi', platform: 'jd', productId: '1001', previousSnapshotId: 's0', currentSnapshotId: 's1', detectedAt: '2026-08-26T02:00:00.000Z', changeTypes: ['price'], changes: [{ field: 'priceCents', before: 209900, after: 199900 }] }, latestCollectionResult: { productId: '1001', status: 'changed', observedAt: '2026-08-26T02:00:00.000Z' }, monitorStatus: 'changed' },
+    { productId: '1002', platform: 'jd', title: '测试冰箱', brand: '品牌 B', mainImageUrl: null, category: '冷柜', relatedProducts: [], currentSnapshot: null, latestChange: null, latestCollectionResult: null, monitorStatus: 'not_initialized' },
+  ],
+};
+
+function terminal(status: 'completed' | 'partial' | 'failed'): CompetitorListingRefreshRun {
+  return { id: 'run-1', workspaceId: 'demashi', platform: 'jd', trigger: 'manual', status, total: 2, completed: 2, baseline: 1, unchanged: 0, changed: 0, failed: status === 'completed' ? 0 : 1, itemResults: [], requestedAt: '2026-08-26T00:00:00.000Z', startedAt: '2026-08-26T00:00:01.000Z', completedAt: '2026-08-26T00:00:02.000Z' };
+}
+
+describe('CompetitorListingOverviewStore', () => {
+  let params$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
+  let api: jasmine.SpyObj<CompetitorListingApiService>;
+  let router: jasmine.SpyObj<Router>;
+
+  function createStore(initial: Record<string, unknown>): CompetitorListingOverviewStore {
+    params$ = new BehaviorSubject(convertToParamMap(initial));
+    api = jasmine.createSpyObj<CompetitorListingApiService>('CompetitorListingApiService', ['overview', 'refresh', 'run']);
+    router = jasmine.createSpyObj<Router>('Router', ['navigate']);
+    router.navigate.and.resolveTo(true);
+    TestBed.configureTestingModule({ providers: [
+      CompetitorListingOverviewStore,
+      { provide: CompetitorListingApiService, useValue: api },
+      { provide: ActivatedRoute, useValue: { queryParamMap: params$.asObservable() } },
+      { provide: Router, useValue: router },
+    ] });
+    return TestBed.inject(CompetitorListingOverviewStore);
+  }
+
+  afterEach(() => TestBed.resetTestingModule());
+
+  it('restores URL state and filters and sorts the small overview client-side', () => {
+    const store = createStore({ search: '冷柜', brand: '品牌 A', category: '冷柜', status: 'changed', sort: 'price', direction: 'asc' });
+    api.overview.and.returnValue(of(OVERVIEW));
+    store.connect('demashi');
+    expect(store.state()).toBe('ready');
+    expect(store.items().map((item) => item.productId)).toEqual(['1001']);
+    expect(store.query()).toEqual({ search: '冷柜', brand: '品牌 A', category: '冷柜', status: 'changed', sort: 'price', direction: 'asc' });
+    store.patchQuery({ search: undefined, brand: undefined, status: undefined, sort: 'updatedAt', direction: 'desc' });
+    expect(store.items().map((item) => item.productId)).toEqual(['1001', '1002']);
+    expect(router.navigate.calls.mostRecent().args[1]?.queryParams).toEqual({ category: '冷柜', sort: 'updatedAt', direction: 'desc' });
+    expect(api.overview).toHaveBeenCalledTimes(1);
+  });
+
+  it('polls every two seconds and reloads overview after a terminal run', fakeAsync(() => {
+    const store = createStore({});
+    api.overview.and.returnValues(of(OVERVIEW), of(OVERVIEW));
+    api.refresh.and.returnValue(of({ run: { id: 'run-1', status: 'queued', total: 2, requestedAt: '2026-08-26T00:00:00.000Z' } }));
+    api.run.and.returnValues(of({ run: { ...terminal('completed'), status: 'running', completed: 1, completedAt: null } }), of({ run: terminal('partial') }));
+    store.connect('demashi');
+    store.refresh();
+    tick(0);
+    expect(api.run).toHaveBeenCalledTimes(1);
+    expect(store.refreshing()).toBeTrue();
+    tick(2_000);
+    expect(api.run).toHaveBeenCalledTimes(2);
+    expect(store.refreshing()).toBeFalse();
+    expect(store.refreshMessage()).toContain('部分完成');
+    expect(api.overview).toHaveBeenCalledTimes(2);
+  }));
+
+  it('turns a 409 refresh conflict into a visible running message without polling', () => {
+    const store = createStore({});
+    api.overview.and.returnValues(of(OVERVIEW), of(OVERVIEW));
+    api.refresh.and.returnValue(throwError(() => ({ status: 409, error: { error: 'competitor_listing_refresh_running' } })));
+    store.connect('demashi');
+    store.refresh();
+    expect(store.refreshing()).toBeFalse();
+    expect(store.refreshMessage()).toContain('已有刷新任务');
+    expect(api.run).not.toHaveBeenCalled();
+    expect(api.overview).toHaveBeenCalledTimes(2);
+  });
+
+  it('round-trips only supported URL parameters with canonical defaults', () => {
+    const query = competitorListingQueryFromParamMap(convertToParamMap({ search: '  冷柜 ', brand: '品牌 A', category: '冷柜', status: 'failed', sort: 'changePriority', direction: 'asc', ignored: 'x' }));
+    expect(query.search).toBe('冷柜');
+    expect(competitorListingQueryToParams(query)).toEqual({ search: '冷柜', brand: '品牌 A', category: '冷柜', status: 'failed', sort: 'changePriority', direction: 'asc' });
+  });
+});

+ 258 - 0
src/modules/competitor-listing-monitor/services/competitor-listing-overview.store.ts

@@ -0,0 +1,258 @@
+import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
+import { Subscription, switchMap, timer } from 'rxjs';
+import type {
+  CompetitorListingDirection,
+  CompetitorListingMonitorStatus,
+  CompetitorListingOverview,
+  CompetitorListingOverviewRow,
+  CompetitorListingQuery,
+  CompetitorListingQueryState,
+  CompetitorListingRefreshRun,
+  CompetitorListingSort,
+} from '../models/competitor-listing.models';
+import { CompetitorListingApiService } from './competitor-listing-api.service';
+
+const SORTS: CompetitorListingSort[] = ['updatedAt', 'price', 'changePriority'];
+const DIRECTIONS: CompetitorListingDirection[] = ['asc', 'desc'];
+const STATUSES: CompetitorListingMonitorStatus[] = ['not_initialized', 'awaiting_comparison', 'changed', 'unchanged', 'failed'];
+const TERMINAL_RUN_STATUSES = new Set(['completed', 'partial', 'failed']);
+
+export type CompetitorListingLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
+
+function optionalText(value: string | null | undefined): string | undefined {
+  const normalized = value?.normalize('NFKC').trim();
+  return normalized || undefined;
+}
+
+function enumValue<T extends string>(value: string | null | undefined, allowed: readonly T[]): T | undefined {
+  return value && allowed.includes(value as T) ? value as T : undefined;
+}
+
+export function normalizeCompetitorListingQuery(query: CompetitorListingQuery): CompetitorListingQueryState {
+  const output: CompetitorListingQueryState = {
+    sort: enumValue(query.sort, SORTS) ?? 'updatedAt',
+    direction: enumValue(query.direction, DIRECTIONS) ?? 'desc',
+  };
+  const search = optionalText(query.search);
+  const brand = optionalText(query.brand);
+  const category = optionalText(query.category);
+  const status = enumValue(query.status, STATUSES);
+  if (search) output.search = search;
+  if (brand) output.brand = brand;
+  if (category) output.category = category;
+  if (status) output.status = status;
+  return output;
+}
+
+export function competitorListingQueryFromParamMap(params: ParamMap): CompetitorListingQueryState {
+  return normalizeCompetitorListingQuery({
+    search: params.get('search') ?? undefined,
+    brand: params.get('brand') ?? undefined,
+    category: params.get('category') ?? undefined,
+    status: params.get('status') as CompetitorListingMonitorStatus | undefined,
+    sort: params.get('sort') as CompetitorListingSort | undefined,
+    direction: params.get('direction') as CompetitorListingDirection | undefined,
+  });
+}
+
+export function competitorListingQueryToParams(query: CompetitorListingQuery): Params {
+  const normalized = normalizeCompetitorListingQuery(query);
+  const params: Params = { sort: normalized.sort, direction: normalized.direction };
+  for (const key of ['search', 'brand', 'category', 'status'] as const) {
+    if (normalized[key]) params[key] = normalized[key];
+  }
+  return params;
+}
+
+@Injectable()
+export class CompetitorListingOverviewStore {
+  private readonly api = inject(CompetitorListingApiService);
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly destroyRef = inject(DestroyRef);
+  private workspaceId = '';
+  private connected = false;
+  private overviewRequest?: Subscription;
+  private pollRequest?: Subscription;
+  private requestVersion = 0;
+
+  readonly query = signal<CompetitorListingQueryState>(normalizeCompetitorListingQuery({}));
+  readonly overview = signal<CompetitorListingOverview | null>(null);
+  readonly loading = signal(false);
+  readonly error = signal('');
+  readonly refreshing = signal(false);
+  readonly refreshMessage = signal('');
+  readonly currentRun = signal<CompetitorListingRefreshRun | null>(null);
+  readonly items = computed(() => filterAndSort(this.overview()?.items ?? [], this.query()));
+  readonly state = computed<CompetitorListingLoadState>(() => {
+    if (this.loading() && !this.overview()) return 'loading';
+    if (this.error() && !this.overview()) return 'error';
+    if (!this.overview()) return 'idle';
+    return this.overview()!.items.length ? 'ready' : 'empty';
+  });
+
+  connect(workspaceId: string): void {
+    if (this.connected) return;
+    this.connected = true;
+    this.workspaceId = workspaceId;
+    this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
+      const next = competitorListingQueryFromParamMap(params);
+      this.query.set(next);
+      if (!paramMapMatches(params, next)) this.syncUrl(next);
+      if (!this.overview() && !this.loading()) this.loadOverview();
+    });
+  }
+
+  patchQuery(patch: Partial<CompetitorListingQuery>): void {
+    const next = normalizeCompetitorListingQuery({ ...this.query(), ...patch });
+    this.query.set(next);
+    this.syncUrl(next);
+  }
+
+  resetQuery(): void {
+    const next = normalizeCompetitorListingQuery({});
+    this.query.set(next);
+    this.syncUrl(next);
+  }
+
+  retry(): void { this.loadOverview(true); }
+
+  refresh(): void {
+    if (!this.workspaceId || this.refreshing()) return;
+    this.pollRequest?.unsubscribe();
+    this.refreshing.set(true);
+    this.refreshMessage.set('正在创建刷新任务…');
+    this.api.refresh(this.workspaceId).subscribe({
+      next: ({ run }) => {
+        this.refreshMessage.set(`正在刷新 0 / ${run.total}`);
+        this.pollRun(run.id);
+      },
+      error: (error: unknown) => {
+        this.refreshing.set(false);
+        if (isRefreshConflict(error)) {
+          this.refreshMessage.set('已有刷新任务正在运行,请稍后查看最新状态');
+          this.loadOverview(true);
+        } else {
+          this.refreshMessage.set('刷新任务创建失败,请稍后重试');
+        }
+      },
+    });
+  }
+
+  private pollRun(runId: string): void {
+    this.pollRequest = timer(0, 2_000).pipe(
+      switchMap(() => this.api.run(this.workspaceId, runId)),
+      takeUntilDestroyed(this.destroyRef),
+    ).subscribe({
+      next: ({ run }) => {
+        this.currentRun.set(run);
+        this.refreshMessage.set(runMessage(run));
+        if (TERMINAL_RUN_STATUSES.has(run.status)) {
+          this.pollRequest?.unsubscribe();
+          this.refreshing.set(false);
+          this.loadOverview(true);
+        }
+      },
+      error: () => {
+        this.pollRequest?.unsubscribe();
+        this.refreshing.set(false);
+        this.refreshMessage.set('刷新状态查询失败,请重新加载页面');
+      },
+    });
+  }
+
+  private loadOverview(force = false): void {
+    if (!this.workspaceId || (this.loading() && !force)) return;
+    this.overviewRequest?.unsubscribe();
+    const version = ++this.requestVersion;
+    this.loading.set(true);
+    this.error.set('');
+    this.overviewRequest = this.api.overview(this.workspaceId).subscribe({
+      next: (overview) => {
+        if (version === this.requestVersion) this.overview.set(overview);
+      },
+      error: () => {
+        if (version !== this.requestVersion) return;
+        this.loading.set(false);
+        this.error.set('竞品 Listing 监控数据加载失败,请稍后重试');
+      },
+      complete: () => {
+        if (version === this.requestVersion) this.loading.set(false);
+      },
+    });
+  }
+
+  private syncUrl(query: CompetitorListingQuery): void {
+    void this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: competitorListingQueryToParams(query),
+      replaceUrl: true,
+    });
+  }
+}
+
+function filterAndSort(items: CompetitorListingOverviewRow[], query: CompetitorListingQueryState): CompetitorListingOverviewRow[] {
+  const search = query.search?.toLocaleLowerCase('zh-CN');
+  const filtered = items.filter((item) => {
+    if (search && !`${item.title ?? ''} ${item.productId}`.normalize('NFKC').toLocaleLowerCase('zh-CN').includes(search)) return false;
+    if (query.brand && item.brand !== query.brand) return false;
+    if (query.category && item.category !== query.category) return false;
+    if (query.status && item.monitorStatus !== query.status) return false;
+    return true;
+  });
+  return [...filtered].sort((left, right) => {
+    let compared = 0;
+    if (query.sort === 'price') compared = nullableNumber(left.currentSnapshot?.priceCents, right.currentSnapshot?.priceCents);
+    else if (query.sort === 'changePriority') compared = changePriority(left) - changePriority(right);
+    else compared = nullableText(updatedAt(left), updatedAt(right));
+    if (compared === 0) compared = left.productId.localeCompare(right.productId);
+    return query.direction === 'asc' ? compared : -compared;
+  });
+}
+
+function updatedAt(item: CompetitorListingOverviewRow): string | null {
+  return item.latestCollectionResult?.observedAt ?? item.currentSnapshot?.observedAt ?? null;
+}
+
+function changePriority(item: CompetitorListingOverviewRow): number {
+  if (item.monitorStatus === 'failed') return 5;
+  if (!item.latestChange) return item.monitorStatus === 'not_initialized' ? 0 : 1;
+  if (item.latestChange.changeTypes.includes('availability')) return 4;
+  if (item.latestChange.changeTypes.includes('price')) return 3;
+  return 2;
+}
+
+function nullableNumber(left: number | null | undefined, right: number | null | undefined): number {
+  if (left == null && right == null) return 0;
+  if (left == null) return -1;
+  if (right == null) return 1;
+  return left - right;
+}
+
+function nullableText(left: string | null, right: string | null): number {
+  if (!left && !right) return 0;
+  if (!left) return -1;
+  if (!right) return 1;
+  return left.localeCompare(right);
+}
+
+function paramMapMatches(params: ParamMap, query: CompetitorListingQuery): boolean {
+  const desired = competitorListingQueryToParams(query);
+  const keys = Object.keys(desired).sort();
+  if (params.keys.slice().sort().join('|') !== keys.join('|')) return false;
+  return keys.every((key) => params.get(key) === String(desired[key]));
+}
+
+function isRefreshConflict(error: unknown): boolean {
+  const value = error as { status?: unknown; error?: { error?: unknown } };
+  return value?.status === 409 && value.error?.error === 'competitor_listing_refresh_running';
+}
+
+function runMessage(run: CompetitorListingRefreshRun): string {
+  if (run.status === 'completed') return `刷新完成:${run.completed} 个竞品已处理`;
+  if (run.status === 'partial') return `刷新部分完成:${run.failed} 个竞品失败`;
+  if (run.status === 'failed') return `刷新失败:${run.failed || run.total} 个竞品未完成`;
+  return `正在刷新 ${run.completed} / ${run.total}`;
+}

+ 62 - 19
src/modules/domestic-voc/competitor-detail.component.ts

@@ -5,7 +5,10 @@ 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 { RUNTIME_CONFIG } from '../../app/core/config/runtime-config';
 import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
+import type { CompetitorListingOverviewRow } from '../competitor-listing-monitor/models/competitor-listing.models';
+import { CompetitorListingApiService } from '../competitor-listing-monitor/services/competitor-listing-api.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';
@@ -114,15 +117,15 @@ interface OwnComparisonRow {
 
             <section class="detail-tab-panel" [hidden]="activeDetailTab !== 'overview'">
               <div class="metric-grid">
-                <app-summary-metric-card label="当前价" [value]="currentPriceLabel" icon="元" tone="amber" description="品牌品类检索快照"></app-summary-metric-card>
-                <app-summary-metric-card label="销量信号" [value]="salesSignal" icon="↗" tone="purple" description="平台公开销量文案"></app-summary-metric-card>
+                <app-summary-metric-card label="当前价" [value]="currentPriceLabel" icon="元" tone="amber" [description]="competitorPriceDescription"></app-summary-metric-card>
+                <app-summary-metric-card label="销量信号" [value]="salesSignal" icon="↗" tone="purple" description="平台公开销量文案;未返回时不作推算"></app-summary-metric-card>
                 <app-summary-metric-card label="有效评论" [value]="number(reviews.length)" icon="□" tone="blue" [description]="reviewQualityDescription"></app-summary-metric-card>
                 <app-summary-metric-card label="平均评分" [value]="averageRating ? averageRating.toFixed(1) : '-'" unit="/ 5" icon="★" tone="green" [sampleSize]="ratedReviews.length"></app-summary-metric-card>
                 <app-summary-metric-card label="4-5 星占比" [value]="percent(positiveRate)" icon="↑" tone="purple" description="评分分组,不等同正文情绪"></app-summary-metric-card>
                 <app-summary-metric-card label="关联本品" [value]="number(ownProducts.length)" icon="⇄" tone="amber" [description]="number(relations.length) + ' 条映射关系'"></app-summary-metric-card>
               </div>
 
-              <app-content-card class="own-vs-card" title="本品关联均值 vs 竞品" subtitle="关联本品的经营汇总与竞品市场快照并排对比;仅价格同量纲可计算差异" tone="blue">
+              <app-content-card class="own-vs-card" title="本品关联均值 vs 竞品" subtitle="关联本品的经营汇总与竞品 Listing 快照并排对比;仅价格同量纲可计算差异" tone="blue">
                 <div class="vs-grid">
                   <div class="vs-col vs-own">
                     <div class="vs-col-title"><span class="vs-tag own">本品关联</span><strong>{{ number(ownProducts.length) }} 个德玛仕商品</strong></div>
@@ -139,16 +142,16 @@ interface OwnComparisonRow {
                     <dl class="vs-list">
                       <div><dt>当前价</dt><dd>{{ competitorPriceLabel }}</dd></div>
                       <div><dt>销量信号</dt><dd [title]="salesSignal">{{ salesSignal }}</dd></div>
-                      <div><dt>成交经营数据</dt><dd>无同量纲数据</dd></div>
-                      <div><dt>数据口径</dt><dd>市场搜索快照</dd></div>
+                      <div><dt>成交经营数据</dt><dd>无公开同量纲数据</dd></div>
+                      <div><dt>数据口径</dt><dd>{{ competitorDataSourceLabel }}</dd></div>
                     </dl>
                   </div>
                 </div>
                 <div class="vs-diff-row">
-                  <span class="vs-diff-label">参考价差异(本品成交均价·内部经营数据 vs 竞品挂牌价·市场快照)</span>
+                  <span class="vs-diff-label">参考价差异(本品成交均价·内部经营数据 vs 竞品挂牌价·Listing 快照)</span>
                   <span class="diff-badge" [ngClass]="ownAvgVsCompetitor.cls">{{ ownAvgVsCompetitor.label }}</span>
                 </div>
-                <p class="vs-note">仅「参考价」为同量纲可比(均为 元/件,本品为成交均价、竞品为挂牌价快照);竞品无成交金额/件数经营数据,销量为平台文案区间,故其余指标仅并列展示、不计算百分比差异。</p>
+                <p class="vs-note">仅「参考价」为同量纲可比(均为 元/件,本品为成交均价、竞品为挂牌价快照);竞品无公开成交金额/件数,销量信号仅展示平台实际返回文案,不作反推,故其余指标不计算百分比差异。</p>
               </app-content-card>
 
               <div class="detail-grid">
@@ -191,7 +194,7 @@ interface OwnComparisonRow {
                   </tbody>
                 </table>
                 <ng-container dataTableFooter>
-                  <span class="table-foot-note">对比口径:本品成交均价 = 成交金额 ÷ 成交件数(内部经营数据);竞品当前价来自市场搜索快照。绿色 = 本品均价低于竞品当前价,红色 = 高于,仅反映相对定价、不构成优劣结论。竞品侧无成交金额/件数经营数据,故仅在本列做价格对比。</span>
+                  <span class="table-foot-note">对比口径:本品成交均价 = 成交金额 ÷ 成交件数(内部经营数据);竞品当前价优先来自 Listing 监控快照。绿色 = 本品均价低于竞品当前价,红色 = 高于,仅反映相对定价、不构成优劣结论。竞品侧无公开成交金额/件数,故仅在本列做价格对比。</span>
                 </ng-container>
               </app-data-table-shell>
             </section>
@@ -288,6 +291,7 @@ interface OwnComparisonRow {
 })
 export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   private readonly datasetService = inject(DomesticDatasetService);
+  private readonly listingApi = inject(CompetitorListingApiService);
   private readonly route = inject(ActivatedRoute);
   private readonly router = inject(Router);
   private readonly cdr = inject(ChangeDetectorRef);
@@ -353,6 +357,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   activeDetailTab = 'overview';
   dataset?: DomesticDataset;
   product?: DomesticProduct;
+  listingRow?: CompetitorListingOverviewRow;
   selectedReview?: DomesticReview;
   showAllReviews = false;
   rawReviews: DomesticReview[] = [];
@@ -385,9 +390,23 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   ];
 
   ngOnInit(): void {
+    const productId = this.route.snapshot.paramMap.get('productId') || '';
+    this.listingApi.overview(RUNTIME_CONFIG.domesticWorkspaceId)
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (overview) => {
+          this.listingRow = overview.items.find((item) => item.productId === productId);
+          this.refreshPriceComparisons();
+          if (this.product) this.aiReviewPrompt = this.buildAiReviewPrompt();
+          this.cdr.markForCheck();
+        },
+        error: () => {
+          this.listingRow = undefined;
+          this.cdr.markForCheck();
+        },
+      });
     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) {
@@ -397,10 +416,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
       this.relations = dataset.relations.filter((relation) => relation.competitorProductId === productId);
       const ownIds = new Set(this.relations.map((relation) => relation.ownProductId));
       this.ownProducts = dataset.products.filter((product) => product.role === 'own' && ownIds.has(product.productId));
-      this.ownComparisonRows = this.ownProducts.map((own) => ({
-        own,
-        diff: this.priceDiffState(this.ownUnitPrice(own)),
-      }));
+      this.refreshPriceComparisons();
       this.rawReviews = (dataset.reviews ?? [])
         .filter((review) => review.productId === productId)
         .sort((left, right) => String(right.reviewDate ?? '').localeCompare(String(left.reviewDate ?? '')));
@@ -425,18 +441,35 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
   }
 
   get statusLabel(): string {
+    const availability = this.listingRow?.currentSnapshot?.availability;
+    if (availability === 'online') return '在售';
+    if (availability === 'offline') return '已下架';
     if (this.product?.detail?.skuStatus === '1') return '在售';
     if (this.product?.detail?.skuStatus) return `状态 ${this.product.detail.skuStatus}`;
     return this.product?.market ? '市场快照已采集' : '状态待确认';
   }
 
   get currentPriceLabel(): string {
-    const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
-    return price > 0 ? this.currency(price) : '-';
+    return this.competitorPriceLabel;
   }
 
   get salesSignal(): string {
-    return this.product?.market?.monthSalesText || this.product?.market?.salesText || '-';
+    return this.product?.market?.monthSalesText || this.product?.market?.salesText || '平台未返回';
+  }
+
+  get competitorDataSourceLabel(): string {
+    if (this.listingRow?.currentSnapshot) return '竞品 Listing 监控快照';
+    if (this.product?.market) return '市场搜索快照';
+    return '暂无可用快照';
+  }
+
+  get competitorPriceDescription(): string {
+    const observedAt = this.listingRow?.currentSnapshot?.observedAt;
+    return observedAt
+      ? `Listing 监控快照 · ${this.dateTime(observedAt)}`
+      : this.product?.market
+        ? '市场搜索快照'
+        : '平台未返回有效价格';
   }
 
   get weightLabel(): string {
@@ -512,8 +545,10 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
 
   // ── 本品 vs 竞品 对比(组件内基于现有字段派生,不改动数据获取逻辑)──
 
-  /** 竞品参考价:优先市场快照当前价,回退均价字段;无有效值返回 null。 */
+  /** 竞品参考价:优先 Listing 监控快照,回退旧市场快照;无有效值返回 null。 */
   get competitorPrice(): number | null {
+    const priceCents = this.listingRow?.currentSnapshot?.priceCents;
+    if (priceCents != null && Number.isFinite(priceCents) && priceCents > 0) return priceCents / 100;
     const price = this.product?.market?.currentPrice || this.product?.summary.averageUnitPrice || 0;
     return price > 0 ? price : null;
   }
@@ -526,6 +561,13 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
     return this.product?.brand || '竞品';
   }
 
+  private refreshPriceComparisons(): void {
+    this.ownComparisonRows = this.ownProducts.map((own) => ({
+      own,
+      diff: this.priceDiffState(this.ownUnitPrice(own)),
+    }));
+  }
+
   get ownLinkedGmv(): number {
     return this.ownProducts.reduce((sum, own) => sum + own.summary.gmv, 0);
   }
@@ -660,7 +702,8 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
       `当前价快照: ${this.currentPriceLabel}`,
       `平台销量信号: ${this.salesSignal}`,
       `店铺: ${this.product.market?.shopName || '-'}`,
-      `市场快照时间: ${this.dateTime(this.product.market?.collectedAt)}`,
+      `竞品快照口径: ${this.competitorDataSourceLabel}`,
+      `竞品快照时间: ${this.dateTime(this.listingRow?.currentSnapshot?.observedAt || this.product.market?.collectedAt)}`,
       `在售状态: ${this.statusLabel}`,
       `原始入库评论: ${this.rawReviews.length}`,
       `有效评论: ${this.reviews.length}`,
@@ -677,7 +720,7 @@ export class DomesticCompetitorDetailComponent implements OnInit, OnDestroy {
       '【真实评论证据】',
       reviewEvidence,
       '',
-      '【数据边界】当前价和销量文案来自本次市场搜索快照,可用于横向观察;价格历史与销量历史序列尚未入库,不得生成时间趋势,也不得根据德玛仕本品成交数据反推竞品销量。',
+      '【数据边界】当前价优先来自竞品 Listing 监控快照;销量文案仅在平台实际返回时展示。竞品无公开成交金额/件数,不得根据德玛仕本品经营数据或挂牌价反推竞品销量、GMV或市场份额。',
       '',
       '请生成可视化报告:核心指标只使用上述真实统计;评分分布使用图表;证据卡引用评论原句;单独检查评分与正文情绪是否冲突;行动建议区分产品、详情页和数据采集动作。',
     ].join('\n');

+ 6 - 4
src/modules/domestic-voc/operating-overview.component.scss

@@ -214,16 +214,18 @@ app-page-toolbar {
 
 .kpi-cell.is-emphasis {
   z-index: 1;
-  background: tokens.$color-surface;
-  box-shadow: inset 0 0 0 1px tokens.$color-primary;
+  background: #fffbeb;
+  box-shadow: inset 0 3px #b45309, inset 0 0 0 1px rgba(180, 83, 9, 0.34);
 }
 
 .kpi-cell.is-emphasis.tone-critical {
-  box-shadow: inset 0 3px tokens.$color-red, inset 0 0 0 1px tokens.$color-primary;
+  background: #fef2f2;
+  box-shadow: inset 0 3px tokens.$color-red, inset 0 0 0 1px rgba(185, 28, 28, 0.34);
 }
 
 .kpi-cell.is-emphasis.tone-caution {
-  box-shadow: inset 0 3px tokens.$color-amber, inset 0 0 0 1px tokens.$color-primary;
+  background: #fffbeb;
+  box-shadow: inset 0 3px tokens.$color-amber, inset 0 0 0 1px rgba(180, 83, 9, 0.34);
 }
 
 .kpi-cell.is-emphasis > strong {

+ 82 - 2
src/modules/listing-ai/components/listing-dimension-panel/listing-dimension-panel.component.ts

@@ -7,9 +7,89 @@ import type { ListingScorePresentationDimension } from '../../models/listing-ai.
   standalone:true,
   imports:[CommonModule],
   changeDetection:ChangeDetectionStrategy.OnPush,
-  template:`@if(score){<section class="panel"><header><div><span>本项得分</span><strong>{{score.scoreText}}</strong></div><mark>{{score.conclusion}}</mark></header>@if(score.key==='images'){<p class="vision-note">目前只检查图片资产是否完整,不评价图片的构图、清晰度和内容质量。</p>}<h3>评分明细 <small>共 {{score.items.length}} 项</small></h3><div class="evidence">@for(item of score.items;track item.title){<article [class.pass]="item.resultLabel==='表现良好'||item.resultLabel==='表现优秀'" [class.warning]="item.resultLabel==='有待优化'" [class.fail]="item.resultLabel==='未达标'"><b>{{item.resultLabel}}</b><div><strong>{{item.title}}</strong><span>{{item.reason}}</span>@if(item.sources.length){<small>评分依据:{{item.sources.join('、')}}</small>}@if(item.action){<p>优化建议:{{item.action}}</p>}</div><em>{{item.impactText}}</em></article>}</div></section>}@else{<p class="empty">该项将在完成智能评分后展示。</p>}`,
-  styles:[`.panel header{display:flex;gap:24px;align-items:center;padding-bottom:16px;border-bottom:1px solid #e8ecf3}.panel header div span{display:block;color:#7b8494;font-size:12px}.panel header strong{font-size:24px;color:#245fda}.panel mark{margin-left:auto;border-radius:20px;padding:5px 10px;background:#eef3ff;color:#2f64cd;white-space:nowrap}.vision-note{padding:10px 12px;border-radius:8px;background:#fff7df;color:#7b5b0b;font-size:12px}.panel h3{display:flex;align-items:center;gap:8px;font-size:14px;margin:18px 0 10px}.panel h3 small{font-size:12px;font-weight:500;color:#8993a3}.evidence{display:grid;gap:10px}.evidence article{display:grid;grid-template-columns:66px minmax(0,1fr) 78px;gap:12px;align-items:center;padding:13px;border:1px solid #e5e9f1;border-radius:10px;background:#fbfcfe}.evidence article.fail{border-color:#ffd0cc;background:#fff8f7}.evidence article.warning{border-color:#f4ddb0;background:#fffbf2}.evidence article.pass{border-color:#cdebdc;background:#f8fffb}.evidence article>b{font-size:12px;color:#758095;white-space:nowrap}.evidence article.pass>b{color:#168455}.evidence article.warning>b{color:#a66a09}.evidence article.fail>b{color:#c54038}.evidence article div>strong,.evidence article div>span,.evidence article small{display:block}.evidence article div>span{margin-top:5px;color:#526077;font-size:13px;line-height:1.55}.evidence article small{margin-top:5px;color:#8993a3;font-size:12px}.evidence article p{margin:7px 0 0;font-size:12px;color:#7d5900}.evidence em{text-align:right;color:#38516f;font-style:normal;font-weight:650;white-space:nowrap}.empty{padding:30px;text-align:center;color:#8791a2}@media(max-width:700px){.evidence article{grid-template-columns:1fr}.evidence em{text-align:left}}`],
+  template:`@if(score){<section class="panel">
+    <header>
+      <div>
+        <span>本项得分</span>
+        <strong>{{score.score !== null ? score.score : '—'}} / {{score.maxScore}}</strong>
+      </div>
+      <mark>{{score.conclusion}}</mark>
+    </header>
+    @if(score.key==='images'){
+      <p class="vision-note">目前只检查图片资产是否完整,不评价图片的构图、清晰度和内容质量。</p>
+    }
+    <h3>评分明细 <small>共 {{score.items.length}} 项</small></h3>
+    <div class="evidence">
+      @for(item of score.items;track item.title){
+        <article [class.pass]="isGood(item.resultLabel)" [class.warning]="isWeak(item.resultLabel)" [class.fail]="isFail(item.resultLabel)">
+          <b>{{item.resultLabel}}</b>
+          <div>
+            <strong>{{item.title}}</strong>
+            <span>{{item.reason}}</span>
+            @if(item.sources.length){<small>评分依据:{{item.sources.join('、')}}</small>}
+            @if(item.action){<p>优化建议:{{item.action}}</p>}
+          </div>
+          <em>{{item.impactText}}</em>
+        </article>
+      }@empty{
+        <p class="empty">暂无细则,将随智能评分补充。</p>
+      }
+    </div>
+  </section>}@else{
+    <p class="empty">该项将在完成智能评分后展示。</p>
+  }`,
+  styles:[`
+    :host{
+      --blue:#2f67e8;--blue-2:#eaf0ff;--green:#12805c;--green-2:#e8f6f0;
+      --amber:#946200;--amber-2:#fff4d8;--red:#bc3d3d;
+      --ink:#172033;--muted:#667085;--line:#e4e9f1;--soft:#f5f7fb;
+      display:block;min-width:0;container-type:inline-size;
+    }
+    .panel header{display:flex;gap:24px;align-items:center;padding-bottom:16px;border-bottom:1px solid var(--line)}
+    .panel header div span{display:block;color:var(--muted);font-size:12px}
+    .panel header strong{font-size:24px;color:var(--blue);display:block;margin-top:3px}
+    .panel mark{margin-left:auto;border-radius:20px;padding:5px 10px;background:var(--blue-2);color:var(--blue);white-space:nowrap}
+    .vision-note{padding:10px 12px;border-radius:8px;background:var(--amber-2);color:var(--amber);font-size:12px;margin:12px 0}
+    .panel h3{display:flex;align-items:center;gap:8px;font-size:14px;margin:18px 0 10px}
+    .panel h3 small{font-size:12px;font-weight:500;color:var(--muted)}
+    .evidence{display:grid;gap:10px}
+    .evidence article{display:grid;grid-template-columns:80px minmax(0,1fr) max-content;gap:12px;align-items:center;min-width:0;padding:13px;border:1px solid var(--line);border-radius:10px;background:#fbfcfe}
+    .evidence article.fail{border-color:#ffd0cc;background:#fff8f7}
+    .evidence article.warning{border-color:#f4ddb0;background:var(--amber-2)}
+    .evidence article.pass{border-color:#cdebdc;background:var(--green-2)}
+    .evidence article>b{font-size:13px;font-weight:700;white-space:nowrap;text-align:center}
+    .evidence article.pass>b{color:var(--green)}
+    .evidence article.warning>b{color:var(--amber)}
+    .evidence article.fail>b{color:var(--red)}
+    .evidence article>div{min-width:0}
+    .evidence article div>strong,.evidence article div>span,.evidence article small{display:block;overflow-wrap:anywhere}
+    .evidence article div>strong{font-size:13px}
+    .evidence article div>span{margin-top:5px;color:#526077;font-size:13px;line-height:1.55}
+    .evidence article small{margin-top:5px;color:var(--muted);font-size:12px}
+    .evidence article p{margin:7px 0 0;font-size:12px;color:var(--amber)}
+    .evidence em{display:flex;align-items:center;justify-content:flex-end;align-self:stretch;min-width:104px;padding-left:12px;border-left:1px solid rgba(56,81,111,.14);text-align:right;color:#38516f;font-size:12px;font-style:normal;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+    .empty{padding:30px;text-align:center;color:var(--muted)}
+    @container (min-width:980px){
+      .evidence{grid-template-columns:repeat(2,minmax(0,1fr));align-items:stretch}
+      .evidence article{height:100%;box-sizing:border-box}
+    }
+    @media(max-width:700px){
+      .evidence article{grid-template-columns:1fr}
+      .evidence article>b{text-align:left}
+      .evidence em{justify-content:flex-start;min-width:0;padding:9px 0 0;border-top:1px solid rgba(56,81,111,.14);border-left:0;text-align:left}
+    }
+  `],
 })
 export class ListingDimensionPanelComponent{
   @Input()score:ListingScorePresentationDimension|null=null;
+
+  isGood(label:string):boolean{
+    return label.includes('优秀')||label.includes('良好')||label.includes('通过')||label.includes('pass')||label.includes('Pass');
+  }
+  isWeak(label:string):boolean{
+    return label.includes('有待优化')||label.includes('弱')||label.includes('weak')||label.includes('Weak')||label.includes('待提升');
+  }
+  isFail(label:string):boolean{
+    return label.includes('未达标')||label.includes('fail')||label.includes('Fail');
+  }
 }

+ 16 - 5
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.html

@@ -10,11 +10,14 @@
     <div class="phone-bar">9:41 <strong>JD</strong> 5G</div>
     <div class="phone-screen">
       @if(mode()==='product'){
-        <div class="hero">@if(source.images[selectedImage()];as image){<img [src]="image.url" [alt]="source.title || '商品主图'">}@else{<span>暂无主图</span>}</div>
-        <div class="price">@if(source.price?.jd !== null && source.price?.jd !== undefined){¥{{source.price.jd | number:'1.0-2'}}}@else{—}</div>
-        <h2>{{source.title || '—'}}</h2>
-        <div class="badges"><span>AI 评分</span><span>{{source.brand.name || '品牌未返回'}}</span><span>{{source.skus.length}} SKU</span></div>
-        <div class="gallery">@for(image of source.images.slice(0,6);track image.url;let index=$index){<button type="button" [class.active]="selectedImage()===index" (click)="selectImage(index)"><img [src]="image.url" [alt]="'商品图 '+(index+1)"></button>}</div>
+        <div class="hero">@if(source.images[selectedImage()];as image){<img [src]="image.url" width="330" height="330" fetchpriority="high" [alt]="source.title || '商品主图'">}@else{<span>暂无主图</span>}<span class="image-index">{{selectedImage()+1}} / {{source.images.length || 1}}</span></div>
+        <div class="thumbs">@for(image of source.images;track image.url;let index=$index){<button type="button" [class.active]="selectedImage()===index" (click)="selectImage(index)" [attr.aria-label]="thumbAriaLabel(index)"><img [src]="image.url" width="56" height="56" loading="lazy" [alt]="thumbAlt(index)"></button>}</div>
+        <div class="product-copy">
+          <div class="price">@if(priceValue();as price){¥{{price | number:'1.0-2'}}}@else{—}<small>当前商品价</small></div>
+          <h2>{{source.title || '—'}}</h2>
+          <span class="platform-pill">京东 · 商品资料已同步</span>
+          <div class="badges"><span>{{source.brand.name || '品牌未返回'}}</span><span>{{source.skus.length}} SKU</span></div>
+        </div>
         <button type="button" class="detail-entry" (click)="mode.set('detail')">查看图文详情 <span>›</span></button>
       }@else{
         <div class="detail-title"><button type="button" (click)="mode.set('product')">‹</button><strong>商品详情</strong><span></span></div>
@@ -23,4 +26,12 @@
     </div>
     <div class="home-indicator"></div>
   </section>
+
+  <!-- Quick facts strip -->
+  <div class="quick-facts">
+    <div><span>品牌</span><strong>{{source.brand.name || '—'}}</strong></div>
+    <div><span>可选规格</span><strong>{{source.skus.length}} 个</strong></div>
+    <div><span>商品展示图</span><strong>{{source.images.length}} 张</strong></div>
+    <div><span>详情素材</span><strong>{{detailImageCount()}} 张图片</strong></div>
+  </div>
 </div>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.scss


+ 12 - 0
src/modules/listing-ai/components/listing-mobile-preview/listing-mobile-preview.component.ts

@@ -8,4 +8,16 @@ export class ListingMobilePreviewComponent {
   readonly mode = signal<'product'|'detail'>('product');
   readonly selectedImage = signal(0);
   selectImage(index: number): void { this.selectedImage.set(index); }
+  priceValue():number|null{
+    return this.source?.price?.jd ?? null;
+  }
+  detailImageCount(): number{
+    return this.source.descriptionStructure?.imageCount ?? 0;
+  }
+  thumbAriaLabel(index: number): string {
+    return '查看第 ' + (index + 1) + ' 张图';
+  }
+  thumbAlt(index: number): string {
+    return '商品图 ' + (index + 1);
+  }
 }

+ 154 - 2
src/modules/listing-ai/components/listing-score-summary/listing-score-summary.component.ts

@@ -7,9 +7,161 @@ import type { ListingScorePresentation } from '../../models/listing-ai.models';
   standalone:true,
   imports:[CommonModule],
   changeDetection:ChangeDetectionStrategy.OnPush,
-  template:`<section class="score"><div class="overall"><strong>{{score?.scoreText || '等待智能评分'}}</strong><span>{{score?.statusLabel || '尚未评分'}}</span></div><div class="dimensions">@for(item of score?.dimensions || [];track item.key){<div><span>{{item.name}}</span><b>{{item.scoreText}}</b><i><em [style.width.%]="item.maxScore && item.score!==null ? item.score/item.maxScore*100 : 0"></em></i></div>}</div></section>`,
-  styles:[`:host{display:block}.score{display:grid;grid-template-columns:210px 1fr;gap:24px;align-items:center}.overall{text-align:center}.overall strong{display:block;font-size:30px;color:#2563eb}.overall span{display:block;margin-top:5px;color:#8791a2;font-size:12px}.dimensions{display:grid;gap:8px}.dimensions div{display:grid;grid-template-columns:100px 120px 1fr;gap:10px;align-items:center;font-size:13px}.dimensions b{text-align:right}.dimensions i{height:7px;background:#e9edf5;border-radius:8px;overflow:hidden}.dimensions em{display:block;height:100%;background:#3876f6}@media(max-width:700px){.score{grid-template-columns:1fr}.dimensions div{grid-template-columns:78px 110px 1fr}}`],
+  template:`@if(score){<section class="score-summary">
+    <!-- Ring + quick stats -->
+    <div class="score-layout">
+      <div class="score-ring" [style.background]="ringGradient(score.score)">
+        <div class="ring-center">
+          <strong>{{score.score !== null ? score.score : '—'}}</strong>
+          <span>总分 / 100</span>
+          <span class="sr-only">{{score.scoreText}}</span>
+        </div>
+      </div>
+      <div class="score-bars">
+        @for(dim of score.dimensions;track dim.key){
+          <div class="score-row">
+            <span>{{dim.name}}</span>
+            <b>{{dim.score !== null ? dim.score : '?'}} / {{dim.maxScore}}</b>
+            <div class="bar"><i [style.width.%]="dim.maxScore && dim.score!==null ? dim.score/dim.maxScore*100 : 0"></i></div>
+            <small>{{dim.maxScore && dim.score!==null ? (dim.score/dim.maxScore*100|number:'1.0-0')+'%' : '—'}}</small>
+          </div>
+        }
+      </div>
+    </div>
+
+    <!-- Quick stats row -->
+    <div class="stats">
+      <div class="stat"><strong>{{score.statusLabel}}</strong><span>评分状态</span></div>
+      <div class="stat"><strong>{{score.score!==null ? (100-score.score).toFixed(1)+' 分' : '—'}}</strong><span>内容提升空间</span></div>
+      <div class="stat"><strong>{{firstWeakDim(score)}}</strong><span>优先优化方向</span></div>
+      <div class="stat"><strong>{{complianceStatus(score)}}</strong><span>合规检查</span></div>
+      <div class="stat"><strong>{{confidenceText(score)}}</strong><span>AI 判断可信度</span></div>
+    </div>
+
+    <!-- AI suggestions -->
+    @if(hasSuggestions(score)){
+      <div class="actions">
+        <h3>AI 给出的优先优化建议</h3>
+        <ol class="suggestion-list">
+          @for(item of suggestionItems(score);track item){
+            <li>{{item}}</li>
+          }
+        </ol>
+        @if(score.scopeNote){
+          <p class="scope-note">{{score.scopeNote}}</p>
+        }
+        @if(score.methodLabel || score.standardLabel){
+          <p class="method-note">{{score.methodLabel}} · {{score.standardLabel}}</p>
+        }
+      </div>
+    }@else if(score.scopeNote || score.methodLabel){
+      <div class="actions">
+        <h3>本次评分说明</h3>
+        @if(score.scopeNote){
+          <p class="scope-note">{{score.scopeNote}}</p>
+        }
+        @if(score.methodLabel || score.standardLabel){
+          <p class="method-note">{{score.methodLabel}} · {{score.standardLabel}}</p>
+        }
+      </div>
+    }
+    @if(score.complianceFindings?.length){
+      <div class="compliance-alert" role="status">
+        <h3>合规检查提示</h3>
+        <ul>@for(finding of score.complianceFindings;track finding.message){<li>{{finding.message}}</li>}</ul>
+      </div>
+    }
+  </section>}`,
+  styles:[`
+:host{
+  --blue:#2f67e8;--blue-2:#eaf0ff;--green:#12805c;--green-2:#e8f6f0;
+  --amber:#946200;--amber-2:#fff4d8;--red:#bc3d3d;
+  --ink:#172033;--muted:#667085;--line:#e4e9f1;--soft:#f5f7fb;
+  display:block;
+}
+.score-summary{display:flex;flex-direction:column;gap:0}
+    .score-layout{display:grid;grid-template-columns:200px 1fr;gap:28px;align-items:center;padding:20px}
+    .score-ring{width:154px;height:154px;border-radius:50%;display:grid;place-items:center;position:relative;flex-shrink:0}
+    .score-ring::after{content:'';position:absolute;inset:13px;background:#fff;border-radius:50%}
+    .ring-center{position:relative;z-index:1;text-align:center}
+    .ring-center strong{font-size:36px;line-height:1;color:#265bcf}
+    .ring-center span{display:block;margin-top:6px;color:var(--muted);font-size:11px}
+    .sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}
+    .score-bars{display:grid;gap:11px}
+    .score-row{display:grid;grid-template-columns:96px 88px 1fr 70px;align-items:center;gap:10px;font-size:13px}
+    .score-row b{text-align:right}
+    .bar{height:8px;background:#e9edf5;overflow:hidden;border-radius:99px}
+    .bar i{display:block;height:100%;background:var(--blue);border-radius:99px}
+    .score-row small{color:var(--muted);text-align:right;font-size:12px}
+    .stats{display:grid;grid-template-columns:repeat(5,1fr);border-top:1px solid var(--line)}
+    .stat{padding:12px 14px;border-right:1px solid var(--line)}
+    .stat:last-child{border-right:none}
+    .stat strong,.stat span{display:block}
+    .stat strong{font-size:17px;margin-bottom:4px}
+    .stat span{color:var(--muted);font-size:11px}
+    .actions{margin:16px 20px 20px;padding:14px;border:1px solid #d8e3fa;background:#f7f9fe;border-radius:8px}
+    .actions h3{font-size:13px;margin:0 0 9px;font-weight:700}
+    .actions p{margin:0;line-height:1.55}
+    .scope-note{color:#31507f;font-size:12px;margin-bottom:6px}
+    .method-note{color:var(--muted);font-size:11px}
+    .suggestion-list{margin:0 0 10px;padding-left:20px;color:#3f4d63}
+    .suggestion-list li{padding:4px 0;line-height:1.55;font-size:13px}
+    .compliance-alert{margin:0 20px 20px;padding:14px;border:1px solid #f0ddb0;background:var(--amber-2);border-radius:8px;color:#765100}
+    .compliance-alert h3{font-size:13px;margin:0 0 8px}.compliance-alert ul{margin:0;padding-left:20px}.compliance-alert li{padding:3px 0;font-size:12px;line-height:1.5}
+    @media(max-width:1100px){
+      .score-layout{grid-template-columns:160px 1fr}
+      .stats{grid-template-columns:repeat(3,1fr)}
+      .stat:nth-child(3){border-right:none}
+      .stat:nth-child(2),.stat:nth-child(3){border-top:1px solid var(--line)}
+    }
+    @media(max-width:700px){
+      .score-layout{grid-template-columns:1fr}
+      .score-ring{margin:auto}
+      .score-row{grid-template-columns:82px 72px 1fr}
+      .score-row small{display:none}
+      .stats{grid-template-columns:repeat(2,1fr)}
+      .stat:nth-child(even){border-right:none}
+      .stat:nth-child(3),.stat:nth-child(4),.stat:nth-child(5){border-top:1px solid var(--line)}
+    }
+  `],
 })
 export class ListingScoreSummaryComponent{
   @Input()score:ListingScorePresentation|null=null;
+
+  ringGradient(score:number|null):string{
+    const pct=score!==null ? score : 0;
+    return `conic-gradient(var(--blue) 0 ${pct}%,#e8edf6 ${pct}% 100%)`;
+  }
+
+  firstWeakDim(score:ListingScorePresentation):string{
+    const dim=score.dimensions.filter(d=>d.maxScore&&d.score!==null&&d.score/d.maxScore<1).sort((a,b)=>(a.score!/a.maxScore)-(b.score!/b.maxScore))[0];
+    return dim?.name ?? '—';
+  }
+
+  complianceStatus(score:ListingScorePresentation):string{
+    const labels:Record<NonNullable<ListingScorePresentation['complianceStatus']>,string>={normal:'正常',warning:'有提示',needs_review:'需复核',blocked:'已阻断'};
+    return score.complianceStatus ? labels[score.complianceStatus] : '未检查';
+  }
+
+  confidenceText(score:ListingScorePresentation):string{
+    return score.aiConfidence===null||score.aiConfidence===undefined ? '—' : Math.round(score.aiConfidence*100)+'%';
+  }
+
+  hasSuggestions(score:ListingScorePresentation):boolean{
+    return this.suggestionItems(score).length>0;
+  }
+
+  suggestionItems(score:ListingScorePresentation):string[]{
+    if(score.suggestions?.length)return score.suggestions.slice(0,6);
+    const items:string[]=[];
+    for(const dim of score.dimensions){
+      for(const item of dim.items){
+        if(item.action&&items.length<6){
+          const formatted=`${dim.name}:${item.title}——${item.action}`;
+          if(!items.includes(formatted))items.push(formatted);
+        }
+      }
+    }
+    return items;
+  }
 }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
src/modules/listing-ai/listing-ai.shared.scss


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 1
src/modules/listing-ai/models/listing-ai.models.ts


+ 121 - 8
src/modules/listing-ai/pages/product/listing-ai-product.component.html

@@ -1,26 +1,139 @@
-<main class="listing-page">@if(loading()){<div class="state">正在加载商品详情…</div>}@else if(error()){<div class="state error">{{error()}} <button class="link" (click)="load()">重试</button></div>}@else{@if(detail();as vm){
-  <app-page-header [title]="vm.source.title || '商品详情'" eyebrow="商品评分详情" [description]="'京东商品 '+vm.source.productId" [backLabel]="returnTo() ? '' : '返回工作台'" [backRoute]="returnTo() ? null : '/listing-ai/workbench'" [badges]="[{label:vm.source.detailStatus==='available'?'数据完整':'数据待补充',tone:vm.source.detailStatus==='available'?'green':'amber'}]">
+<main class="listing-page listing-product-page">@if(loading()){<div class="state">正在加载商品详情…</div>}@else if(error()){<div class="state error">{{error()}} <button class="link" (click)="load()">重试</button></div>}@else{@if(detail();as vm){
+  <app-page-header [title]="vm.source.title || '商品详情'" eyebrow="Listing 商品诊断报告" [badges]="[{label:vm.source.detailStatus==='available'?'商品资料完整':'商品资料待补充',tone:vm.source.detailStatus==='available'?'green':'amber'}]" [meta]="buildHeaderMeta(vm.source, vm.currentScore)" [confidence]="aiConfidenceTone()" [backLabel]="returnTo() ? '' : '返回工作台'" [backRoute]="returnTo() ? null : '/listing-ai/workbench'">
     <div pageHeaderActions class="header-actions">@if(returnTo()){<button class="btn secondary" type="button" (click)="backToOverview()">返回评分看板</button>}<button class="btn secondary" (click)="load()">刷新</button></div>
   </app-page-header>
-  @if(action()){<div class="alert" [class.error]="action().includes('失败')">{{action()}}</div>}
+  @if(vm.currentScore){<div class="snapshot-note"><b>正式评分已完成</b><span>{{snapshotNoteText(vm.currentScore)}}</span></div>}@else if(vm.rulePrecheck){<div class="snapshot-note pending"><b>仅完成规则预检</b><span>当前仅有规则预检结果,AI 智能评分完成后将生成固定满分 100 分的正式报告。</span></div>}
+  @if(action()){<div class="alert" aria-live="polite" [class.error]="action().includes('失败')">{{action()}}</div>}
   <div class="detail-grid"><aside class="sticky"><app-listing-mobile-preview [source]="vm.source"></app-listing-mobile-preview></aside><section class="listing-detail-content">
-    <app-content-card title="评分总览" [subtitle]="vm.currentScore?.dataUpdatedAtText || '尚未完成智能评分'">
+    <app-content-card title="评分总览" [subtitle]="scoreSubtitle(vm.currentScore || vm.rulePrecheck)">
       <app-listing-score-summary [score]="vm.currentScore || vm.rulePrecheck"></app-listing-score-summary>
       @if(vm.currentScore;as score){<div class="source-box"><h3>本次评分说明</h3><p>{{score.scopeNote}}</p><p>{{score.methodLabel}} · {{score.standardLabel}}</p></div>}@else if(vm.rulePrecheck){<div class="alert">基础检查已经完成。标题和核心卖点将在智能评分完成后计入,届时生成固定满分 100 分的正式结果。</div>}
     </app-content-card>
     <app-content-card title="商品内容与评分说明" subtitle="每项评分均使用当前可从京东接口读取的商品信息">
       <app-board-tabs [tabs]="tabs" [activeKey]="active" (tabChange)="select($event)"></app-board-tabs>
       <div class="tab-content source-grid" [class.description-layout]="active==='description'">
-        <div class="source-box"><h3>当前商品内容</h3>@switch(active){
+        <section class="source-box current-content-box"><h3>当前商品内容</h3>@switch(active){
           @case('title'){<p>{{vm.source.title || '暂无商品标题'}}</p>}
           @case('selling_points'){@if(sellingPointRows(vm.source);as points){@if(points.length){<div class="selling-points">@for(item of points;track item.label+item.value){<article><span>{{item.label}}</span><strong>{{item.value}}</strong></article>}</div>}@else{<p>暂无商品广告语或规格短标题。</p>}}}
           @case('images'){<p class="field-note">当前只检查图片资产完整性,不进行真实视觉质量评分。</p>@if(vm.source.images.length){<div class="gallery">@for(image of vm.source.images;track image.url){<img [src]="image.url" alt="商品图片">}</div>}@else{<p>暂无商品图片。</p>}}
           @case('description'){<div class="listing-description-content" [innerHTML]="vm.source.descriptions.mobileHtml || vm.source.descriptions.desktopHtml || '暂无商品详情内容'"></div>}
-          @case('specifications'){@if(vm.source.attributes.length){<dl>@for(item of vm.source.attributes;track item.id){<dt>{{item.name}}</dt><dd>{{item.values.join('、')}}</dd>}</dl>}@else{<p>暂无商品规格信息。</p>}}
-        }</div>
-        <div class="source-box"><app-listing-dimension-panel [score]="dimension()"></app-listing-dimension-panel></div>
+          @case('specifications'){@if(vm.source.attributes.length){<dl class="specification-list">@for(item of vm.source.attributes;track item.id){<div><dt>{{item.name}}</dt><dd>{{item.values.join('、')}}</dd></div>}</dl>}@else{<p>暂无商品规格信息。</p>}}
+        }</section>
+        <section class="source-box score-detail-box" aria-label="评分说明"><app-listing-dimension-panel [score]="dimension()"></app-listing-dimension-panel></section>
       </div>
     </app-content-card>
+
+    <!-- 商品资料全览 (mirrors listing-detail-standalone.html API data section) -->
+    <app-content-card title="商品资料全览" subtitle="集中查看价格、规格、属性、图片和详情内容,便于核对与优化">
+      <div class="api-nav">
+        <button type="button" class="api-nav-btn" (click)="scrollTo('base')">基本信息</button>
+        <button type="button" class="api-nav-btn" (click)="scrollTo('category')">类目建议</button>
+        <button type="button" class="api-nav-btn" (click)="scrollTo('sku')">可选规格</button>
+        <button type="button" class="api-nav-btn" (click)="scrollTo('image-meta')">商品图片</button>
+        <button type="button" class="api-nav-btn" (click)="scrollTo('detail-assets')">详情内容</button>
+      </div>
+
+      <!-- 基本信息 -->
+      <div class="api-section" id="base">
+        <h3 class="api-section-title">基本信息与服务</h3>
+        <div class="data-grid">
+          <div class="data-item"><span>销售平台</span><strong>京东</strong></div>
+          <div class="data-item"><span>商品 ID</span><strong>{{vm.source.productId}}</strong></div>
+          <div class="data-item"><span>品牌</span><strong>{{vm.source.brand.name || '—'}}</strong></div>
+          <div class="data-item"><span>商品资料</span><strong [class.text-green]="vm.source.detailStatus==='available'" [class.text-amber]="vm.source.detailStatus!=='available'">{{vm.source.detailStatus==='available'?'已同步':'待补充'}}</strong></div>
+          <div class="data-item"><span>当前商品价</span><strong>{{getPriceDisplay(vm.source)}}</strong></div>
+          <div class="data-item"><span>可选规格</span><strong>{{vm.source.skus.length}} 个</strong></div>
+          <div class="data-item"><span>尺寸资料</span><strong>{{getDimensionsNote(vm.source)}}</strong></div>
+          <div class="data-item"><span>重量资料</span><strong>{{vm.source.dimensions.weight !== null ? vm.source.dimensions.weight+'kg' : '—'}}</strong></div>
+          <div class="data-item wide"><span>商品广告语</span><strong>{{getAdword(vm.source) || '—'}}</strong></div>
+          <div class="data-item wide"><span>服务信息</span><strong>{{getAfterServiceNote(vm.source)}}</strong></div>
+        </div>
+      </div>
+
+      <!-- 类目建议 -->
+      <div class="api-section" id="category">
+        <h3 class="api-section-title">类目与内容优化参考</h3>
+        <div class="data-grid">
+          <div class="data-item wide"><span>所属类目</span><strong>{{vm.source.categoryContext?.displayName || vm.source.categoryContext?.names?.join(' / ') || '—'}}</strong></div>
+          <div class="data-item wide"><span>资质提示</span><strong>{{getQualificationNames(vm.source).join('、') || '当前类目没有额外资质提示'}}</strong></div>
+          <div class="data-item wide"><span>标题核心词</span><strong>{{getCategoryCoreTerms(vm.source).join('、') || '—'}}</strong></div>
+          <div class="data-item wide"><span>可参考搜索词</span><strong>{{getCategoryAliases(vm.source).join('、') || '—'}}</strong></div>
+          <div class="data-item wide"><span>建议完善的规格</span><strong>{{getRequiredSpecs(vm.source).join('、') || '—'}}</strong></div>
+        </div>
+      </div>
+
+      <!-- 商品属性 -->
+      <div class="api-section">
+        <h3 class="api-section-title">已填写商品属性({{vm.source.attributes.length}} 项)</h3>
+        <div class="attr-grid">
+          @for(attr of getAttributeRows(vm.source);track attr.name){
+            <div class="attr"><span>{{attr.name}}</span><strong>{{attr.values.join('、')}}</strong></div>
+          }
+        </div>
+      </div>
+
+      <!-- SKU -->
+      <div class="api-section" id="sku">
+        <h3 class="api-section-title">可选规格({{vm.source.skus.length}} 个)</h3>
+        <div class="table-wrap">
+          <table>
+            <thead><tr><th>规格名称</th><th>价格 / 库存</th><th>销售状态</th><th>规格参数</th></tr></thead>
+            <tbody>
+              @for(sku of getSkuTableRows(vm.source);track sku.skuId){
+                <tr>
+                  <td class="sku-name"><strong>{{sku.name || '—'}}</strong><br><span class="sku-id">规格编号 {{sku.skuId}}</span></td>
+                  <td>{{sku.price !== null ? formatCurrency(sku.price) : '—'}}<br>库存 {{sku.stock ?? '—'}}</td>
+                  <td><span [class.status-ok]="sku.status==='正常销售'">{{sku.status}}</span></td>
+                  <td>{{sku.attrs || '—'}}</td>
+                </tr>
+              }
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <!-- 图片元数据 -->
+      <div class="api-section" id="image-meta">
+        <h3 class="api-section-title">商品图片({{vm.source.images.length}} 张)</h3>
+        <div class="table-wrap">
+          <table>
+            <thead><tr><th>顺序</th><th>缩略图</th><th>图片用途</th><th>链接状态</th></tr></thead>
+            <tbody>
+              @for(img of getImageTableRows(vm.source);track img.url;let i=$index){
+                <tr>
+                  <td>第 {{i+1}} 张</td>
+                  <td><img [src]="img.url" width="48" height="48" style="object-fit:cover;border-radius:4px" loading="lazy" alt="图 {{i+1}}"></td>
+                  <td>{{img.isPrimary ? '商品主图' : '商品展示图'}}</td>
+                  <td><span class="status-ok">正常</span></td>
+                </tr>
+              }
+            </tbody>
+          </table>
+        </div>
+        <p class="img-note">{{getImageAssetNote(vm.source)}}</p>
+      </div>
+
+      <!-- 详情内容 -->
+      <div class="api-section" id="detail-assets">
+        <h3 class="api-section-title">商品详情内容</h3>
+        <div class="data-grid" style="margin-bottom:10px">
+          <div class="data-item"><span>电脑端详情</span><strong [class.text-green]="vm.source.descriptions.desktopHtml">{{vm.source.descriptions.desktopHtml ? '内容完整' : '暂未配置'}}</strong></div>
+          <div class="data-item"><span>移动端详情</span><strong [class.text-green]="vm.source.descriptions.mobileHtml">{{vm.source.descriptions.mobileHtml ? '内容完整' : '暂未配置'}}</strong></div>
+          <div class="data-item"><span>详情图片</span><strong>{{getDetailImageCount(vm.source)}} 张</strong></div>
+          <div class="data-item"><span>视频内容</span><strong [class.text-muted]="!vm.source.descriptionStructure?.videoCount">{{vm.source.descriptionStructure?.videoCount ? vm.source.descriptionStructure?.videoCount+' 个' : '暂未配置'}}</strong></div>
+        </div>
+        @if(detailAssetUrls().length){
+          <div class="detail-assets" aria-label="商品详情图片">
+            @for(url of detailAssetUrls();track url;let i=$index){
+              <img [src]="url" width="240" height="240" loading="lazy" [alt]="'详情素材 '+(i+1)">
+            }
+          </div>
+        }@else{
+          <p class="img-note">暂未提取到可展示的详情图片。</p>
+        }
+      </div>
+    </app-content-card>
+
     <app-content-card title="优化版本" subtitle="保存人工或智能优化后的内部草稿;采纳不会自动发布到京东">
       <div class="header-actions"><button class="btn secondary" (click)="createVersion()">保存当前内容为草稿</button></div>
       @if(vm.versionsSummary.items.length){<div class="version-list">@for(version of vm.versionsSummary.items;track version.id){<article class="version-card"><div class="version-heading"><strong>版本 {{version.versionNo}} · {{version.status==='adopted'?'已采用':'草稿'}}</strong><span [class.changed]="versionChangeCount(version,vm.source)>0">{{versionChangeCount(version,vm.source)}} 项内容变化</span></div><span>{{version.createdAt|date:'yyyy-MM-dd HH:mm'}}</span><p>{{version.content.title || '暂无标题'}}</p><div><button class="btn primary" [disabled]="version.status==='adopted'||versionChangeCount(version,vm.source)===0" (click)="adopt(version)">{{version.status==='adopted'?'已采用':versionChangeCount(version,vm.source)===0?'内容无变化':'采用此版本'}}</button></div></article>}</div>}@else{<div class="state">尚无优化草稿。</div>}

+ 137 - 3
src/modules/listing-ai/pages/product/listing-ai-product.component.ts

@@ -8,16 +8,17 @@ import { BoardTabsComponent, type BoardTabItem } from '../../../shared/component
 import { ListingMobilePreviewComponent } from '../../components/listing-mobile-preview/listing-mobile-preview.component';
 import { ListingScoreSummaryComponent } from '../../components/listing-score-summary/listing-score-summary.component';
 import { ListingDimensionPanelComponent } from '../../components/listing-dimension-panel/listing-dimension-panel.component';
-import type { ListingDimension, ListingProductDetailResponse, ListingScorePresentationDimension, ListingVersion } from '../../models/listing-ai.models';
+import type { ListingDimension, ListingProductDetailResponse, ListingScorePresentation, ListingScorePresentationDimension, ListingVersion } from '../../models/listing-ai.models';
 import { ListingAiApiService } from '../../services/listing-ai-api.service';
+import type { PageHeaderMetaItem } from '../../../shared/components/page-header/page-header.component';
 
 @Component({selector:'app-listing-ai-product',standalone:true,imports:[CommonModule,PageHeaderComponent,ContentCardComponent,BoardTabsComponent,ListingMobilePreviewComponent,ListingScoreSummaryComponent,ListingDimensionPanelComponent],changeDetection:ChangeDetectionStrategy.OnPush,templateUrl:'./listing-ai-product.component.html',styleUrl:'../../listing-ai.shared.scss'})
 export class ListingAiProductComponent implements OnInit{
-  private readonly route=inject(ActivatedRoute);private readonly router=inject(Router);private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly detail=signal<ListingProductDetailResponse|null>(null);readonly loading=signal(true);readonly error=signal('');readonly action=signal('');readonly returnTo=signal<string|null>(null);active:ListingDimension='title';
+  private readonly route=inject(ActivatedRoute);private readonly router=inject(Router);private readonly api=inject(ListingAiApiService);readonly workspaceId=RUNTIME_CONFIG.domesticWorkspaceId;readonly detail=signal<ListingProductDetailResponse|null>(null);readonly detailAssetUrls=signal<string[]>([]);readonly loading=signal(true);readonly error=signal('');readonly action=signal('');readonly returnTo=signal<string|null>(null);active:ListingDimension='title';
   readonly tabs:BoardTabItem[]=[{key:'title',label:'商品标题 · 30分'},{key:'selling_points',label:'核心卖点 · 25分'},{key:'images',label:'图片资产 · 20分'},{key:'description',label:'商品详情 · 15分'},{key:'specifications',label:'规格与履约 · 10分'}];
   ngOnInit():void{const returnTo=this.route.snapshot.queryParamMap?.get('returnTo');if(returnTo?.startsWith('/listing-ai/overview')&&this.router.parseUrl(returnTo).root.children['primary']?.segments.map((segment)=>segment.path).join('/')==='listing-ai/overview')this.returnTo.set(returnTo);this.load();}
   backToOverview():void{const returnTo=this.returnTo();if(returnTo)void this.router.navigateByUrl(returnTo);}
-  load():void{const productId=this.route.snapshot.paramMap.get('productId')??'';this.loading.set(true);this.api.product(this.workspaceId,productId).subscribe({next:(detail)=>this.detail.set(detail),error:()=>{this.error.set('商品加载失败,请稍后重试');this.loading.set(false);},complete:()=>this.loading.set(false)});}
+  load():void{const productId=this.route.snapshot.paramMap.get('productId')??'';this.loading.set(true);this.error.set('');this.api.product(this.workspaceId,productId).subscribe({next:(detail)=>{this.detail.set(detail);this.detailAssetUrls.set(this.extractDetailImageUrls(detail.source));},error:()=>{this.error.set('商品加载失败,请稍后重试');this.loading.set(false);},complete:()=>this.loading.set(false)});}
   select(key:string):void{this.active=key as ListingDimension;}
   dimension():ListingScorePresentationDimension|null{const score=this.detail()?.currentScore??this.detail()?.rulePrecheck;return score?.dimensions.find((item)=>item.key===this.active)??null;}
   sellingPointRows(source: ListingProductDetailResponse['source']): Array<{label:string;value:string}> {
@@ -34,4 +35,137 @@ export class ListingAiProductComponent implements OnInit{
   }
   createVersion():void{const value=this.detail();if(!value)return;const source=value.source;const content={title:source.title,sellingPoints:source.marketing?.sellingPoints.map((item)=>item.value)??[],descriptionHtml:source.descriptions.mobileHtml??source.descriptions.desktopHtml,specifications:source.attributes,imageUrls:source.images.map((item)=>item.url)};this.action.set('正在创建版本…');this.api.createVersion(this.workspaceId,source.productId,source.sourceHash,content).subscribe({next:({version})=>{this.detail.update((current)=>current?{...current,versionsSummary:{...current.versionsSummary,items:[version,...current.versionsSummary.items]}}:current);this.action.set('优化草稿已创建');},error:()=>this.action.set('版本创建失败,请稍后重试')});}
   adopt(version:ListingVersion):void{this.action.set('正在采用版本…');this.api.adoptVersion(this.workspaceId,version.id).subscribe({next:({version:updated})=>{this.detail.update((current)=>current?{...current,versionsSummary:{...current.versionsSummary,items:current.versionsSummary.items.map((item)=>item.id===updated.id?updated:item)}}:current);this.action.set('已采用内部版本;未向京东发布');},error:(error)=>this.action.set(error instanceof Error?error.message:'采用失败')});}
+
+  scrollTo(id:string):void{
+    const el=document.getElementById(id);
+    if(el)el.scrollIntoView({behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'});
+  }
+
+  getDetailImageCount(source:ListingProductDetailResponse['source']):number{
+    return source.descriptionStructure?.imageCount ?? 0;
+  }
+
+  getSkuTableRows(source:ListingProductDetailResponse['source']):Array<{skuId:string;name:string|null;price:number|null;stock:number|null;status:string;attrs:string}>{
+    return source.skus.map(s=>({skuId:s.skuId,name:s.name,price:s.price,stock:s.stock,status:this.skuStatusLabel(s.status),attrs:s.attributes.map(a=>`${a.name}:${a.values.join('、')}`).join(';')}));
+  }
+
+  getImageTableRows(source:ListingProductDetailResponse['source']):Array<{url:string;order:number|null;isPrimary:boolean|null}>{
+    return source.images.map(img=>({url:img.url,order:img.order,isPrimary:img.isPrimary}));
+  }
+
+  getAttributeRows(source:ListingProductDetailResponse['source']):Array<{name:string;values:string[]}>{
+    return source.attributes;
+  }
+
+  getAdword(source:ListingProductDetailResponse['source']):string|null{
+    return source.marketing?.adword ?? null;
+  }
+
+  getAfterServiceNote(source:ListingProductDetailResponse['source']):string{
+    const service=source.afterService as Record<string,unknown>;
+    const sevenDays=service['to7ReturnFlag'] ?? service['sevenDayReturn'] ?? service['七天无理由退货'];
+    const note=sevenDays===true||sevenDays===1||sevenDays==='1'||String(sevenDays).toLowerCase()==='true' ? '已支持七天无理由退货' : '暂未标记七天无理由退货';
+    const description=[service['serviceDesc'],service['service']].map((item)=>typeof item==='string'?item.trim():'').find(Boolean);
+    return description ? `${note};${description}` : `${note},详细售后说明有待补充`;
+  }
+
+  getDimensionsNote(source:ListingProductDetailResponse['source']):string{
+    const d=source.dimensions;
+    const parts:string[]=[];
+    if(d.length!==null)parts.push(`长 ${this.dimensionText(d.length)}`);
+    if(d.width!==null)parts.push(`宽 ${this.dimensionText(d.width)}`);
+    if(d.height!==null)parts.push(`高 ${this.dimensionText(d.height)}`);
+    return parts.length>0 ? `已填写(${parts.join('、')})` : '尚未填写';
+  }
+
+  getCategoryCoreTerms(source:ListingProductDetailResponse['source']):string[]{
+    return source.categoryContext?.coreTerms ?? [];
+  }
+
+  getCategoryAliases(source:ListingProductDetailResponse['source']):string[]{
+    return source.categoryContext?.aliases ?? [];
+  }
+
+  getRequiredSpecs(source:ListingProductDetailResponse['source']):string[]{
+    return source.categoryContext?.requiredSpecificationNames ?? [];
+  }
+
+  getPriceDisplay(source:ListingProductDetailResponse['source']):string{
+    return source.price?.jd != null ? this.formatCurrency(source.price.jd) : '—';
+  }
+  getQualificationNames(source:ListingProductDetailResponse['source']):string[]{
+    return source.categoryContext?.qualificationNames ?? [];
+  }
+
+  getImageAssetNote(source:ListingProductDetailResponse['source']):string{
+    const skuImageCount=(source.imageAssets?.skuImages??[]).reduce((sum,item)=>sum+(item.images?.length??0),0);
+    const whiteImageCount=source.imageAssets?.whiteBackgroundImages?.length??0;
+    const parts=[`当前已有 ${source.images.length} 张商品展示图`];
+    parts.push(skuImageCount ? `已配置 ${skuImageCount} 张规格图片` : '各规格尚未配置独立图片');
+    parts.push(whiteImageCount ? `已配置 ${whiteImageCount} 张白底图片` : '尚未配置单独的白底图片');
+    return `${parts.join(';')}。`;
+  }
+
+  scoreSubtitle(score:ListingScorePresentation|null):string{
+    if(!score)return '尚未完成智能评分';
+    const scoredAt=score.scoredAtText ? this.formatSyncedAt(score.scoredAtText) : '';
+    const dataAt=score.dataUpdatedAtText ? this.formatSyncedAt(score.dataUpdatedAtText) : '';
+    return scoredAt ? `评分时间:${scoredAt}${dataAt?` · 商品资料:${dataAt}`:''}` : (dataAt||'评分已完成');
+  }
+
+  buildHeaderMeta(source: ListingProductDetailResponse['source'], score: ListingScorePresentation | null): PageHeaderMetaItem[] {
+    const categoryText = source.categoryContext?.displayName
+      || source.categoryContext?.pathNames?.join(' / ')
+      || source.categoryContext?.names?.join(' / ')
+      || '类目未识别';
+    const updated = score?.dataUpdatedAtText || this.formatSyncedAt(source.syncedAt);
+    return [
+      { label: '商品 ID:', value: source.productId },
+      { label: '类目:', value: categoryText },
+      { label: '资料更新:', value: updated },
+    ];
+  }
+
+  aiConfidenceTone(): 'high' | 'medium' | 'low' | null {
+    const detail = this.detail();
+    const score = detail?.currentScore ?? detail?.rulePrecheck;
+    if (!score) return null;
+    if (score.aiConfidence === null || score.aiConfidence === undefined) return null;
+    if (score.aiConfidence >= 0.8) return 'high';
+    if (score.aiConfidence >= 0.6) return 'medium';
+    return 'low';
+  }
+
+  snapshotNoteText(score: ListingScorePresentation): string {
+    return score.scopeNote || (score.methodLabel + ' · ' + score.standardLabel) || '本商品已通过项目真实流程完成规则预检与 AI 智能评分。评分范围不包含图片审美、详情图片文字识别、用户评价和竞品差异。';
+  }
+
+  private formatSyncedAt(value: string): string {
+    if (!value) return '—';
+    const date = new Date(value);
+    if (Number.isNaN(date.getTime())) return value;
+    return new Intl.DateTimeFormat('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).format(date);
+  }
+
+  formatCurrency(value:number):string{
+    return new Intl.NumberFormat('zh-CN',{style:'currency',currency:'CNY',minimumFractionDigits:0,maximumFractionDigits:2}).format(value);
+  }
+
+  private dimensionText(value:number):string{
+    return value>=100 ? `${value} mm(${value/10} cm)` : `${value} mm`;
+  }
+
+  private skuStatusLabel(status:string|null):string{
+    if(status==='1'||status==='onsale'||status==='on_sale'||status==='enabled')return '正常销售';
+    if(status==='0'||status==='offsale'||status==='off_sale'||status==='disabled')return '已下架';
+    return status ? `状态 ${status}` : '状态未知';
+  }
+
+  private extractDetailImageUrls(source:ListingProductDetailResponse['source']):string[]{
+    const html=source.descriptions.mobileHtml||source.descriptions.desktopHtml||'';
+    if(!html||typeof DOMParser==='undefined')return [];
+    const parsed=new DOMParser().parseFromString(html,'text/html');
+    const urls=Array.from(parsed.querySelectorAll('img')).map((image)=>image.getAttribute('src')?.trim()||'').filter(Boolean).map((url)=>url.startsWith('//')?`https:${url}`:url.replace(/^http:\/\//i,'https://'));
+    return [...new Set(urls)];
+  }
 }

+ 82 - 8
src/modules/monitoring/pages/category-voc/category-voc.component.ts

@@ -36,6 +36,15 @@ interface BubblePoint {
   raw: CategoryVocDensityItem;  // 关联原数据,用于 tooltip
 }
 
+interface BubbleLayoutPoint extends BubblePoint {
+  bubbleX: number;
+  bubbleY: number;
+  bubbleR: number;
+  labelX: number;
+  labelY: number;
+  labelShifted: boolean;
+}
+
 /** 表格分组(按维度生成) */
 interface TableGroup {
   groupName: string;
@@ -193,11 +202,11 @@ interface TableGroup {
                     }
 
                     <!-- 数据气泡 -->
-                    @for (b of bubblePoints; track b.key) {
+                    @for (b of bubbleLayoutPoints; track b.key) {
                       <circle
-                        [attr.cx]="getBubbleX(b.x)"
-                        [attr.cy]="getBubbleY(b.y)"
-                        [attr.r]="getBubbleR(b.r)"
+                        [attr.cx]="b.bubbleX"
+                        [attr.cy]="b.bubbleY"
+                        [attr.r]="b.bubbleR"
                         [attr.fill]="b.bucketColor"
                         [attr.stroke]="b.bucketColor"
                         stroke-width="1.5" opacity="0.7" class="quadrant-bubble"
@@ -206,9 +215,17 @@ interface TableGroup {
                         (click)="drillIntoCategory(b)">
                         <title>{{ b.label }}:销量 {{ b.y | number }} / 差评率 {{ b.x }}% / {{ b.r }} 产品</title>
                       </circle>
+                      @if (b.labelShifted) {
+                        <line
+                          [attr.x1]="b.bubbleX + b.bubbleR"
+                          [attr.y1]="b.bubbleY"
+                          [attr.x2]="b.labelX - 2"
+                          [attr.y2]="b.labelY - 3"
+                          class="quadrant-label-line" />
+                      }
                       <text
-                        [attr.x]="getBubbleX(b.x) + getBubbleR(b.r) + 4"
-                        [attr.y]="getBubbleY(b.y) + 4"
+                        [attr.x]="b.labelX"
+                        [attr.y]="b.labelY"
                         text-anchor="start" fill="#475569" font-size="10" font-weight="500"
                         class="quadrant-label">{{ b.label.length > 12 ? b.label.slice(0,12) + '..' : b.label }}</text>
                     }
@@ -421,6 +438,7 @@ interface TableGroup {
     .quadrant-bubble:hover { opacity: 1 !important; stroke-width: 2.5; }
     .quadrant-bubble.selected { opacity: 1 !important; stroke-width: 3; filter: drop-shadow(0 0 10px rgba(37, 99, 235, 0.35)); }
     .quadrant-label { pointer-events: none; }
+    .quadrant-label-line { stroke: #94a3b8; stroke-width: 0.8; opacity: 0.72; pointer-events: none; }
     .quadrant-legend { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; margin-top: 6px; padding-top: 10px; border-top: 1px dashed #e5e7eb; }
     .lg-item { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #475569; }
     .lg-item i { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
@@ -804,8 +822,8 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
   // ─── 四象限气泡数据 ───
   get bubblePoints(): BubblePoint[] {
     if (this.dimension === 'subCategory') return [];
-    return this.categories.map(c => ({
-      key: c.category || `cat-${Math.random()}`,
+    return this.categories.map((c, index) => ({
+      key: c.category || `cat-${index}`,
       label: c.category || '未知',
       x: c.negativeRate,
       y: c.totalSales || c.avgSales || c.productCount,  // 退化路径:总销量→均销→产品数
@@ -815,6 +833,62 @@ export class CategoryVocComponent implements OnInit, OnDestroy {
     }));
   }
 
+  /**
+   * 保持气泡坐标不变,仅在纵向为文字标签留出最小间距。
+   * 标签被移动时由模板绘制引导线,避免名称与气泡失去对应关系。
+   */
+  get bubbleLayoutPoints(): BubbleLayoutPoint[] {
+    const points = this.bubblePoints.map((bubble, index) => {
+      const bubbleX = this.getBubbleX(bubble.x);
+      const bubbleY = this.getBubbleY(bubble.y);
+      const bubbleR = this.getBubbleR(bubble.r);
+      const desiredLabelY = bubbleY + 4;
+      return {
+        ...bubble,
+        index,
+        bubbleX,
+        bubbleY,
+        bubbleR,
+        labelX: bubbleX + bubbleR + 4,
+        labelY: desiredLabelY,
+        desiredLabelY,
+      };
+    });
+    if (points.length < 2) {
+      return points.map(({ index: _index, desiredLabelY, ...point }) => ({
+        ...point,
+        labelShifted: Math.abs(point.labelY - desiredLabelY) > 1,
+      }));
+    }
+
+    const minLabelY = 32;
+    const maxLabelY = 336;
+    const minGap = 14;
+    const sorted = [...points].sort((left, right) => left.desiredLabelY - right.desiredLabelY || left.index - right.index);
+
+    sorted[0].labelY = Math.max(minLabelY, Math.min(maxLabelY, sorted[0].desiredLabelY));
+    for (let index = 1; index < sorted.length; index += 1) {
+      sorted[index].labelY = Math.max(
+        Math.max(minLabelY, Math.min(maxLabelY, sorted[index].desiredLabelY)),
+        sorted[index - 1].labelY + minGap,
+      );
+    }
+
+    if (sorted[sorted.length - 1].labelY > maxLabelY) {
+      sorted[sorted.length - 1].labelY = maxLabelY;
+      for (let index = sorted.length - 2; index >= 0; index -= 1) {
+        sorted[index].labelY = Math.min(sorted[index].labelY, sorted[index + 1].labelY - minGap);
+      }
+    }
+
+    return sorted
+      .sort((left, right) => left.index - right.index)
+      .map(({ index: _index, desiredLabelY, ...point }) => ({
+        ...point,
+        labelShifted: Math.abs(point.labelY - desiredLabelY) > 1,
+      }));
+  }
+
   private colorForBubble(c: CategoryVocDensityItem): string {
     if (this.dimension === 'category') {
       if (c.quadrant === 'star') return '#16a34a';

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

@@ -153,6 +153,7 @@ export class NavigationComponent implements OnDestroy {
         { path: '/voc-insight/products', displayName: '商品洞察', icon: PackageSearch, requiresAuth: false },
         { path: '/domestic/category-voc', displayName: '品类洞察', icon: Tags, requiresAuth: false },
         { path: '/domestic/competitors', displayName: '竞品证据', icon: ChartColumn, requiresAuth: false },
+        { path: '/domestic/competitor-listings', displayName: '竞品 Listing 监控', icon: RefreshCw, requiresAuth: false },
       ],
     },
     {

+ 6 - 1
src/modules/shared/components/summary-metric-card/summary-metric-card.component.scss

@@ -3,13 +3,18 @@
 @use '../../styles/tokens' as t;
 
 :host {
-  display: block;
+  display: flex;
   min-width: 0;
 }
 
 .summary-metric-card {
   --metric-accent: #{t.$color-text-secondary};
   @include card.metric-card(default);
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  width: 100%;
+  box-sizing: border-box;
   min-height: 132px;
 
   &.compact {

+ 1 - 1
src/modules/voc-insight/shared/components/voc-insight-rail/voc-insight-rail.component.scss

@@ -205,12 +205,12 @@
 }
 
 .voc-rail-scope-section {
+  flex: 0 0 auto;
   background: linear-gradient(180deg, #f8fafc, #ffffff);
   border: 1px solid #e5e7eb;
   border-radius: 12px;
   padding: 12px 10px 10px;
   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
-  min-height: 250px;
 }
 
 .voc-rail-scope-mobile-toggle {

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно