competitor-relations.component.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { CommonModule } from '@angular/common';
  2. import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
  3. import { FormsModule } from '@angular/forms';
  4. import { Router } from '@angular/router';
  5. import { Subject, combineLatest, takeUntil } from 'rxjs';
  6. import { DomesticDataset } from '../../app/core/models/domestic.models';
  7. import { DomesticAnalyticsService, DomesticCompetitorRelationRow } from '../../app/core/services/domestic-analytics.service';
  8. import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
  9. import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
  10. import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
  11. import { MsqSearchBoxComponent } from '../shared/components/msq-search-box/msq-search-box.component';
  12. import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
  13. import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
  14. import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
  15. import { VocInsightStateService } from '../shared/services/voc-insight-state.service';
  16. @Component({
  17. selector: 'app-domestic-competitor-relations',
  18. standalone: true,
  19. imports: [CommonModule, FormsModule, PageHeaderComponent, SummaryMetricCardComponent, DataTableShellComponent, LoadingSpinnerComponent, MsqSearchBoxComponent, ProductIdentityComponent],
  20. template: `
  21. <section class="domestic-page">
  22. <app-page-header title="竞品关系" eyebrow="Competitor Mapping" description="管理德玛仕商品与国内平台竞品的映射,并明确区分已配置关系和待采集指标。" icon="⇄"></app-page-header>
  23. @if (dataset) {
  24. <div class="metric-grid">
  25. <app-summary-metric-card label="竞品关系" [value]="number(dataset.summary.relations)" icon="⇄" tone="blue"></app-summary-metric-card>
  26. <app-summary-metric-card label="映射商品" [value]="number(dataset.summary.mappingRows)" icon="□" tone="green"></app-summary-metric-card>
  27. <app-summary-metric-card label="唯一竞品" [value]="number(dataset.summary.uniqueCompetitorProducts)" icon="◇" tone="purple"></app-summary-metric-card>
  28. <app-summary-metric-card label="映射覆盖率" [value]="percent(mappingCoverage)" icon="◎" tone="amber" description="按配置商品计算"></app-summary-metric-card>
  29. </div>
  30. <app-data-table-shell title="竞品映射明细" subtitle="当前展示已配置的本品与竞品映射关系" [count]="filteredRows.length" variant="admin" [scrollable]="true" [hasToolbar]="true" [empty]="filteredRows.length === 0" emptyTitle="没有匹配的竞品关系">
  31. <div dataTableToolbar class="table-toolbar">
  32. <msq-search-box [(ngModel)]="searchQuery" placeholder="搜索商品ID、型号、竞品品牌或类目" (valueChange)="applyFilter($event)"></msq-search-box>
  33. </div>
  34. <table class="analytics-table">
  35. <thead><tr><th>本品</th><th>类目</th><th>竞品品牌</th><th>竞品商品ID</th><th>数据状态</th></tr></thead>
  36. <tbody>
  37. @for (row of filteredRows; track row.relationKey) {
  38. <tr>
  39. <td>
  40. <app-product-identity
  41. [showImage]="false"
  42. [title]="row.ownTitle"
  43. [productSku]="row.ownModel"
  44. [asin]="row.ownProductId"
  45. [subtitle]="row.ownProductAvailable ? '' : '经营数据缺失'"
  46. [clickable]="row.ownProductAvailable"
  47. [muted]="!row.ownProductAvailable"
  48. size="xs"
  49. (identityClick)="openOwnProduct(row)">
  50. </app-product-identity>
  51. </td>
  52. <td>{{ row.category }}</td>
  53. <td>
  54. <app-product-identity
  55. [imageUrl]="row.competitorProduct?.detail?.imageUrl || ''"
  56. [title]="row.competitorTitle"
  57. [productSku]="row.competitorModel"
  58. [asin]="row.competitorProductId"
  59. [subtitle]="row.competitorBrand || row.competitorProduct?.brand || '未知品牌'"
  60. [clickable]="row.competitorProductAvailable"
  61. [muted]="!row.competitorProductAvailable"
  62. size="xs"
  63. (identityClick)="openCompetitor(row)">
  64. </app-product-identity>
  65. </td>
  66. <td>{{ row.competitorProductId }}</td>
  67. <td>
  68. <span class="status-pill" [class.success]="row.competitorProductAvailable">
  69. {{ row.competitorProductAvailable ? (row.competitorReviewCount > 0 ? '详情 · ' + row.competitorReviewCount + ' 条评论' : '商品详情已入库') : '待采集商品详情' }}
  70. </span>
  71. </td>
  72. </tr>
  73. }
  74. </tbody>
  75. </table>
  76. </app-data-table-shell>
  77. } @else {
  78. <app-loading-spinner variant="card" tone="blue" text="正在加载竞品关系"></app-loading-spinner>
  79. }
  80. </section>
  81. `,
  82. styleUrls: ['./domestic-page.shared.scss'],
  83. })
  84. export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
  85. private readonly analytics = inject(DomesticAnalyticsService);
  86. private readonly datasetService = inject(DomesticDatasetService);
  87. private readonly state = inject(VocInsightStateService);
  88. private readonly router = inject(Router);
  89. private readonly cdr = inject(ChangeDetectorRef);
  90. private readonly destroy$ = new Subject<void>();
  91. private allRows: DomesticCompetitorRelationRow[] = [];
  92. dataset?: DomesticDataset;
  93. filteredRows: DomesticCompetitorRelationRow[] = [];
  94. searchQuery = '';
  95. mappingCoverage = 0;
  96. ngOnInit(): void {
  97. combineLatest([this.datasetService.dataset$, this.analytics.competitorRelations$])
  98. .pipe(takeUntil(this.destroy$))
  99. .subscribe(([dataset, rows]) => {
  100. this.dataset = dataset;
  101. this.allRows = rows;
  102. const mapped = new Set(rows.map((row) => row.ownProductId)).size;
  103. this.mappingCoverage = dataset.summary.mappingRows ? mapped / dataset.summary.mappingRows : 0;
  104. this.applyFilter(this.searchQuery);
  105. });
  106. }
  107. ngOnDestroy(): void {
  108. this.destroy$.next();
  109. this.destroy$.complete();
  110. }
  111. applyFilter(value: string): void {
  112. this.searchQuery = value || '';
  113. const query = this.searchQuery.trim().toLowerCase();
  114. this.filteredRows = !query ? [...this.allRows] : this.allRows.filter((row) => [
  115. row.ownProductId,
  116. row.ownModel,
  117. row.ownTitle,
  118. row.competitorProductId,
  119. row.competitorBrand,
  120. row.competitorTitle,
  121. row.competitorModel,
  122. row.category,
  123. ].join(' ').toLowerCase().includes(query));
  124. this.cdr.markForCheck();
  125. }
  126. openOwnProduct(row: DomesticCompetitorRelationRow): void {
  127. const product = row.ownProduct;
  128. if (!product) return;
  129. this.state.setProduct({
  130. asin: product.productId,
  131. title: product.title,
  132. photo: 'assets/images/placeholder.svg',
  133. productSku: product.model,
  134. sellerSku: product.productId,
  135. storeName: '京东',
  136. reviewCount: 0,
  137. });
  138. this.router.navigate(['/domestic/product'], { queryParams: { productId: product.productId } });
  139. }
  140. openCompetitor(row: DomesticCompetitorRelationRow): void {
  141. if (!row.competitorProductAvailable) return;
  142. this.router.navigate(['/domestic/competitors', row.competitorProductId]);
  143. }
  144. number(value: number): string {
  145. return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
  146. }
  147. percent(value: number): string {
  148. return `${((value || 0) * 100).toFixed(1)}%`;
  149. }
  150. }