Prechádzať zdrojové kódy

feat(listing-ai): add score overview dashboard

Yi Jiarui 3 týždňov pred
rodič
commit
92d6622f20
30 zmenil súbory, kde vykonal 1389 pridanie a 9 odobranie
  1. 21 0
      src/modules/listing-ai/components/listing-dimension-health/listing-dimension-health.component.html
  2. 1 0
      src/modules/listing-ai/components/listing-dimension-health/listing-dimension-health.component.scss
  3. 37 0
      src/modules/listing-ai/components/listing-dimension-health/listing-dimension-health.component.ts
  4. 59 0
      src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.html
  5. 0 0
      src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.scss
  6. 37 0
      src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.spec.ts
  7. 136 0
      src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.ts
  8. 50 0
      src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.html
  9. 0 0
      src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.scss
  10. 40 0
      src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.spec.ts
  11. 56 0
      src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.ts
  12. 27 0
      src/modules/listing-ai/components/listing-score-distribution/listing-score-distribution.component.html
  13. 1 0
      src/modules/listing-ai/components/listing-score-distribution/listing-score-distribution.component.scss
  14. 34 0
      src/modules/listing-ai/components/listing-score-distribution/listing-score-distribution.component.ts
  15. 2 1
      src/modules/listing-ai/listing-ai.routes.ts
  16. 112 0
      src/modules/listing-ai/models/listing-ai.models.ts
  17. 82 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.html
  18. 0 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.scss
  19. 127 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.spec.ts
  20. 65 0
      src/modules/listing-ai/pages/overview/listing-score-overview.component.ts
  21. 2 2
      src/modules/listing-ai/pages/product/listing-ai-product.component.html
  22. 18 2
      src/modules/listing-ai/pages/product/listing-ai-product.component.spec.ts
  23. 4 3
      src/modules/listing-ai/pages/product/listing-ai-product.component.ts
  24. 22 0
      src/modules/listing-ai/services/listing-ai-api.service.spec.ts
  25. 10 1
      src/modules/listing-ai/services/listing-ai-api.service.ts
  26. 146 0
      src/modules/listing-ai/services/listing-ai-overview.store.spec.ts
  27. 232 0
      src/modules/listing-ai/services/listing-ai-overview.store.ts
  28. 41 0
      src/modules/listing-ai/services/listing-overview-navigation.service.spec.ts
  29. 26 0
      src/modules/listing-ai/services/listing-overview-navigation.service.ts
  30. 1 0
      src/modules/shared/components/navigation/navigation.component.ts

+ 21 - 0
src/modules/listing-ai/components/listing-dimension-health/listing-dimension-health.component.html

@@ -0,0 +1,21 @@
+<section aria-labelledby="dimension-health-title">
+  <div class="section-heading">
+    <h2 id="dimension-health-title">五维健康度</h2>
+    <p>统一使用得分率比较,避免不同满分量纲造成误导;点击维度按得分升序定位短板。</p>
+  </div>
+  <div class="health-grid">
+    @for (key of dimensions; track key) {
+      @if (stats[key]; as stat) {
+        <button type="button" class="health-card" [class.active]="activeSort === key" [attr.aria-pressed]="activeSort === key" (click)="dimensionSelect.emit(key)">
+          <span class="health-title">{{ stat.label }} <small>/ {{ stat.maxScore }} 分</small></span>
+          <strong>{{ percent(stat.averageRate) === null ? '—' : percent(stat.averageRate) + '%' }}</strong>
+          <span class="rate-track" aria-hidden="true"><i [style.width.%]="percent(stat.averageRate) ?? 0"></i></span>
+          <span>中位得分率 {{ medianRate(stat) === null ? '—' : medianRate(stat) + '%' }}</span>
+          <span>待优化至少 {{ minimumNeedsOptimization(stat) }} 条</span>
+          <span>扣分贡献 {{ gapContribution(stat) === null ? '—' : gapContribution(stat) + '%' }}</span>
+          <small>已评估 {{ stat.scoredCount }} 条 · 点击按短板排序</small>
+        </button>
+      }
+    }
+  </div>
+</section>

+ 1 - 0
src/modules/listing-ai/components/listing-dimension-health/listing-dimension-health.component.scss

@@ -0,0 +1 @@
+:host{display:block}.section-heading{margin-bottom:16px}.section-heading h2{margin:0;color:#172033;font-size:18px}.section-heading p{margin:5px 0 0;color:#738096;font-size:13px}.health-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.health-card{display:grid;gap:7px;min-width:0;overflow-wrap:anywhere;border:1px solid #e1e7f0;background:#fff;border-radius:10px;padding:14px;text-align:left;color:#536076;cursor:pointer}.health-card:hover,.health-card:focus-visible{border-color:#9cbaf0;box-shadow:0 5px 18px rgba(38,94,187,.08)}.health-card.active{border-color:#4e7fe0;background:#f2f7ff}.health-title{color:#26334a;font-weight:700}.health-title small{color:#8994a5;font-weight:500}.health-card strong{font-size:25px;color:#235fc9}.health-card>span:not(.health-title):not(.rate-track){font-size:12px}.health-card>small{font-size:11px;color:#8994a5}.rate-track{height:7px;background:#edf0f5;border-radius:20px;overflow:hidden}.rate-track i{display:block;height:100%;border-radius:inherit;background:#4d80e5}@media(max-width:1100px){.health-grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.health-grid{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:4px}.health-card{min-width:220px;scroll-snap-align:start}}

+ 37 - 0
src/modules/listing-ai/components/listing-dimension-health/listing-dimension-health.component.ts

@@ -0,0 +1,37 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import type { ListingDimension, ListingOverviewDimensionStat } from '../../models/listing-ai.models';
+
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+
+@Component({
+  selector: 'app-listing-dimension-health',
+  standalone: true,
+  imports: [CommonModule],
+  templateUrl: './listing-dimension-health.component.html',
+  styleUrl: './listing-dimension-health.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ListingDimensionHealthComponent {
+  @Input({ required: true }) stats!: Record<ListingDimension, ListingOverviewDimensionStat>;
+  @Input() activeSort: string | undefined;
+  @Output() dimensionSelect = new EventEmitter<ListingDimension>();
+  readonly dimensions = DIMENSIONS;
+
+  percent(rate: number | null): number | null {
+    return rate === null ? null : Math.round(rate * 1000) / 10;
+  }
+
+  medianRate(stat: ListingOverviewDimensionStat): number | null {
+    return stat.medianScore === null ? null : Math.round(stat.medianScore / stat.maxScore * 1000) / 10;
+  }
+
+  minimumNeedsOptimization(stat: ListingOverviewDimensionStat): number {
+    return stat.totalGap ? Math.ceil(stat.totalGap / stat.maxScore) : 0;
+  }
+
+  gapContribution(stat: ListingOverviewDimensionStat): number | null {
+    const total = this.dimensions.reduce((sum, key) => sum + (this.stats[key].totalGap ?? 0), 0);
+    return total && stat.totalGap !== null ? Math.round(stat.totalGap / total * 1000) / 10 : null;
+  }
+}

+ 59 - 0
src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.html

@@ -0,0 +1,59 @@
+<section class="filter-panel" aria-labelledby="overview-filter-title">
+  <div class="section-heading">
+    <div><h2 id="overview-filter-title">筛选与排序</h2><p>筛选和排序会写入 URL,并自动从第一页重新查询。</p></div>
+    <button class="clear-button" type="button" (click)="clearAll.emit()" [disabled]="!activeTags.length">一键清空</button>
+  </div>
+
+  <div class="filter-grid">
+    <div class="field search-field">
+      <label for="overview-search">商品 ID 或标题</label>
+      <div class="input-action"><input id="overview-search" type="search" [(ngModel)]="search" (keyup.enter)="applySearch()" placeholder="搜索冻结 Listing"><button type="button" (click)="applySearch()">搜索</button></div>
+    </div>
+    <div class="field">
+      <span class="field-label" id="overview-categories-label">分类(可多选)</span>
+      <details class="category-picker">
+        <summary aria-labelledby="overview-categories-label">{{ categoryIds.length ? '已选 ' + categoryIds.length + ' 个分类' : '全部分类' }}</summary>
+        <div class="category-options" role="group" aria-labelledby="overview-categories-label">
+          @for (category of categories; track category.categoryId) {
+            <label><input type="checkbox" [checked]="categoryIds.includes(category.categoryId)" (change)="toggleCategory(category.categoryId, $any($event.target).checked)"><span>{{ category.categoryName }}({{ category.count }})</span></label>
+          }
+        </div>
+      </details>
+    </div>
+    <div class="field">
+      <label for="overview-score-nature">评分性质</label>
+      <select id="overview-score-nature" [(ngModel)]="scoreNature" (ngModelChange)="applyScoreNature()"><option value="">全部性质</option>@for (nature of scoreNatures; track nature.value) { <option [value]="nature.value">{{ nature.label }}({{ nature.count }})</option> }</select>
+    </div>
+    <div class="field">
+      <label for="overview-weakest">最弱维度</label>
+      <select id="overview-weakest" [(ngModel)]="weakestDimension" (ngModelChange)="applyWeakest()"><option value="">全部维度</option>@for (dimension of dimensions; track dimension.key) { <option [value]="dimension.key">{{ dimension.label }}</option> }</select>
+    </div>
+    <div class="field">
+      <label for="overview-sort">排序字段</label>
+      <select id="overview-sort" [(ngModel)]="sort" (ngModelChange)="applySort()">
+        <option value="improvementPotential">可提升分</option><option value="overallScore">总分</option><option value="productId">商品 ID</option>
+        @for (dimension of dimensions; track dimension.key) { <option [value]="dimension.key">{{ dimension.label }}得分</option> }
+        <option value="scoredAt">评分时间</option><option value="syncedAt">同步时间</option>
+      </select>
+    </div>
+    <div class="field">
+      <label for="overview-direction">排序方向</label>
+      <select id="overview-direction" [(ngModel)]="direction" (ngModelChange)="applySort()"><option value="desc">降序</option><option value="asc">升序</option></select>
+    </div>
+  </div>
+
+  <details class="range-panel">
+    <summary>分数范围与五维得分率</summary>
+    <div class="range-grid">
+      <fieldset><legend>总分(0–100)</legend><label>最低分<input type="number" min="0" max="100" [(ngModel)]="minScore"></label><label>最高分<input type="number" min="0" max="100" [(ngModel)]="maxScore"></label></fieldset>
+      @for (dimension of dimensions; track dimension.key) {
+        <fieldset><legend>{{ dimension.label }}得分率</legend><label>最低 %<input type="number" min="0" max="100" [(ngModel)]="dimensionRanges[dimension.key].min"></label><label>最高 %<input type="number" min="0" max="100" [(ngModel)]="dimensionRanges[dimension.key].max"></label></fieldset>
+      }
+    </div>
+    <button class="apply-range" type="button" (click)="applyRanges()">应用分数范围</button>
+  </details>
+
+  @if (activeTags.length) {
+    <div class="active-tags" aria-label="已生效筛选条件"><span class="tag-title">已生效</span>@for (tag of activeTags; track tag.key) { <button type="button" (click)="removeTag(tag)" [attr.aria-label]="'移除条件:' + tag.label">{{ tag.label }} <span aria-hidden="true">×</span></button> }</div>
+  }
+</section>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.scss


+ 37 - 0
src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.spec.ts

@@ -0,0 +1,37 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import type { ListingOverviewQuery } from '../../models/listing-ai.models';
+import { normalizeListingOverviewQuery } from '../../services/listing-ai-overview.store';
+import { ListingOverviewFilterBarComponent } from './listing-overview-filter-bar.component';
+
+describe('ListingOverviewFilterBarComponent', () => {
+  let fixture: ComponentFixture<ListingOverviewFilterBarComponent>;
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [ListingOverviewFilterBarComponent] }).compileComponents();
+    fixture = TestBed.createComponent(ListingOverviewFilterBarComponent);
+    fixture.componentRef.setInput('query', normalizeListingOverviewQuery({ search: '冷柜', titleMin: 15, categoryIds: ['cat-a'] }));
+    fixture.componentRef.setInput('categories', [{ categoryId: 'cat-a', categoryName: '冷柜', categoryPath: ['家电'], count: 25 }]);
+    fixture.componentRef.setInput('scoreNatures', [{ value: 'simulation', label: '模拟评分', count: 20 }]);
+    fixture.detectChanges();
+  });
+
+  it('maps raw dimension scores to percentage controls and emits raw API ranges', () => {
+    const component = fixture.componentInstance;
+    expect(component.dimensionRanges.title.min).toBe(50);
+    component.dimensionRanges.images = { min: 25, max: 75 };
+    let patch: Partial<ListingOverviewQuery> | undefined;
+    component.queryChange.subscribe((value) => patch = value);
+    component.applyRanges();
+    expect(patch?.imagesMin).toBe(5);
+    expect(patch?.imagesMax).toBe(15);
+  });
+
+  it('exposes active condition tags and one-click clearing', () => {
+    expect(fixture.nativeElement.textContent).toContain('搜索:冷柜');
+    expect(fixture.nativeElement.textContent).toContain('分类:冷柜');
+    expect(fixture.nativeElement.textContent).toContain('商品标题得分率:50–不限%');
+    let cleared = false;
+    fixture.componentInstance.clearAll.subscribe(() => cleared = true);
+    (fixture.nativeElement.querySelector('.clear-button') as HTMLButtonElement).click();
+    expect(cleared).toBeTrue();
+  });
+});

+ 136 - 0
src/modules/listing-ai/components/listing-overview-filter-bar/listing-overview-filter-bar.component.ts

@@ -0,0 +1,136 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import type {
+  ListingDimension,
+  ListingOverviewCategoryFacet,
+  ListingOverviewDirection,
+  ListingOverviewQuery,
+  ListingOverviewQueryState,
+  ListingOverviewScoreNature,
+  ListingOverviewScoreNatureFacet,
+  ListingOverviewSort,
+} from '../../models/listing-ai.models';
+
+interface ActiveFilterTag { key: string; label: string; patch: Partial<ListingOverviewQuery>; }
+interface DimensionFilter { key: ListingDimension; label: string; minKey: keyof ListingOverviewQuery; maxKey: keyof ListingOverviewQuery; maxScore: number; }
+
+@Component({
+  selector: 'app-listing-overview-filter-bar',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './listing-overview-filter-bar.component.html',
+  styleUrl: './listing-overview-filter-bar.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ListingOverviewFilterBarComponent implements OnChanges {
+  @Input({ required: true }) query!: ListingOverviewQueryState;
+  @Input() categories: ListingOverviewCategoryFacet[] = [];
+  @Input() scoreNatures: ListingOverviewScoreNatureFacet[] = [];
+  @Output() queryChange = new EventEmitter<Partial<ListingOverviewQuery>>();
+  @Output() clearAll = new EventEmitter<void>();
+
+  search = '';
+  categoryIds: string[] = [];
+  scoreNature = '';
+  weakestDimension = '';
+  sort: ListingOverviewSort = 'improvementPotential';
+  direction: ListingOverviewDirection = 'desc';
+  minScore: number | null = null;
+  maxScore: number | null = null;
+  dimensionRanges: Record<ListingDimension, { min: number | null; max: number | null }> = {
+    title: { min: null, max: null }, selling_points: { min: null, max: null }, images: { min: null, max: null },
+    description: { min: null, max: null }, specifications: { min: null, max: null },
+  };
+  readonly dimensions: DimensionFilter[] = [
+    { key: 'title', label: '商品标题', minKey: 'titleMin', maxKey: 'titleMax', maxScore: 30 },
+    { key: 'selling_points', label: '核心卖点', minKey: 'sellingPointsMin', maxKey: 'sellingPointsMax', maxScore: 25 },
+    { key: 'images', label: '图片资产', minKey: 'imagesMin', maxKey: 'imagesMax', maxScore: 20 },
+    { key: 'description', label: '商品详情', minKey: 'descriptionMin', maxKey: 'descriptionMax', maxScore: 15 },
+    { key: 'specifications', label: '规格与履约', minKey: 'specificationsMin', maxKey: 'specificationsMax', maxScore: 10 },
+  ];
+  readonly dimensionLabels: Record<ListingDimension, string> = {
+    title: '商品标题', selling_points: '核心卖点', images: '图片资产', description: '商品详情', specifications: '规格与履约',
+  };
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (!changes['query'] || !this.query) return;
+    this.search = this.query.search ?? '';
+    this.categoryIds = [...(this.query.categoryIds ?? [])];
+    this.scoreNature = this.query.scoreNature ?? '';
+    this.weakestDimension = this.query.weakestDimension ?? '';
+    this.sort = this.query.sort;
+    this.direction = this.query.direction;
+    this.minScore = this.query.minScore ?? null;
+    this.maxScore = this.query.maxScore ?? null;
+    for (const dimension of this.dimensions) {
+      this.dimensionRanges[dimension.key] = {
+        min: this.toRate(this.query[dimension.minKey] as number | undefined, dimension.maxScore),
+        max: this.toRate(this.query[dimension.maxKey] as number | undefined, dimension.maxScore),
+      };
+    }
+  }
+
+  applySearch(): void { this.queryChange.emit({ search: this.search.trim() || undefined }); }
+  toggleCategory(categoryId: string, checked: boolean): void {
+    const next = new Set(this.categoryIds);
+    if (checked) next.add(categoryId); else next.delete(categoryId);
+    this.categoryIds = [...next];
+    this.queryChange.emit({ categoryIds: this.categoryIds.length ? this.categoryIds : undefined });
+  }
+  applyScoreNature(): void { this.queryChange.emit({ scoreNature: (this.scoreNature || undefined) as ListingOverviewScoreNature | undefined }); }
+  applyWeakest(): void { this.queryChange.emit({ weakestDimension: (this.weakestDimension || undefined) as ListingDimension | undefined }); }
+  applySort(): void { this.queryChange.emit({ sort: this.sort, direction: this.direction }); }
+
+  applyRanges(): void {
+    const patch: Partial<ListingOverviewQuery> = {
+      minScore: this.value(this.minScore), maxScore: this.value(this.maxScore),
+    };
+    for (const dimension of this.dimensions) {
+      const range = this.dimensionRanges[dimension.key];
+      (patch as Record<string, unknown>)[dimension.minKey] = this.toRawScore(range.min, dimension.maxScore);
+      (patch as Record<string, unknown>)[dimension.maxKey] = this.toRawScore(range.max, dimension.maxScore);
+    }
+    this.queryChange.emit(patch);
+  }
+
+  removeTag(tag: ActiveFilterTag): void { this.queryChange.emit(tag.patch); }
+
+  get activeTags(): ActiveFilterTag[] {
+    const tags: ActiveFilterTag[] = [];
+    if (this.query.search) tags.push({ key: 'search', label: `搜索:${this.query.search}`, patch: { search: undefined } });
+    for (const categoryId of this.query.categoryIds ?? []) {
+      const category = this.categories.find((item) => item.categoryId === categoryId);
+      tags.push({ key: `category-${categoryId}`, label: `分类:${category?.categoryName ?? categoryId}`, patch: { categoryIds: this.query.categoryIds?.filter((item) => item !== categoryId) } });
+    }
+    if (this.query.scoreNature) {
+      const nature = this.scoreNatures.find((item) => item.value === this.query.scoreNature);
+      tags.push({ key: 'nature', label: `评分性质:${nature?.label ?? this.query.scoreNature}`, patch: { scoreNature: undefined } });
+    }
+    if (this.query.minScore !== undefined || this.query.maxScore !== undefined) tags.push({ key: 'overall', label: `总分:${this.range(this.query.minScore, this.query.maxScore)}`, patch: { minScore: undefined, maxScore: undefined } });
+    for (const dimension of this.dimensions) {
+      const min = this.query[dimension.minKey] as number | undefined;
+      const max = this.query[dimension.maxKey] as number | undefined;
+      if (min !== undefined || max !== undefined) tags.push({
+        key: dimension.key, label: `${dimension.label}得分率:${this.range(this.toRate(min, dimension.maxScore), this.toRate(max, dimension.maxScore))}%`,
+        patch: { [dimension.minKey]: undefined, [dimension.maxKey]: undefined },
+      });
+    }
+    if (this.query.weakestDimension) tags.push({ key: 'weakest', label: `最弱维度:${this.dimensionLabels[this.query.weakestDimension]}`, patch: { weakestDimension: undefined } });
+    if (this.query.sort !== 'improvementPotential' || this.query.direction !== 'desc') tags.push({ key: 'sort', label: `排序:${this.sortLabel(this.query.sort)} ${this.query.direction === 'asc' ? '升序' : '降序'}`, patch: { sort: 'improvementPotential', direction: 'desc' } });
+    return tags;
+  }
+
+  sortLabel(sort: ListingOverviewSort): string {
+    const labels: Record<ListingOverviewSort, string> = {
+      productId: '商品 ID', overallScore: '总分', title: '商品标题维度', selling_points: '核心卖点维度', images: '图片维度',
+      description: '详情维度', specifications: '规格维度', improvementPotential: '可提升分', scoredAt: '评分时间', syncedAt: '同步时间',
+    };
+    return labels[sort];
+  }
+
+  private value(value: number | null): number | undefined { return value === null || !Number.isFinite(value) ? undefined : value; }
+  private toRate(value: number | undefined, maxScore: number): number | null { return value === undefined ? null : Math.round(value / maxScore * 1000) / 10; }
+  private toRawScore(value: number | null, maxScore: number): number | undefined { const rate = this.value(value); return rate === undefined ? undefined : Math.round(rate * maxScore) / 100; }
+  private range(minimum: number | null | undefined, maximum: number | null | undefined): string { return `${minimum ?? '不限'}–${maximum ?? '不限'}`; }
+}

+ 50 - 0
src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.html

@@ -0,0 +1,50 @@
+<section aria-labelledby="overview-table-title">
+  <div class="table-heading">
+    <div><h2 id="overview-table-title">Listing 评分列表</h2><p>默认按可提升分降序,商品 ID 始终作为稳定次级排序键。</p></div>
+    @if (currentCursor) { <button type="button" class="page-action" (click)="firstPage.emit()">回到第一页</button> }
+  </div>
+
+  <div class="desktop-table">
+    <table>
+      <thead><tr>
+        <th scope="col">商品</th><th scope="col">分类 / 状态</th>
+        <th scope="col"><button type="button" (click)="toggleSort('overallScore')">总分 {{ sortIndicator('overallScore') }}</button></th>
+        @for (dimension of dimensions; track dimension.key) { <th scope="col"><button type="button" (click)="toggleSort(dimension.key)">{{ dimension.label }} {{ sortIndicator(dimension.key) }}</button></th> }
+        <th scope="col">最弱维度</th><th scope="col"><button type="button" (click)="toggleSort('improvementPotential')">可提升分 {{ sortIndicator('improvementPotential') }}</button></th>
+        <th scope="col">评分性质</th><th scope="col"><button type="button" (click)="toggleSort('scoredAt')">评分时间 {{ sortIndicator('scoredAt') }}</button></th><th scope="col">操作</th>
+      </tr></thead>
+      <tbody>
+        @for (item of items; track item.productId) {
+          <tr [class.simulation-row]="item.scoreNature === 'simulation'">
+            <td><div class="product-cell">@if (item.imageUrl) { <img [src]="item.imageUrl" [alt]="item.title || '商品图片'"> } @else { <span class="image-placeholder" aria-label="无商品图片">无图</span> }<div><strong>{{ item.title || '暂无商品标题' }}</strong><span>ID {{ item.productId }}</span></div></div></td>
+            <td><strong>{{ item.categoryName || '未分类' }}</strong><small>{{ item.categoryPath.length ? item.categoryPath.join(' / ') : item.itemStatusLabel }}</small><span class="status-text">{{ item.itemStatusLabel }}</span></td>
+            <td class="overall-cell"><strong class="overall-score" [class.missing]="item.overallScore === null">{{ overallText(item) }}</strong></td>
+            @for (dimension of dimensions; track dimension.key) { <td><strong [class.zero-score]="item.dimensions[dimension.key].score === 0" [class.missing]="item.dimensions[dimension.key].score === null">{{ dimensionText(item, dimension.key) }}</strong><small>{{ rateText(item.dimensions[dimension.key].rate) }}</small></td> }
+            <td>{{ item.weakestDimension ? dimensionLabels[item.weakestDimension] : '无法判断' }}</td>
+            <td><strong>{{ item.improvementPotential === null ? '未知' : item.improvementPotential + ' 分' }}</strong></td>
+            <td><span class="nature-badge" [class.simulation]="item.scoreNature === 'simulation'">{{ item.scoreNatureLabel }}</span>@if (item.scoreNature === 'simulation') { <small class="simulation-note">非正式业务评分</small> }</td>
+            <td><time [attr.datetime]="item.scoredAt || null">{{ item.scoredAt ? (item.scoredAt | date:'yyyy-MM-dd HH:mm') : '尚未评分' }}</time><small>同步 {{ item.syncedAt | date:'MM-dd HH:mm' }}</small></td>
+            <td><a class="detail-link" [routerLink]="['/listing-ai/workbench', item.productId]" [queryParams]="{ returnTo: returnTo }" (click)="detailOpen.emit()">查看详情</a></td>
+          </tr>
+        }
+      </tbody>
+    </table>
+  </div>
+
+  <div class="mobile-list" aria-label="移动端 Listing 评分列表">
+    @for (item of items; track item.productId) {
+      <article class="mobile-card" [class.simulation-row]="item.scoreNature === 'simulation'">
+        <header><div><strong>{{ item.title || '暂无商品标题' }}</strong><span>ID {{ item.productId }} · {{ item.categoryName || '未分类' }}</span></div><strong class="mobile-score">{{ overallText(item) }}{{ item.overallScore === null ? '' : ' 分' }}</strong></header>
+        <div class="mobile-meta"><span>{{ item.itemStatusLabel }}</span><span class="nature-badge" [class.simulation]="item.scoreNature === 'simulation'">{{ item.scoreNatureLabel }}{{ item.scoreNature === 'simulation' ? ' · 非正式业务评分' : '' }}</span></div>
+        <div class="mobile-dimensions">@for (dimension of dimensions; track dimension.key) { <span><small>{{ dimension.label }}</small><strong>{{ dimensionText(item, dimension.key) }}</strong><small>{{ rateText(item.dimensions[dimension.key].rate) }}</small></span> }</div>
+        <footer><span>最弱:{{ item.weakestDimension ? dimensionLabels[item.weakestDimension] : '无法判断' }}</span><span>可提升:{{ item.improvementPotential === null ? '未知' : item.improvementPotential + ' 分' }}</span><a [routerLink]="['/listing-ai/workbench', item.productId]" [queryParams]="{ returnTo: returnTo }" (click)="detailOpen.emit()">查看详情</a></footer>
+      </article>
+    }
+  </div>
+
+  <div class="pagination" aria-label="游标分页">
+    @if (currentCursor) { <button type="button" (click)="firstPage.emit()" [disabled]="loading">第一页</button> }
+    @if (nextCursor) { <button type="button" class="next" (click)="nextPage.emit()" [disabled]="loading">{{ loading ? '加载中…' : '下一页' }}</button> }
+    @if (!nextCursor && items.length) { <span>已到当前筛选结果末页</span> }
+  </div>
+</section>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.scss


+ 40 - 0
src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.spec.ts

@@ -0,0 +1,40 @@
+import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import type { ListingDimension, ListingOverviewRow } from '../../models/listing-ai.models';
+import { ListingOverviewTableComponent } from './listing-overview-table.component';
+
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+
+describe('ListingOverviewTableComponent', () => {
+  it('visibly distinguishes zero, empty dimensions, unscored rows, and simulation results on desktop and mobile', async () => {
+    await TestBed.configureTestingModule({ imports: [ListingOverviewTableComponent], providers: [provideRouter([])] }).compileComponents();
+    const fixture = TestBed.createComponent(ListingOverviewTableComponent);
+    const dimensions = Object.fromEntries(DIMENSIONS.map((key) => [key, { score: key === 'images' ? 0 : null, maxScore: 20, rate: key === 'images' ? 0 : null, gap: key === 'images' ? 20 : null }])) as ListingOverviewRow['dimensions'];
+    const item = { productId: '1001', title: '移动端关键商品', imageUrl: null, categoryId: null, categoryName: null, categoryPath: [], categoryIds: [], itemStatusLabel: '商品状态未知', scoreNature: 'simulation', scoreNatureLabel: '模拟评分', overallScore: null, overallRate: null, dimensions, weakestDimension: 'images', improvementPotential: null, scoredAt: null, syncedAt: '2026-08-26T00:00:00Z' } satisfies ListingOverviewRow;
+    fixture.componentRef.setInput('items', [item]);
+    fixture.componentRef.setInput('returnTo', '/listing-ai/overview?search=test&cursor=page-2');
+    fixture.detectChanges();
+    const text = fixture.nativeElement.textContent as string;
+    expect(text).toContain('总分缺失');
+    expect(text).toContain('0/20');
+    expect(text).toContain('空维度');
+    expect(text).toContain('非正式业务评分');
+    expect(fixture.nativeElement.querySelector('.mobile-list')?.textContent).toContain('移动端关键商品');
+    expect(decodeURIComponent((fixture.nativeElement.querySelector('a.detail-link') as HTMLAnchorElement).href)).toContain('cursor=page-2');
+
+    fixture.componentRef.setInput('items', [{ ...item, scoreNature: 'unscored', scoreNatureLabel: '未评分' }]);
+    fixture.detectChanges();
+    expect(fixture.nativeElement.textContent).toContain('未评分');
+  });
+
+  it('shows the overall score without a duplicated percentage', async () => {
+    await TestBed.configureTestingModule({ imports: [ListingOverviewTableComponent], providers: [provideRouter([])] }).compileComponents();
+    const fixture = TestBed.createComponent(ListingOverviewTableComponent);
+    const dimensions = Object.fromEntries(DIMENSIONS.map((key) => [key, { score: 15, maxScore: 20, rate: 75, gap: 5 }])) as ListingOverviewRow['dimensions'];
+    const item = { productId: '1001', title: '商品', imageUrl: null, categoryId: null, categoryName: null, categoryPath: [], categoryIds: [], itemStatusLabel: '已获取', scoreNature: 'simulation', scoreNatureLabel: '模拟评分', overallScore: 73, overallRate: 73, dimensions, weakestDimension: 'images', improvementPotential: 27, scoredAt: '2026-08-26T00:00:00Z', syncedAt: '2026-08-26T00:00:00Z' } satisfies ListingOverviewRow;
+    fixture.componentRef.setInput('items', [item]);
+    fixture.detectChanges();
+
+    expect(fixture.nativeElement.querySelector('.overall-cell')?.textContent.trim()).toBe('73');
+  });
+});

+ 56 - 0
src/modules/listing-ai/components/listing-overview-table/listing-overview-table.component.ts

@@ -0,0 +1,56 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import type { ListingDimension, ListingOverviewDirection, ListingOverviewRow, ListingOverviewSort } from '../../models/listing-ai.models';
+
+@Component({
+  selector: 'app-listing-overview-table',
+  standalone: true,
+  imports: [CommonModule, RouterLink],
+  templateUrl: './listing-overview-table.component.html',
+  styleUrl: './listing-overview-table.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ListingOverviewTableComponent {
+  @Input() items: ListingOverviewRow[] = [];
+  @Input() loading = false;
+  @Input() nextCursor: string | null = null;
+  @Input() currentCursor: string | undefined;
+  @Input() sort: ListingOverviewSort = 'improvementPotential';
+  @Input() direction: ListingOverviewDirection = 'desc';
+  @Input() returnTo = '/listing-ai/overview';
+  @Output() nextPage = new EventEmitter<void>();
+  @Output() firstPage = new EventEmitter<void>();
+  @Output() sortChange = new EventEmitter<{ sort: ListingOverviewSort; direction: ListingOverviewDirection }>();
+  @Output() detailOpen = new EventEmitter<void>();
+
+  readonly dimensions: Array<{ key: ListingDimension; label: string }> = [
+    { key: 'title', label: '标题' }, { key: 'selling_points', label: '卖点' }, { key: 'images', label: '图片' },
+    { key: 'description', label: '详情' }, { key: 'specifications', label: '规格' },
+  ];
+  readonly dimensionLabels: Record<ListingDimension, string> = {
+    title: '商品标题', selling_points: '核心卖点', images: '图片资产', description: '商品详情', specifications: '规格与履约',
+  };
+
+  toggleSort(sort: ListingOverviewSort): void {
+    this.sortChange.emit({ sort, direction: this.sort === sort && this.direction === 'desc' ? 'asc' : 'desc' });
+  }
+
+  sortIndicator(sort: ListingOverviewSort): string {
+    return this.sort === sort ? (this.direction === 'asc' ? '↑' : '↓') : '';
+  }
+
+  overallText(item: ListingOverviewRow): string {
+    if (item.overallScore !== null) return `${item.overallScore}`;
+    return item.scoreNature === 'unscored' ? '未评分' : '总分缺失';
+  }
+
+  dimensionText(item: ListingOverviewRow, key: ListingDimension): string {
+    const dimension = item.dimensions[key];
+    return dimension.score === null ? '空维度' : `${dimension.score}/${dimension.maxScore}`;
+  }
+
+  rateText(rate: number | null): string {
+    return rate === null ? '得分率未知' : `${Math.round(rate * 1000) / 10}%`;
+  }
+}

+ 27 - 0
src/modules/listing-ai/components/listing-score-distribution/listing-score-distribution.component.html

@@ -0,0 +1,27 @@
+<section class="distribution" aria-labelledby="score-distribution-title">
+  <div class="section-heading">
+    <div>
+      <h2 id="score-distribution-title">总分分布</h2>
+      <p>基于当前筛选结果;选择分数段后立即联动列表。</p>
+    </div>
+    <button class="quick-action" type="button" (click)="bottomTenSelect.emit()" [disabled]="!scoredTotal">
+      查看底部 10%({{ bottomTenCount }} 条)
+    </button>
+  </div>
+  <div class="bucket-list">
+    @for (bucket of buckets; track bucket.key) {
+      <button
+        type="button"
+        class="bucket"
+        [class.active]="active(bucket)"
+        [attr.aria-pressed]="active(bucket)"
+        [attr.aria-label]="bucket.label + ',' + bucket.count + ' 个商品'"
+        (click)="bucketSelect.emit(bucket)"
+      >
+        <span class="bucket-label">{{ bucket.label }}</span>
+        <span class="bar-track" aria-hidden="true"><i [style.width.%]="width(bucket.count)"></i></span>
+        <strong>{{ bucket.count }}</strong>
+      </button>
+    }
+  </div>
+</section>

+ 1 - 0
src/modules/listing-ai/components/listing-score-distribution/listing-score-distribution.component.scss

@@ -0,0 +1 @@
+:host{display:block}.distribution{height:100%}.section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}.section-heading h2{margin:0;color:#172033;font-size:18px}.section-heading p{margin:5px 0 0;color:#738096;font-size:13px}.quick-action{border:1px solid #cfdcf8;background:#f3f7ff;color:#245fc8;border-radius:8px;padding:8px 12px;font-weight:650;cursor:pointer}.quick-action:disabled{opacity:.5;cursor:not-allowed}.bucket-list{display:grid;gap:10px}.bucket{display:grid;grid-template-columns:92px minmax(80px,1fr) 42px;align-items:center;gap:12px;width:100%;border:1px solid transparent;background:transparent;border-radius:8px;padding:8px;text-align:left;cursor:pointer;color:#27344b}.bucket:hover,.bucket:focus-visible{background:#f6f9ff;border-color:#d6e1f5}.bucket.active{background:#edf4ff;border-color:#8eb3f5}.bucket-label{font-size:13px}.bar-track{height:10px;background:#e9edf4;border-radius:999px;overflow:hidden}.bar-track i{display:block;height:100%;min-width:0;border-radius:inherit;background:linear-gradient(90deg,#4c7ded,#72a3ff)}.bucket strong{text-align:right;font-variant-numeric:tabular-nums}@media(max-width:620px){.section-heading{display:grid}.quick-action{width:100%}.bucket{grid-template-columns:84px minmax(60px,1fr) 36px;padding-inline:0}}

+ 34 - 0
src/modules/listing-ai/components/listing-score-distribution/listing-score-distribution.component.ts

@@ -0,0 +1,34 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import type { ListingOverviewScoreDistributionBucket } from '../../models/listing-ai.models';
+
+@Component({
+  selector: 'app-listing-score-distribution',
+  standalone: true,
+  imports: [CommonModule],
+  templateUrl: './listing-score-distribution.component.html',
+  styleUrl: './listing-score-distribution.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ListingScoreDistributionComponent {
+  @Input() buckets: ListingOverviewScoreDistributionBucket[] = [];
+  @Input() scoredTotal = 0;
+  @Input() activeMin: number | undefined;
+  @Input() activeMax: number | undefined;
+  @Output() bucketSelect = new EventEmitter<ListingOverviewScoreDistributionBucket>();
+  @Output() bottomTenSelect = new EventEmitter<void>();
+
+  width(count: number): number {
+    const maximum = Math.max(1, ...this.buckets.map((bucket) => bucket.count));
+    return Math.max(count ? 8 : 0, Math.round(count / maximum * 100));
+  }
+
+  active(bucket: ListingOverviewScoreDistributionBucket): boolean {
+    const maximum = bucket.maxScoreExclusive ? bucket.maxScore - 0.0001 : bucket.maxScore;
+    return this.activeMin === bucket.minScore && this.activeMax === maximum;
+  }
+
+  get bottomTenCount(): number {
+    return Math.ceil(this.scoredTotal * 0.1);
+  }
+}

+ 2 - 1
src/modules/listing-ai/listing-ai.routes.ts

@@ -1,6 +1,7 @@
 import type { Routes } from '@angular/router';
 import type { Routes } from '@angular/router';
 export const LISTING_AI_ROUTES:Routes=[
 export const LISTING_AI_ROUTES:Routes=[
-  {path:'',pathMatch:'full',redirectTo:'workbench'},
+  {path:'',pathMatch:'full',redirectTo:'overview'},
+  {path:'overview',title:'Listing 评分看板',loadComponent:()=>import('./pages/overview/listing-score-overview.component').then((m)=>m.ListingScoreOverviewComponent)},
   {path:'workbench',title:'Listing AI 优化工作台',loadComponent:()=>import('./pages/workbench/listing-ai-workbench.component').then((m)=>m.ListingAiWorkbenchComponent)},
   {path:'workbench',title:'Listing AI 优化工作台',loadComponent:()=>import('./pages/workbench/listing-ai-workbench.component').then((m)=>m.ListingAiWorkbenchComponent)},
   {path:'workbench/:productId',title:'Listing AI 商品详情',loadComponent:()=>import('./pages/product/listing-ai-product.component').then((m)=>m.ListingAiProductComponent)},
   {path:'workbench/:productId',title:'Listing AI 商品详情',loadComponent:()=>import('./pages/product/listing-ai-product.component').then((m)=>m.ListingAiProductComponent)},
   {path:'versions',title:'Listing 优化版本',loadComponent:()=>import('./pages/versions/listing-version-list.component').then((m)=>m.ListingVersionListComponent)},
   {path:'versions',title:'Listing 优化版本',loadComponent:()=>import('./pages/versions/listing-version-list.component').then((m)=>m.ListingVersionListComponent)},

+ 112 - 0
src/modules/listing-ai/models/listing-ai.models.ts

@@ -24,3 +24,115 @@ export interface ListingVersion{id:string;workspaceId:string;productId:string;ve
 export interface ListingProductDetailResponse{source:ListingSource;coverage:ListingCoverage|null;currentScore:ListingScorePresentation|null;rulePrecheck:ListingScorePresentation|null;versionsSummary:CursorPage<ListingVersion>;}
 export interface ListingProductDetailResponse{source:ListingSource;coverage:ListingCoverage|null;currentScore:ListingScorePresentation|null;rulePrecheck:ListingScorePresentation|null;versionsSummary:CursorPage<ListingVersion>;}
 export interface ListingScoreJob{id:string;workspaceId:string;platform:'jd';rubricVersion:string;includeAiSuggestions:boolean;rescorePolicy:'reuse'|'force';status:ListingJobStatus;statusLabel?:string;total:number;processed:number;succeeded:number;partial:number;blocked:number;failed:number;requestedBy:string;requestedAt:string;startedAt:string|null;completedAt:string|null;updatedAt:string;}
 export interface ListingScoreJob{id:string;workspaceId:string;platform:'jd';rubricVersion:string;includeAiSuggestions:boolean;rescorePolicy:'reuse'|'force';status:ListingJobStatus;statusLabel?:string;total:number;processed:number;succeeded:number;partial:number;blocked:number;failed:number;requestedBy:string;requestedAt:string;startedAt:string|null;completedAt:string|null;updatedAt:string;}
 export interface ListingScoreJobItem{id:string;jobId:string;workspaceId:string;productId:string;sourceHash:string;status:ListingItemStatus;statusLabel?:string;attempts:number;errorCode:string|null;errorDetail:string|null;errorMessage?:string|null;statusReasonCodes?:string[];updatedAt:string;}
 export interface ListingScoreJobItem{id:string;jobId:string;workspaceId:string;productId:string;sourceHash:string;status:ListingItemStatus;statusLabel?:string;attempts:number;errorCode:string|null;errorDetail:string|null;errorMessage?:string|null;statusReasonCodes?:string[];updatedAt:string;}
+
+export type ListingOverviewScoreNature = 'simulation' | 'formal_ai' | 'rule_precheck' | 'unscored';
+export type ListingOverviewSort = 'productId' | 'overallScore' | ListingDimension | 'improvementPotential' | 'scoredAt' | 'syncedAt';
+export type ListingOverviewDirection = 'asc' | 'desc';
+
+export interface ListingOverviewQuery {
+  search?: string;
+  categoryIds?: string[];
+  scoreNature?: ListingOverviewScoreNature;
+  minScore?: number;
+  maxScore?: number;
+  titleMin?: number;
+  titleMax?: number;
+  sellingPointsMin?: number;
+  sellingPointsMax?: number;
+  imagesMin?: number;
+  imagesMax?: number;
+  descriptionMin?: number;
+  descriptionMax?: number;
+  specificationsMin?: number;
+  specificationsMax?: number;
+  weakestDimension?: ListingDimension;
+  sort?: ListingOverviewSort;
+  direction?: ListingOverviewDirection;
+  limit?: number;
+  cursor?: string;
+}
+
+export interface ListingOverviewQueryState extends ListingOverviewQuery {
+  sort: ListingOverviewSort;
+  direction: ListingOverviewDirection;
+  limit: number;
+}
+
+export interface ListingOverviewDimensionValue {
+  score: number | null;
+  maxScore: number;
+  rate: number | null;
+  gap: number | null;
+}
+
+export interface ListingOverviewRow {
+  productId: string;
+  title: string | null;
+  imageUrl: string | null;
+  categoryId: string | null;
+  categoryName: string | null;
+  categoryPath: string[];
+  categoryIds: string[];
+  itemStatusLabel: string;
+  scoreNature: ListingOverviewScoreNature;
+  scoreNatureLabel: string;
+  overallScore: number | null;
+  overallRate: number | null;
+  dimensions: Record<ListingDimension, ListingOverviewDimensionValue>;
+  weakestDimension: ListingDimension | null;
+  improvementPotential: number | null;
+  scoredAt: string | null;
+  syncedAt: string;
+}
+
+export interface ListingOverviewScoreDistributionBucket {
+  key: string;
+  label: string;
+  minScore: number;
+  maxScore: number;
+  maxScoreExclusive: boolean;
+  count: number;
+}
+
+export interface ListingOverviewDimensionStat {
+  key: ListingDimension;
+  label: string;
+  maxScore: number;
+  scoredCount: number;
+  averageScore: number | null;
+  averageRate: number | null;
+  medianScore: number | null;
+  totalGap: number | null;
+}
+
+export interface ListingOverviewCategoryFacet {
+  categoryId: string;
+  categoryName: string;
+  categoryPath: string[];
+  count: number;
+}
+
+export interface ListingOverviewScoreNatureFacet {
+  value: ListingOverviewScoreNature;
+  label: string;
+  count: number;
+}
+
+export interface ListingOverviewSummary {
+  sourceTotal: number;
+  matchedTotal: number;
+  scoredTotal: number;
+  simulationTotal: number;
+  averageScore: number | null;
+  medianScore: number | null;
+  scoreDistribution: ListingOverviewScoreDistributionBucket[];
+  dimensionStats: Record<ListingDimension, ListingOverviewDimensionStat>;
+  categoryFacets: ListingOverviewCategoryFacet[];
+  scoreNatureFacets: ListingOverviewScoreNatureFacet[];
+  snapshotId: string;
+  generatedAt: string;
+}
+
+export interface ListingOverviewPage extends CursorPage<ListingOverviewRow> {
+  summary: ListingOverviewSummary;
+}

+ 82 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.html

@@ -0,0 +1,82 @@
+<main class="listing-page overview-page" data-testid="listing-score-overview">
+  <app-page-header
+    eyebrow="Listing 当前评分"
+    title="Listing 评分看板"
+    description="分析当前冻结 Listing 的五维评分、短板维度与优化优先级"
+    groupBadge="京东五维 V7"
+    groupBadgeTone="blue"
+    [lastUpdated]="store.summary()?.generatedAt || ''"
+    [sampleSize]="store.summary()?.sourceTotal ?? null"
+    [badges]="store.summary()?.simulationTotal ? [{ label: '包含模拟评分', tone: 'amber' }] : []"
+  >
+    <div pageHeaderActions class="header-actions">
+      <a class="btn secondary" routerLink="/listing-ai/workbench">前往评分工作台</a>
+    </div>
+  </app-page-header>
+
+  @if (store.error()) {
+    <div class="state error" role="alert">{{ store.error() }} <button class="link" type="button" (click)="store.retry()">重试</button></div>
+  } @else if (store.loading() && !store.summary()) {
+    <div class="state loading-state" role="status">正在加载 625 个冻结 Listing 的评分看板数据…</div>
+  }
+
+  @if (store.summary(); as summary) {
+    <section class="scope-panel" aria-labelledby="scope-title">
+      <div><span class="scope-kicker" id="scope-title">数据口径</span><strong>{{ summary.sourceTotal }} 个冻结 Listing</strong><small>固定 cohort · 非实时商品全量</small></div>
+      <div><span>评分标准</span><strong>京东五维 V7</strong><small>不包含历史趋势或环比</small></div>
+      <div><span>数据更新时间</span><strong>{{ summary.generatedAt | date:'yyyy-MM-dd HH:mm' }}</strong><small>快照 {{ summary.snapshotId | slice:0:10 }}…</small></div>
+      <div><span>当前筛选覆盖率</span><strong>{{ coverageRate() }}</strong><small>{{ summary.scoredTotal }} / {{ summary.matchedTotal }} 已评分</small></div>
+    </section>
+
+    @if (summary.simulationTotal) {
+      <div class="simulation-warning" role="note">
+        <strong>模拟评分 · 非正式业务结论</strong>
+        <span>当前筛选结果包含 {{ summary.simulationTotal }} 条展示用模拟评分,仅用于验证看板交互,不应用于绩效、运营决策或模型效果判断。</span>
+      </div>
+    }
+
+    <section class="kpi-grid" aria-label="Listing 评分核心指标">
+      <article><span>商品总数</span><strong>{{ summary.sourceTotal }}</strong><small>冻结数据范围</small></article>
+      <article><span>当前筛选结果</span><strong>{{ summary.matchedTotal }}</strong><small>URL 条件命中</small></article>
+      <article><span>已评分覆盖率</span><strong>{{ coverageRate() }}</strong><small>{{ summary.scoredTotal }} 条有总分</small></article>
+      <article><span>平均分</span><strong>{{ summary.averageScore === null ? '—' : summary.averageScore }}</strong><small>仅统计已评分</small></article>
+      <article><span>中位数</span><strong>{{ summary.medianScore === null ? '—' : summary.medianScore }}</strong><small>降低极值干扰</small></article>
+      <article class="priority-kpi"><span>底部 10%</span><strong>{{ bottomTenCount() }}</strong><button type="button" (click)="selectBottomTen()" [disabled]="!summary.scoredTotal">查看待优化商品</button></article>
+    </section>
+
+    @if (store.loading()) { <div class="refresh-indicator" role="status">正在按新条件更新数据…</div> }
+
+    <div class="analysis-grid">
+      <app-content-card>
+        <app-listing-score-distribution [buckets]="summary.scoreDistribution" [scoredTotal]="summary.scoredTotal" [activeMin]="store.query().minScore" [activeMax]="store.query().maxScore" (bucketSelect)="selectBucket($event)" (bottomTenSelect)="selectBottomTen()"></app-listing-score-distribution>
+      </app-content-card>
+      <app-content-card>
+        <app-listing-dimension-health [stats]="summary.dimensionStats" [activeSort]="store.query().sort" (dimensionSelect)="selectDimension($event)"></app-listing-dimension-health>
+      </app-content-card>
+    </div>
+
+    <app-content-card>
+      <app-listing-overview-filter-bar [query]="store.query()" [categories]="summary.categoryFacets" [scoreNatures]="summary.scoreNatureFacets" (queryChange)="applyQuery($event)" (clearAll)="clearFilters()"></app-listing-overview-filter-bar>
+    </app-content-card>
+
+    <app-content-card>
+      @if (!store.items().length && !store.loading()) {
+        <div class="empty-state"><strong>当前条件下没有 Listing</strong><span>可移除已生效条件或一键清空筛选后重试。</span><button type="button" (click)="clearFilters()">清空筛选</button></div>
+      } @else {
+        <app-listing-overview-table
+          [items]="store.items()"
+          [loading]="store.loading()"
+          [nextCursor]="store.nextCursor()"
+          [currentCursor]="store.query().cursor"
+          [sort]="store.query().sort"
+          [direction]="store.query().direction"
+          [returnTo]="overviewUrl()"
+          (nextPage)="store.nextPage()"
+          (firstPage)="firstPage()"
+          (sortChange)="applyQuery($event)"
+          (detailOpen)="rememberPosition()"
+        ></app-listing-overview-table>
+      }
+    </app-content-card>
+  }
+</main>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.scss


+ 127 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.spec.ts

@@ -0,0 +1,127 @@
+import { TestBed } from '@angular/core/testing';
+import { Router, provideRouter } from '@angular/router';
+import { RouterTestingHarness } from '@angular/router/testing';
+import { of, throwError } from 'rxjs';
+import type { ListingDimension, ListingOverviewDimensionStat, ListingOverviewPage, ListingOverviewRow } from '../../models/listing-ai.models';
+import { ListingAiApiService } from '../../services/listing-ai-api.service';
+import { LISTING_AI_ROUTES } from '../../listing-ai.routes';
+import { ListingScoreOverviewComponent } from './listing-score-overview.component';
+
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+const MAX: Record<ListingDimension, number> = { title: 30, selling_points: 25, images: 20, description: 15, specifications: 10 };
+const LABEL: Record<ListingDimension, string> = { title: '商品标题', selling_points: '核心卖点', images: '图片资产', description: '商品详情', specifications: '规格与履约' };
+
+function row(productId: string, scoreNature: 'simulation' | 'formal_ai' = 'simulation'): ListingOverviewRow {
+  return {
+    productId, title: productId === '1001' ? '测试冷柜' : '测试冰箱', imageUrl: null, categoryId: 'cat-a', categoryName: '冷柜', categoryPath: ['家电', '冷柜'], categoryIds: ['cat-a'],
+    itemStatusLabel: '商品状态已获取', scoreNature, scoreNatureLabel: scoreNature === 'simulation' ? '模拟评分' : '正式评分', overallScore: productId === '1001' ? 58 : 88,
+    overallRate: productId === '1001' ? .58 : .88, dimensions: Object.fromEntries(DIMENSIONS.map((key) => [key, { score: key === 'images' && productId === '1001' ? 0 : MAX[key] * .8, maxScore: MAX[key], rate: key === 'images' && productId === '1001' ? 0 : .8, gap: key === 'images' && productId === '1001' ? MAX[key] : MAX[key] * .2 }])) as ListingOverviewRow['dimensions'],
+    weakestDimension: 'images', improvementPotential: productId === '1001' ? 42 : 12, scoredAt: '2026-08-26T11:00:00.000Z', syncedAt: '2026-08-26T10:00:00.000Z',
+  };
+}
+
+const dimensionStats = Object.fromEntries(DIMENSIONS.map((key) => [key, { key, label: LABEL[key], maxScore: MAX[key], scoredCount: 625, averageScore: MAX[key] * .8, averageRate: .8, medianScore: MAX[key] * .82, totalGap: MAX[key] * 125 } satisfies ListingOverviewDimensionStat])) as Record<ListingDimension, ListingOverviewDimensionStat>;
+const PAGE: ListingOverviewPage = {
+  items: [row('1001'), row('1002', 'formal_ai')], nextCursor: 'next-page',
+  summary: {
+    sourceTotal: 625, matchedTotal: 500, scoredTotal: 450, simulationTotal: 300, averageScore: 76.4, medianScore: 78,
+    scoreDistribution: [
+      { key: 'below_60', label: '60 分以下', minScore: 0, maxScore: 60, maxScoreExclusive: true, count: 45 },
+      { key: '60_69', label: '60–69 分', minScore: 60, maxScore: 70, maxScoreExclusive: true, count: 80 },
+      { key: '70_79', label: '70–79 分', minScore: 70, maxScore: 80, maxScoreExclusive: true, count: 140 },
+      { key: '80_89', label: '80–89 分', minScore: 80, maxScore: 90, maxScoreExclusive: true, count: 130 },
+      { key: '90_100', label: '90–100 分', minScore: 90, maxScore: 100, maxScoreExclusive: false, count: 55 },
+    ],
+    dimensionStats, categoryFacets: [{ categoryId: 'cat-a', categoryName: '冷柜', categoryPath: ['家电', '冷柜'], count: 500 }],
+    scoreNatureFacets: [{ value: 'simulation', label: '模拟评分', count: 300 }, { value: 'formal_ai', label: '正式评分', count: 150 }, { value: 'rule_precheck', label: '自动检查', count: 0 }, { value: 'unscored', label: '未评分', count: 50 }],
+    snapshotId: 'snapshot-625-fixed', generatedAt: '2026-08-26T12:00:00.000Z',
+  },
+};
+
+describe('ListingScoreOverviewComponent', () => {
+  async function open(url = '/listing-ai/overview?sort=improvementPotential&direction=desc&limit=25', responses: ListingOverviewPage[] = [PAGE]) {
+    const api = jasmine.createSpyObj<ListingAiApiService>('ListingAiApiService', ['overview']);
+    api.overview.and.returnValues(...responses.map((response) => of(response)));
+    await TestBed.configureTestingModule({ providers: [
+      provideRouter([{ path: 'listing-ai', children: LISTING_AI_ROUTES }]),
+      { provide: ListingAiApiService, useValue: api },
+    ] }).compileComponents();
+    const harness = await RouterTestingHarness.create(url);
+    return { api, harness, router: TestBed.inject(Router), element: harness.routeNativeElement as HTMLElement };
+  }
+
+  it('declares the overview route and preserves the workbench route', () => {
+    expect(LISTING_AI_ROUTES.find((route) => route.path === '')?.redirectTo).toBe('overview');
+    expect(LISTING_AI_ROUTES.find((route) => route.path === 'overview')?.loadComponent).toBeDefined();
+    expect(LISTING_AI_ROUTES.find((route) => route.path === 'workbench')?.loadComponent).toBeDefined();
+  });
+
+  it('maps real API summary data to scope, KPI, distribution, health, and persistent simulation labels', async () => {
+    const { element } = await open();
+    const text = element.textContent ?? '';
+    expect(text).toContain('625 个冻结 Listing');
+    expect(text).toContain('500');
+    expect(text).toContain('90%');
+    expect(text).toContain('76.4');
+    expect(text).toContain('78');
+    expect(text).toContain('底部 10%');
+    expect(text).toContain('45 条');
+    expect(text).toContain('80%');
+    expect(text).toContain('模拟评分 · 非正式业务结论');
+    expect(element.querySelectorAll('.nature-badge.simulation').length).toBeGreaterThan(0);
+    expect(element.querySelector('.mobile-list')?.textContent).toContain('测试冷柜');
+    expect(element.querySelector('.mobile-list')?.textContent).toContain('非正式业务评分');
+  });
+
+  it('applies a score bucket and a dimension sort through the Store while clearing cursors', async () => {
+    const { api, element } = await open('/listing-ai/overview?sort=improvementPotential&direction=desc&limit=25&cursor=old', [PAGE, PAGE, PAGE]);
+    (element.querySelector('.bucket') as HTMLButtonElement).click();
+    expect(api.overview.calls.argsFor(1)[1]).toEqual(jasmine.objectContaining({ minScore: 0, maxScore: 59.9999 }));
+    expect(api.overview.calls.argsFor(1)[1]!.cursor).toBeUndefined();
+    (element.querySelectorAll('.health-card')[2] as HTMLButtonElement).click();
+    expect(api.overview.calls.argsFor(2)[1]).toEqual(jasmine.objectContaining({ sort: 'images', direction: 'asc' }));
+  });
+
+  it('clears active filters and synchronizes the canonical state to the URL', async () => {
+    const { api, element, router } = await open('/listing-ai/overview?search=%E5%86%B7%E6%9F%9C&minScore=70&sort=images&direction=asc&limit=50&cursor=old', [PAGE, PAGE]);
+    (element.querySelector('.clear-button') as HTMLButtonElement).click();
+    await new Promise((resolve) => setTimeout(resolve));
+    const query = api.overview.calls.argsFor(1)[1]!;
+    expect(query).toEqual(jasmine.objectContaining({ sort: 'improvementPotential', direction: 'desc', limit: 25 }));
+    expect(query.search).toBeUndefined();
+    expect(query.cursor).toBeUndefined();
+    expect(router.url).not.toContain('search=');
+    expect(router.url).not.toContain('cursor=');
+  });
+
+  it('paginates with the API cursor and retains the complete Overview URL in detail links', async () => {
+    const { api, element, router } = await open('/listing-ai/overview?search=%E5%86%B7%E6%9F%9C&sort=overallScore&direction=asc&limit=25', [PAGE, { ...PAGE, nextCursor: null }]);
+    const link = element.querySelector('a.detail-link') as HTMLAnchorElement;
+    expect(decodeURIComponent(decodeURIComponent(link.href))).toContain('returnTo=/listing-ai/overview?search=冷柜');
+    (element.querySelector('.pagination .next') as HTMLButtonElement).click();
+    await new Promise((resolve) => setTimeout(resolve));
+    expect(api.overview.calls.argsFor(1)[1]!.cursor).toBe('next-page');
+    expect(router.url).toContain('cursor=next-page');
+  });
+
+  it('renders an actionable empty state', async () => {
+    const empty = { ...PAGE, items: [], nextCursor: null, summary: { ...PAGE.summary, matchedTotal: 0, scoredTotal: 0, simulationTotal: 0 } };
+    const { api, element } = await open('/listing-ai/overview?search=none&sort=improvementPotential&direction=desc&limit=25', [empty, PAGE]);
+    expect(element.textContent).toContain('当前条件下没有 Listing');
+    (element.querySelector('.empty-state button') as HTMLButtonElement).click();
+    expect(api.overview).toHaveBeenCalledTimes(2);
+  });
+
+  it('renders a request error and retries once from the same query', async () => {
+    const api = jasmine.createSpyObj<ListingAiApiService>('ListingAiApiService', ['overview']);
+    api.overview.and.returnValues(throwError(() => new Error('offline')), of(PAGE));
+    await TestBed.configureTestingModule({ providers: [provideRouter([{ path: 'listing-ai', children: LISTING_AI_ROUTES }]), { provide: ListingAiApiService, useValue: api }] }).compileComponents();
+    const harness = await RouterTestingHarness.create('/listing-ai/overview?sort=improvementPotential&direction=desc&limit=25');
+    const element = harness.routeNativeElement as HTMLElement;
+    expect(element.textContent).toContain('加载失败');
+    (element.querySelector('.state.error button') as HTMLButtonElement).click();
+    harness.detectChanges();
+    expect(api.overview).toHaveBeenCalledTimes(2);
+    expect(element.textContent).toContain('测试冷柜');
+  });
+});

+ 65 - 0
src/modules/listing-ai/pages/overview/listing-score-overview.component.ts

@@ -0,0 +1,65 @@
+import { ChangeDetectionStrategy, Component, OnInit, effect, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Router, RouterLink } from '@angular/router';
+import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
+import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
+import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
+import { ListingDimensionHealthComponent } from '../../components/listing-dimension-health/listing-dimension-health.component';
+import { ListingOverviewFilterBarComponent } from '../../components/listing-overview-filter-bar/listing-overview-filter-bar.component';
+import { ListingOverviewTableComponent } from '../../components/listing-overview-table/listing-overview-table.component';
+import { ListingScoreDistributionComponent } from '../../components/listing-score-distribution/listing-score-distribution.component';
+import type { ListingDimension, ListingOverviewQuery, ListingOverviewScoreDistributionBucket } from '../../models/listing-ai.models';
+import { ListingOverviewNavigationService } from '../../services/listing-overview-navigation.service';
+import { ListingAiOverviewStore } from '../../services/listing-ai-overview.store';
+
+@Component({
+  selector: 'app-listing-score-overview',
+  standalone: true,
+  imports: [CommonModule, RouterLink, PageHeaderComponent, ContentCardComponent, ListingOverviewFilterBarComponent, ListingScoreDistributionComponent, ListingDimensionHealthComponent, ListingOverviewTableComponent],
+  providers: [ListingAiOverviewStore],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  templateUrl: './listing-score-overview.component.html',
+  styleUrls: ['../../listing-ai.shared.scss', './listing-score-overview.component.scss'],
+})
+export class ListingScoreOverviewComponent implements OnInit {
+  readonly store = inject(ListingAiOverviewStore);
+  readonly workspaceId = RUNTIME_CONFIG.domesticWorkspaceId;
+  private readonly router = inject(Router);
+  private readonly overviewNavigation = inject(ListingOverviewNavigationService);
+  private readonly restoreScroll = effect(() => {
+    const state = this.store.state();
+    if (state === 'ready' || state === 'empty') {
+      const url = this.router.url;
+      queueMicrotask(() => this.overviewNavigation.restoreAfterRender(url));
+    }
+  });
+
+  ngOnInit(): void {
+    this.store.connect(this.workspaceId);
+  }
+
+  applyQuery(patch: Partial<ListingOverviewQuery>): void { this.store.patchQuery(patch); }
+  clearFilters(): void { this.store.resetQuery(); }
+
+  selectBucket(bucket: ListingOverviewScoreDistributionBucket): void {
+    this.store.patchQuery({ minScore: bucket.minScore, maxScore: bucket.maxScoreExclusive ? bucket.maxScore - 0.0001 : bucket.maxScore });
+  }
+
+  selectBottomTen(): void {
+    const count = Math.min(100, Math.max(1, Math.ceil((this.store.summary()?.scoredTotal ?? 0) * 0.1)));
+    this.store.patchQuery({ minScore: undefined, maxScore: undefined, sort: 'overallScore', direction: 'asc', limit: count });
+  }
+
+  selectDimension(dimension: ListingDimension): void { this.store.patchQuery({ sort: dimension, direction: 'asc' }); }
+  firstPage(): void { this.store.goToCursor(null); }
+
+  coverageRate(): string {
+    const summary = this.store.summary();
+    if (!summary?.matchedTotal) return '0%';
+    return `${Math.round(summary.scoredTotal / summary.matchedTotal * 1000) / 10}%`;
+  }
+
+  bottomTenCount(): number { return Math.ceil((this.store.summary()?.scoredTotal ?? 0) * 0.1); }
+  overviewUrl(): string { return this.router.url; }
+  rememberPosition(): void { this.overviewNavigation.remember(this.router.url); }
+}

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

@@ -1,6 +1,6 @@
 <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){
 <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="返回工作台" backRoute="/listing-ai/workbench" [badges]="[{label:vm.source.detailStatus==='available'?'数据完整':'数据待补充',tone:vm.source.detailStatus==='available'?'green':'amber'}]">
-    <div pageHeaderActions class="header-actions"><button class="btn secondary" (click)="load()">刷新</button></div>
+  <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'}]">
+    <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>
   </app-page-header>
   @if(action()){<div class="alert" [class.error]="action().includes('失败')">{{action()}}</div>}
   @if(action()){<div class="alert" [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">
   <div class="detail-grid"><aside class="sticky"><app-listing-mobile-preview [source]="vm.source"></app-listing-mobile-preview></aside><section class="listing-detail-content">

+ 18 - 2
src/modules/listing-ai/pages/product/listing-ai-product.component.spec.ts

@@ -1,5 +1,5 @@
 import { TestBed } from '@angular/core/testing';
 import { TestBed } from '@angular/core/testing';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
 import { of } from 'rxjs';
 import { of } from 'rxjs';
 import { ListingAiProductComponent } from './listing-ai-product.component';
 import { ListingAiProductComponent } from './listing-ai-product.component';
 import { ListingAiApiService } from '../../services/listing-ai-api.service';
 import { ListingAiApiService } from '../../services/listing-ai-api.service';
@@ -13,7 +13,8 @@ describe('ListingAiProductComponent',()=>{
       rulePrecheck:null,versionsSummary:{items:[],nextCursor:null},
       rulePrecheck:null,versionsSummary:{items:[],nextCursor:null},
     };
     };
     await TestBed.configureTestingModule({imports:[ListingAiProductComponent],providers:[
     await TestBed.configureTestingModule({imports:[ListingAiProductComponent],providers:[
-      {provide:ActivatedRoute,useValue:{snapshot:{paramMap:{get:()=> '1001'}}}},
+      provideRouter([]),
+      {provide:ActivatedRoute,useValue:{snapshot:{paramMap:{get:()=> '1001'},queryParamMap:convertToParamMap({})}}},
       {provide:ListingAiApiService,useValue:{product:()=>of(detail),createVersion:()=>of({}),adoptVersion:()=>of({})}},
       {provide:ListingAiApiService,useValue:{product:()=>of(detail),createVersion:()=>of({}),adoptVersion:()=>of({})}},
     ]}).compileComponents();
     ]}).compileComponents();
     const fixture=TestBed.createComponent(ListingAiProductComponent);
     const fixture=TestBed.createComponent(ListingAiProductComponent);
@@ -26,4 +27,19 @@ describe('ListingAiProductComponent',()=>{
     expect(text).not.toContain('CTR');
     expect(text).not.toContain('CTR');
     expect(text).not.toContain('CVR');
     expect(text).not.toContain('CVR');
   });
   });
+
+  it('returns to the complete overview URL when returnTo is present without changing the normal workbench fallback',async()=>{
+    const returnTo='/listing-ai/overview?search=%E5%86%B7%E6%9F%9C&sort=images&direction=asc&cursor=page-2';
+    const detail:any={source:{productId:'1001',sourceHash:'hash',title:'测试商品',detailStatus:'available',descriptions:{desktopHtml:null,mobileHtml:null},marketing:{sellingPoints:[]},images:[],attributes:[],brand:{id:null,name:null},categoryIds:[],features:[],skus:[],dimensions:{length:null,width:null,height:null,weight:null},logistics:{},afterService:{},syncedAt:'2026-08-25T00:00:00.000Z'},coverage:null,currentScore:null,rulePrecheck:null,versionsSummary:{items:[],nextCursor:null}};
+    await TestBed.configureTestingModule({imports:[ListingAiProductComponent],providers:[
+      provideRouter([]),
+      {provide:ActivatedRoute,useValue:{snapshot:{paramMap:{get:()=> '1001'},queryParamMap:convertToParamMap({returnTo})}}},
+      {provide:ListingAiApiService,useValue:{product:()=>of(detail),createVersion:()=>of({}),adoptVersion:()=>of({})}},
+    ]}).compileComponents();
+    const router=TestBed.inject(Router);const navigate=spyOn(router,'navigateByUrl').and.resolveTo(true);
+    const fixture=TestBed.createComponent(ListingAiProductComponent);fixture.detectChanges();
+    expect((fixture.nativeElement as HTMLElement).textContent).toContain('返回评分看板');
+    fixture.componentInstance.backToOverview();
+    expect(navigate).toHaveBeenCalledOnceWith(returnTo);
+  });
 });
 });

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

@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
 import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
 import { RUNTIME_CONFIG } from '../../../../app/core/config/runtime-config';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
 import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
@@ -13,9 +13,10 @@ import { ListingAiApiService } from '../../services/listing-ai-api.service';
 
 
 @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'})
 @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{
 export class ListingAiProductComponent implements OnInit{
-  private readonly route=inject(ActivatedRoute);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('');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 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分'}];
   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{this.load();}
+  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.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)});}
   select(key:string):void{this.active=key as ListingDimension;}
   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;}
   dimension():ListingScorePresentationDimension|null{const score=this.detail()?.currentScore??this.detail()?.rulePrecheck;return score?.dimensions.find((item)=>item.key===this.active)??null;}

+ 22 - 0
src/modules/listing-ai/services/listing-ai-api.service.spec.ts

@@ -7,6 +7,28 @@ describe('ListingAiApiService',()=>{
   beforeEach(()=>{TestBed.configureTestingModule({imports:[HttpClientTestingModule]});service=TestBed.inject(ListingAiApiService);http=TestBed.inject(HttpTestingController);});
   beforeEach(()=>{TestBed.configureTestingModule({imports:[HttpClientTestingModule]});service=TestBed.inject(ListingAiApiService);http=TestBed.inject(HttpTestingController);});
   afterEach(()=>http.verify());
   afterEach(()=>http.verify());
 it('queries one cursor page without hardcoding catalog totals',()=>{service.products('demashi',{search:'299L',limit:25}).subscribe();const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/products');expect(request.request.method).toBe('GET');expect(request.request.params.get('workspaceId')).toBe('demashi');expect(request.request.params.get('search')).toBe('299L');expect(request.request.params.get('limit')).toBe('25');request.flush({items:[],nextCursor:null,summary:{sourceTotal:17,eligible:0,scored:0,partial:0,blocked:0,failed:0,averageScore:null,lastCatalogSyncAt:null}});});
 it('queries one cursor page without hardcoding catalog totals',()=>{service.products('demashi',{search:'299L',limit:25}).subscribe();const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/products');expect(request.request.method).toBe('GET');expect(request.request.params.get('workspaceId')).toBe('demashi');expect(request.request.params.get('search')).toBe('299L');expect(request.request.params.get('limit')).toBe('25');request.flush({items:[],nextCursor:null,summary:{sourceTotal:17,eligible:0,scored:0,partial:0,blocked:0,failed:0,averageScore:null,lastCatalogSyncAt:null}});});
+  it('queries the overview endpoint with the workspace contract',()=>{service.overview('demashi').subscribe();const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/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({items:[],nextCursor:null,summary:{}});});
+  it('serializes overview arrays, score ranges, sorting, limit, and cursor without dropping zero',()=>{
+    service.overview('demashi',{search:'',categoryIds:['cat-a','cat-b'],scoreNature:'simulation',minScore:0,maxScore:100,titleMin:0,titleMax:30,sellingPointsMin:1,sellingPointsMax:25,imagesMin:2,imagesMax:20,descriptionMin:3,descriptionMax:15,specificationsMin:4,specificationsMax:10,weakestDimension:'images',sort:'overallScore',direction:'asc',limit:100,cursor:'opaque-cursor'}).subscribe();
+    const request=http.expectOne((candidate)=>candidate.url==='/api/listing-ai/overview');
+    expect(request.request.params.getAll('categoryIds')).toEqual(['cat-a','cat-b']);
+    expect(request.request.params.has('search')).toBeFalse();
+    expect(request.request.params.get('scoreNature')).toBe('simulation');
+    expect(request.request.params.get('minScore')).toBe('0');
+    expect(request.request.params.get('maxScore')).toBe('100');
+    expect(request.request.params.get('titleMin')).toBe('0');
+    expect(request.request.params.get('titleMax')).toBe('30');
+    expect(request.request.params.get('sellingPointsMin')).toBe('1');
+    expect(request.request.params.get('imagesMax')).toBe('20');
+    expect(request.request.params.get('descriptionMax')).toBe('15');
+    expect(request.request.params.get('specificationsMax')).toBe('10');
+    expect(request.request.params.get('weakestDimension')).toBe('images');
+    expect(request.request.params.get('sort')).toBe('overallScore');
+    expect(request.request.params.get('direction')).toBe('asc');
+    expect(request.request.params.get('limit')).toBe('100');
+    expect(request.request.params.get('cursor')).toBe('opaque-cursor');
+    request.flush({items:[],nextCursor:null,summary:{}});
+  });
   it('creates a forced AI score job and lets the server select the current rubric',()=>{service.createScoreJob('demashi',{mode:'selected',productIds:['1001']},'ai','force').subscribe();const request=http.expectOne('/api/listing-ai/score-jobs');expect(request.request.method).toBe('POST');expect(request.request.headers.get('Idempotency-Key')).toBeTruthy();expect(request.request.body.scoringMode).toBe('ai');expect(request.request.body.rubricVersion).toBeUndefined();expect(request.request.body.rescorePolicy).toBe('force');request.flush({job:{}});});
   it('creates a forced AI score job and lets the server select the current rubric',()=>{service.createScoreJob('demashi',{mode:'selected',productIds:['1001']},'ai','force').subscribe();const request=http.expectOne('/api/listing-ai/score-jobs');expect(request.request.method).toBe('POST');expect(request.request.headers.get('Idempotency-Key')).toBeTruthy();expect(request.request.body.scoringMode).toBe('ai');expect(request.request.body.rubricVersion).toBeUndefined();expect(request.request.body.rescorePolicy).toBe('force');request.flush({job:{}});});
   it('creates a version from explicit current content without a score-history id',()=>{const content={title:'标题',sellingPoints:['卖点'],descriptionHtml:null,specifications:[],imageUrls:[]};service.createVersion('demashi','1001','hash',content).subscribe();const request=http.expectOne('/api/listing-ai/products/1001/versions');expect(request.request.body.content).toEqual(content);expect(request.request.body.baseScoreResultId).toBeUndefined();request.flush({version:{}});});
   it('creates a version from explicit current content without a score-history id',()=>{const content={title:'标题',sellingPoints:['卖点'],descriptionHtml:null,specifications:[],imageUrls:[]};service.createVersion('demashi','1001','hash',content).subscribe();const request=http.expectOne('/api/listing-ai/products/1001/versions');expect(request.request.body.content).toEqual(content);expect(request.request.body.baseScoreResultId).toBeUndefined();request.flush({version:{}});});
 });
 });

+ 10 - 1
src/modules/listing-ai/services/listing-ai-api.service.ts

@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
 import { Injectable, inject } from '@angular/core';
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
 import { Observable } from 'rxjs';
 import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
 import { RUNTIME_CONFIG } from '../../../app/core/config/runtime-config';
-import type { CursorPage, ListingProductDetailResponse, ListingProductPage, ListingScoreJob, ListingScoreJobItem, ListingVersion } from '../models/listing-ai.models';
+import type { CursorPage, ListingOverviewPage, ListingOverviewQuery, ListingProductDetailResponse, ListingProductPage, ListingScoreJob, ListingScoreJobItem, ListingVersion } from '../models/listing-ai.models';
 
 
 export interface ListingProductFilters {search?:string;categoryId?:string;itemStatus?:string;scoreStatus?:string;aiScoreStatus?:string;coverageStatus?:string;minScore?:number;maxScore?:number;sort?:string;cursor?:string;limit?:number;}
 export interface ListingProductFilters {search?:string;categoryId?:string;itemStatus?:string;scoreStatus?:string;aiScoreStatus?:string;coverageStatus?:string;minScore?:number;maxScore?:number;sort?:string;cursor?:string;limit?:number;}
 @Injectable({providedIn:'root'})
 @Injectable({providedIn:'root'})
@@ -10,6 +10,15 @@ export class ListingAiApiService{
   private readonly http=inject(HttpClient);
   private readonly http=inject(HttpClient);
   private readonly baseUrl=`${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/,'')}/${RUNTIME_CONFIG.listingAiPath.replace(/^\/+|\/+$/g,'')}`;
   private readonly baseUrl=`${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/,'')}/${RUNTIME_CONFIG.listingAiPath.replace(/^\/+|\/+$/g,'')}`;
   products(workspaceId:string,filters:ListingProductFilters={}):Observable<ListingProductPage>{let params=new HttpParams().set('workspaceId',workspaceId).set('platform','jd').set('limit',filters.limit??25);for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!==null&&value!=='')params=params.set(key,String(value));}return this.http.get<ListingProductPage>(`${this.baseUrl}/products`,{params});}
   products(workspaceId:string,filters:ListingProductFilters={}):Observable<ListingProductPage>{let params=new HttpParams().set('workspaceId',workspaceId).set('platform','jd').set('limit',filters.limit??25);for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!==null&&value!=='')params=params.set(key,String(value));}return this.http.get<ListingProductPage>(`${this.baseUrl}/products`,{params});}
+  overview(workspaceId:string,query:ListingOverviewQuery={}):Observable<ListingOverviewPage>{
+    let params=new HttpParams().set('workspaceId',workspaceId).set('platform','jd');
+    for(const [key,value] of Object.entries(query)){
+      if(value===undefined||value===null||value==='')continue;
+      if(Array.isArray(value)){for(const item of value){if(item)params=params.append(key,String(item));}}
+      else params=params.set(key,String(value));
+    }
+    return this.http.get<ListingOverviewPage>(`${this.baseUrl}/overview`,{params});
+  }
   product(workspaceId:string,productId:string):Observable<ListingProductDetailResponse>{return this.http.get<ListingProductDetailResponse>(`${this.baseUrl}/products/${encodeURIComponent(productId)}`,{params:{workspaceId,platform:'jd'}});}
   product(workspaceId:string,productId:string):Observable<ListingProductDetailResponse>{return this.http.get<ListingProductDetailResponse>(`${this.baseUrl}/products/${encodeURIComponent(productId)}`,{params:{workspaceId,platform:'jd'}});}
   createScoreJob(workspaceId:string,scope:{mode:'filter';filter:Record<string,unknown>}|{mode:'selected';productIds:string[]},scoringMode:'rules'|'ai'='ai',rescorePolicy:'reuse'|'force'='reuse'):Observable<{job:ListingScoreJob}>{const idempotencyKey=crypto.randomUUID();return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs`,{workspaceId,platform:'jd',scope,scoringMode,rescorePolicy},{headers:new HttpHeaders({'Idempotency-Key':idempotencyKey})});}
   createScoreJob(workspaceId:string,scope:{mode:'filter';filter:Record<string,unknown>}|{mode:'selected';productIds:string[]},scoringMode:'rules'|'ai'='ai',rescorePolicy:'reuse'|'force'='reuse'):Observable<{job:ListingScoreJob}>{const idempotencyKey=crypto.randomUUID();return this.http.post<{job:ListingScoreJob}>(`${this.baseUrl}/score-jobs`,{workspaceId,platform:'jd',scope,scoringMode,rescorePolicy},{headers:new HttpHeaders({'Idempotency-Key':idempotencyKey})});}
   jobs(workspaceId:string,status='',cursor='',limit=25):Observable<CursorPage<ListingScoreJob>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(status)params=params.set('status',status);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingScoreJob>>(`${this.baseUrl}/score-jobs`,{params});}
   jobs(workspaceId:string,status='',cursor='',limit=25):Observable<CursorPage<ListingScoreJob>>{let params=new HttpParams().set('workspaceId',workspaceId).set('limit',limit);if(status)params=params.set('status',status);if(cursor)params=params.set('cursor',cursor);return this.http.get<CursorPage<ListingScoreJob>>(`${this.baseUrl}/score-jobs`,{params});}

+ 146 - 0
src/modules/listing-ai/services/listing-ai-overview.store.spec.ts

@@ -0,0 +1,146 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
+import { BehaviorSubject, Subject, of, throwError } from 'rxjs';
+import type { ListingOverviewPage } from '../models/listing-ai.models';
+import { ListingAiApiService } from './listing-ai-api.service';
+import { ListingAiOverviewStore, listingOverviewQueryFromParamMap, listingOverviewQueryToParams } from './listing-ai-overview.store';
+
+const PAGE = {
+  items: [{ productId: '1001', title: '测试商品', overallScore: 88 }],
+  nextCursor: 'next-page',
+  summary: { sourceTotal: 1, matchedTotal: 1, scoredTotal: 1, simulationTotal: 1, generatedAt: '2026-08-26T00:00:00.000Z' },
+} as unknown as ListingOverviewPage;
+
+describe('ListingAiOverviewStore', () => {
+  let params$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
+  let api: jasmine.SpyObj<ListingAiApiService>;
+  let router: jasmine.SpyObj<Router>;
+
+  function createStore(initial: Record<string, unknown>): ListingAiOverviewStore {
+    params$ = new BehaviorSubject(convertToParamMap(initial));
+    api = jasmine.createSpyObj<ListingAiApiService>('ListingAiApiService', ['overview']);
+    router = jasmine.createSpyObj<Router>('Router', ['navigate']);
+    router.navigate.and.resolveTo(true);
+    TestBed.configureTestingModule({ providers: [
+      ListingAiOverviewStore,
+      { provide: ListingAiApiService, useValue: api },
+      { provide: ActivatedRoute, useValue: { queryParamMap: params$.asObservable() } },
+      { provide: Router, useValue: router },
+    ] });
+    return TestBed.inject(ListingAiOverviewStore);
+  }
+
+  afterEach(() => TestBed.resetTestingModule());
+
+  it('restores URL query state and exposes successful and empty load states', () => {
+    const store = createStore({ search: '冷柜', categoryIds: ['cat-a', 'cat-b'], sort: 'overallScore', direction: 'asc', limit: '25', cursor: 'page-2' });
+    api.overview.and.returnValue(of(PAGE));
+    store.connect('demashi');
+
+    expect(api.overview).toHaveBeenCalledOnceWith('demashi', jasmine.objectContaining({ search: '冷柜', categoryIds: ['cat-a', 'cat-b'], sort: 'overallScore', direction: 'asc', limit: 25, cursor: 'page-2' }));
+    expect(store.state()).toBe('ready');
+    expect(store.items()[0]?.productId).toBe('1001');
+    expect(store.summary()?.matchedTotal).toBe(1);
+    expect(store.nextCursor()).toBe('next-page');
+
+    api.overview.and.returnValue(of({ ...PAGE, items: [], nextCursor: null }));
+    store.patchQuery({ search: '不存在的商品' });
+    expect(store.state()).toBe('empty');
+  });
+
+  it('clears an old cursor when filters or sorting change and writes the new state to the URL', () => {
+    const store = createStore({ sort: 'overallScore', direction: 'desc', limit: '25', cursor: 'old-cursor' });
+    api.overview.and.returnValue(of(PAGE));
+    store.connect('demashi');
+    store.patchQuery({ minScore: 0, sort: 'images', direction: 'asc' });
+
+    const secondQuery = api.overview.calls.argsFor(1)[1]!;
+    expect(secondQuery.cursor).toBeUndefined();
+    expect(secondQuery.minScore).toBe(0);
+    expect(secondQuery.sort).toBe('images');
+    const navigation = router.navigate.calls.mostRecent().args[1];
+    expect(navigation?.queryParams?.['cursor']).toBeUndefined();
+    expect(navigation?.queryParams?.['minScore']).toBe(0);
+    expect(navigation?.queryParams?.['sort']).toBe('images');
+  });
+
+  it('writes the next-page cursor to the URL and requests that exact page', () => {
+    const store = createStore({ sort: 'improvementPotential', direction: 'desc', limit: '25' });
+    api.overview.and.returnValues(of(PAGE), of({ ...PAGE, nextCursor: null }));
+    store.connect('demashi');
+    store.nextPage();
+
+    expect(api.overview).toHaveBeenCalledTimes(2);
+    expect(api.overview.calls.argsFor(1)[1]!.cursor).toBe('next-page');
+    expect(store.query().cursor).toBe('next-page');
+    expect(router.navigate.calls.mostRecent().args[1]?.queryParams?.['cursor']).toBe('next-page');
+  });
+
+  it('resets all filters, sorting, paging, and page size to canonical defaults', () => {
+    const store = createStore({ search: '冷柜', minScore: '70', sort: 'images', direction: 'asc', limit: '50', cursor: 'page-2' });
+    api.overview.and.returnValues(of(PAGE), of(PAGE));
+    store.connect('demashi');
+    store.resetQuery();
+
+    expect(api.overview.calls.argsFor(1)[1]).toEqual(jasmine.objectContaining({ sort: 'improvementPotential', direction: 'desc', limit: 25 }));
+    expect(api.overview.calls.argsFor(1)[1]!.search).toBeUndefined();
+    expect(api.overview.calls.argsFor(1)[1]!.minScore).toBeUndefined();
+    expect(api.overview.calls.argsFor(1)[1]!.cursor).toBeUndefined();
+    expect(router.navigate.calls.mostRecent().args[1]?.queryParams).toEqual({ sort: 'improvementPotential', direction: 'desc', limit: 25 });
+  });
+
+  it('clears a stale cursor in the URL and retries the first page exactly once', () => {
+    const store = createStore({ sort: 'overallScore', direction: 'desc', limit: '25', cursor: 'stale-cursor' });
+    api.overview.and.returnValues(
+      throwError(() => ({ status: 409, error: { error: 'listing_overview_cursor_stale' } })),
+      of({ ...PAGE, nextCursor: null }),
+    );
+    store.connect('demashi');
+
+    expect(api.overview).toHaveBeenCalledTimes(2);
+    expect(api.overview.calls.argsFor(0)[1]!.cursor).toBe('stale-cursor');
+    expect(api.overview.calls.argsFor(1)[1]!.cursor).toBeUndefined();
+    expect(store.query().cursor).toBeUndefined();
+    expect(store.state()).toBe('ready');
+    const navigation = router.navigate.calls.mostRecent().args[1];
+    expect(navigation?.queryParams?.['cursor']).toBeUndefined();
+  });
+
+  it('does not loop when the one stale-cursor recovery request also fails', () => {
+    const store = createStore({ sort: 'overallScore', direction: 'desc', limit: '25', cursor: 'stale-cursor' });
+    const stale = () => throwError(() => ({ status: 409, error: { error: 'listing_overview_cursor_stale' } }));
+    api.overview.and.returnValues(stale(), stale());
+    store.connect('demashi');
+
+    expect(api.overview).toHaveBeenCalledTimes(2);
+    expect(store.state()).toBe('error');
+    expect(store.error()).toContain('加载失败');
+  });
+
+  it('deduplicates identical in-flight URL emissions', () => {
+    const store = createStore({ sort: 'improvementPotential', direction: 'desc', limit: '25' });
+    const response = new Subject<ListingOverviewPage>();
+    api.overview.and.returnValue(response);
+    store.connect('demashi');
+    expect(store.state()).toBe('loading');
+    params$.next(convertToParamMap({ sort: 'improvementPotential', direction: 'desc', limit: '25' }));
+    expect(api.overview).toHaveBeenCalledTimes(1);
+
+    response.next(PAGE);
+    response.complete();
+    expect(store.state()).toBe('ready');
+  });
+
+  it('round-trips every supported URL parameter with canonical defaults', () => {
+    const query = listingOverviewQueryFromParamMap(convertToParamMap({
+      search: '  冷柜  ', categoryIds: ['cat-a,cat-b', 'cat-a'], scoreNature: 'simulation', minScore: '0', maxScore: '100',
+      titleMin: '1', titleMax: '30', sellingPointsMin: '2', sellingPointsMax: '25', imagesMin: '3', imagesMax: '20',
+      descriptionMin: '4', descriptionMax: '15', specificationsMin: '5', specificationsMax: '10', weakestDimension: 'images',
+      sort: 'syncedAt', direction: 'asc', limit: '100', cursor: 'opaque',
+    }));
+    expect(query.categoryIds).toEqual(['cat-a', 'cat-b']);
+    expect(query.search).toBe('冷柜');
+    expect(query.minScore).toBe(0);
+    expect(listingOverviewQueryToParams(query)).toEqual(jasmine.objectContaining({ sort: 'syncedAt', direction: 'asc', limit: 100, cursor: 'opaque', categoryIds: ['cat-a', 'cat-b'], minScore: 0 }));
+  });
+});

+ 232 - 0
src/modules/listing-ai/services/listing-ai-overview.store.ts

@@ -0,0 +1,232 @@
+import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
+import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router';
+import { Subscription } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import type {
+  ListingDimension,
+  ListingOverviewDirection,
+  ListingOverviewPage,
+  ListingOverviewQuery,
+  ListingOverviewQueryState,
+  ListingOverviewRow,
+  ListingOverviewScoreNature,
+  ListingOverviewSort,
+  ListingOverviewSummary,
+} from '../models/listing-ai.models';
+import { ListingAiApiService } from './listing-ai-api.service';
+
+const SORTS: ListingOverviewSort[] = ['productId', 'overallScore', 'title', 'selling_points', 'images', 'description', 'specifications', 'improvementPotential', 'scoredAt', 'syncedAt'];
+const DIRECTIONS: ListingOverviewDirection[] = ['asc', 'desc'];
+const SCORE_NATURES: ListingOverviewScoreNature[] = ['simulation', 'formal_ai', 'rule_precheck', 'unscored'];
+const DIMENSIONS: ListingDimension[] = ['title', 'selling_points', 'images', 'description', 'specifications'];
+const NUMBER_KEYS = ['minScore', 'maxScore', 'titleMin', 'titleMax', 'sellingPointsMin', 'sellingPointsMax', 'imagesMin', 'imagesMax', 'descriptionMin', 'descriptionMax', 'specificationsMin', 'specificationsMax'] as const;
+
+export type ListingOverviewLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'error';
+
+function optionalText(value: string | null | undefined): string | undefined {
+  const normalized = value?.trim();
+  return normalized ? normalized : undefined;
+}
+
+function optionalNumber(value: unknown): number | undefined {
+  if (value === '' || value === null || value === undefined) return undefined;
+  const parsed = typeof value === 'number' ? value : Number(value);
+  return Number.isFinite(parsed) ? parsed : 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 normalizeListingOverviewQuery(query: ListingOverviewQuery): ListingOverviewQueryState {
+  const output: ListingOverviewQueryState = {
+    sort: enumValue(query.sort, SORTS) ?? 'improvementPotential',
+    direction: enumValue(query.direction, DIRECTIONS) ?? 'desc',
+    limit: Math.min(100, Math.max(1, Math.trunc(optionalNumber(query.limit) ?? 25))),
+  };
+  const search = optionalText(query.search);
+  if (search) output.search = search;
+  const categoryIds = [...new Set((query.categoryIds ?? []).map((value) => value.trim()).filter(Boolean))];
+  if (categoryIds.length) output.categoryIds = categoryIds;
+  const scoreNature = enumValue(query.scoreNature, SCORE_NATURES);
+  if (scoreNature) output.scoreNature = scoreNature;
+  const weakestDimension = enumValue(query.weakestDimension, DIMENSIONS);
+  if (weakestDimension) output.weakestDimension = weakestDimension;
+  for (const key of NUMBER_KEYS) {
+    const value = optionalNumber(query[key]);
+    if (value !== undefined) output[key] = value;
+  }
+  const cursor = optionalText(query.cursor);
+  if (cursor) output.cursor = cursor;
+  return output;
+}
+
+export function listingOverviewQueryFromParamMap(params: ParamMap): ListingOverviewQueryState {
+  const categories = params.getAll('categoryIds').flatMap((value) => value.split(','));
+  return normalizeListingOverviewQuery({
+    search: params.get('search') ?? undefined,
+    categoryIds: categories,
+    scoreNature: params.get('scoreNature') as ListingOverviewScoreNature | undefined,
+    minScore: optionalNumber(params.get('minScore')),
+    maxScore: optionalNumber(params.get('maxScore')),
+    titleMin: optionalNumber(params.get('titleMin')),
+    titleMax: optionalNumber(params.get('titleMax')),
+    sellingPointsMin: optionalNumber(params.get('sellingPointsMin')),
+    sellingPointsMax: optionalNumber(params.get('sellingPointsMax')),
+    imagesMin: optionalNumber(params.get('imagesMin')),
+    imagesMax: optionalNumber(params.get('imagesMax')),
+    descriptionMin: optionalNumber(params.get('descriptionMin')),
+    descriptionMax: optionalNumber(params.get('descriptionMax')),
+    specificationsMin: optionalNumber(params.get('specificationsMin')),
+    specificationsMax: optionalNumber(params.get('specificationsMax')),
+    weakestDimension: params.get('weakestDimension') as ListingDimension | undefined,
+    sort: params.get('sort') as ListingOverviewSort | undefined,
+    direction: params.get('direction') as ListingOverviewDirection | undefined,
+    limit: optionalNumber(params.get('limit')),
+    cursor: params.get('cursor') ?? undefined,
+  });
+}
+
+export function listingOverviewQueryToParams(query: ListingOverviewQuery): Params {
+  const normalized = normalizeListingOverviewQuery(query);
+  const params: Params = { sort: normalized.sort, direction: normalized.direction, limit: normalized.limit };
+  for (const key of ['search', 'scoreNature', 'weakestDimension', 'cursor'] as const) {
+    if (normalized[key] !== undefined) params[key] = normalized[key];
+  }
+  if (normalized.categoryIds?.length) params['categoryIds'] = normalized.categoryIds;
+  for (const key of NUMBER_KEYS) {
+    if (normalized[key] !== undefined) params[key] = normalized[key];
+  }
+  return params;
+}
+
+function queryKey(query: ListingOverviewQuery): string {
+  const params = listingOverviewQueryToParams(query);
+  return JSON.stringify(Object.keys(params).sort().map((key) => [key, params[key]]));
+}
+
+function paramMapMatches(params: ParamMap, query: ListingOverviewQuery): boolean {
+  const desired = listingOverviewQueryToParams(query);
+  const desiredKeys = Object.keys(desired).sort();
+  if (params.keys.slice().sort().join('|') !== desiredKeys.join('|')) return false;
+  return desiredKeys.every((key) => {
+    const expected = Array.isArray(desired[key]) ? desired[key].map(String) : [String(desired[key])];
+    return params.getAll(key).join('|') === expected.join('|');
+  });
+}
+
+function isStaleCursorError(error: unknown): boolean {
+  const value = error as { status?: unknown; error?: { error?: unknown } };
+  return value?.status === 409 && value.error?.error === 'listing_overview_cursor_stale';
+}
+
+@Injectable()
+export class ListingAiOverviewStore {
+  private readonly api = inject(ListingAiApiService);
+  private readonly route = inject(ActivatedRoute);
+  private readonly router = inject(Router);
+  private readonly destroyRef = inject(DestroyRef);
+  private workspaceId = '';
+  private connected = false;
+  private request?: Subscription;
+  private requestVersion = 0;
+  private inFlightKey = '';
+  private successfulKey = '';
+
+  readonly query = signal<ListingOverviewQueryState>(normalizeListingOverviewQuery({}));
+  readonly items = signal<ListingOverviewRow[]>([]);
+  readonly summary = signal<ListingOverviewSummary | null>(null);
+  readonly nextCursor = signal<string | null>(null);
+  readonly loading = signal(false);
+  readonly error = signal('');
+  readonly state = computed<ListingOverviewLoadState>(() => {
+    if (this.loading()) return 'loading';
+    if (this.error()) return 'error';
+    if (!this.summary()) return 'idle';
+    return this.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 = listingOverviewQueryFromParamMap(params);
+      this.query.set(next);
+      if (!paramMapMatches(params, next)) this.syncUrl(next);
+      this.loadQuery(next);
+    });
+  }
+
+  patchQuery(patch: Partial<ListingOverviewQuery>): void {
+    const next = normalizeListingOverviewQuery({ ...this.query(), ...patch, cursor: undefined });
+    this.query.set(next);
+    this.syncUrl(next);
+    this.loadQuery(next);
+  }
+
+  resetQuery(): void {
+    const next = normalizeListingOverviewQuery({});
+    this.query.set(next);
+    this.syncUrl(next);
+    this.loadQuery(next);
+  }
+
+  goToCursor(cursor: string | null): void {
+    const next = normalizeListingOverviewQuery({ ...this.query(), cursor: cursor ?? undefined });
+    this.query.set(next);
+    this.syncUrl(next);
+    this.loadQuery(next);
+  }
+
+  nextPage(): void {
+    if (this.nextCursor()) this.goToCursor(this.nextCursor());
+  }
+
+  retry(): void {
+    this.loadQuery(this.query(), true);
+  }
+
+  private syncUrl(query: ListingOverviewQuery): void {
+    void this.router.navigate([], { relativeTo: this.route, queryParams: listingOverviewQueryToParams(query), replaceUrl: true });
+  }
+
+  private loadQuery(query: ListingOverviewQueryState, force = false, staleRetry = false): void {
+    if (!this.workspaceId) return;
+    const key = `${this.workspaceId}|${queryKey(query)}`;
+    if (!force && (this.inFlightKey === key || this.successfulKey === key)) return;
+    this.request?.unsubscribe();
+    const version = ++this.requestVersion;
+    this.inFlightKey = key;
+    this.loading.set(true);
+    this.error.set('');
+    const request = this.api.overview(this.workspaceId, query).subscribe({
+      next: (page: ListingOverviewPage) => {
+        if (version !== this.requestVersion) return;
+        this.items.set(page.items);
+        this.summary.set(page.summary);
+        this.nextCursor.set(page.nextCursor);
+        this.successfulKey = key;
+      },
+      error: (error: unknown) => {
+        if (version !== this.requestVersion) return;
+        this.inFlightKey = '';
+        if (!staleRetry && query.cursor && isStaleCursorError(error)) {
+          const firstPage = normalizeListingOverviewQuery({ ...query, cursor: undefined });
+          this.query.set(firstPage);
+          this.syncUrl(firstPage);
+          this.loadQuery(firstPage, true, true);
+          return;
+        }
+        this.loading.set(false);
+        this.error.set('Listing 评分看板加载失败,请稍后重试');
+      },
+      complete: () => {
+        if (version !== this.requestVersion) return;
+        this.inFlightKey = '';
+        this.loading.set(false);
+      },
+    });
+    if (version === this.requestVersion) this.request = request;
+  }
+}

+ 41 - 0
src/modules/listing-ai/services/listing-overview-navigation.service.spec.ts

@@ -0,0 +1,41 @@
+import { TestBed } from '@angular/core/testing';
+import { ListingOverviewNavigationService } from './listing-overview-navigation.service';
+
+describe('ListingOverviewNavigationService', () => {
+  it('restores a remembered Overview scroll position once after rendering', () => {
+    const service = TestBed.inject(ListingOverviewNavigationService);
+    const animation = spyOn(window, 'requestAnimationFrame').and.callFake((callback: FrameRequestCallback) => { callback(0); return 1; });
+    const scroll = spyOn(window, 'scrollTo') as jasmine.Spy;
+    const url = '/listing-ai/overview?sort=images&cursor=page-2';
+    service.remember(url, 480);
+    service.restoreAfterRender(url);
+    service.restoreAfterRender(url);
+    expect(animation).toHaveBeenCalledTimes(2);
+    expect(scroll).toHaveBeenCalledOnceWith({ top: 480, behavior: 'auto' });
+  });
+
+  it('does not retain scroll state for unrelated routes', () => {
+    const service = TestBed.inject(ListingOverviewNavigationService);
+    const animation = spyOn(window, 'requestAnimationFrame');
+    service.remember('/listing-ai/workbench', 300);
+    service.restoreAfterRender('/listing-ai/workbench');
+    expect(animation).not.toHaveBeenCalled();
+  });
+
+  it('uses the application main scroll container when it is present', () => {
+    const service = TestBed.inject(ListingOverviewNavigationService);
+    const container = document.createElement('main');
+    container.className = 'main-content';
+    Object.defineProperty(container, 'scrollTop', { value: 640, writable: true });
+    document.body.appendChild(container);
+    const animation = spyOn(window, 'requestAnimationFrame').and.callFake((callback: FrameRequestCallback) => { callback(0); return 1; });
+    const scroll = spyOn(container, 'scrollTo') as jasmine.Spy;
+    const url = '/listing-ai/overview?cursor=page-2';
+    service.remember(url);
+    container.scrollTop = 0;
+    service.restoreAfterRender(url);
+    expect(animation).toHaveBeenCalledTimes(2);
+    expect(scroll).toHaveBeenCalledOnceWith({ top: 640, behavior: 'auto' });
+    container.remove();
+  });
+});

+ 26 - 0
src/modules/listing-ai/services/listing-overview-navigation.service.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+export class ListingOverviewNavigationService {
+  private readonly scrollPositions = new Map<string, number>();
+
+  remember(url: string, scrollY = this.scrollContainer()?.scrollTop ?? window.scrollY): void {
+    if (!url.startsWith('/listing-ai/overview')) return;
+    this.scrollPositions.set(url, Math.max(0, scrollY));
+  }
+
+  restoreAfterRender(url: string): void {
+    const scrollY = this.scrollPositions.get(url);
+    if (scrollY === undefined) return;
+    this.scrollPositions.delete(url);
+    requestAnimationFrame(() => requestAnimationFrame(() => {
+      const container = this.scrollContainer();
+      if (container) container.scrollTo({ top: scrollY, behavior: 'auto' });
+      else window.scrollTo({ top: scrollY, behavior: 'auto' });
+    }));
+  }
+
+  private scrollContainer(): HTMLElement | null {
+    return document.querySelector<HTMLElement>('.main-content');
+  }
+}

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

@@ -163,6 +163,7 @@ export class NavigationComponent implements OnDestroy {
       expanded: false,
       expanded: false,
       section: '行动',
       section: '行动',
       children: [
       children: [
+        { path: '/listing-ai/overview', displayName: 'Listing 评分看板', icon: CircleGauge, requiresAuth: false },
         { path: '/listing-ai/workbench', displayName: 'Listing AI 工作台', icon: Lightbulb, requiresAuth: false },
         { path: '/listing-ai/workbench', displayName: 'Listing AI 工作台', icon: Lightbulb, requiresAuth: false },
         { path: '/listing-ai/versions', displayName: 'Listing 版本', icon: History, requiresAuth: false },
         { path: '/listing-ai/versions', displayName: 'Listing 版本', icon: History, requiresAuth: false },
         { path: '/listing-ai/tasks', displayName: 'Listing 任务', icon: ListChecks, requiresAuth: false },
         { path: '/listing-ai/tasks', displayName: 'Listing 任务', icon: ListChecks, requiresAuth: false },

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov