import { CommonModule } from '@angular/common'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { FilterOptions } from '../../../shared/interfaces/voc-data.interface'; import { FilterBarComponent } from '../../../shared/components/filter-bar/filter-bar.component'; import { AiAnalysisPanelComponent } from '../../../shared/components/ai-analysis-panel/ai-analysis-panel.component'; import { DesignerPerformanceService } from '../../../shared/services/designer-performance.service'; import { DesignerIssueMatrixRow, DesignerPerformanceDashboardData, DesignerPerformanceFilters, DesignerPerformanceRankingItem, DesignerProductDrilldownItem, DesignerStarDistributionPoint } from '../../../shared/interfaces/designer-performance.interface'; import { SummaryMetricCardComponent } from '../../../shared/components/summary-metric-card/summary-metric-card.component'; import { LoadingSpinnerComponent } from '../../../shared/components/loading-spinner/loading-spinner.component'; import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component'; import { ErrorStateComponent } from '../../../shared/components/error-state/error-state.component'; import { DataTableShellComponent } from '../../../shared/components/data-table-shell/data-table-shell.component'; import { ContentCardComponent } from '../../../shared/components/content-card/content-card.component'; import { BoardTabsComponent, BoardTabItem } from '../../../shared/components/board-tabs/board-tabs.component'; import { ProductIdentityComponent } from '../../../shared/components/product-identity/product-identity.component'; import { AlertCardComponent } from '../../../shared/components/alert-card/alert-card.component'; import { SplitPanelComponent } from '../../../shared/components/split-panel/split-panel.component'; import { ProductDimensionService } from '../../../shared/services/product-dimension.service'; import { Subscription } from 'rxjs'; type DesignerPerformanceTabKey = 'ranking' | 'trend' | 'issues' | 'ai'; type DesignerProductSortKey = 'risk' | 'rating' | 'negative' | 'return'; type DesignerRankingSortKey = 'health' | 'rating' | 'negative'; @Component({ selector: 'app-designer-performance', standalone: true, imports: [CommonModule, FormsModule, FilterBarComponent, AiAnalysisPanelComponent, SummaryMetricCardComponent, LoadingSpinnerComponent, EmptyStateComponent, ErrorStateComponent, DataTableShellComponent, ContentCardComponent, BoardTabsComponent, ProductIdentityComponent, AlertCardComponent, SplitPanelComponent], templateUrl: './designer-performance.component.html', styleUrls: ['./designer-performance.component.scss'] }) export class DesignerPerformanceComponent implements OnInit, OnDestroy { currentFilters: DesignerPerformanceFilters = { timeRange: '30days' }; data?: DesignerPerformanceDashboardData; private dataRevision = 0; private highRiskRankingsCacheKey = ''; private highRiskRankingsCache: DesignerPerformanceRankingItem[] = []; private highRiskHeadlineCacheKey = ''; private highRiskHeadlineCache = ''; private selectedDesignerSummaryCacheKey = ''; private selectedDesignerSummaryCache: DesignerPerformanceRankingItem | undefined; private pageTabsCacheKey = ''; private pageTabsCache: BoardTabItem[] = []; private designerFilterOptionsCacheKey = ''; private designerFilterOptionsCache: string[] = []; private dimensionDesignerOptions: string[] = []; private designerOptionsSub?: Subscription; private lastRequestedFilterKey = ''; private loadRequestSeq = 0; private initialFilterApplied = false; private initialLoadFallbackId: number | null = null; loading = false; error = ''; selectedDesigner = ''; designerFilterOptions: string[] = []; productSearch = ''; productCurrentPage = 1; productPageSize = 10; productSortKey: DesignerProductSortKey = 'risk'; productSortDir: 'asc' | 'desc' = 'desc'; readonly productPageSizeOptions = [10, 20, 50]; filteredProducts: DesignerProductDrilldownItem[] = []; visibleProducts: DesignerProductDrilldownItem[] = []; productTotalPages = 1; productDisplayPage = 1; productPageStart = 0; productPageEnd = 0; private readonly starDistributionCache = new WeakMap(); private readonly starCountCache = new WeakMap(); lastUpdateTime = new Date(); constructor( private designerPerformanceService: DesignerPerformanceService, private productDimensionService: ProductDimensionService, private router: Router ) {} ngOnInit(): void { this.loadDesignerOptions(); } ngOnDestroy(): void { if (this.initialLoadFallbackId !== null) { window.clearTimeout(this.initialLoadFallbackId); this.initialLoadFallbackId = null; } this.designerOptionsSub?.unsubscribe(); } onFilterChange(filters: FilterOptions): void { this.initialFilterApplied = true; if (this.initialLoadFallbackId !== null) { window.clearTimeout(this.initialLoadFallbackId); this.initialLoadFallbackId = null; } const nextFilters = this.normalizeFilters(filters); const nextFilterKey = this.getFilterKey(nextFilters); if (nextFilterKey === this.lastRequestedFilterKey && (this.loading || this.data)) return; this.currentFilters = nextFilters; this.selectedDesigner = this.currentFilters.designerName?.[0] || ''; this.productCurrentPage = 1; this.loadData(); } loadData(): void { if (!this.hasStoreScope(this.currentFilters)) { this.loading = false; this.error = ''; this.data = undefined; this.refreshProductView(); return; } this.lastRequestedFilterKey = this.getFilterKey(this.currentFilters); const requestSeq = ++this.loadRequestSeq; this.loading = true; this.error = ''; this.designerPerformanceService.getDashboardData(this.currentFilters).subscribe({ next: data => { if (requestSeq !== this.loadRequestSeq) return; this.data = data; this.dataRevision++; this.designerFilterOptions = this.buildDesignerFilterOptions(); this.refreshProductView(); this.lastUpdateTime = new Date(); this.loading = false; }, error: err => { if (requestSeq !== this.loadRequestSeq) return; this.error = err?.message || '设计师表现看板加载失败'; this.loading = false; } }); } get rankings(): DesignerPerformanceRankingItem[] { return this.data?.rankings || []; } // #23 设计师健康排行:评分/差评独立可排序 rankingSortKey: DesignerRankingSortKey = 'health'; rankingSortDir: 'asc' | 'desc' = 'asc'; setRankingSort(key: DesignerRankingSortKey): void { if (this.rankingSortKey === key) { this.rankingSortDir = this.rankingSortDir === 'desc' ? 'asc' : 'desc'; return; } this.rankingSortKey = key; // 默认:健康分升序(差的在前);评分升序;差评率降序 this.rankingSortDir = key === 'negative' ? 'desc' : 'asc'; } getRankingSortIndicator(key: DesignerRankingSortKey): string { if (this.rankingSortKey !== key) return ''; return this.rankingSortDir === 'desc' ? '↓' : '↑'; } sortedRankings(): DesignerPerformanceRankingItem[] { const list = [...this.rankings]; const val = (it: DesignerPerformanceRankingItem): number => this.rankingSortKey === 'rating' ? Number(it.avgRating || 0) : this.rankingSortKey === 'negative' ? Number(it.negativeRate || 0) : Number(it.healthScore || 0); const dir = this.rankingSortDir === 'desc' ? -1 : 1; return list.sort((a, b) => (val(a) - val(b)) * dir); } get matrixRows(): DesignerIssueMatrixRow[] { return this.data?.issueMatrix.rows || []; } // 高风险设计师摘要(预警条用) get highRiskRankings(): DesignerPerformanceRankingItem[] { const key = String(this.dataRevision); if (key === this.highRiskRankingsCacheKey) return this.highRiskRankingsCache; this.highRiskRankingsCacheKey = key; this.highRiskRankingsCache = this.rankings.filter(r => r.riskLevel === 'high'); return this.highRiskRankingsCache; } get highRiskCount(): number { return this.highRiskRankings.length; } get highRiskHeadline(): string { const key = String(this.dataRevision); if (key === this.highRiskHeadlineCacheKey) return this.highRiskHeadlineCache; const head = this.highRiskRankings.slice(0, 2) .map(r => `${r.designerName} ${r.negativeRate}%`) .join('、'); this.highRiskHeadlineCacheKey = key; this.highRiskHeadlineCache = head || '暂无高风险'; return this.highRiskHeadlineCache; } private getFilteredProducts(): DesignerProductDrilldownItem[] { const keyword = this.productSearch.trim().toLowerCase(); const items = (this.data?.products || []).filter(product => { const matchDesigner = !this.selectedDesigner || product.designerName === this.selectedDesigner; const matchKeyword = !keyword || [ product.asin, product.title, product.sellerSku, product.productSku, product.operatorName, product.developerName, product.fulfillmentType ].some(value => String(value || '').toLowerCase().includes(keyword)); return matchDesigner && matchKeyword; }); return this.sortProducts(items); } get selectedDesignerSummary(): DesignerPerformanceRankingItem | undefined { const key = `${this.dataRevision}|${this.selectedDesigner}`; if (key === this.selectedDesignerSummaryCacheKey) return this.selectedDesignerSummaryCache; this.selectedDesignerSummaryCacheKey = key; this.selectedDesignerSummaryCache = this.rankings.find(item => item.designerName === this.selectedDesigner); return this.selectedDesignerSummaryCache; } get aiPrompt(): string { if (!this.data) return ''; const data = this.data; const summary = data.summary; const starDesc = (distribution: DesignerStarDistributionPoint[]): string => distribution?.length ? [...distribution].sort((a, b) => b.star - a.star).map(point => `${point.star}★${point.count}(${point.pct}%)`).join(' ') : '-'; const rankingLines = this.rankings.map((item, index) => `[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('/') || '暂无'}` ).join('\n'); const issueMatrix = data.issueMatrix; const issueHeader = issueMatrix.issues?.length ? `问题维度:${issueMatrix.issues.join('、')}` : ''; const matrixLines = issueMatrix.rows.map((row, index) => { const taxonomy = row.topIssueTaxonomy; const confidenceLabel = this.getIssueTaxonomyConfidenceLabel(taxonomy?.confidence); const keywords = taxonomy?.matchedKeywords?.length ? `,命中关键词${taxonomy.matchedKeywords.join('/')}` : ''; const cells = row.cells.filter(cell => cell.count > 0) .map(cell => `${cell.issue}:${cell.count}(${cell.pct}%/${cell.riskLevel})`).join(',') || '暂无'; return `[M${index + 1}] ${row.designerName}: 问题总数${row.totalIssueCount}, TOP问题${row.topIssue || '暂无'}, 责任角色${taxonomy?.ownerHint || '未识别'}, 置信度${confidenceLabel}${keywords};各问题分布:${cells}`; }).join('\n'); const reasonMatrix = data.returnReasonMatrix; const reasonHeader = reasonMatrix?.reasons?.length ? `退货原因维度:${reasonMatrix.reasons.join('、')}` : ''; const reasonLines = (reasonMatrix?.rows || []).map((row, index) => { const cells = row.cells.filter(cell => cell.count > 0) .map(cell => `${cell.reason}:${cell.count}(${cell.pct}%/${cell.riskLevel})`).join(',') || '暂无'; return `[RM${index + 1}] ${row.designerName}: 退货总数${row.totalReturnCount}, TOP原因${row.topReason || '暂无'};各原因分布:${cells}`; }).join('\n'); const returnRankLines = (data.returnRanking || []).map((item, index) => { const skus = (item.highRiskSkus || []).slice(0, 3) .map(sku => `${sku.asin || sku.sku}(${sku.productName || '-'},退${sku.returnCount ?? '-'}/${sku.returnRate}%,主因${sku.mainReason || '-'})`).join(';') || '暂无'; 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}`; }).join('\n'); const productScope = this.filteredProducts; const scopeLabel = this.selectedDesigner ? `已下钻设计师「${this.selectedDesigner}」` : '全部设计师'; const searchLabel = this.productSearch.trim() ? `,搜索「${this.productSearch.trim()}」` : ''; const productLines = productScope.map((product, index) => { const alerts = product.alerts?.length ? product.alerts.map(alert => `${alert.severity}:${alert.type}-${alert.message}`).join(';') : ''; 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}` : ''}`; }).join('\n'); const sampleWarnings: string[] = []; if (summary.totalVoc < 30) sampleWarnings.push('VOC样本少于30条,差评率结论需降级为趋势判断'); if (summary.totalReturns < 10) sampleWarnings.push('退货样本少于10件,退货主因仅作线索'); if (issueMatrix.rows.length < 3) sampleWarnings.push('问题矩阵覆盖设计师少于3人,避免过度横向排名'); return [ `【设计师表现总览】设计师${summary.designerCount}人,产品${summary.totalProducts}个,在售${summary.activeProducts}个,VOC${summary.totalVoc}条,退货${summary.totalReturns}件,严重风险${summary.criticalRiskCount}个,平均差评率${summary.avgNegativeRate}%`, `【健康排行·全部${this.rankings.length}人】\n${rankingLines || '暂无'}`, `【统一问题归因矩阵·全部${issueMatrix.rows.length}行】${issueHeader ? '\n' + issueHeader : ''}\n${matrixLines || '暂无'}`, `【退货原因矩阵·全部${reasonMatrix?.rows?.length || 0}行】${reasonHeader ? '\n' + reasonHeader : ''}\n${reasonLines || '暂无'}`, `【退货风险排行·全部${(data.returnRanking || []).length}人】\n${returnRankLines || '暂无'}`, `【下钻商品证据·${scopeLabel}${searchLabel},共${productScope.length}个】\n${productLines || '暂无'}`, `【证据链约束】输出结论必须引用[D]/[M]/[RM]/[RR]/[P]证据编号;优先使用统一问题归因与退货原因矩阵中的责任角色与置信度;${sampleWarnings.length ? sampleWarnings.join(';') : '当前样本量可支持方向性判断'}` ].join('\n') + '\n\n请基于以上完整数据输出设计师表现诊断:①高风险设计师及核心原因 ②优先处理的商品/问题 ③设计、运营、开发协同建议。控制在400字内。'; } get aiSystemPrompt(): string { return '你是一名亚马逊跨境电商VOC与退货风险分析专家,擅长从设计师、运营负责人和商品维度定位产品问题。请用中文、Markdown格式输出,结论要可执行,并引用用户提供的证据编号。'; } selectDesigner(designerName: string): void { this.selectedDesigner = this.selectedDesigner === designerName ? '' : designerName; this.productCurrentPage = 1; this.refreshProductView(); } clearDesignerSelection(): void { this.selectedDesigner = ''; this.productCurrentPage = 1; this.refreshProductView(); } onProductSearchChange(value: string): void { this.productSearch = value; this.productCurrentPage = 1; this.refreshProductView(); } onProductPageSizeChange(value: string): void { const nextSize = Number(value); if (!Number.isFinite(nextSize) || nextSize <= 0) return; this.productPageSize = nextSize; this.productCurrentPage = 1; this.refreshProductView(); } onProductPageChange(page: number): void { const nextPage = Math.max(1, Math.min(this.productTotalPages, page)); if (nextPage === this.productCurrentPage) return; this.productCurrentPage = nextPage; this.refreshProductView(); } setProductSort(key: DesignerProductSortKey): void { if (this.productSortKey === key) { this.productSortDir = this.productSortDir === 'desc' ? 'asc' : 'desc'; this.productCurrentPage = 1; this.refreshProductView(); return; } this.productSortKey = key; this.productSortDir = key === 'rating' ? 'asc' : 'desc'; this.productCurrentPage = 1; this.refreshProductView(); } getProductSortIndicator(key: DesignerProductSortKey): string { if (this.productSortKey !== key) return ''; return this.productSortDir === 'desc' ? '↓' : '↑'; } getStarDistributionDesc(distribution: DesignerProductDrilldownItem['starDistribution']): DesignerProductDrilldownItem['starDistribution'] { if (!distribution?.length) return []; const cached = this.starDistributionCache.get(distribution); if (cached) return cached; const sorted = [...distribution].sort((a, b) => b.star - a.star); this.starDistributionCache.set(distribution, sorted); return sorted; } getTotalStarCount(distribution: DesignerProductDrilldownItem['starDistribution']): number { if (!distribution?.length) return 0; const cached = this.starCountCache.get(distribution); if (cached !== undefined) return cached; const total = distribution.reduce((sum, item) => sum + item.count, 0); this.starCountCache.set(distribution, total); return total; } // ─── 页内 Tab 切换 ─── activeTab: DesignerPerformanceTabKey = 'ranking'; get pageTabs(): BoardTabItem[] { const critical = this.data?.summary.criticalRiskCount || 0; const key = `${this.dataRevision}|${critical}`; if (key === this.pageTabsCacheKey) return this.pageTabsCache; this.pageTabsCacheKey = key; this.pageTabsCache = [ { key: 'ranking', label: '风险排行', icon: '📉', badges: critical ? [{ label: critical, tone: 'red' }] : undefined }, { key: 'trend', label: '趋势图', icon: '📈' }, { key: 'issues', label: '问题分布', icon: '📊' }, { key: 'ai', label: 'AI分析', icon: '🤖' } ]; return this.pageTabsCache; } setActiveTab(key: string): void { this.activeTab = key as DesignerPerformanceTabKey; } openRiskAlert(): void { this.router.navigate(['/monitoring/risk-alert']); } openReturnAnalysis(): void { this.router.navigate(['/return-analysis/overview']); } getHealthBarWidth(score: number): number { return Math.max(4, Math.min(100, score)); } getRiskLevelLabel(level: 'high' | 'medium' | 'low'): string { const map = { high: '高风险', medium: '中风险', low: '低风险' }; return map[level]; } getIssueTaxonomyConfidenceLabel(confidence?: 'high' | 'medium' | 'low'): string { const map = { high: '高置信', medium: '中置信', low: '低置信' }; return confidence ? map[confidence] : '未识别'; } getProductRiskLabel(level: 'critical' | 'warning' | 'normal'): string { const map = { critical: '严重', warning: '预警', normal: '正常' }; return map[level]; } trackByDesigner(_: number, item: DesignerPerformanceRankingItem | DesignerIssueMatrixRow): string { return item.designerName; } trackByProduct(_: number, item: DesignerProductDrilldownItem): string { return item.asin; } private refreshProductView(): void { const products = this.getFilteredProducts(); this.filteredProducts = products; this.productTotalPages = Math.max(1, Math.ceil(products.length / this.productPageSize)); this.productDisplayPage = Math.max(1, Math.min(this.productCurrentPage, this.productTotalPages)); if (this.productCurrentPage !== this.productDisplayPage) { this.productCurrentPage = this.productDisplayPage; } const start = (this.productDisplayPage - 1) * this.productPageSize; this.visibleProducts = products.slice(start, start + this.productPageSize); this.productPageStart = products.length ? start + 1 : 0; this.productPageEnd = Math.min(start + this.productPageSize, products.length); } private sortProducts(products: DesignerProductDrilldownItem[]): DesignerProductDrilldownItem[] { const factor = this.productSortDir === 'desc' ? -1 : 1; const riskScore = { critical: 3, warning: 2, normal: 1 }; const valueOf = (product: DesignerProductDrilldownItem): number => { if (this.productSortKey === 'rating') return Number(product.rating || 0); if (this.productSortKey === 'negative') return Number(product.negativeRate || 0); if (this.productSortKey === 'return') return Number(product.returnRate || 0); return riskScore[product.riskLevel] || 0; }; return [...products].sort((a, b) => { const diff = valueOf(a) - valueOf(b); if (diff !== 0) return diff * factor; return b.negativeRate - a.negativeRate; }); } private normalizeFilters(filters: FilterOptions): DesignerPerformanceFilters { return { timeRange: filters.timeRange || '30days', category: filters.category, sku: filters.sku, store: filters.store, storeNames: filters.storeNames, site: filters.site, designerName: this.cleanDesignerFilterValues(filters.designerName), operatorName: filters.operatorName, developerName: filters.developerName, productSku: filters.productSku, sellerSku: filters.sellerSku, itemStatus: filters.itemStatus, fulfillmentType: filters.fulfillmentType, customDateRange: filters.customDateRange }; } private getFilterKey(filters: DesignerPerformanceFilters): string { const list = (values?: Array) => (values || []) .map(value => String(value || '').trim()) .filter(Boolean) .sort() .join('|'); const range = filters.customDateRange; return [ filters.timeRange || '30days', range?.start || '', range?.end || '', list(filters.category), list(filters.sku), list(filters.store), list(filters.storeNames), list(filters.site), list(filters.designerName), list(filters.operatorName), list(filters.developerName), list(filters.productSku), list(filters.sellerSku), list(filters.itemStatus), list(filters.fulfillmentType) ].join('::'); } private hasStoreScope(filters: DesignerPerformanceFilters): boolean { return !!(filters.store?.length || filters.storeNames?.length); } private buildDesignerFilterOptions(): string[] { const key = `${this.dataRevision}|${this.dimensionDesignerOptions.join('|')}`; if (key === this.designerFilterOptionsCacheKey) return this.designerFilterOptionsCache; this.designerFilterOptionsCacheKey = key; const scopedDesignerOptions = [ ...this.rankings.map(item => item.designerName), ...(this.data?.products || []).map(item => item.designerName), ...(this.data?.returnRanking || []).map(item => item.designerName) ].filter(name => this.isValidDesignerName(name)); const fallbackDesignerOptions = scopedDesignerOptions.length ? [] : this.dimensionDesignerOptions; this.designerFilterOptionsCache = Array.from(new Set([ ...scopedDesignerOptions, ...fallbackDesignerOptions, ...(this.currentFilters.designerName || []) ].filter(name => this.isValidDesignerName(name)))) .sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')); return this.designerFilterOptionsCache; } private loadDesignerOptions(): void { this.designerOptionsSub?.unsubscribe(); this.designerOptionsSub = this.productDimensionService.getDesignerOptions().subscribe(options => { this.dimensionDesignerOptions = options.filter(name => this.isValidDesignerName(name)); this.designerFilterOptionsCacheKey = ''; this.designerFilterOptions = this.buildDesignerFilterOptions(); }); } private cleanDesignerFilterValues(values?: string[]): string[] | undefined { if (!values?.length) return values; const cleaned = values.map(value => String(value || '').trim()).filter(value => this.isValidDesignerName(value)); return cleaned.length ? cleaned : undefined; } private isValidDesignerName(value: any): boolean { const text = String(value || '').trim(); if (!text) return false; const normalized = text.toLowerCase(); return !['-', '—', '–', '未分配', '未分配设计师', '未归属', '未归属设计师', 'unknown', 'null', 'undefined', 'n/a', 'na'].includes(normalized); } }