import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Router } from '@angular/router'; import { Observable, forkJoin } from 'rxjs'; import { finalize, catchError, map } from 'rxjs/operators'; import { of } from 'rxjs'; // 导入国家代码转换库 import * as countries from 'i18n-iso-countries'; // 导入中文语言包 import * as countriesZh from 'i18n-iso-countries/langs/zh.json'; // 导入英文语言包(可选) import * as countriesEn from 'i18n-iso-countries/langs/en.json'; // 注册语言包 countries.registerLocale(countriesZh as any); countries.registerLocale(countriesEn as any); // NG-ZORRO 组件 import { NzCardModule } from 'ng-zorro-antd/card'; import { NzGridModule } from 'ng-zorro-antd/grid'; import { NzStatisticModule } from 'ng-zorro-antd/statistic'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzTabsModule } from 'ng-zorro-antd/tabs'; import { NzTagModule } from 'ng-zorro-antd/tag'; import { NzSpinModule } from 'ng-zorro-antd/spin'; import { NzMessageService } from 'ng-zorro-antd/message'; import { NzDividerModule } from 'ng-zorro-antd/divider'; import { NzDatePickerModule } from 'ng-zorro-antd/date-picker'; import { FormsModule } from '@angular/forms'; import { NzSelectModule } from 'ng-zorro-antd/select'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzEmptyModule } from 'ng-zorro-antd/empty'; import { NzTooltipModule } from 'ng-zorro-antd/tooltip'; import { NzProgressModule } from 'ng-zorro-antd/progress'; import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions'; import { NzImageModule } from 'ng-zorro-antd/image'; // Chart.js import { BaseChartDirective } from 'ng2-charts'; import { Chart, ChartConfiguration, ChartOptions, registerables, } from 'chart.js'; @Component({ selector: 'app-message-dashboard', standalone: true, imports: [ CommonModule, NzCardModule, NzGridModule, NzStatisticModule, NzTableModule, NzTabsModule, NzTagModule, NzSpinModule, NzDividerModule, NzDatePickerModule, FormsModule, NzSelectModule, NzButtonModule, NzIconModule, NzEmptyModule, NzTooltipModule, NzProgressModule, NzImageModule, NzDescriptionsModule, BaseChartDirective, ], templateUrl: './message-dashboard.component.html', styleUrls: ['./message-dashboard.component.css'], }) export class MessageDashboardComponent implements OnInit { overallLoading = false; strategyLoading = false; templateLoading = false; ccLoading = false; imageLoading = false; dailyTrendsLoading = false; chartLoading = false; overallStats: any = null; strategyStats: any[] = []; templateStats: any[] = []; ccStats: any[] = []; imageStats: any[] = []; dailyTrends: any[] = []; avgDeliveryTime: any = null; activeTab: number = 0; // 排序相关属性 strategySortField: string | null = null; strategySortDirection: 'ascend' | 'descend' | null = null; templateSortField: string | null = null; templateSortDirection: 'ascend' | 'descend' | null = null; ccSortField: string | null = null; ccSortDirection: 'ascend' | 'descend' | null = null; imageSortField: string | null = null; imageSortDirection: 'ascend' | 'descend' | null = null; // 日期范围 dateRange: Date[] = []; strategies: string[] = []; // 存储所有策略名称 selectedStrategy: string = ''; // 当前选中的策略 private summaryRequestInFlight = false; private lastSummaryRequestKey = ''; // 组合图表配置 public combinedChartData: ChartConfiguration<'bar' | 'line'>['data'] = { labels: [], datasets: [ { label: '总发送量', data: [], backgroundColor: '#1890ff', yAxisID: 'y', }, { label: '成功发送', data: [], backgroundColor: '#52c41a', yAxisID: 'y', }, { label: '已送达', data: [], backgroundColor: '#13c2c2', yAxisID: 'y', }, { label: '展示数', data: [], backgroundColor: '#faad14', yAxisID: 'y', }, { label: '展示用户', data: [], backgroundColor: '#482880', yAxisID: 'y', }, { label: '点击数', data: [], backgroundColor: '#722ed1', yAxisID: 'y', }, { label: '点击用户', data: [], backgroundColor: '#0066CC', yAxisID: 'y', }, // 折线图数据集 { label: '送达率', data: [], borderColor: '#13c2c2', backgroundColor: 'transparent', yAxisID: 'y1', type: 'line', tension: 0.3, borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, }, { label: '展示率', data: [], borderColor: '#faad14', backgroundColor: 'transparent', yAxisID: 'y1', type: 'line', tension: 0.3, borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, }, { label: '点击率', data: [], borderColor: '#722ed1', backgroundColor: 'transparent', yAxisID: 'y1', type: 'line', tension: 0.3, borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, }, { label: '用户点击率', data: [], borderColor: '#fb56fb', backgroundColor: 'transparent', yAxisID: 'y1', type: 'line', tension: 0.3, borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, }, ], }; public combinedChartOptions: ChartOptions<'bar' | 'line'> = { responsive: true, maintainAspectRatio: false, plugins: { tooltip: { mode: 'index', intersect: false, callbacks: { label: (context) => { let label = context.dataset.label || ''; if (label) { label += ': '; } // 如果是折线图(转化率),格式化显示两位小数并添加百分号 if (context.datasetIndex >= 7) { // 假设6-8是折线图数据集 const value = typeof context.raw === 'number' ? context.raw : 0; label += value.toFixed(2) + '%'; } else { // 柱状图数据保持不变 label += context.raw; } return label; }, }, }, legend: { position: 'top', }, }, scales: { y: { type: 'linear', display: true, position: 'left', title: { display: true, text: '消息数量', }, beginAtZero: true, }, y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: '百分比(%)', }, min: 0, max: 100, grid: { drawOnChartArea: false, }, ticks: { callback: (value) => { // 确保刻度值显示两位小数 return typeof value === 'number' ? value.toFixed(2) + '%' : value; }, }, }, }, }; constructor( private http: HttpClient, private message: NzMessageService, private router: Router, private cd: ChangeDetectorRef, ) { Chart.register(...registerables); } ngOnInit(): void { if (!this.dateRange || this.dateRange.length < 2) { const end = new Date(); end.setHours(23, 59, 59, 999); const start = new Date(end); start.setDate(end.getDate() - 6); start.setHours(0, 0, 0, 0); this.dateRange = [start, end]; } this.loadAllStatistics(); this.loadStrategies(); } // 格式化国家显示:国家名称(国家代码) formatCountry(cc: string): string { if (!cc) return '-'; const code = cc.toUpperCase(); const countryName = countries.getName(code, 'zh') || countries.getName(code, 'en') || '未知国家'; return `${countryName}(${code})`; } // 获取策略列表 private loadStrategies(): void { this.http .get('/api/message-strategies') .pipe( map((res: any) => res || []), catchError((err) => { console.error('Failed to load strategies:', err); this.message.error('加载策略列表失败'); return of([]); }), ) .subscribe((data) => { this.strategies = data.map((item: any) => item.name).filter(Boolean); }); } loadAllStatistics(): void { if (!this.isDateRangeValid()) { return; } // 仅重置当前激活标签页的数据 if (this.activeTab === 0) this.strategyStats = []; if (this.activeTab === 1) this.templateStats = []; if (this.activeTab === 2) this.ccStats = []; if (this.activeTab === 3) this.imageStats = []; const params = new HttpParams() .set( 'startDate', this.dateRange[0] ? this.formatDateForApi(this.dateRange[0]) : '', ) .set( 'endDate', this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : '', ) .set('strategyName', this.selectedStrategy || '') .set('page', '1') .set('limit', '50'); const summaryRequestKey = params.toString(); if ( this.summaryRequestInFlight && this.lastSummaryRequestKey === summaryRequestKey ) { return; } // 首屏汇总数据:overall + dailyTrends + strategy(默认第一页) this.overallLoading = true; this.chartLoading = true; this.dailyTrendsLoading = true; this.strategyLoading = this.activeTab === 0; this.summaryRequestInFlight = true; this.lastSummaryRequestKey = summaryRequestKey; this.http .get(`/api/message/statistics/summary`, { params }) .pipe( map((res: any) => res?.data || null), catchError((err) => { console.error('Failed to load summary statistics:', err); this.message.error('加载汇总统计失败'); return of(null); }), finalize(() => { this.overallLoading = false; this.chartLoading = false; this.dailyTrendsLoading = false; this.strategyLoading = false; this.summaryRequestInFlight = false; }), ) .subscribe((summary) => { if (!summary) { this.overallStats = null; this.dailyTrends = []; this.strategyStats = []; this.updateChartData(); return; } this.overallStats = summary.overall || null; this.dailyTrends = summary.dailyTrends || []; this.updateChartData(); if (this.activeTab === 0) { const strategyData = Array.isArray(summary.strategyStats) ? summary.strategyStats : []; this.strategyStats = strategyData.map((item: any) => ({ ...item, expanded: false, dailyData: null, loading: false, })); } }); // 非策略页签在首次加载时再单独拉取对应数据 if (this.activeTab !== 0) { this.loadActiveTabData(params); } } // 加载当前激活标签页的数据 loadActiveTabData(params: HttpParams): void { switch (this.activeTab) { case 0: // 策略统计 this.loadStrategyData(params); break; case 1: // 模板统计 this.loadTemplateData(params); break; case 2: // 国家统计 this.loadCcData(params); break; case 3: // 图片统计 this.loadImageData(params); break; } } // 拆分各标签页数据加载方法 private loadStrategyData(params: HttpParams): void { this.strategyLoading = true; this.http .get(`/api/message/statistics/by-strategy`, { params }) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load strategy statistics:', err); this.message.error('加载策略统计失败'); return of([]); }), finalize(() => (this.strategyLoading = false)), ) .subscribe((data) => { this.strategyStats = data.map((item: any) => ({ ...item, expanded: false, dailyData: null, loading: false, })); }); } private loadTemplateData(params: HttpParams): void { this.templateLoading = true; this.http .get(`/api/message/statistics/by-template`, { params }) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load template statistics:', err); this.message.error('加载模板统计失败'); return of([]); }), finalize(() => (this.templateLoading = false)), ) .subscribe((data) => { this.templateStats = data.map((item: any) => ({ ...item, expanded: false, dailyData: null, loading: false, })); }); } private loadCcData(params: HttpParams): void { this.ccLoading = true; this.http .get(`/api/message/statistics/by-cc`, { params }) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load cc statistics:', err); this.message.error('加载国家统计失败'); return of([]); }), finalize(() => (this.ccLoading = false)), ) .subscribe((data) => { this.ccStats = data.map((item: any) => ({ ...item, expanded: false, dailyData: null, loading: false, })); }); } private loadImageData(params: HttpParams): void { this.imageLoading = true; this.http .get(`/api/message/statistics/by-image`, { params }) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load image statistics:', err); this.message.error('加载图片统计失败'); return of([]); }), finalize(() => (this.imageLoading = false)), ) .subscribe((data) => { this.imageStats = data.map((item: any) => ({ ...item, expanded: false, dailyData: null, loading: false, })); }); } // 添加标签页切换事件处理 onTabChange(index: number): void { this.activeTab = index; // 当切换到新标签页时加载对应数据 const params = new HttpParams() .set( 'startDate', this.dateRange[0] ? this.formatDateForApi(this.dateRange[0]) : '', ) .set( 'endDate', this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : '', ) .set('strategyName', this.selectedStrategy || ''); this.loadActiveTabData(params); } // 验证日期范围是否有效 private isDateRangeValid(): boolean { // 如果只选择了一个日期或未选择日期,视为有效 if ( !this.dateRange || this.dateRange.length < 2 || !this.dateRange[0] || !this.dateRange[1] ) { return true; } // 比较开始日期和结束日期 const startDate = new Date(this.dateRange[0]); const endDate = new Date(this.dateRange[1]); // 清除时间部分,只比较日期 startDate.setHours(0, 0, 0, 0); endDate.setHours(0, 0, 0, 0); if (endDate < startDate) { this.message.error('结束日期不能早于开始日期,请重新选择'); return false; } return true; } // 处理日期范围变化 onDateRangeChange(dateRange: Date[]): void { this.dateRange = dateRange; // 当日期范围变化时自动验证并刷新数据 if (this.isDateRangeValid()) { this.loadAllStatistics(); } } // 为API格式化日期为ISO字符串(YYYY-MM-DD) private formatDateForApi(date: Date): string { return date.toISOString().split('T')[0]; } // private formatDateForApi(date: Date): string { // // 手动拼接年月日,确保使用本地日期 // const year = date.getFullYear(); // const month = String(date.getMonth() + 1).padStart(2, '0'); // const day = String(date.getDate()).padStart(2, '0'); // return `${year}-${month}-${day}`; // } // 排序方法 sortData( data: any[], field: string, currentSortField: string | null, currentSortDirection: 'ascend' | 'descend' | null, ): { sortedData: any[]; newSortField: string; newSortDirection: 'ascend' | 'descend' | null; } { // 复制数据以避免直接修改原始数组 const sortedData = [...data]; // 确定新的排序方向 let newSortDirection: 'ascend' | 'descend' | null = 'ascend'; if (currentSortField === field) { if (currentSortDirection === 'ascend') { newSortDirection = 'descend'; } else if (currentSortDirection === 'descend') { newSortDirection = null; return { sortedData: data, newSortField: field, newSortDirection }; } } // 执行排序 sortedData.sort((a, b) => { const valueA = a[field] ?? 0; const valueB = b[field] ?? 0; // 处理数字比较 if (typeof valueA === 'number' && typeof valueB === 'number') { return newSortDirection === 'ascend' ? valueA - valueB : valueB - valueA; } // 处理字符串比较 const strA = String(valueA).toLowerCase(); const strB = String(valueB).toLowerCase(); return newSortDirection === 'ascend' ? strA.localeCompare(strB) : strB.localeCompare(strA); }); return { sortedData, newSortField: field, newSortDirection }; } // 策略表格排序 sortStrategyTable(field: string): void { const result = this.sortData( this.strategyStats, field, this.strategySortField, this.strategySortDirection, ); this.strategyStats = result.sortedData; this.strategySortField = result.newSortField; this.strategySortDirection = result.newSortDirection; } // 模板表格排序 sortTemplateTable(field: string): void { const result = this.sortData( this.templateStats, field, this.templateSortField, this.templateSortDirection, ); this.templateStats = result.sortedData; this.templateSortField = result.newSortField; this.templateSortDirection = result.newSortDirection; } // 国家表格排序 sortCcTable(field: string): void { const result = this.sortData( this.ccStats, field, this.ccSortField, this.ccSortDirection, ); this.ccStats = result.sortedData; this.ccSortField = result.newSortField; this.ccSortDirection = result.newSortDirection; } // 图片表格排序 sortImageTable(field: string): void { const result = this.sortData( this.imageStats, field, this.imageSortField, this.imageSortDirection, ); this.imageStats = result.sortedData; this.imageSortField = result.newSortField; this.imageSortDirection = result.newSortDirection; } private updateChartData(): void { this.combinedChartData = { labels: this.dailyTrends.map((t) => this.formatDate(t.date)), datasets: [ { ...this.combinedChartData.datasets[0], data: this.dailyTrends.map((t) => t.totalRecords || 0), }, { ...this.combinedChartData.datasets[1], data: this.dailyTrends.map((t) => t.sent || 0), }, { ...this.combinedChartData.datasets[2], data: this.dailyTrends.map((t) => t.delivered || 0), }, { ...this.combinedChartData.datasets[3], data: this.dailyTrends.map((t) => t.displayCount || 0), }, { ...this.combinedChartData.datasets[4], data: this.dailyTrends.map((t) => t.displayedUsers || 0), }, { ...this.combinedChartData.datasets[5], data: this.dailyTrends.map((t) => t.opened || 0), }, { ...this.combinedChartData.datasets[6], data: this.dailyTrends.map((t) => t.openedUsers || 0), }, // 折线图数据 { ...this.combinedChartData.datasets[7], data: this.dailyTrends.map((t) => this.preciseRound((t.deliveredRate || 0) * 100, 2), ), }, { ...this.combinedChartData.datasets[8], data: this.dailyTrends.map((t) => this.preciseRound((t.displayRate || 0) * 100, 2), ), }, { ...this.combinedChartData.datasets[9], data: this.dailyTrends.map((t) => this.preciseRound((t.clickThroughRate || 0) * 100, 2), ), }, { ...this.combinedChartData.datasets[10], data: this.dailyTrends.map((t) => this.preciseRound((t.actualClickThroughRate || 0) * 100, 2), ), }, ], }; } refreshData(): void { this.loadAllStatistics(); } navigateToStrategy(strategyName: string): void { this.router.navigate(['/message-strategy'], { queryParams: { strategyName: strategyName }, }); } navigateToTemplate(templateName: string): void { this.router.navigate(['/message-template'], { queryParams: { templateName: templateName }, }); } formatPercentage(value: number): string { return (value * 100).toFixed(2) + '%'; } formatPercentageToNumber(value: number): number { return Number((value * 100).toFixed(2)); } formatSeconds(seconds: number): string { if (seconds < 60) { return seconds.toFixed(2) + '秒'; } else { return (seconds / 60).toFixed(2) + '分钟'; } } preciseRound(num: number, decimalPlaces: number): number { if (decimalPlaces === 0) return Math.round(num); const multiplier = Math.pow(10, decimalPlaces); return Number(Math.round(num * multiplier) / multiplier); } public formatDate(date: string | null): string { if (!date) return '未知日期'; return new Date(date).toLocaleDateString(); } // 展开/折叠行并加载数据 toggleExpand( element: any, type: 'strategy' | 'template' | 'cc' | 'image', ): void { element.expanded = !element.expanded; // 如果展开且没有加载过数据,则加载 if (element.expanded && !element.dailyData && !element.loading) { this.loadDailyData(element, type); } } // 加载每日数据 private loadDailyData(element: any, type: string): void { // 如果已有请求,先取消 if (element.subscription) { element.subscription.unsubscribe(); } element.loading = true; let url = ''; const params = new HttpParams() .set( 'startDate', this.dateRange[0] ? this.formatDateForApi(this.dateRange[0]) : '', ) .set( 'endDate', this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : '', ) .set('strategyName', this.selectedStrategy || ''); // 根据类型构建请求URL switch (type) { case 'strategy': url = `/api/message/daily/trends/by-strategy/${encodeURIComponent( element.strategyName, )}`; break; case 'template': url = `/api/message/daily/trends/by-template/${encodeURIComponent( element.templateName, )}`; break; case 'cc': url = `/api/message/daily/trends/by-cc/${encodeURIComponent( element.cc, )}`; break; case 'image': url = `/api/message/daily/trends/by-image/${encodeURIComponent( element.image, )}`; break; default: element.loading = false; return; } // 保存订阅以便取消 element.subscription = this.http .get(url, { params }) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error(`Error loading daily data for ${type}:`, err); this.message.error(`加载每日数据失败`); return of([]); }), finalize(() => { element.loading = false; element.subscription = null; }), ) .subscribe((data) => { element.dailyData = data; }); } }