| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- import { CommonModule } from '@angular/common';
- import { Component, OnDestroy, OnInit } from '@angular/core';
- import { ActivatedRoute, Router, RouterLink } 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, DomesticReviewScope } from '../../../app/core/services/domestic-dataset.service';
- import {
- DomesticCompetitorReviewReference,
- DomesticReviewInsightService,
- } from '../../../app/core/services/domestic-review-insight.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 { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.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,
- RouterLink,
- NgxEchartsModule,
- AlertCardComponent,
- BoardTabsComponent,
- ContentCardComponent,
- DataTableShellComponent,
- EmptyStateComponent,
- ErrorStateComponent,
- LoadingSpinnerComponent,
- PageHeaderComponent,
- PageToolbarComponent,
- 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;
- reviewScope: DomesticReviewScope | null = null;
- summary: DomesticAggregateSummary | null = null;
- categories: DomesticCategoryAggregate[] = [];
- topProducts: DomesticProduct[] = [];
- benchmarkGroups: DomesticMappingGroup[] = [];
- selectedProduct: DomesticProduct | null = null;
- categoryOptions: EChartsOption = {};
- lifecycleOptions: EChartsOption = {};
- competitorReference: DomesticCompetitorReviewReference = {
- rawCount: 0,
- meaningfulCount: 0,
- placeholderCount: 0,
- negativeCount: 0,
- meaningful: [],
- negative: [],
- topics: [],
- };
- constructor(
- private readonly route: ActivatedRoute,
- private readonly router: Router,
- private readonly datasetService: DomesticDatasetService,
- private readonly analytics: DomesticAnalyticsAdapterService,
- private readonly stateService: VocInsightStateService,
- private readonly reviewInsights: DomesticReviewInsightService,
- ) {}
- 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.reviewScope = this.datasetService.reviewScope(dataset);
- this.competitorReference = this.reviewInsights.build(this.reviewScope.competitor);
- 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 toolbarFields(): Array<{ label: string; value: string }> {
- const range = this.dataset?.source.dateRange;
- return [
- { label: '周期', value: range ? `${range.start} 至 ${range.end}` : '—' },
- { label: '店铺', value: '—' },
- { label: '类目', value: '—' },
- { label: '负责人', value: '—' },
- ];
- }
- openOwnProduct(productId: string): void {
- const id = productId?.trim();
- if (!id) return;
- void this.router.navigate(['/voc-insight/detail'], { queryParams: { productId: id } });
- }
- openCompetitor(productId: string): void {
- const id = productId?.trim();
- if (!id) return;
- void this.router.navigate(['/domestic/competitors', id]);
- }
- openCategoryProducts(category: string): void {
- const value = category?.trim();
- if (!value) return;
- void this.router.navigate(['/voc-insight/products'], { queryParams: { category: value } });
- }
- 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('、') || '-';
- }
- get selectedCompetitors(): DomesticProduct[] {
- if (!this.dataset || !this.selectedProduct) return [];
- const competitorKeys = new Set(
- this.dataset.relations
- .filter((relation) => relation.ownProductKey === this.selectedProduct?.productKey)
- .map((relation) => relation.competitorProductKey),
- );
- return this.dataset.products
- .filter((product) => product.role === 'competitor' && competitorKeys.has(product.productKey))
- .sort((left, right) => Number(Boolean(right.market)) - Number(Boolean(left.market)));
- }
- competitorRelationSource(product: DomesticProduct): string {
- const relation = this.dataset?.relations.find((item) =>
- item.ownProductKey === this.selectedProduct?.productKey
- && item.competitorProductKey === product.productKey,
- );
- return relation?.discoverySource === 'brand_category_search' ? '品牌品类发现' : '表格关系';
- }
- reviewCount(productId: string): number {
- return (this.dataset?.reviews ?? []).filter((review) => review.productId === productId).length;
- }
- averageSoldPrice(gmv: number, soldUnits: number): number {
- return soldUnits > 0 ? gmv / soldUnits : 0;
- }
- 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);
- }
- date(value = ''): string {
- return value ? value.slice(0, 10) : '-';
- }
- 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) },
- ],
- };
- }
- }
|