import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { Router } from '@angular/router'; import { Observable, forkJoin } from 'rxjs'; // Import Observable, forkJoin import { finalize, catchError, map } from 'rxjs/operators'; // Import operators import { of } from 'rxjs'; // Import 'of' to create an Observable from a value // 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'; // 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, NzDescriptionsModule, DatePipe, BaseChartDirective, ], templateUrl: './message-dashboard.component.html', styleUrls: ['./message-dashboard.component.css'], }) export class MessageDashboardComponent implements OnInit { isLoading = false; overallStats: any = null; strategyStats: any[] = []; templateStats: any[] = []; dailyTrends: any[] = []; avgDeliveryTime: any = null; activeTab: number = 0; // 日期范围 dateRange: Date[] = []; // 组合图表配置 public combinedChartData: ChartConfiguration<'bar'>['data'] = { labels: [], datasets: [ { label: '总发送量', data: [], backgroundColor: '#1890ff', yAxisID: 'y', }, { label: '成功发送', data: [], backgroundColor: '#52c41a', yAxisID: 'y', }, { label: '已送达', data: [], backgroundColor: '#13c2c2', yAxisID: 'y', }, { label: '已打开', data: [], backgroundColor: '#722ed1', yAxisID: 'y', }, { label: '展示数', data: [], backgroundColor: '#faad14', yAxisID: 'y', }, { label: '失败数', data: [], backgroundColor: '#f5222d', yAxisID: 'y', }, ], }; public rateChartData: ChartConfiguration<'line'>['data'] = { labels: [], datasets: [ { label: '发送成功率', data: [], borderColor: '#1890ff', backgroundColor: 'rgba(24, 144, 255, 0.1)', yAxisID: 'y1', tension: 0.3, fill: true, }, { label: '送达率', data: [], borderColor: '#52c41a', backgroundColor: 'rgba(82, 196, 26, 0.1)', yAxisID: 'y1', tension: 0.3, fill: true, }, { label: '展示率', data: [], borderColor: '#faad14', backgroundColor: 'rgba(250, 173, 20, 0.1)', yAxisID: 'y1', tension: 0.3, fill: true, }, { label: '点击率', data: [], borderColor: '#722ed1', backgroundColor: 'rgba(114, 46, 209, 0.1)', yAxisID: 'y1', tension: 0.3, fill: true, }, ], }; public combinedChartOptions: ChartOptions<'bar' | 'line'> = { responsive: true, maintainAspectRatio: false, plugins: { tooltip: { mode: 'index', intersect: false, }, 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, }, }, }, }; constructor( private http: HttpClient, private message: NzMessageService, private router: Router, private cd: ChangeDetectorRef // 注入 ChangeDetectorRef ) { Chart.register(...registerables); } ngOnInit(): void { this.loadAllStatistics(); } loadAllStatistics(): void { this.isLoading = true; // Use forkJoin to run all requests in parallel and wait for all to complete forkJoin({ overall: this.http .get(`/api/message-records/statistics/overall?t=${new Date()}`) .pipe( map((res: any) => res?.data || null), catchError((err) => { console.error('Failed to load overall statistics:', err); this.message.error('加载整体统计失败'); return of(null); // Return a default value to prevent forkJoin from failing }) ), strategies: this.http .get(`/api/message-records/statistics/by-strategy?t=${new Date()}`) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load strategy statistics:', err); this.message.error('加载策略统计失败'); return of([]); }) ), templates: this.http .get(`/api/message-records/statistics/by-template?t=${new Date()}`) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load template statistics:', err); this.message.error('加载模板统计失败'); return of([]); }) ), dailyTrends: this.http .get(`/api/message-records/statistics/daily-trends?t=${new Date()}`) .pipe( map((res: any) => res?.data || []), catchError((err) => { console.error('Failed to load daily trends:', err); this.message.error('加载每日趋势失败'); return of([]); }) ), // avgTime: this.http // .get('/api/message-records/statistics/avg-delivery-time') // .pipe( // map((res: any) => res?.data || null), // catchError((err) => { // console.error('Failed to load average delivery time:', err); // this.message.error('加载平均送达时间失败'); // return of(null); // }) // ), }) .pipe( finalize(() => { this.isLoading = false; // 在所有数据都赋值完毕后,手动触发变更检测 this.cd.detectChanges(); }) ) .subscribe((results: any) => { // Assign data to component properties this.overallStats = results.overall; this.strategyStats = results.strategies; this.templateStats = results.templates; this.dailyTrends = results.dailyTrends; // this.avgDeliveryTime = results.avgTime; // Update charts based on the latest data this.updateChartData(); }); } 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.opened || 0), }, { ...this.combinedChartData.datasets[4], data: this.dailyTrends.map((t) => t.displayCount || 0), }, { ...this.combinedChartData.datasets[5], data: this.dailyTrends.map((t) => t.failed || 0), }, ], }; // 更新比率图表数据 this.rateChartData = { labels: this.dailyTrends.map((t) => this.formatDate(t.date)), datasets: [ { ...this.rateChartData.datasets[0], data: this.dailyTrends.map((t) => (t.sentSuccessRate || 0) * 100), }, { ...this.rateChartData.datasets[1], data: this.dailyTrends.map((t) => (t.deliveredRate || 0) * 100), }, { ...this.rateChartData.datasets[2], data: this.dailyTrends.map((t) => (t.displayRate || 0) * 100), }, { ...this.rateChartData.datasets[3], data: this.dailyTrends.map((t) => (t.clickThroughRate || 0) * 100), }, ], }; } 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); } private formatDate(date: string | null): string { if (!date) return '未知日期'; return new Date(date).toLocaleDateString(); } }