designer-performance.component.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { CommonModule } from '@angular/common';
  2. import { Component, OnDestroy, OnInit } from '@angular/core';
  3. import { FormsModule } from '@angular/forms';
  4. import { Router } from '@angular/router';
  5. import { FilterOptions } from '../../../shared/interfaces/voc-data.interface';
  6. import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component';
  7. import { AiAnalysisPanelComponent } from '../../../shared/components/ai-analysis-panel/ai-analysis-panel.component';
  8. import { DesignerPerformanceService } from '../../../shared/services/designer-performance.service';
  9. import {
  10. DesignerIssueMatrixRow,
  11. DesignerPerformanceDashboardData,
  12. DesignerPerformanceFilters,
  13. DesignerPerformanceRankingItem,
  14. DesignerProductDrilldownItem,
  15. DesignerStarDistributionPoint
  16. } from '../../../shared/interfaces/designer-performance.interface';
  17. import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component';
  18. import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component';
  19. import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
  20. import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component';
  21. import { DataTableShellComponent } from '../../../shared/components/data-table-shell/data-table-shell.component';
  22. import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component';
  23. import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component';
  24. import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component';
  25. import { AlertCardComponent } from '../../../shared/components/alert-card/alert-card.component';
  26. import { SplitPanelComponent } from '../../../shared/components/split-panel/split-panel.component';
  27. import { ProductDimensionService } from '../../../shared/services/product-dimension.service';
  28. import { Subscription } from 'rxjs';
  29. type DesignerPerformanceTabKey = 'ranking' | 'trend' | 'issues' | 'ai';
  30. type DesignerProductSortKey = 'risk' | 'rating' | 'negative' | 'return';
  31. type DesignerRankingSortKey = 'health' | 'rating' | 'negative';
  32. @Component({
  33. selector: 'app-designer-performance',
  34. standalone: true,
  35. imports: [CommonModule, FormsModule, FilterBarComponent, AiAnalysisPanelComponent, SummaryMetricCardComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent, DataTableShellComponent, ContentCardComponent, BoardTabsComponent, ProductIdentityComponent, AlertCardComponent, SplitPanelComponent],
  36. templateUrl: './designer-performance.component.html',
  37. styleUrls: ['./designer-performance.component.scss']
  38. })
  39. export class DesignerPerformanceComponent implements OnInit, OnDestroy {
  40. currentFilters: DesignerPerformanceFilters = { timeRange: '30days' };
  41. data?: DesignerPerformanceDashboardData;
  42. private dataRevision = 0;
  43. private highRiskRankingsCacheKey = '';
  44. private highRiskRankingsCache: DesignerPerformanceRankingItem[] = [];
  45. private highRiskHeadlineCacheKey = '';
  46. private highRiskHeadlineCache = '';
  47. private selectedDesignerSummaryCacheKey = '';
  48. private selectedDesignerSummaryCache: DesignerPerformanceRankingItem | undefined;
  49. private pageTabsCacheKey = '';
  50. private pageTabsCache: BoardTabItem[] = [];
  51. private designerFilterOptionsCacheKey = '';
  52. private designerFilterOptionsCache: string[] = [];
  53. private dimensionDesignerOptions: string[] = [];
  54. private designerOptionsSub?: Subscription;
  55. private lastRequestedFilterKey = '';
  56. private loadRequestSeq = 0;
  57. private initialFilterApplied = false;
  58. private initialLoadFallbackId: number | null = null;
  59. loading = false;
  60. error = '';
  61. selectedDesigner = '';
  62. designerFilterOptions: string[] = [];
  63. productSearch = '';
  64. productCurrentPage = 1;
  65. productPageSize = 10;
  66. productSortKey: DesignerProductSortKey = 'risk';
  67. productSortDir: 'asc' | 'desc' = 'desc';
  68. readonly productPageSizeOptions = [10, 20, 50];
  69. filteredProducts: DesignerProductDrilldownItem[] = [];
  70. visibleProducts: DesignerProductDrilldownItem[] = [];
  71. productTotalPages = 1;
  72. productDisplayPage = 1;
  73. productPageStart = 0;
  74. productPageEnd = 0;
  75. private readonly starDistributionCache = new WeakMap<object, DesignerProductDrilldownItem['starDistribution']>();
  76. private readonly starCountCache = new WeakMap<object, number>();
  77. lastUpdateTime = new Date();
  78. constructor(
  79. private designerPerformanceService: DesignerPerformanceService,
  80. private productDimensionService: ProductDimensionService,
  81. private router: Router
  82. ) {}
  83. ngOnInit(): void {
  84. this.loadDesignerOptions();
  85. }
  86. ngOnDestroy(): void {
  87. if (this.initialLoadFallbackId !== null) {
  88. window.clearTimeout(this.initialLoadFallbackId);
  89. this.initialLoadFallbackId = null;
  90. }
  91. this.designerOptionsSub?.unsubscribe();
  92. }
  93. onFilterChange(filters: FilterOptions): void {
  94. this.initialFilterApplied = true;
  95. if (this.initialLoadFallbackId !== null) {
  96. window.clearTimeout(this.initialLoadFallbackId);
  97. this.initialLoadFallbackId = null;
  98. }
  99. const nextFilters = this.normalizeFilters(filters);
  100. const nextFilterKey = this.getFilterKey(nextFilters);
  101. if (nextFilterKey === this.lastRequestedFilterKey && (this.loading || this.data)) return;
  102. this.currentFilters = nextFilters;
  103. this.selectedDesigner = this.currentFilters.designerName?.[0] || '';
  104. this.productCurrentPage = 1;
  105. this.loadData();
  106. }
  107. loadData(): void {
  108. if (!this.hasStoreScope(this.currentFilters)) {
  109. this.loading = false;
  110. this.error = '';
  111. this.data = undefined;
  112. this.refreshProductView();
  113. return;
  114. }
  115. this.lastRequestedFilterKey = this.getFilterKey(this.currentFilters);
  116. const requestSeq = ++this.loadRequestSeq;
  117. this.loading = true;
  118. this.error = '';
  119. this.designerPerformanceService.getDashboardData(this.currentFilters).subscribe({
  120. next: data => {
  121. if (requestSeq !== this.loadRequestSeq) return;
  122. this.data = data;
  123. this.dataRevision++;
  124. this.designerFilterOptions = this.buildDesignerFilterOptions();
  125. this.refreshProductView();
  126. this.lastUpdateTime = new Date();
  127. this.loading = false;
  128. },
  129. error: err => {
  130. if (requestSeq !== this.loadRequestSeq) return;
  131. this.error = err?.message || '设计师表现看板加载失败';
  132. this.loading = false;
  133. }
  134. });
  135. }
  136. get rankings(): DesignerPerformanceRankingItem[] {
  137. return this.data?.rankings || [];
  138. }
  139. // #23 设计师健康排行:评分/差评独立可排序
  140. rankingSortKey: DesignerRankingSortKey = 'health';
  141. rankingSortDir: 'asc' | 'desc' = 'asc';
  142. setRankingSort(key: DesignerRankingSortKey): void {
  143. if (this.rankingSortKey === key) {
  144. this.rankingSortDir = this.rankingSortDir === 'desc' ? 'asc' : 'desc';
  145. return;
  146. }
  147. this.rankingSortKey = key;
  148. // 默认:健康分升序(差的在前);评分升序;差评率降序
  149. this.rankingSortDir = key === 'negative' ? 'desc' : 'asc';
  150. }
  151. getRankingSortIndicator(key: DesignerRankingSortKey): string {
  152. if (this.rankingSortKey !== key) return '';
  153. return this.rankingSortDir === 'desc' ? '↓' : '↑';
  154. }
  155. sortedRankings(): DesignerPerformanceRankingItem[] {
  156. const list = [...this.rankings];
  157. const val = (it: DesignerPerformanceRankingItem): number =>
  158. this.rankingSortKey === 'rating' ? Number(it.avgRating || 0)
  159. : this.rankingSortKey === 'negative' ? Number(it.negativeRate || 0)
  160. : Number(it.healthScore || 0);
  161. const dir = this.rankingSortDir === 'desc' ? -1 : 1;
  162. return list.sort((a, b) => (val(a) - val(b)) * dir);
  163. }
  164. get matrixRows(): DesignerIssueMatrixRow[] {
  165. return this.data?.issueMatrix.rows || [];
  166. }
  167. // 高风险设计师摘要(预警条用)
  168. get highRiskRankings(): DesignerPerformanceRankingItem[] {
  169. const key = String(this.dataRevision);
  170. if (key === this.highRiskRankingsCacheKey) return this.highRiskRankingsCache;
  171. this.highRiskRankingsCacheKey = key;
  172. this.highRiskRankingsCache = this.rankings.filter(r => r.riskLevel === 'high');
  173. return this.highRiskRankingsCache;
  174. }
  175. get highRiskCount(): number {
  176. return this.highRiskRankings.length;
  177. }
  178. get highRiskHeadline(): string {
  179. const key = String(this.dataRevision);
  180. if (key === this.highRiskHeadlineCacheKey) return this.highRiskHeadlineCache;
  181. const head = this.highRiskRankings.slice(0, 2)
  182. .map(r => `${r.designerName} ${r.negativeRate}%`)
  183. .join('、');
  184. this.highRiskHeadlineCacheKey = key;
  185. this.highRiskHeadlineCache = head || '暂无高风险';
  186. return this.highRiskHeadlineCache;
  187. }
  188. private getFilteredProducts(): DesignerProductDrilldownItem[] {
  189. const keyword = this.productSearch.trim().toLowerCase();
  190. const items = (this.data?.products || []).filter(product => {
  191. const matchDesigner = !this.selectedDesigner || product.designerName === this.selectedDesigner;
  192. const matchKeyword = !keyword || [
  193. product.asin,
  194. product.title,
  195. product.sellerSku,
  196. product.productSku,
  197. product.operatorName,
  198. product.developerName,
  199. product.fulfillmentType
  200. ].some(value => String(value || '').toLowerCase().includes(keyword));
  201. return matchDesigner && matchKeyword;
  202. });
  203. return this.sortProducts(items);
  204. }
  205. get selectedDesignerSummary(): DesignerPerformanceRankingItem | undefined {
  206. const key = `${this.dataRevision}|${this.selectedDesigner}`;
  207. if (key === this.selectedDesignerSummaryCacheKey) return this.selectedDesignerSummaryCache;
  208. this.selectedDesignerSummaryCacheKey = key;
  209. this.selectedDesignerSummaryCache = this.rankings.find(item => item.designerName === this.selectedDesigner);
  210. return this.selectedDesignerSummaryCache;
  211. }
  212. get aiPrompt(): string {
  213. if (!this.data) return '';
  214. const data = this.data;
  215. const summary = data.summary;
  216. const starDesc = (distribution: DesignerStarDistributionPoint[]): string =>
  217. distribution?.length
  218. ? [...distribution].sort((a, b) => b.star - a.star).map(point => `${point.star}★${point.count}(${point.pct}%)`).join(' ')
  219. : '-';
  220. const rankingLines = this.rankings.map((item, index) =>
  221. `[D${index + 1}] ${item.designerName}: 健康分${item.healthScore}, 风险${item.riskLevel}, 运营${item.operatorNames.join('/') || '-'}, 产品${item.productCount}(在售${item.activeProductCount}), VOC${item.vocCount}条, 平均评分${item.avgRating}, 差评${item.negativeReviewCount}条/${item.negativeRate}%, 退货${item.returnCount}件/${item.returnPct}%, 严重风险${item.criticalRiskCount}个, 预警${item.warningRiskCount}个, 配送${item.fulfillmentTypes.join('/') || '-'}, 星级分布${starDesc(item.starDistribution)}, 主因${item.topReasons.join('/') || '暂无'}`
  222. ).join('\n');
  223. const issueMatrix = data.issueMatrix;
  224. const issueHeader = issueMatrix.issues?.length ? `问题维度:${issueMatrix.issues.join('、')}` : '';
  225. const matrixLines = issueMatrix.rows.map((row, index) => {
  226. const taxonomy = row.topIssueTaxonomy;
  227. const confidenceLabel = this.getIssueTaxonomyConfidenceLabel(taxonomy?.confidence);
  228. const keywords = taxonomy?.matchedKeywords?.length ? `,命中关键词${taxonomy.matchedKeywords.join('/')}` : '';
  229. const cells = row.cells.filter(cell => cell.count > 0)
  230. .map(cell => `${cell.issue}:${cell.count}(${cell.pct}%/${cell.riskLevel})`).join(',') || '暂无';
  231. return `[M${index + 1}] ${row.designerName}: 问题总数${row.totalIssueCount}, TOP问题${row.topIssue || '暂无'}, 责任角色${taxonomy?.ownerHint || '未识别'}, 置信度${confidenceLabel}${keywords};各问题分布:${cells}`;
  232. }).join('\n');
  233. const reasonMatrix = data.returnReasonMatrix;
  234. const reasonHeader = reasonMatrix?.reasons?.length ? `退货原因维度:${reasonMatrix.reasons.join('、')}` : '';
  235. const reasonLines = (reasonMatrix?.rows || []).map((row, index) => {
  236. const cells = row.cells.filter(cell => cell.count > 0)
  237. .map(cell => `${cell.reason}:${cell.count}(${cell.pct}%/${cell.riskLevel})`).join(',') || '暂无';
  238. return `[RM${index + 1}] ${row.designerName}: 退货总数${row.totalReturnCount}, TOP原因${row.topReason || '暂无'};各原因分布:${cells}`;
  239. }).join('\n');
  240. const returnRankLines = (data.returnRanking || []).map((item, index) => {
  241. const skus = (item.highRiskSkus || []).slice(0, 3)
  242. .map(sku => `${sku.asin || sku.sku}(${sku.productName || '-'},退${sku.returnCount ?? '-'}/${sku.returnRate}%,主因${sku.mainReason || '-'})`).join(';') || '暂无';
  243. return `[RR${index + 1}] ${item.designerName}: 运营${item.operatorNames.join('/') || '-'}, 产品${item.productCount}, SKU${item.skuCount}, 退货${item.returnCount}件/${item.returnPct}%, 配送${item.fulfillmentTypes.join('/') || '-'}, 主因${item.topReasons.join('/') || '暂无'};高风险SKU:${skus}`;
  244. }).join('\n');
  245. const productScope = this.filteredProducts;
  246. const scopeLabel = this.selectedDesigner ? `已下钻设计师「${this.selectedDesigner}」` : '全部设计师';
  247. const searchLabel = this.productSearch.trim() ? `,搜索「${this.productSearch.trim()}」` : '';
  248. const productLines = productScope.map((product, index) => {
  249. const alerts = product.alerts?.length
  250. ? product.alerts.map(alert => `${alert.severity}:${alert.type}-${alert.message}`).join(';')
  251. : '';
  252. return `[P${index + 1}] ${product.asin}/${product.productSku || product.sellerSku || '-'}: 标题${product.title || '-'}, 设计师${product.designerName}, 负责人${product.operatorName || '-'}, 开发${product.developerName || '-'}, 状态${product.itemStatus ?? '-'}, 配送${product.fulfillmentType || '-'}, 月销${product.monthlySales}, 评分${product.rating}(${product.ratingsCount}评), 差评${product.negativeReviewCount}条/${product.negativeRate}%, VOC${product.vocCount}条, 退货${product.returnCount}件/${product.returnRate}%, 风险${product.riskLevel}, 星级分布${starDesc(product.starDistribution)}${alerts ? `, 预警${alerts}` : ''}`;
  253. }).join('\n');
  254. const sampleWarnings: string[] = [];
  255. if (summary.totalVoc < 30) sampleWarnings.push('VOC样本少于30条,差评率结论需降级为趋势判断');
  256. if (summary.totalReturns < 10) sampleWarnings.push('退货样本少于10件,退货主因仅作线索');
  257. if (issueMatrix.rows.length < 3) sampleWarnings.push('问题矩阵覆盖设计师少于3人,避免过度横向排名');
  258. return [
  259. `【设计师表现总览】设计师${summary.designerCount}人,产品${summary.totalProducts}个,在售${summary.activeProducts}个,VOC${summary.totalVoc}条,退货${summary.totalReturns}件,严重风险${summary.criticalRiskCount}个,平均差评率${summary.avgNegativeRate}%`,
  260. `【健康排行·全部${this.rankings.length}人】\n${rankingLines || '暂无'}`,
  261. `【统一问题归因矩阵·全部${issueMatrix.rows.length}行】${issueHeader ? '\n' + issueHeader : ''}\n${matrixLines || '暂无'}`,
  262. `【退货原因矩阵·全部${reasonMatrix?.rows?.length || 0}行】${reasonHeader ? '\n' + reasonHeader : ''}\n${reasonLines || '暂无'}`,
  263. `【退货风险排行·全部${(data.returnRanking || []).length}人】\n${returnRankLines || '暂无'}`,
  264. `【下钻商品证据·${scopeLabel}${searchLabel},共${productScope.length}个】\n${productLines || '暂无'}`,
  265. `【证据链约束】输出结论必须引用[D]/[M]/[RM]/[RR]/[P]证据编号;优先使用统一问题归因与退货原因矩阵中的责任角色与置信度;${sampleWarnings.length ? sampleWarnings.join(';') : '当前样本量可支持方向性判断'}`
  266. ].join('\n') + '\n\n请基于以上完整数据输出设计师表现诊断:①高风险设计师及核心原因 ②优先处理的商品/问题 ③设计、运营、开发协同建议。控制在400字内。';
  267. }
  268. get aiSystemPrompt(): string {
  269. return '你是一名亚马逊跨境电商VOC与退货风险分析专家,擅长从设计师、运营负责人和商品维度定位产品问题。请用中文、Markdown格式输出,结论要可执行,并引用用户提供的证据编号。';
  270. }
  271. selectDesigner(designerName: string): void {
  272. this.selectedDesigner = this.selectedDesigner === designerName ? '' : designerName;
  273. this.productCurrentPage = 1;
  274. this.refreshProductView();
  275. }
  276. clearDesignerSelection(): void {
  277. this.selectedDesigner = '';
  278. this.productCurrentPage = 1;
  279. this.refreshProductView();
  280. }
  281. onProductSearchChange(value: string): void {
  282. this.productSearch = value;
  283. this.productCurrentPage = 1;
  284. this.refreshProductView();
  285. }
  286. onProductPageSizeChange(value: string): void {
  287. const nextSize = Number(value);
  288. if (!Number.isFinite(nextSize) || nextSize <= 0) return;
  289. this.productPageSize = nextSize;
  290. this.productCurrentPage = 1;
  291. this.refreshProductView();
  292. }
  293. onProductPageChange(page: number): void {
  294. const nextPage = Math.max(1, Math.min(this.productTotalPages, page));
  295. if (nextPage === this.productCurrentPage) return;
  296. this.productCurrentPage = nextPage;
  297. this.refreshProductView();
  298. }
  299. setProductSort(key: DesignerProductSortKey): void {
  300. if (this.productSortKey === key) {
  301. this.productSortDir = this.productSortDir === 'desc' ? 'asc' : 'desc';
  302. this.productCurrentPage = 1;
  303. this.refreshProductView();
  304. return;
  305. }
  306. this.productSortKey = key;
  307. this.productSortDir = key === 'rating' ? 'asc' : 'desc';
  308. this.productCurrentPage = 1;
  309. this.refreshProductView();
  310. }
  311. getProductSortIndicator(key: DesignerProductSortKey): string {
  312. if (this.productSortKey !== key) return '';
  313. return this.productSortDir === 'desc' ? '↓' : '↑';
  314. }
  315. getStarDistributionDesc(distribution: DesignerProductDrilldownItem['starDistribution']): DesignerProductDrilldownItem['starDistribution'] {
  316. if (!distribution?.length) return [];
  317. const cached = this.starDistributionCache.get(distribution);
  318. if (cached) return cached;
  319. const sorted = [...distribution].sort((a, b) => b.star - a.star);
  320. this.starDistributionCache.set(distribution, sorted);
  321. return sorted;
  322. }
  323. getTotalStarCount(distribution: DesignerProductDrilldownItem['starDistribution']): number {
  324. if (!distribution?.length) return 0;
  325. const cached = this.starCountCache.get(distribution);
  326. if (cached !== undefined) return cached;
  327. const total = distribution.reduce((sum, item) => sum + item.count, 0);
  328. this.starCountCache.set(distribution, total);
  329. return total;
  330. }
  331. // ─── 页内 Tab 切换 ───
  332. activeTab: DesignerPerformanceTabKey = 'ranking';
  333. get pageTabs(): BoardTabItem[] {
  334. const critical = this.data?.summary.criticalRiskCount || 0;
  335. const key = `${this.dataRevision}|${critical}`;
  336. if (key === this.pageTabsCacheKey) return this.pageTabsCache;
  337. this.pageTabsCacheKey = key;
  338. this.pageTabsCache = [
  339. {
  340. key: 'ranking',
  341. label: '风险排行',
  342. icon: '📉',
  343. badges: critical ? [{ label: critical, tone: 'red' }] : undefined
  344. },
  345. { key: 'trend', label: '趋势图', icon: '📈' },
  346. { key: 'issues', label: '问题分布', icon: '📊' },
  347. { key: 'ai', label: 'AI分析', icon: '🤖' }
  348. ];
  349. return this.pageTabsCache;
  350. }
  351. setActiveTab(key: string): void {
  352. this.activeTab = key as DesignerPerformanceTabKey;
  353. }
  354. openRiskAlert(): void {
  355. this.router.navigate(['/monitoring/risk-alert']);
  356. }
  357. openReturnAnalysis(): void {
  358. this.router.navigate(['/return-analysis/overview']);
  359. }
  360. getHealthBarWidth(score: number): number {
  361. return Math.max(4, Math.min(100, score));
  362. }
  363. getRiskLevelLabel(level: 'high' | 'medium' | 'low'): string {
  364. const map = { high: '高风险', medium: '中风险', low: '低风险' };
  365. return map[level];
  366. }
  367. getIssueTaxonomyConfidenceLabel(confidence?: 'high' | 'medium' | 'low'): string {
  368. const map = { high: '高置信', medium: '中置信', low: '低置信' };
  369. return confidence ? map[confidence] : '未识别';
  370. }
  371. getProductRiskLabel(level: 'critical' | 'warning' | 'normal'): string {
  372. const map = { critical: '严重', warning: '预警', normal: '正常' };
  373. return map[level];
  374. }
  375. trackByDesigner(_: number, item: DesignerPerformanceRankingItem | DesignerIssueMatrixRow): string {
  376. return item.designerName;
  377. }
  378. trackByProduct(_: number, item: DesignerProductDrilldownItem): string {
  379. return item.asin;
  380. }
  381. private refreshProductView(): void {
  382. const products = this.getFilteredProducts();
  383. this.filteredProducts = products;
  384. this.productTotalPages = Math.max(1, Math.ceil(products.length / this.productPageSize));
  385. this.productDisplayPage = Math.max(1, Math.min(this.productCurrentPage, this.productTotalPages));
  386. if (this.productCurrentPage !== this.productDisplayPage) {
  387. this.productCurrentPage = this.productDisplayPage;
  388. }
  389. const start = (this.productDisplayPage - 1) * this.productPageSize;
  390. this.visibleProducts = products.slice(start, start + this.productPageSize);
  391. this.productPageStart = products.length ? start + 1 : 0;
  392. this.productPageEnd = Math.min(start + this.productPageSize, products.length);
  393. }
  394. private sortProducts(products: DesignerProductDrilldownItem[]): DesignerProductDrilldownItem[] {
  395. const factor = this.productSortDir === 'desc' ? -1 : 1;
  396. const riskScore = { critical: 3, warning: 2, normal: 1 };
  397. const valueOf = (product: DesignerProductDrilldownItem): number => {
  398. if (this.productSortKey === 'rating') return Number(product.rating || 0);
  399. if (this.productSortKey === 'negative') return Number(product.negativeRate || 0);
  400. if (this.productSortKey === 'return') return Number(product.returnRate || 0);
  401. return riskScore[product.riskLevel] || 0;
  402. };
  403. return [...products].sort((a, b) => {
  404. const diff = valueOf(a) - valueOf(b);
  405. if (diff !== 0) return diff * factor;
  406. return b.negativeRate - a.negativeRate;
  407. });
  408. }
  409. private normalizeFilters(filters: FilterOptions): DesignerPerformanceFilters {
  410. return {
  411. timeRange: filters.timeRange || '30days',
  412. category: filters.category,
  413. sku: filters.sku,
  414. store: filters.store,
  415. storeNames: filters.storeNames,
  416. site: filters.site,
  417. designerName: this.cleanDesignerFilterValues(filters.designerName),
  418. operatorName: filters.operatorName,
  419. developerName: filters.developerName,
  420. productSku: filters.productSku,
  421. sellerSku: filters.sellerSku,
  422. itemStatus: filters.itemStatus,
  423. fulfillmentType: filters.fulfillmentType,
  424. customDateRange: filters.customDateRange
  425. };
  426. }
  427. private getFilterKey(filters: DesignerPerformanceFilters): string {
  428. const list = (values?: Array<string | number>) => (values || [])
  429. .map(value => String(value || '').trim())
  430. .filter(Boolean)
  431. .sort()
  432. .join('|');
  433. const range = filters.customDateRange;
  434. return [
  435. filters.timeRange || '30days',
  436. range?.start || '',
  437. range?.end || '',
  438. list(filters.category),
  439. list(filters.sku),
  440. list(filters.store),
  441. list(filters.storeNames),
  442. list(filters.site),
  443. list(filters.designerName),
  444. list(filters.operatorName),
  445. list(filters.developerName),
  446. list(filters.productSku),
  447. list(filters.sellerSku),
  448. list(filters.itemStatus),
  449. list(filters.fulfillmentType)
  450. ].join('::');
  451. }
  452. private hasStoreScope(filters: DesignerPerformanceFilters): boolean {
  453. return !!(filters.store?.length || filters.storeNames?.length);
  454. }
  455. private buildDesignerFilterOptions(): string[] {
  456. const key = `${this.dataRevision}|${this.dimensionDesignerOptions.join('|')}`;
  457. if (key === this.designerFilterOptionsCacheKey) return this.designerFilterOptionsCache;
  458. this.designerFilterOptionsCacheKey = key;
  459. const scopedDesignerOptions = [
  460. ...this.rankings.map(item => item.designerName),
  461. ...(this.data?.products || []).map(item => item.designerName),
  462. ...(this.data?.returnRanking || []).map(item => item.designerName)
  463. ].filter(name => this.isValidDesignerName(name));
  464. const fallbackDesignerOptions = scopedDesignerOptions.length ? [] : this.dimensionDesignerOptions;
  465. this.designerFilterOptionsCache = Array.from(new Set([
  466. ...scopedDesignerOptions,
  467. ...fallbackDesignerOptions,
  468. ...(this.currentFilters.designerName || [])
  469. ].filter(name => this.isValidDesignerName(name))))
  470. .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  471. return this.designerFilterOptionsCache;
  472. }
  473. private loadDesignerOptions(): void {
  474. this.designerOptionsSub?.unsubscribe();
  475. this.designerOptionsSub = this.productDimensionService.getDesignerOptions().subscribe(options => {
  476. this.dimensionDesignerOptions = options.filter(name => this.isValidDesignerName(name));
  477. this.designerFilterOptionsCacheKey = '';
  478. this.designerFilterOptions = this.buildDesignerFilterOptions();
  479. });
  480. }
  481. private cleanDesignerFilterValues(values?: string[]): string[] | undefined {
  482. if (!values?.length) return values;
  483. const cleaned = values.map(value => String(value || '').trim()).filter(value => this.isValidDesignerName(value));
  484. return cleaned.length ? cleaned : undefined;
  485. }
  486. private isValidDesignerName(value: any): boolean {
  487. const text = String(value || '').trim();
  488. if (!text) return false;
  489. const normalized = text.toLowerCase();
  490. return !['-', '—', '–', '未分配', '未分配设计师', '未归属', '未归属设计师', 'unknown', 'null', 'undefined', 'n/a', 'na'].includes(normalized);
  491. }
  492. }