|
@@ -0,0 +1,185 @@
|
|
|
|
|
+import { CommonModule } from '@angular/common';
|
|
|
|
|
+import { Component, OnDestroy, OnInit } from '@angular/core';
|
|
|
|
|
+import { ActivatedRoute } from '@angular/router';
|
|
|
|
|
+import type { EChartsOption } from 'echarts';
|
|
|
|
|
+import { NgxEchartsModule } from 'ngx-echarts';
|
|
|
|
|
+import { Subject, combineLatest, takeUntil } from 'rxjs';
|
|
|
|
|
+import {
|
|
|
|
|
+ DomesticDataset,
|
|
|
|
|
+ DomesticMappingGroup,
|
|
|
|
|
+ DomesticProduct,
|
|
|
|
|
+} from '../../../app/core/models/domestic.models';
|
|
|
|
|
+import {
|
|
|
|
|
+ DomesticAggregateSummary,
|
|
|
|
|
+ DomesticAnalyticsAdapterService,
|
|
|
|
|
+ DomesticCategoryAggregate,
|
|
|
|
|
+} from '../../../app/core/services/domestic-analytics-adapter.service';
|
|
|
|
|
+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 { DataTableShellComponent } from '../../shared/components/data-table-shell/data-table-shell.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 { 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';
|
|
|
|
|
+import { VocProductPickerComponent } from '../../voc-insight/shared/components/voc-product-picker/voc-product-picker.component';
|
|
|
|
|
+
|
|
|
|
|
+type ProductDevelopmentSection = 'trend' | 'requirement' | 'benchmark' | 'definition' | 'lifecycle';
|
|
|
|
|
+
|
|
|
|
|
+@Component({
|
|
|
|
|
+ selector: 'app-domestic-product-development',
|
|
|
|
|
+ standalone: true,
|
|
|
|
|
+ imports: [
|
|
|
|
|
+ CommonModule,
|
|
|
|
|
+ NgxEchartsModule,
|
|
|
|
|
+ AlertCardComponent,
|
|
|
|
|
+ BoardTabsComponent,
|
|
|
|
|
+ ContentCardComponent,
|
|
|
|
|
+ DataTableShellComponent,
|
|
|
|
|
+ EmptyStateComponent,
|
|
|
|
|
+ ErrorStateComponent,
|
|
|
|
|
+ LoadingSpinnerComponent,
|
|
|
|
|
+ PageHeaderComponent,
|
|
|
|
|
+ ProductIdentityComponent,
|
|
|
|
|
+ SummaryMetricCardComponent,
|
|
|
|
|
+ VocProductPickerComponent,
|
|
|
|
|
+ ],
|
|
|
|
|
+ templateUrl: './domestic-product-development.component.html',
|
|
|
|
|
+ styleUrls: ['./domestic-framework.shared.scss'],
|
|
|
|
|
+})
|
|
|
|
|
+export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
|
|
|
|
|
+ private readonly destroy$ = new Subject<void>();
|
|
|
|
|
+
|
|
|
|
|
+ readonly tabs: BoardTabItem[] = [
|
|
|
|
|
+ { key: 'trend', label: '趋势洞察', icon: '↗', route: ['/product-development/trend'] },
|
|
|
|
|
+ { key: 'requirement', label: '需求挖掘', icon: '◉', route: ['/product-development/requirement'] },
|
|
|
|
|
+ { key: 'benchmark', label: '竞品对标', icon: '↔', route: ['/product-development/competitor-benchmark'] },
|
|
|
|
|
+ { key: 'definition', label: '产品定义', icon: '▣', route: ['/product-development/product-definition'] },
|
|
|
|
|
+ { key: 'lifecycle', label: '生命周期复盘', icon: '⌁', route: ['/product-development/lifecycle'] },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ section: ProductDevelopmentSection = 'trend';
|
|
|
|
|
+ loading = true;
|
|
|
|
|
+ error = '';
|
|
|
|
|
+ dataset: DomesticDataset | null = null;
|
|
|
|
|
+ summary: DomesticAggregateSummary | null = null;
|
|
|
|
|
+ categories: DomesticCategoryAggregate[] = [];
|
|
|
|
|
+ topProducts: DomesticProduct[] = [];
|
|
|
|
|
+ benchmarkGroups: DomesticMappingGroup[] = [];
|
|
|
|
|
+ selectedProduct: DomesticProduct | null = null;
|
|
|
|
|
+ categoryOptions: EChartsOption = {};
|
|
|
|
|
+ lifecycleOptions: EChartsOption = {};
|
|
|
|
|
+
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private readonly route: ActivatedRoute,
|
|
|
|
|
+ private readonly datasetService: DomesticDatasetService,
|
|
|
|
|
+ private readonly analytics: DomesticAnalyticsAdapterService,
|
|
|
|
|
+ private readonly stateService: VocInsightStateService,
|
|
|
|
|
+ ) {}
|
|
|
|
|
+
|
|
|
|
|
+ ngOnInit(): void {
|
|
|
|
|
+ combineLatest([this.route.data, this.datasetService.dataset$, this.stateService.product$])
|
|
|
|
|
+ .pipe(takeUntil(this.destroy$))
|
|
|
|
|
+ .subscribe({
|
|
|
|
|
+ next: ([routeData, dataset, context]) => {
|
|
|
|
|
+ this.section = (routeData['section'] || 'trend') as ProductDevelopmentSection;
|
|
|
|
|
+ this.dataset = dataset;
|
|
|
|
|
+ this.summary = this.analytics.aggregate(dataset);
|
|
|
|
|
+ this.categories = this.analytics.categories(dataset, 10);
|
|
|
|
|
+ this.topProducts = this.analytics.topProducts(dataset, 10);
|
|
|
|
|
+ this.benchmarkGroups = this.analytics.benchmarkGroups(dataset, 20);
|
|
|
|
|
+ this.selectedProduct = this.analytics.selectedProduct(dataset, context?.asin);
|
|
|
|
|
+ this.categoryOptions = this.buildCategoryOptions(this.categories);
|
|
|
|
|
+ this.lifecycleOptions = this.buildLifecycleOptions(this.selectedProduct);
|
|
|
|
|
+ this.loading = false;
|
|
|
|
|
+ this.error = '';
|
|
|
|
|
+ },
|
|
|
|
|
+ error: () => {
|
|
|
|
|
+ this.error = '新品开发框架数据加载失败,请检查国内数据文件。';
|
|
|
|
|
+ this.loading = false;
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ngOnDestroy(): void {
|
|
|
|
|
+ this.destroy$.next();
|
|
|
|
|
+ this.destroy$.complete();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get title(): string {
|
|
|
|
|
+ const labels: Record<ProductDevelopmentSection, string> = {
|
|
|
|
|
+ trend: '趋势洞察',
|
|
|
|
|
+ requirement: '需求挖掘',
|
|
|
|
|
+ benchmark: '竞品对标',
|
|
|
|
|
+ definition: '产品定义',
|
|
|
|
|
+ lifecycle: '产品生命周期复盘',
|
|
|
|
|
+ };
|
|
|
|
|
+ return labels[this.section];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get description(): string {
|
|
|
|
|
+ const labels: Record<ProductDevelopmentSection, string> = {
|
|
|
|
|
+ trend: '从真实成交、类目和商品表现中识别业务趋势,不将短周期经营波动包装成市场长期结论。',
|
|
|
|
|
+ requirement: '保留原用户需求挖掘框架,等待评论原声后提炼需求、场景和未满足机会。',
|
|
|
|
|
+ benchmark: '基于德玛仕自营商品与竞品映射,建立国内商品对标入口。',
|
|
|
|
|
+ definition: '将已选择商品的标准身份、经营表现和类目归属汇总为产品定义底稿。',
|
|
|
|
|
+ lifecycle: '按当前数据周期复盘单商品的成交、销量、转化和退款变化。',
|
|
|
|
|
+ };
|
|
|
|
|
+ return labels[this.section];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ relationBrands(group: DomesticMappingGroup): string {
|
|
|
|
|
+ return [...new Set(group.competitors.map((item) => item.competitorBrand).filter(Boolean))].join('、') || '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildCategoryOptions(categories: DomesticCategoryAggregate[]): EChartsOption {
|
|
|
|
|
+ const rows = categories.slice(0, 8).reverse();
|
|
|
|
|
+ return {
|
|
|
|
|
+ color: ['#2563eb', '#059669'],
|
|
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
|
|
+ legend: { data: ['成交金额', '退款金额'], top: 0, right: 0 },
|
|
|
|
|
+ grid: { left: 12, right: 18, top: 44, bottom: 12, containLabel: true },
|
|
|
|
|
+ xAxis: { type: 'value', axisLabel: { color: '#64748b' }, splitLine: { lineStyle: { color: '#eef2f7' } } },
|
|
|
|
|
+ yAxis: { type: 'category', data: rows.map((item) => item.category), axisLabel: { color: '#475569', width: 110, overflow: 'truncate' } },
|
|
|
|
|
+ series: [
|
|
|
|
|
+ { name: '成交金额', type: 'bar', data: rows.map((item) => item.gmv), barMaxWidth: 16 },
|
|
|
|
|
+ { name: '退款金额', type: 'bar', data: rows.map((item) => item.refundAmount), barMaxWidth: 16 },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildLifecycleOptions(product: DomesticProduct | null): EChartsOption {
|
|
|
|
|
+ const trend = product?.trend ?? [];
|
|
|
|
|
+ return {
|
|
|
|
|
+ color: ['#2563eb', '#059669'],
|
|
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
|
|
+ legend: { data: ['成交金额', '成交件数'], top: 0, right: 0 },
|
|
|
|
|
+ grid: { left: 18, right: 18, top: 48, bottom: 16, containLabel: true },
|
|
|
|
|
+ xAxis: { type: 'category', boundaryGap: false, data: trend.map((item) => item.date.slice(5)), 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, data: trend.map((item) => item.gmv), areaStyle: { color: 'rgba(37,99,235,.08)' } },
|
|
|
|
|
+ { name: '成交件数', type: 'line', smooth: true, yAxisIndex: 1, data: trend.map((item) => item.soldUnits) },
|
|
|
|
|
+ ],
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|