| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- import { CommonModule } from '@angular/common';
- import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
- import { FormsModule } from '@angular/forms';
- import { Router } from '@angular/router';
- import { Subject, combineLatest, takeUntil } from 'rxjs';
- import { DomesticDataset } from '../../app/core/models/domestic.models';
- import { DomesticAnalyticsService, DomesticCompetitorRelationRow } from '../../app/core/services/domestic-analytics.service';
- import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
- import { DataTableShellComponent } from '../shared/components/data-table-shell/data-table-shell.component';
- import { LoadingSpinnerComponent } from '../shared/components/loading-spinner/loading-spinner.component';
- import { MsqSearchBoxComponent } from '../shared/components/msq-search-box/msq-search-box.component';
- import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
- import { ProductIdentityComponent } from '../shared/components/product-identity/product-identity.component';
- import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
- import { VocInsightStateService } from '../shared/services/voc-insight-state.service';
- @Component({
- selector: 'app-domestic-competitor-relations',
- standalone: true,
- imports: [CommonModule, FormsModule, PageHeaderComponent, SummaryMetricCardComponent, DataTableShellComponent, LoadingSpinnerComponent, MsqSearchBoxComponent, ProductIdentityComponent],
- template: `
- <section class="domestic-page">
- <app-page-header title="竞品关系" eyebrow="Competitor Mapping" description="管理德玛仕商品与国内平台竞品的映射,并明确区分已配置关系和待采集指标。" icon="⇄"></app-page-header>
- @if (dataset) {
- <div class="metric-grid">
- <app-summary-metric-card label="竞品关系" [value]="number(dataset.summary.relations)" icon="⇄" tone="blue"></app-summary-metric-card>
- <app-summary-metric-card label="映射商品" [value]="number(dataset.summary.mappingRows)" icon="□" tone="green"></app-summary-metric-card>
- <app-summary-metric-card label="唯一竞品" [value]="number(dataset.summary.uniqueCompetitorProducts)" icon="◇" tone="purple"></app-summary-metric-card>
- <app-summary-metric-card label="映射覆盖率" [value]="percent(mappingCoverage)" icon="◎" tone="amber" description="按配置商品计算"></app-summary-metric-card>
- </div>
- <app-data-table-shell title="竞品映射明细" subtitle="当前展示已配置的本品与竞品映射关系" [count]="filteredRows.length" variant="admin" [scrollable]="true" [hasToolbar]="true" [empty]="filteredRows.length === 0" emptyTitle="没有匹配的竞品关系">
- <div dataTableToolbar class="table-toolbar">
- <msq-search-box [(ngModel)]="searchQuery" placeholder="搜索商品ID、型号、竞品品牌或类目" (valueChange)="applyFilter($event)"></msq-search-box>
- </div>
- <table class="analytics-table">
- <thead><tr><th>本品</th><th>类目</th><th>竞品品牌</th><th>竞品商品ID</th><th>数据状态</th></tr></thead>
- <tbody>
- @for (row of filteredRows; track row.relationKey) {
- <tr>
- <td>
- <app-product-identity
- [showImage]="false"
- [title]="row.ownTitle"
- [productSku]="row.ownModel"
- [asin]="row.ownProductId"
- [subtitle]="row.ownProductAvailable ? '' : '经营数据缺失'"
- [clickable]="row.ownProductAvailable"
- [muted]="!row.ownProductAvailable"
- size="xs"
- (identityClick)="openOwnProduct(row)">
- </app-product-identity>
- </td>
- <td>{{ row.category }}</td>
- <td>
- <app-product-identity
- [imageUrl]="row.competitorProduct?.detail?.imageUrl || ''"
- [title]="row.competitorTitle"
- [productSku]="row.competitorModel"
- [asin]="row.competitorProductId"
- [subtitle]="row.competitorBrand || row.competitorProduct?.brand || '未知品牌'"
- [clickable]="row.competitorProductAvailable"
- [muted]="!row.competitorProductAvailable"
- size="xs"
- (identityClick)="openCompetitor(row)">
- </app-product-identity>
- </td>
- <td>{{ row.competitorProductId }}</td>
- <td>
- <span class="status-pill" [class.success]="row.competitorProductAvailable">
- {{ row.competitorProductAvailable ? (row.competitorReviewCount > 0 ? '详情 · ' + row.competitorReviewCount + ' 条评论' : '商品详情已入库') : '待采集商品详情' }}
- </span>
- </td>
- </tr>
- }
- </tbody>
- </table>
- </app-data-table-shell>
- } @else {
- <app-loading-spinner variant="card" tone="blue" text="正在加载竞品关系"></app-loading-spinner>
- }
- </section>
- `,
- styleUrls: ['./domestic-page.shared.scss'],
- })
- export class DomesticCompetitorRelationsComponent implements OnInit, OnDestroy {
- private readonly analytics = inject(DomesticAnalyticsService);
- private readonly datasetService = inject(DomesticDatasetService);
- private readonly state = inject(VocInsightStateService);
- private readonly router = inject(Router);
- private readonly cdr = inject(ChangeDetectorRef);
- private readonly destroy$ = new Subject<void>();
- private allRows: DomesticCompetitorRelationRow[] = [];
- dataset?: DomesticDataset;
- filteredRows: DomesticCompetitorRelationRow[] = [];
- searchQuery = '';
- mappingCoverage = 0;
- ngOnInit(): void {
- combineLatest([this.datasetService.dataset$, this.analytics.competitorRelations$])
- .pipe(takeUntil(this.destroy$))
- .subscribe(([dataset, rows]) => {
- this.dataset = dataset;
- this.allRows = rows;
- const mapped = new Set(rows.map((row) => row.ownProductId)).size;
- this.mappingCoverage = dataset.summary.mappingRows ? mapped / dataset.summary.mappingRows : 0;
- this.applyFilter(this.searchQuery);
- });
- }
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
- applyFilter(value: string): void {
- this.searchQuery = value || '';
- const query = this.searchQuery.trim().toLowerCase();
- this.filteredRows = !query ? [...this.allRows] : this.allRows.filter((row) => [
- row.ownProductId,
- row.ownModel,
- row.ownTitle,
- row.competitorProductId,
- row.competitorBrand,
- row.competitorTitle,
- row.competitorModel,
- row.category,
- ].join(' ').toLowerCase().includes(query));
- this.cdr.markForCheck();
- }
- openOwnProduct(row: DomesticCompetitorRelationRow): void {
- const product = row.ownProduct;
- if (!product) return;
- this.state.setProduct({
- asin: product.productId,
- title: product.title,
- photo: 'assets/images/placeholder.svg',
- productSku: product.model,
- sellerSku: product.productId,
- storeName: '京东',
- reviewCount: 0,
- });
- this.router.navigate(['/domestic/product'], { queryParams: { productId: product.productId } });
- }
- openCompetitor(row: DomesticCompetitorRelationRow): void {
- if (!row.competitorProductAvailable) return;
- this.router.navigate(['/domestic/competitors', row.competitorProductId]);
- }
- number(value: number): string {
- return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
- }
- percent(value: number): string {
- return `${((value || 0) * 100).toFixed(1)}%`;
- }
- }
|