domestic-product-development.component.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. import { CommonModule } from '@angular/common';
  2. import { Component, OnDestroy, OnInit } from '@angular/core';
  3. import { ActivatedRoute, Router, RouterLink } from '@angular/router';
  4. import type { EChartsOption } from 'echarts';
  5. import { NgxEchartsModule } from 'ngx-echarts';
  6. import { Subject, combineLatest, takeUntil } from 'rxjs';
  7. import {
  8. DomesticDataset,
  9. DomesticMappingGroup,
  10. DomesticProduct,
  11. } from '../../../app/core/models/domestic.models';
  12. import {
  13. DomesticAggregateSummary,
  14. DomesticAnalyticsAdapterService,
  15. DomesticCategoryAggregate,
  16. } from '../../../app/core/services/domestic-analytics-adapter.service';
  17. import { DomesticDatasetService, DomesticReviewScope } from '../../../app/core/services/domestic-dataset.service';
  18. import {
  19. DomesticCompetitorReviewReference,
  20. DomesticReviewInsightService,
  21. } from '../../../app/core/services/domestic-review-insight.service';
  22. import { AlertCardComponent } from '../../shared/components/alert-card/alert-card.component';
  23. import { BoardTabItem, BoardTabsComponent } from '../../shared/components/board-tabs/board-tabs.component';
  24. import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
  25. import { DataTableShellComponent } from '../../shared/components/data-table-shell/data-table-shell.component';
  26. import { EmptyStateComponent } from '../../shared/components/empty-state/empty-state.component';
  27. import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
  28. import { LoadingSpinnerComponent } from '../../shared/components/loading-spinner/loading-spinner.component';
  29. import { PageHeaderComponent } from '../../shared/components/page-header/page-header.component';
  30. import { PageToolbarComponent } from '../../shared/components/page-toolbar/page-toolbar.component';
  31. import { ProductIdentityComponent } from '../../shared/components/product-identity/product-identity.component';
  32. import { SummaryMetricCardComponent } from '../../shared/components/summary-metric-card/summary-metric-card.component';
  33. import { VocInsightStateService } from '../../shared/services/voc-insight-state.service';
  34. import { VocProductPickerComponent } from '../../voc-insight/shared/components/voc-product-picker/voc-product-picker.component';
  35. type ProductDevelopmentSection = 'trend' | 'requirement' | 'benchmark' | 'definition' | 'lifecycle';
  36. @Component({
  37. selector: 'app-domestic-product-development',
  38. standalone: true,
  39. imports: [
  40. CommonModule,
  41. RouterLink,
  42. NgxEchartsModule,
  43. AlertCardComponent,
  44. BoardTabsComponent,
  45. ContentCardComponent,
  46. DataTableShellComponent,
  47. EmptyStateComponent,
  48. ErrorStateComponent,
  49. LoadingSpinnerComponent,
  50. PageHeaderComponent,
  51. PageToolbarComponent,
  52. ProductIdentityComponent,
  53. SummaryMetricCardComponent,
  54. VocProductPickerComponent,
  55. ],
  56. templateUrl: './domestic-product-development.component.html',
  57. styleUrls: ['./domestic-framework.shared.scss'],
  58. })
  59. export class DomesticProductDevelopmentComponent implements OnInit, OnDestroy {
  60. private readonly destroy$ = new Subject<void>();
  61. readonly tabs: BoardTabItem[] = [
  62. { key: 'trend', label: '趋势洞察', icon: '↗', route: ['/product-development/trend'] },
  63. { key: 'requirement', label: '需求挖掘', icon: '◉', route: ['/product-development/requirement'] },
  64. { key: 'benchmark', label: '竞品对标', icon: '↔', route: ['/product-development/competitor-benchmark'] },
  65. { key: 'definition', label: '产品定义', icon: '▣', route: ['/product-development/product-definition'] },
  66. { key: 'lifecycle', label: '生命周期复盘', icon: '⌁', route: ['/product-development/lifecycle'] },
  67. ];
  68. section: ProductDevelopmentSection = 'trend';
  69. loading = true;
  70. error = '';
  71. dataset: DomesticDataset | null = null;
  72. reviewScope: DomesticReviewScope | null = null;
  73. summary: DomesticAggregateSummary | null = null;
  74. categories: DomesticCategoryAggregate[] = [];
  75. topProducts: DomesticProduct[] = [];
  76. benchmarkGroups: DomesticMappingGroup[] = [];
  77. selectedProduct: DomesticProduct | null = null;
  78. categoryOptions: EChartsOption = {};
  79. lifecycleOptions: EChartsOption = {};
  80. competitorReference: DomesticCompetitorReviewReference = {
  81. rawCount: 0,
  82. meaningfulCount: 0,
  83. placeholderCount: 0,
  84. negativeCount: 0,
  85. meaningful: [],
  86. negative: [],
  87. topics: [],
  88. };
  89. constructor(
  90. private readonly route: ActivatedRoute,
  91. private readonly router: Router,
  92. private readonly datasetService: DomesticDatasetService,
  93. private readonly analytics: DomesticAnalyticsAdapterService,
  94. private readonly stateService: VocInsightStateService,
  95. private readonly reviewInsights: DomesticReviewInsightService,
  96. ) {}
  97. ngOnInit(): void {
  98. combineLatest([this.route.data, this.datasetService.dataset$, this.stateService.product$])
  99. .pipe(takeUntil(this.destroy$))
  100. .subscribe({
  101. next: ([routeData, dataset, context]) => {
  102. this.section = (routeData['section'] || 'trend') as ProductDevelopmentSection;
  103. this.dataset = dataset;
  104. this.reviewScope = this.datasetService.reviewScope(dataset);
  105. this.competitorReference = this.reviewInsights.build(this.reviewScope.competitor);
  106. this.summary = this.analytics.aggregate(dataset);
  107. this.categories = this.analytics.categories(dataset, 10);
  108. this.topProducts = this.analytics.topProducts(dataset, 10);
  109. this.benchmarkGroups = this.analytics.benchmarkGroups(dataset, 20);
  110. this.selectedProduct = this.analytics.selectedProduct(dataset, context?.asin);
  111. this.categoryOptions = this.buildCategoryOptions(this.categories);
  112. this.lifecycleOptions = this.buildLifecycleOptions(this.selectedProduct);
  113. this.loading = false;
  114. this.error = '';
  115. },
  116. error: () => {
  117. this.error = '新品开发框架数据加载失败,请检查国内数据文件。';
  118. this.loading = false;
  119. },
  120. });
  121. }
  122. ngOnDestroy(): void {
  123. this.destroy$.next();
  124. this.destroy$.complete();
  125. }
  126. get title(): string {
  127. const labels: Record<ProductDevelopmentSection, string> = {
  128. trend: '趋势洞察',
  129. requirement: '需求挖掘',
  130. benchmark: '竞品对标',
  131. definition: '产品定义',
  132. lifecycle: '产品生命周期复盘',
  133. };
  134. return labels[this.section];
  135. }
  136. get toolbarFields(): Array<{ label: string; value: string }> {
  137. const range = this.dataset?.source.dateRange;
  138. return [
  139. { label: '周期', value: range ? `${range.start} 至 ${range.end}` : '—' },
  140. { label: '店铺', value: '—' },
  141. { label: '类目', value: '—' },
  142. { label: '负责人', value: '—' },
  143. ];
  144. }
  145. openOwnProduct(productId: string): void {
  146. const id = productId?.trim();
  147. if (!id) return;
  148. void this.router.navigate(['/voc-insight/detail'], { queryParams: { productId: id } });
  149. }
  150. openCompetitor(productId: string): void {
  151. const id = productId?.trim();
  152. if (!id) return;
  153. void this.router.navigate(['/domestic/competitors', id]);
  154. }
  155. openCategoryProducts(category: string): void {
  156. const value = category?.trim();
  157. if (!value) return;
  158. void this.router.navigate(['/voc-insight/products'], { queryParams: { category: value } });
  159. }
  160. get description(): string {
  161. const labels: Record<ProductDevelopmentSection, string> = {
  162. trend: '从真实成交、类目和商品表现中识别业务趋势,不将短周期经营波动包装成市场长期结论。',
  163. requirement: '保留原用户需求挖掘框架,等待评论原声后提炼需求、场景和未满足机会。',
  164. benchmark: '基于德玛仕自营商品与竞品映射,建立国内商品对标入口。',
  165. definition: '将已选择商品的标准身份、经营表现和类目归属汇总为产品定义底稿。',
  166. lifecycle: '按当前数据周期复盘单商品的成交、销量、转化和退款变化。',
  167. };
  168. return labels[this.section];
  169. }
  170. relationBrands(group: DomesticMappingGroup): string {
  171. return [...new Set(group.competitors.map((item) => item.competitorBrand).filter(Boolean))].join('、') || '-';
  172. }
  173. get selectedCompetitors(): DomesticProduct[] {
  174. if (!this.dataset || !this.selectedProduct) return [];
  175. const competitorKeys = new Set(
  176. this.dataset.relations
  177. .filter((relation) => relation.ownProductKey === this.selectedProduct?.productKey)
  178. .map((relation) => relation.competitorProductKey),
  179. );
  180. return this.dataset.products
  181. .filter((product) => product.role === 'competitor' && competitorKeys.has(product.productKey))
  182. .sort((left, right) => Number(Boolean(right.market)) - Number(Boolean(left.market)));
  183. }
  184. competitorRelationSource(product: DomesticProduct): string {
  185. const relation = this.dataset?.relations.find((item) =>
  186. item.ownProductKey === this.selectedProduct?.productKey
  187. && item.competitorProductKey === product.productKey,
  188. );
  189. return relation?.discoverySource === 'brand_category_search' ? '品牌品类发现' : '表格关系';
  190. }
  191. reviewCount(productId: string): number {
  192. return (this.dataset?.reviews ?? []).filter((review) => review.productId === productId).length;
  193. }
  194. averageSoldPrice(gmv: number, soldUnits: number): number {
  195. return soldUnits > 0 ? gmv / soldUnits : 0;
  196. }
  197. currency(value: number): string {
  198. return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', maximumFractionDigits: 0 }).format(value || 0);
  199. }
  200. number(value: number): string {
  201. return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }).format(value || 0);
  202. }
  203. percent(value: number): string {
  204. return new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(value || 0);
  205. }
  206. date(value = ''): string {
  207. return value ? value.slice(0, 10) : '-';
  208. }
  209. private buildCategoryOptions(categories: DomesticCategoryAggregate[]): EChartsOption {
  210. const rows = categories.slice(0, 8).reverse();
  211. return {
  212. color: ['#2563eb', '#059669'],
  213. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  214. legend: { data: ['成交金额', '退款金额'], top: 0, right: 0 },
  215. grid: { left: 12, right: 18, top: 44, bottom: 12, containLabel: true },
  216. xAxis: { type: 'value', axisLabel: { color: '#64748b' }, splitLine: { lineStyle: { color: '#eef2f7' } } },
  217. yAxis: { type: 'category', data: rows.map((item) => item.category), axisLabel: { color: '#475569', width: 110, overflow: 'truncate' } },
  218. series: [
  219. { name: '成交金额', type: 'bar', data: rows.map((item) => item.gmv), barMaxWidth: 16 },
  220. { name: '退款金额', type: 'bar', data: rows.map((item) => item.refundAmount), barMaxWidth: 16 },
  221. ],
  222. };
  223. }
  224. private buildLifecycleOptions(product: DomesticProduct | null): EChartsOption {
  225. const trend = product?.trend ?? [];
  226. return {
  227. color: ['#2563eb', '#059669'],
  228. tooltip: { trigger: 'axis' },
  229. legend: { data: ['成交金额', '成交件数'], top: 0, right: 0 },
  230. grid: { left: 18, right: 18, top: 48, bottom: 16, containLabel: true },
  231. xAxis: { type: 'category', boundaryGap: false, data: trend.map((item) => item.date.slice(5)), axisLabel: { color: '#64748b' } },
  232. yAxis: [
  233. { type: 'value', name: '金额(元)', axisLabel: { color: '#64748b' }, splitLine: { lineStyle: { color: '#eef2f7' } } },
  234. { type: 'value', name: '件数', axisLabel: { color: '#64748b' }, splitLine: { show: false } },
  235. ],
  236. series: [
  237. { name: '成交金额', type: 'line', smooth: true, data: trend.map((item) => item.gmv), areaStyle: { color: 'rgba(37,99,235,.08)' } },
  238. { name: '成交件数', type: 'line', smooth: true, yAxisIndex: 1, data: trend.map((item) => item.soldUnits) },
  239. ],
  240. };
  241. }
  242. }