|
|
@@ -0,0 +1,184 @@
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
+import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core';
|
|
|
+import { ActivatedRoute, Router } from '@angular/router';
|
|
|
+import { NgxEchartsModule } from 'ngx-echarts';
|
|
|
+import type { EChartsOption } from 'echarts';
|
|
|
+import { Subject, combineLatest, takeUntil } from 'rxjs';
|
|
|
+import { DomesticDataset, DomesticProduct } from '../../app/core/models/domestic.models';
|
|
|
+import { DomesticDatasetService } from '../../app/core/services/domestic-dataset.service';
|
|
|
+import { ContentCardComponent } from '../shared/components/content-card/content-card.component';
|
|
|
+import { PageHeaderComponent } from '../shared/components/page-header/page-header.component';
|
|
|
+import { SummaryMetricCardComponent } from '../shared/components/summary-metric-card/summary-metric-card.component';
|
|
|
+import { VocProductPickerComponent } from '../voc-insight/shared/components/voc-product-picker/voc-product-picker.component';
|
|
|
+import { VocInsightStateService } from '../shared/services/voc-insight-state.service';
|
|
|
+
|
|
|
+@Component({
|
|
|
+ selector: 'app-domestic-product-operating-detail',
|
|
|
+ standalone: true,
|
|
|
+ imports: [CommonModule, NgxEchartsModule, PageHeaderComponent, SummaryMetricCardComponent, ContentCardComponent, VocProductPickerComponent],
|
|
|
+ template: `
|
|
|
+ <section class="domestic-page">
|
|
|
+ <app-page-header
|
|
|
+ title="商品经营详情"
|
|
|
+ eyebrow="Product Performance"
|
|
|
+ description="查看单个商品在当前数据周期内的成交、流量、转化与退款表现。"
|
|
|
+ icon="□">
|
|
|
+ <button pageHeaderActions class="page-command" type="button" (click)="goCompetitors()">竞品关系</button>
|
|
|
+ <button pageHeaderActions class="page-command primary" type="button" (click)="goVoc()">查看 VOC →</button>
|
|
|
+ </app-page-header>
|
|
|
+
|
|
|
+ <div class="product-picker-wrap"><app-voc-product-picker></app-voc-product-picker></div>
|
|
|
+
|
|
|
+ @if (product) {
|
|
|
+ <div class="metric-grid">
|
|
|
+ <app-summary-metric-card label="成交金额" [value]="currency(product.summary.gmv)" icon="¥" tone="blue" [description]="number(product.summary.transactionOrders) + ' 笔成交订单'"></app-summary-metric-card>
|
|
|
+ <app-summary-metric-card label="成交件数" [value]="number(product.summary.soldUnits)" icon="□" tone="green" [description]="currency(product.summary.averageUnitPrice) + ' 客单价'"></app-summary-metric-card>
|
|
|
+ <app-summary-metric-card label="访客转化率" [value]="percent(product.summary.conversionRate)" icon="◎" tone="purple" [description]="number(product.summary.visitors) + ' 位访客'"></app-summary-metric-card>
|
|
|
+ <app-summary-metric-card label="退款金额占比" [value]="percent(product.summary.refundToGmvRate)" icon="↩" [tone]="product.summary.refundToGmvRate > 0.12 ? 'red' : 'amber'" [description]="currency(product.summary.refundAmount) + ' 退款金额'"></app-summary-metric-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="detail-grid">
|
|
|
+ <app-content-card title="商品经营趋势" subtitle="每日成交金额、成交件数与订单数" tone="blue">
|
|
|
+ <div echarts class="chart" [options]="trendOptions"></div>
|
|
|
+ </app-content-card>
|
|
|
+ <app-content-card title="商品档案" subtitle="国内商品标准身份与竞品配置" tone="green">
|
|
|
+ <dl class="info-list">
|
|
|
+ <div><dt>商品ID</dt><dd>{{ product.productId }}</dd></div>
|
|
|
+ <div><dt>型号</dt><dd>{{ product.model || '-' }}</dd></div>
|
|
|
+ <div><dt>品牌</dt><dd>{{ product.brand }}</dd></div>
|
|
|
+ <div><dt>平台</dt><dd>京东</dd></div>
|
|
|
+ <div><dt>二级类目</dt><dd>{{ product.category2 || '-' }}</dd></div>
|
|
|
+ <div><dt>三级类目</dt><dd>{{ product.category3 || '-' }}</dd></div>
|
|
|
+ <div><dt>竞品关系</dt><dd>{{ relationCount }} 条</dd></div>
|
|
|
+ <div><dt>评论样本</dt><dd>0 条 · 等待 JustOneAPI</dd></div>
|
|
|
+ </dl>
|
|
|
+ </app-content-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="detail-grid">
|
|
|
+ <app-content-card title="流量与转化路径" subtitle="展示真实经营指标,不推断缺失环节" tone="purple">
|
|
|
+ <div class="funnel-list">
|
|
|
+ @for (row of funnelRows; track row.label) {
|
|
|
+ <div class="funnel-row">
|
|
|
+ <span class="funnel-label">{{ row.label }}</span>
|
|
|
+ <span class="funnel-value"><span class="funnel-bar" [style.width.%]="row.width"></span><span class="funnel-number">{{ number(row.value) }}</span></span>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </div>
|
|
|
+ </app-content-card>
|
|
|
+ <app-content-card title="数据口径" subtitle="当前详情页的指标来源" tone="amber">
|
|
|
+ <dl class="info-list">
|
|
|
+ <div><dt>源文件</dt><dd>{{ dataset?.source?.sourceFile }}</dd></div>
|
|
|
+ <div><dt>开始日期</dt><dd>{{ dataset?.source?.dateRange?.start }}</dd></div>
|
|
|
+ <div><dt>结束日期</dt><dd>{{ dataset?.source?.dateRange?.end }}</dd></div>
|
|
|
+ <div><dt>数据来源</dt><dd>{{ product.source }}</dd></div>
|
|
|
+ </dl>
|
|
|
+ </app-content-card>
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ </section>
|
|
|
+ `,
|
|
|
+ styleUrls: ['./domestic-page.shared.scss'],
|
|
|
+})
|
|
|
+export class DomesticProductOperatingDetailComponent implements OnInit, OnDestroy {
|
|
|
+ private readonly datasetService = inject(DomesticDatasetService);
|
|
|
+ private readonly state = inject(VocInsightStateService);
|
|
|
+ private readonly route = inject(ActivatedRoute);
|
|
|
+ private readonly router = inject(Router);
|
|
|
+ private readonly cdr = inject(ChangeDetectorRef);
|
|
|
+ private readonly destroy$ = new Subject<void>();
|
|
|
+ private initialRouteApplied = false;
|
|
|
+
|
|
|
+ dataset?: DomesticDataset;
|
|
|
+ product?: DomesticProduct;
|
|
|
+ relationCount = 0;
|
|
|
+ trendOptions: EChartsOption = {};
|
|
|
+ funnelRows: Array<{ label: string; value: number; width: number }> = [];
|
|
|
+
|
|
|
+ ngOnInit(): void {
|
|
|
+ combineLatest([this.datasetService.dataset$, this.state.product$])
|
|
|
+ .pipe(takeUntil(this.destroy$))
|
|
|
+ .subscribe(([dataset, context]) => {
|
|
|
+ this.dataset = dataset;
|
|
|
+ const requested = !this.initialRouteApplied ? this.route.snapshot.queryParamMap.get('productId') : '';
|
|
|
+ const fallback = [...dataset.products].sort((a, b) => b.summary.gmv - a.summary.gmv)[0];
|
|
|
+ const productId = requested || context?.asin || fallback?.productId;
|
|
|
+ const product = dataset.products.find((item) => item.productId === productId) || fallback;
|
|
|
+ this.initialRouteApplied = true;
|
|
|
+ if (!product) return;
|
|
|
+ if (context?.asin !== product.productId) {
|
|
|
+ this.state.setProduct({
|
|
|
+ asin: product.productId,
|
|
|
+ title: product.title,
|
|
|
+ photo: 'assets/images/placeholder.svg',
|
|
|
+ productSku: product.model,
|
|
|
+ sellerSku: product.productId,
|
|
|
+ storeName: '京东',
|
|
|
+ reviewCount: 0,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.product = product;
|
|
|
+ this.relationCount = dataset.relations.filter((relation) => relation.ownProductId === product.productId).length;
|
|
|
+ this.trendOptions = this.buildTrendOptions(product);
|
|
|
+ this.funnelRows = this.buildFunnelRows(product);
|
|
|
+ this.cdr.markForCheck();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ this.destroy$.next();
|
|
|
+ this.destroy$.complete();
|
|
|
+ }
|
|
|
+
|
|
|
+ goVoc(): void {
|
|
|
+ this.router.navigate(['/voc-insight/voice']);
|
|
|
+ }
|
|
|
+
|
|
|
+ goCompetitors(): void {
|
|
|
+ this.router.navigate(['/domestic/competitors'], { queryParams: { productId: this.product?.productId || null } });
|
|
|
+ }
|
|
|
+
|
|
|
+ currency(value: number): string {
|
|
|
+ return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ 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)}%`;
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildTrendOptions(product: DomesticProduct): EChartsOption {
|
|
|
+ return {
|
|
|
+ color: ['#4f6bed', '#38a169', '#d69e2e'],
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ legend: { data: ['成交金额', '成交件数', '成交订单'], top: 0 },
|
|
|
+ grid: { left: 56, right: 50, top: 48, bottom: 34 },
|
|
|
+ xAxis: { type: 'category', data: product.trend.map((row) => row.date.slice(5)), axisTick: { show: false } },
|
|
|
+ yAxis: [
|
|
|
+ { type: 'value', name: '金额', splitLine: { lineStyle: { color: '#edf0f4' } } },
|
|
|
+ { type: 'value', name: '数量', splitLine: { show: false } },
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ { name: '成交金额', type: 'line', smooth: true, symbolSize: 7, areaStyle: { opacity: 0.08 }, data: product.trend.map((row) => Math.round(row.gmv)) },
|
|
|
+ { name: '成交件数', type: 'bar', yAxisIndex: 1, barMaxWidth: 20, data: product.trend.map((row) => row.soldUnits) },
|
|
|
+ { name: '成交订单', type: 'line', yAxisIndex: 1, smooth: true, symbol: 'none', data: product.trend.map((row) => row.transactionOrders) },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildFunnelRows(product: DomesticProduct): Array<{ label: string; value: number; width: number }> {
|
|
|
+ const rows = [
|
|
|
+ { label: '曝光人数', value: product.summary.impressions },
|
|
|
+ { label: '点击人数', value: product.summary.clicks },
|
|
|
+ { label: '访客人数', value: product.summary.visitors },
|
|
|
+ { label: '加购件数', value: product.summary.cartUnits },
|
|
|
+ { label: '成交订单', value: product.summary.transactionOrders },
|
|
|
+ { label: '成交件数', value: product.summary.soldUnits },
|
|
|
+ ];
|
|
|
+ const max = Math.max(...rows.map((row) => row.value), 1);
|
|
|
+ return rows.map((row) => ({ ...row, width: Math.max(3, (row.value / max) * 100) }));
|
|
|
+ }
|
|
|
+}
|