|
@@ -0,0 +1,327 @@
|
|
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
|
|
+import { Component, OnDestroy, OnInit } from '@angular/core';
|
|
|
|
|
+import { ActivatedRoute, Router } from '@angular/router';
|
|
|
|
|
+import type { EChartsOption } from 'echarts';
|
|
|
|
|
+import { NgxEchartsModule } from 'ngx-echarts';
|
|
|
|
|
+import { Subscription, combineLatest } from 'rxjs';
|
|
|
|
|
+import {
|
|
|
|
|
+ DomesticDataset,
|
|
|
|
|
+ DomesticProduct,
|
|
|
|
|
+ DomesticReview,
|
|
|
|
|
+} from '../../../app/core/models/domestic.models';
|
|
|
|
|
+import { DomesticDatasetService } from '../../../app/core/services/domestic-dataset.service';
|
|
|
|
|
+import { AlertCardComponent } from '../../shared/components/alert-card/alert-card.component';
|
|
|
|
|
+import { BoardTabItem, BoardTabsComponent } from '../../shared/components/board-tabs/board-tabs.component';
|
|
|
|
|
+import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
|
|
|
|
+import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
|
|
|
|
|
+import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
|
|
|
|
|
+import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
|
|
|
|
|
+import { NeuButtonComponent } from '../../shared/components/neu-button/neu-button.component';
|
|
|
|
|
+import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
|
|
|
|
|
+import { ProductIdentityComponent } from '../../shared/components/product-identity/product-identity.component';
|
|
|
|
|
+import { ReviewCardComponent } from '../../shared/components/review-card/review-card.component';
|
|
|
|
|
+import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
|
|
|
|
|
+import { VocInsightStateService } from '../../shared/services/voc-insight-state.service';
|
|
|
|
|
+
|
|
|
|
|
+type DetailTab = 'overview' | 'voc' | 'reviews' | 'recommendations' | 'trends' | 'details';
|
|
|
|
|
+
|
|
|
|
|
+interface OperatingRecommendation {
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+ metric: string;
|
|
|
|
|
+ icon: string;
|
|
|
|
|
+ tone: 'blue' | 'green' | 'amber' | 'red' | 'neutral';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-domestic-voc-detail',
|
|
|
|
|
+ standalone: true,
|
|
|
|
|
+ imports: [
|
|
|
|
|
+ CommonModule,
|
|
|
|
|
+ NgxEchartsModule,
|
|
|
|
|
+ AlertCardComponent,
|
|
|
|
|
+ BoardTabsComponent,
|
|
|
|
|
+ ContentCardComponent,
|
|
|
|
|
+ EmptyStateComponent,
|
|
|
|
|
+ ErrorStateComponent,
|
|
|
|
|
+ LoadingSpinnerComponent,
|
|
|
|
|
+ NeuButtonComponent,
|
|
|
|
|
+ PageHeaderComponent,
|
|
|
|
|
+ ProductIdentityComponent,
|
|
|
|
|
+ ReviewCardComponent,
|
|
|
|
|
+ SummaryMetricCardComponent,
|
|
|
|
|
+ ],
|
|
|
|
|
+ templateUrl: './domestic-voc-detail.component.html',
|
|
|
|
|
+ styleUrls: [
|
|
|
|
|
+ './voc-insight-detail.component.scss',
|
|
|
|
|
+ './domestic-voc-detail.component.scss',
|
|
|
|
|
+ ],
|
|
|
|
|
+})
|
|
|
|
|
+export class DomesticVocDetailComponent implements OnInit, OnDestroy {
|
|
|
|
|
+ private loadSubscription?: Subscription;
|
|
|
|
|
+
|
|
|
|
|
+ isLoading = true;
|
|
|
|
|
+ error = '';
|
|
|
|
|
+ dataset: DomesticDataset | null = null;
|
|
|
|
|
+ product: DomesticProduct | null = null;
|
|
|
|
|
+ reviews: DomesticReview[] = [];
|
|
|
|
|
+ activeTab: DetailTab = 'overview';
|
|
|
|
|
+ trendOptions: EChartsOption = {};
|
|
|
|
|
+
|
|
|
|
|
+ tabs: BoardTabItem[] = this.buildTabs(0);
|
|
|
|
|
+
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private readonly route: ActivatedRoute,
|
|
|
|
|
+ private readonly router: Router,
|
|
|
|
|
+ private readonly datasetService: DomesticDatasetService,
|
|
|
|
|
+ private readonly stateService: VocInsightStateService,
|
|
|
|
|
+ ) {}
|
|
|
|
|
+
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ this.loadProduct();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngOnDestroy(): void {
|
|
|
|
|
+ this.loadSubscription?.unsubscribe();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ loadProduct(): void {
|
|
|
|
|
+ this.loadSubscription?.unsubscribe();
|
|
|
|
|
+ this.isLoading = true;
|
|
|
|
|
+ this.error = '';
|
|
|
|
|
+
|
|
|
|
|
+ this.loadSubscription = combineLatest([
|
|
|
|
|
+ this.route.queryParamMap,
|
|
|
|
|
+ this.datasetService.dataset$,
|
|
|
|
|
+ ]).subscribe({
|
|
|
|
|
+ next: ([queryParams, dataset]) => {
|
|
|
|
|
+ const requestedId = queryParams.get('productId') || queryParams.get('asin') || '';
|
|
|
|
|
+ const contextId = this.stateService.currentAsin;
|
|
|
|
|
+ const fallback = [...dataset.products]
|
|
|
|
|
+ .filter((item) => item.role === 'own')
|
|
|
|
|
+ .sort((a, b) => b.summary.gmv - a.summary.gmv)[0] || dataset.products[0];
|
|
|
|
|
+ const productId = requestedId || contextId || fallback?.productId || '';
|
|
|
|
|
+ const product = dataset.products.find((item) => item.productId === productId);
|
|
|
|
|
+
|
|
|
|
|
+ if (!product && requestedId) {
|
|
|
|
|
+ this.dataset = dataset;
|
|
|
|
|
+ this.product = null;
|
|
|
|
|
+ this.reviews = [];
|
|
|
|
|
+ this.error = `未找到商品 ${requestedId},请返回商品列表重新选择。`;
|
|
|
|
|
+ this.isLoading = false;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.dataset = dataset;
|
|
|
|
|
+ this.product = product || fallback || null;
|
|
|
|
|
+ this.reviews = this.product
|
|
|
|
|
+ ? (dataset.reviews ?? []).filter((review) => review.productId === this.product?.productId)
|
|
|
|
|
+ : [];
|
|
|
|
|
+ this.tabs = this.buildTabs(this.reviews.length);
|
|
|
|
|
+ this.activeTab = this.toTab(queryParams.get('tab'));
|
|
|
|
|
+
|
|
|
|
|
+ if (this.product) {
|
|
|
|
|
+ this.syncProductContext(this.product);
|
|
|
|
|
+ this.trendOptions = this.buildTrendOptions(this.product);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.error = '当前数据集中没有可查看的商品。';
|
|
|
|
|
+ }
|
|
|
|
|
+ this.isLoading = false;
|
|
|
|
|
+ },
|
|
|
|
|
+ error: () => {
|
|
|
|
|
+ this.product = null;
|
|
|
|
|
+ this.reviews = [];
|
|
|
|
|
+ this.error = '商品详情数据加载失败,请检查本地数据文件后重试。';
|
|
|
|
|
+ this.isLoading = false;
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setActiveTab(key: string): void {
|
|
|
|
|
+ this.activeTab = this.toTab(key);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ backToList(): void {
|
|
|
|
|
+ this.router.navigate(['/voc-insight/products']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ goBusinessDetail(): void {
|
|
|
|
|
+ this.router.navigate(['/domestic/product'], {
|
|
|
|
|
+ queryParams: { productId: this.product?.productId || null },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ goCompetitors(): void {
|
|
|
|
|
+ this.router.navigate(['/domestic/competitors'], {
|
|
|
|
|
+ queryParams: { productId: this.product?.productId || null },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get reviewSummary(): { average: number; positive: number; neutral: number; negative: number } {
|
|
|
|
|
+ if (!this.reviews.length) {
|
|
|
|
|
+ return { average: 0, positive: 0, neutral: 0, negative: 0 };
|
|
|
|
|
+ }
|
|
|
|
|
+ const total = this.reviews.length;
|
|
|
|
|
+ const average = this.reviews.reduce((sum, review) => sum + Number(review.rating || 0), 0) / total;
|
|
|
|
|
+ return {
|
|
|
|
|
+ average,
|
|
|
|
|
+ positive: this.reviews.filter((review) => review.rating >= 4).length / total,
|
|
|
|
|
+ neutral: this.reviews.filter((review) => review.rating === 3).length / total,
|
|
|
|
|
+ negative: this.reviews.filter((review) => review.rating <= 2).length / total,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get recommendations(): OperatingRecommendation[] {
|
|
|
|
|
+ if (!this.product) return [];
|
|
|
|
|
+ const summary = this.product.summary;
|
|
|
|
|
+ const items: OperatingRecommendation[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ if (summary.refundToGmvRate >= 0.1) {
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ title: '优先核查退款原因',
|
|
|
|
|
+ description: '退款金额占比达到两位数。评论明细尚未接入,先从订单售后、型号说明和履约记录中定位原因。',
|
|
|
|
|
+ metric: this.percent(summary.refundToGmvRate),
|
|
|
|
|
+ icon: '↩',
|
|
|
|
|
+ tone: 'red',
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ title: '保持退款指标监控',
|
|
|
|
|
+ description: '当前退款金额占比未触发高风险阈值,建议随数据周期持续复核。',
|
|
|
|
|
+ metric: this.percent(summary.refundToGmvRate),
|
|
|
|
|
+ icon: '✓',
|
|
|
|
|
+ tone: 'green',
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ title: '复核流量到成交的转化路径',
|
|
|
|
|
+ description: '结合访客、加购和成交订单继续排查详情页信息完整度与商品卖点表达。',
|
|
|
|
|
+ metric: this.percent(summary.conversionRate),
|
|
|
|
|
+ icon: '◎',
|
|
|
|
|
+ tone: summary.conversionRate < 0.06 ? 'amber' : 'blue',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ items.push({
|
|
|
|
|
+ title: this.product.relationCount ? '纳入竞品定期对比' : '补充竞品映射',
|
|
|
|
|
+ description: this.product.relationCount
|
|
|
|
|
+ ? '当前商品已有竞品关系,可按同类商品持续对比成交、价格和后续评论主题。'
|
|
|
|
|
+ : '当前商品没有竞品关系,建议先补充同类竞品后再开展横向 VOC 分析。',
|
|
|
|
|
+ metric: `${this.product.relationCount} 条关系`,
|
|
|
|
|
+ icon: '↔',
|
|
|
|
|
+ tone: this.product.relationCount ? 'blue' : 'amber',
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return items;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 new Intl.NumberFormat('zh-CN', {
|
|
|
|
|
+ style: 'percent',
|
|
|
|
|
+ minimumFractionDigits: 1,
|
|
|
|
|
+ maximumFractionDigits: 1,
|
|
|
|
|
+ }).format(value || 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ decimal(value: number): string {
|
|
|
|
|
+ return Number(value || 0).toFixed(1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ date(value?: string): string {
|
|
|
|
|
+ if (!value) return '-';
|
|
|
|
|
+ const parsed = new Date(value);
|
|
|
|
|
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString('zh-CN');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private syncProductContext(product: DomesticProduct): void {
|
|
|
|
|
+ if (this.stateService.currentAsin === product.productId) return;
|
|
|
|
|
+ this.stateService.setProduct({
|
|
|
|
|
+ asin: product.productId,
|
|
|
|
|
+ title: product.title,
|
|
|
|
|
+ photo: 'assets/images/placeholder.svg',
|
|
|
|
|
+ reviewCount: this.reviews.length,
|
|
|
|
|
+ rating: this.reviewSummary.average,
|
|
|
|
|
+ productSku: product.model,
|
|
|
|
|
+ sellerSku: product.productId,
|
|
|
|
|
+ storeName: this.dataset?.caseName || product.brand,
|
|
|
|
|
+ itemStatus: 1,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildTabs(reviewCount: number): BoardTabItem[] {
|
|
|
|
|
+ return [
|
|
|
|
|
+ { key: 'overview', label: '概览', icon: '▦' },
|
|
|
|
|
+ { key: 'voc', label: 'VOC', icon: '◉', badges: [{ label: reviewCount, tone: reviewCount ? 'green' : 'default' }] },
|
|
|
|
|
+ { key: 'reviews', label: '评论', icon: '▤', badges: [{ label: reviewCount, tone: reviewCount ? 'blue' : 'default' }] },
|
|
|
|
|
+ { key: 'recommendations', label: '建议', icon: '✓' },
|
|
|
|
|
+ { key: 'trends', label: '趋势', icon: '↗' },
|
|
|
|
|
+ { key: 'details', label: '详情', icon: '≡' },
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private toTab(value: string | null): DetailTab {
|
|
|
|
|
+ const allowed: DetailTab[] = ['overview', 'voc', 'reviews', 'recommendations', 'trends', 'details'];
|
|
|
|
|
+ return allowed.includes(value as DetailTab) ? value as DetailTab : 'overview';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildTrendOptions(product: DomesticProduct): EChartsOption {
|
|
|
|
|
+ return {
|
|
|
|
|
+ animationDuration: 450,
|
|
|
|
|
+ color: ['#2563eb', '#059669'],
|
|
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
|
|
+ legend: { data: ['成交金额', '成交件数'], top: 0, right: 0 },
|
|
|
|
|
+ grid: { left: 16, right: 18, top: 48, bottom: 12, containLabel: true },
|
|
|
|
|
+ xAxis: {
|
|
|
|
|
+ type: 'category',
|
|
|
|
|
+ boundaryGap: false,
|
|
|
|
|
+ data: product.trend.map((item) => item.date.slice(5)),
|
|
|
|
|
+ axisLine: { lineStyle: { color: '#cbd5e1' } },
|
|
|
|
|
+ axisLabel: { color: '#64748b' },
|
|
|
|
|
+ },
|
|
|
|
|
+ yAxis: [
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'value',
|
|
|
|
|
+ name: '金额(元)',
|
|
|
|
|
+ axisLabel: { color: '#64748b' },
|
|
|
|
|
+ splitLine: { lineStyle: { color: '#eef2f7' } },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ type: 'value',
|
|
|
|
|
+ name: '件数',
|
|
|
|
|
+ axisLabel: { color: '#64748b' },
|
|
|
|
|
+ splitLine: { show: false },
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ series: [
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '成交金额',
|
|
|
|
|
+ type: 'line',
|
|
|
|
|
+ smooth: true,
|
|
|
|
|
+ symbolSize: 7,
|
|
|
|
|
+ data: product.trend.map((item) => item.gmv),
|
|
|
|
|
+ areaStyle: { color: 'rgba(37, 99, 235, 0.08)' },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: '成交件数',
|
|
|
|
|
+ type: 'line',
|
|
|
|
|
+ smooth: true,
|
|
|
|
|
+ yAxisIndex: 1,
|
|
|
|
|
+ symbolSize: 7,
|
|
|
|
|
+ data: product.trend.map((item) => item.soldUnits),
|
|
|
|
|
+ },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|