message-dashboard.component.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { HttpClient, HttpParams } from '@angular/common/http';
  4. import { Router } from '@angular/router';
  5. import { Observable, forkJoin } from 'rxjs';
  6. import { finalize, catchError, map } from 'rxjs/operators';
  7. import { of } from 'rxjs';
  8. // 导入国家代码转换库
  9. import * as countries from 'i18n-iso-countries';
  10. // 导入中文语言包
  11. import * as countriesZh from 'i18n-iso-countries/langs/zh.json';
  12. // 导入英文语言包(可选)
  13. import * as countriesEn from 'i18n-iso-countries/langs/en.json';
  14. // 注册语言包
  15. countries.registerLocale(countriesZh as any);
  16. countries.registerLocale(countriesEn as any);
  17. // NG-ZORRO 组件
  18. import { NzCardModule } from 'ng-zorro-antd/card';
  19. import { NzGridModule } from 'ng-zorro-antd/grid';
  20. import { NzStatisticModule } from 'ng-zorro-antd/statistic';
  21. import { NzTableModule } from 'ng-zorro-antd/table';
  22. import { NzTabsModule } from 'ng-zorro-antd/tabs';
  23. import { NzTagModule } from 'ng-zorro-antd/tag';
  24. import { NzSpinModule } from 'ng-zorro-antd/spin';
  25. import { NzMessageService } from 'ng-zorro-antd/message';
  26. import { NzDividerModule } from 'ng-zorro-antd/divider';
  27. import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
  28. import { FormsModule } from '@angular/forms';
  29. import { NzSelectModule } from 'ng-zorro-antd/select';
  30. import { NzButtonModule } from 'ng-zorro-antd/button';
  31. import { NzIconModule } from 'ng-zorro-antd/icon';
  32. import { NzEmptyModule } from 'ng-zorro-antd/empty';
  33. import { NzTooltipModule } from 'ng-zorro-antd/tooltip';
  34. import { NzProgressModule } from 'ng-zorro-antd/progress';
  35. import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
  36. import { NzImageModule } from 'ng-zorro-antd/image';
  37. // Chart.js
  38. import { BaseChartDirective } from 'ng2-charts';
  39. import {
  40. Chart,
  41. ChartConfiguration,
  42. ChartOptions,
  43. registerables,
  44. } from 'chart.js';
  45. @Component({
  46. selector: 'app-message-dashboard',
  47. standalone: true,
  48. imports: [
  49. CommonModule,
  50. NzCardModule,
  51. NzGridModule,
  52. NzStatisticModule,
  53. NzTableModule,
  54. NzTabsModule,
  55. NzTagModule,
  56. NzSpinModule,
  57. NzDividerModule,
  58. NzDatePickerModule,
  59. FormsModule,
  60. NzSelectModule,
  61. NzButtonModule,
  62. NzIconModule,
  63. NzEmptyModule,
  64. NzTooltipModule,
  65. NzProgressModule,
  66. NzImageModule,
  67. NzDescriptionsModule,
  68. BaseChartDirective,
  69. ],
  70. templateUrl: './message-dashboard.component.html',
  71. styleUrls: ['./message-dashboard.component.css'],
  72. })
  73. export class MessageDashboardComponent implements OnInit {
  74. overallLoading = false;
  75. strategyLoading = false;
  76. templateLoading = false;
  77. ccLoading = false;
  78. imageLoading = false;
  79. dailyTrendsLoading = false;
  80. chartLoading = false;
  81. overallStats: any = null;
  82. strategyStats: any[] = [];
  83. templateStats: any[] = [];
  84. ccStats: any[] = [];
  85. imageStats: any[] = [];
  86. dailyTrends: any[] = [];
  87. avgDeliveryTime: any = null;
  88. activeTab: number = 0;
  89. // 排序相关属性
  90. strategySortField: string | null = null;
  91. strategySortDirection: 'ascend' | 'descend' | null = null;
  92. templateSortField: string | null = null;
  93. templateSortDirection: 'ascend' | 'descend' | null = null;
  94. ccSortField: string | null = null;
  95. ccSortDirection: 'ascend' | 'descend' | null = null;
  96. imageSortField: string | null = null;
  97. imageSortDirection: 'ascend' | 'descend' | null = null;
  98. // 日期范围
  99. dateRange: Date[] = [];
  100. strategies: string[] = []; // 存储所有策略名称
  101. selectedStrategy: string = ''; // 当前选中的策略
  102. private summaryRequestInFlight = false;
  103. private lastSummaryRequestKey = '';
  104. // 组合图表配置
  105. public combinedChartData: ChartConfiguration<'bar' | 'line'>['data'] = {
  106. labels: [],
  107. datasets: [
  108. {
  109. label: '总发送量',
  110. data: [],
  111. backgroundColor: '#1890ff',
  112. yAxisID: 'y',
  113. },
  114. {
  115. label: '成功发送',
  116. data: [],
  117. backgroundColor: '#52c41a',
  118. yAxisID: 'y',
  119. },
  120. {
  121. label: '已送达',
  122. data: [],
  123. backgroundColor: '#13c2c2',
  124. yAxisID: 'y',
  125. },
  126. {
  127. label: '展示数',
  128. data: [],
  129. backgroundColor: '#faad14',
  130. yAxisID: 'y',
  131. },
  132. {
  133. label: '展示用户',
  134. data: [],
  135. backgroundColor: '#482880',
  136. yAxisID: 'y',
  137. },
  138. {
  139. label: '点击数',
  140. data: [],
  141. backgroundColor: '#722ed1',
  142. yAxisID: 'y',
  143. },
  144. {
  145. label: '点击用户',
  146. data: [],
  147. backgroundColor: '#0066CC',
  148. yAxisID: 'y',
  149. },
  150. // 折线图数据集
  151. {
  152. label: '送达率',
  153. data: [],
  154. borderColor: '#13c2c2',
  155. backgroundColor: 'transparent',
  156. yAxisID: 'y1',
  157. type: 'line',
  158. tension: 0.3,
  159. borderWidth: 2,
  160. pointRadius: 4,
  161. pointHoverRadius: 6,
  162. },
  163. {
  164. label: '展示率',
  165. data: [],
  166. borderColor: '#faad14',
  167. backgroundColor: 'transparent',
  168. yAxisID: 'y1',
  169. type: 'line',
  170. tension: 0.3,
  171. borderWidth: 2,
  172. pointRadius: 4,
  173. pointHoverRadius: 6,
  174. },
  175. {
  176. label: '点击率',
  177. data: [],
  178. borderColor: '#722ed1',
  179. backgroundColor: 'transparent',
  180. yAxisID: 'y1',
  181. type: 'line',
  182. tension: 0.3,
  183. borderWidth: 2,
  184. pointRadius: 4,
  185. pointHoverRadius: 6,
  186. },
  187. {
  188. label: '用户点击率',
  189. data: [],
  190. borderColor: '#fb56fb',
  191. backgroundColor: 'transparent',
  192. yAxisID: 'y1',
  193. type: 'line',
  194. tension: 0.3,
  195. borderWidth: 2,
  196. pointRadius: 4,
  197. pointHoverRadius: 6,
  198. },
  199. ],
  200. };
  201. public combinedChartOptions: ChartOptions<'bar' | 'line'> = {
  202. responsive: true,
  203. maintainAspectRatio: false,
  204. plugins: {
  205. tooltip: {
  206. mode: 'index',
  207. intersect: false,
  208. callbacks: {
  209. label: (context) => {
  210. let label = context.dataset.label || '';
  211. if (label) {
  212. label += ': ';
  213. }
  214. // 如果是折线图(转化率),格式化显示两位小数并添加百分号
  215. if (context.datasetIndex >= 7) {
  216. // 假设6-8是折线图数据集
  217. const value = typeof context.raw === 'number' ? context.raw : 0;
  218. label += value.toFixed(2) + '%';
  219. } else {
  220. // 柱状图数据保持不变
  221. label += context.raw;
  222. }
  223. return label;
  224. },
  225. },
  226. },
  227. legend: {
  228. position: 'top',
  229. },
  230. },
  231. scales: {
  232. y: {
  233. type: 'linear',
  234. display: true,
  235. position: 'left',
  236. title: {
  237. display: true,
  238. text: '消息数量',
  239. },
  240. beginAtZero: true,
  241. },
  242. y1: {
  243. type: 'linear',
  244. display: true,
  245. position: 'right',
  246. title: {
  247. display: true,
  248. text: '百分比(%)',
  249. },
  250. min: 0,
  251. max: 100,
  252. grid: {
  253. drawOnChartArea: false,
  254. },
  255. ticks: {
  256. callback: (value) => {
  257. // 确保刻度值显示两位小数
  258. return typeof value === 'number' ? value.toFixed(2) + '%' : value;
  259. },
  260. },
  261. },
  262. },
  263. };
  264. constructor(
  265. private http: HttpClient,
  266. private message: NzMessageService,
  267. private router: Router,
  268. private cd: ChangeDetectorRef,
  269. ) {
  270. Chart.register(...registerables);
  271. }
  272. ngOnInit(): void {
  273. if (!this.dateRange || this.dateRange.length < 2) {
  274. const end = new Date();
  275. end.setHours(23, 59, 59, 999);
  276. const start = new Date(end);
  277. start.setDate(end.getDate() - 6);
  278. start.setHours(0, 0, 0, 0);
  279. this.dateRange = [start, end];
  280. }
  281. this.loadAllStatistics();
  282. this.loadStrategies();
  283. }
  284. // 格式化国家显示:国家名称(国家代码)
  285. formatCountry(cc: string): string {
  286. if (!cc) return '-';
  287. const code = cc.toUpperCase();
  288. const countryName =
  289. countries.getName(code, 'zh') ||
  290. countries.getName(code, 'en') ||
  291. '未知国家';
  292. return `${countryName}(${code})`;
  293. }
  294. // 获取策略列表
  295. private loadStrategies(): void {
  296. this.http
  297. .get('/api/message-strategies')
  298. .pipe(
  299. map((res: any) => res || []),
  300. catchError((err) => {
  301. console.error('Failed to load strategies:', err);
  302. this.message.error('加载策略列表失败');
  303. return of([]);
  304. }),
  305. )
  306. .subscribe((data) => {
  307. this.strategies = data.map((item: any) => item.name).filter(Boolean);
  308. });
  309. }
  310. loadAllStatistics(): void {
  311. if (!this.isDateRangeValid()) {
  312. return;
  313. }
  314. // 仅重置当前激活标签页的数据
  315. if (this.activeTab === 0) this.strategyStats = [];
  316. if (this.activeTab === 1) this.templateStats = [];
  317. if (this.activeTab === 2) this.ccStats = [];
  318. if (this.activeTab === 3) this.imageStats = [];
  319. const params = new HttpParams()
  320. .set(
  321. 'startDate',
  322. this.dateRange[0] ? this.formatDateForApi(this.dateRange[0]) : '',
  323. )
  324. .set(
  325. 'endDate',
  326. this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : '',
  327. )
  328. .set('strategyName', this.selectedStrategy || '')
  329. .set('page', '1')
  330. .set('limit', '50');
  331. const summaryRequestKey = params.toString();
  332. if (
  333. this.summaryRequestInFlight &&
  334. this.lastSummaryRequestKey === summaryRequestKey
  335. ) {
  336. return;
  337. }
  338. // 首屏汇总数据:overall + dailyTrends + strategy(默认第一页)
  339. this.overallLoading = true;
  340. this.chartLoading = true;
  341. this.dailyTrendsLoading = true;
  342. this.strategyLoading = this.activeTab === 0;
  343. this.summaryRequestInFlight = true;
  344. this.lastSummaryRequestKey = summaryRequestKey;
  345. this.http
  346. .get(`/api/message/statistics/summary`, { params })
  347. .pipe(
  348. map((res: any) => res?.data || null),
  349. catchError((err) => {
  350. console.error('Failed to load summary statistics:', err);
  351. this.message.error('加载汇总统计失败');
  352. return of(null);
  353. }),
  354. finalize(() => {
  355. this.overallLoading = false;
  356. this.chartLoading = false;
  357. this.dailyTrendsLoading = false;
  358. this.strategyLoading = false;
  359. this.summaryRequestInFlight = false;
  360. }),
  361. )
  362. .subscribe((summary) => {
  363. if (!summary) {
  364. this.overallStats = null;
  365. this.dailyTrends = [];
  366. this.strategyStats = [];
  367. this.updateChartData();
  368. return;
  369. }
  370. this.overallStats = summary.overall || null;
  371. this.dailyTrends = summary.dailyTrends || [];
  372. this.updateChartData();
  373. if (this.activeTab === 0) {
  374. const strategyData = Array.isArray(summary.strategyStats)
  375. ? summary.strategyStats
  376. : [];
  377. this.strategyStats = strategyData.map((item: any) => ({
  378. ...item,
  379. expanded: false,
  380. dailyData: null,
  381. loading: false,
  382. }));
  383. }
  384. });
  385. // 非策略页签在首次加载时再单独拉取对应数据
  386. if (this.activeTab !== 0) {
  387. this.loadActiveTabData(params);
  388. }
  389. }
  390. // 加载当前激活标签页的数据
  391. loadActiveTabData(params: HttpParams): void {
  392. switch (this.activeTab) {
  393. case 0: // 策略统计
  394. this.loadStrategyData(params);
  395. break;
  396. case 1: // 模板统计
  397. this.loadTemplateData(params);
  398. break;
  399. case 2: // 国家统计
  400. this.loadCcData(params);
  401. break;
  402. case 3: // 图片统计
  403. this.loadImageData(params);
  404. break;
  405. }
  406. }
  407. // 拆分各标签页数据加载方法
  408. private loadStrategyData(params: HttpParams): void {
  409. this.strategyLoading = true;
  410. this.http
  411. .get(`/api/message/statistics/by-strategy`, { params })
  412. .pipe(
  413. map((res: any) => res?.data || []),
  414. catchError((err) => {
  415. console.error('Failed to load strategy statistics:', err);
  416. this.message.error('加载策略统计失败');
  417. return of([]);
  418. }),
  419. finalize(() => (this.strategyLoading = false)),
  420. )
  421. .subscribe((data) => {
  422. this.strategyStats = data.map((item: any) => ({
  423. ...item,
  424. expanded: false,
  425. dailyData: null,
  426. loading: false,
  427. }));
  428. });
  429. }
  430. private loadTemplateData(params: HttpParams): void {
  431. this.templateLoading = true;
  432. this.http
  433. .get(`/api/message/statistics/by-template`, { params })
  434. .pipe(
  435. map((res: any) => res?.data || []),
  436. catchError((err) => {
  437. console.error('Failed to load template statistics:', err);
  438. this.message.error('加载模板统计失败');
  439. return of([]);
  440. }),
  441. finalize(() => (this.templateLoading = false)),
  442. )
  443. .subscribe((data) => {
  444. this.templateStats = data.map((item: any) => ({
  445. ...item,
  446. expanded: false,
  447. dailyData: null,
  448. loading: false,
  449. }));
  450. });
  451. }
  452. private loadCcData(params: HttpParams): void {
  453. this.ccLoading = true;
  454. this.http
  455. .get(`/api/message/statistics/by-cc`, { params })
  456. .pipe(
  457. map((res: any) => res?.data || []),
  458. catchError((err) => {
  459. console.error('Failed to load cc statistics:', err);
  460. this.message.error('加载国家统计失败');
  461. return of([]);
  462. }),
  463. finalize(() => (this.ccLoading = false)),
  464. )
  465. .subscribe((data) => {
  466. this.ccStats = data.map((item: any) => ({
  467. ...item,
  468. expanded: false,
  469. dailyData: null,
  470. loading: false,
  471. }));
  472. });
  473. }
  474. private loadImageData(params: HttpParams): void {
  475. this.imageLoading = true;
  476. this.http
  477. .get(`/api/message/statistics/by-image`, { params })
  478. .pipe(
  479. map((res: any) => res?.data || []),
  480. catchError((err) => {
  481. console.error('Failed to load image statistics:', err);
  482. this.message.error('加载图片统计失败');
  483. return of([]);
  484. }),
  485. finalize(() => (this.imageLoading = false)),
  486. )
  487. .subscribe((data) => {
  488. this.imageStats = data.map((item: any) => ({
  489. ...item,
  490. expanded: false,
  491. dailyData: null,
  492. loading: false,
  493. }));
  494. });
  495. }
  496. // 添加标签页切换事件处理
  497. onTabChange(index: number): void {
  498. this.activeTab = index;
  499. // 当切换到新标签页时加载对应数据
  500. const params = new HttpParams()
  501. .set(
  502. 'startDate',
  503. this.dateRange[0] ? this.formatDateForApi(this.dateRange[0]) : '',
  504. )
  505. .set(
  506. 'endDate',
  507. this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : '',
  508. )
  509. .set('strategyName', this.selectedStrategy || '');
  510. this.loadActiveTabData(params);
  511. }
  512. // 验证日期范围是否有效
  513. private isDateRangeValid(): boolean {
  514. // 如果只选择了一个日期或未选择日期,视为有效
  515. if (
  516. !this.dateRange ||
  517. this.dateRange.length < 2 ||
  518. !this.dateRange[0] ||
  519. !this.dateRange[1]
  520. ) {
  521. return true;
  522. }
  523. // 比较开始日期和结束日期
  524. const startDate = new Date(this.dateRange[0]);
  525. const endDate = new Date(this.dateRange[1]);
  526. // 清除时间部分,只比较日期
  527. startDate.setHours(0, 0, 0, 0);
  528. endDate.setHours(0, 0, 0, 0);
  529. if (endDate < startDate) {
  530. this.message.error('结束日期不能早于开始日期,请重新选择');
  531. return false;
  532. }
  533. return true;
  534. }
  535. // 处理日期范围变化
  536. onDateRangeChange(dateRange: Date[]): void {
  537. this.dateRange = dateRange;
  538. // 当日期范围变化时自动验证并刷新数据
  539. if (this.isDateRangeValid()) {
  540. this.loadAllStatistics();
  541. }
  542. }
  543. // 为API格式化日期为ISO字符串(YYYY-MM-DD)
  544. private formatDateForApi(date: Date): string {
  545. return date.toISOString().split('T')[0];
  546. }
  547. // private formatDateForApi(date: Date): string {
  548. // // 手动拼接年月日,确保使用本地日期
  549. // const year = date.getFullYear();
  550. // const month = String(date.getMonth() + 1).padStart(2, '0');
  551. // const day = String(date.getDate()).padStart(2, '0');
  552. // return `${year}-${month}-${day}`;
  553. // }
  554. // 排序方法
  555. sortData(
  556. data: any[],
  557. field: string,
  558. currentSortField: string | null,
  559. currentSortDirection: 'ascend' | 'descend' | null,
  560. ): {
  561. sortedData: any[];
  562. newSortField: string;
  563. newSortDirection: 'ascend' | 'descend' | null;
  564. } {
  565. // 复制数据以避免直接修改原始数组
  566. const sortedData = [...data];
  567. // 确定新的排序方向
  568. let newSortDirection: 'ascend' | 'descend' | null = 'ascend';
  569. if (currentSortField === field) {
  570. if (currentSortDirection === 'ascend') {
  571. newSortDirection = 'descend';
  572. } else if (currentSortDirection === 'descend') {
  573. newSortDirection = null;
  574. return { sortedData: data, newSortField: field, newSortDirection };
  575. }
  576. }
  577. // 执行排序
  578. sortedData.sort((a, b) => {
  579. const valueA = a[field] ?? 0;
  580. const valueB = b[field] ?? 0;
  581. // 处理数字比较
  582. if (typeof valueA === 'number' && typeof valueB === 'number') {
  583. return newSortDirection === 'ascend'
  584. ? valueA - valueB
  585. : valueB - valueA;
  586. }
  587. // 处理字符串比较
  588. const strA = String(valueA).toLowerCase();
  589. const strB = String(valueB).toLowerCase();
  590. return newSortDirection === 'ascend'
  591. ? strA.localeCompare(strB)
  592. : strB.localeCompare(strA);
  593. });
  594. return { sortedData, newSortField: field, newSortDirection };
  595. }
  596. // 策略表格排序
  597. sortStrategyTable(field: string): void {
  598. const result = this.sortData(
  599. this.strategyStats,
  600. field,
  601. this.strategySortField,
  602. this.strategySortDirection,
  603. );
  604. this.strategyStats = result.sortedData;
  605. this.strategySortField = result.newSortField;
  606. this.strategySortDirection = result.newSortDirection;
  607. }
  608. // 模板表格排序
  609. sortTemplateTable(field: string): void {
  610. const result = this.sortData(
  611. this.templateStats,
  612. field,
  613. this.templateSortField,
  614. this.templateSortDirection,
  615. );
  616. this.templateStats = result.sortedData;
  617. this.templateSortField = result.newSortField;
  618. this.templateSortDirection = result.newSortDirection;
  619. }
  620. // 国家表格排序
  621. sortCcTable(field: string): void {
  622. const result = this.sortData(
  623. this.ccStats,
  624. field,
  625. this.ccSortField,
  626. this.ccSortDirection,
  627. );
  628. this.ccStats = result.sortedData;
  629. this.ccSortField = result.newSortField;
  630. this.ccSortDirection = result.newSortDirection;
  631. }
  632. // 图片表格排序
  633. sortImageTable(field: string): void {
  634. const result = this.sortData(
  635. this.imageStats,
  636. field,
  637. this.imageSortField,
  638. this.imageSortDirection,
  639. );
  640. this.imageStats = result.sortedData;
  641. this.imageSortField = result.newSortField;
  642. this.imageSortDirection = result.newSortDirection;
  643. }
  644. private updateChartData(): void {
  645. this.combinedChartData = {
  646. labels: this.dailyTrends.map((t) => this.formatDate(t.date)),
  647. datasets: [
  648. {
  649. ...this.combinedChartData.datasets[0],
  650. data: this.dailyTrends.map((t) => t.totalRecords || 0),
  651. },
  652. {
  653. ...this.combinedChartData.datasets[1],
  654. data: this.dailyTrends.map((t) => t.sent || 0),
  655. },
  656. {
  657. ...this.combinedChartData.datasets[2],
  658. data: this.dailyTrends.map((t) => t.delivered || 0),
  659. },
  660. {
  661. ...this.combinedChartData.datasets[3],
  662. data: this.dailyTrends.map((t) => t.displayCount || 0),
  663. },
  664. {
  665. ...this.combinedChartData.datasets[4],
  666. data: this.dailyTrends.map((t) => t.displayedUsers || 0),
  667. },
  668. {
  669. ...this.combinedChartData.datasets[5],
  670. data: this.dailyTrends.map((t) => t.opened || 0),
  671. },
  672. {
  673. ...this.combinedChartData.datasets[6],
  674. data: this.dailyTrends.map((t) => t.openedUsers || 0),
  675. },
  676. // 折线图数据
  677. {
  678. ...this.combinedChartData.datasets[7],
  679. data: this.dailyTrends.map((t) =>
  680. this.preciseRound((t.deliveredRate || 0) * 100, 2),
  681. ),
  682. },
  683. {
  684. ...this.combinedChartData.datasets[8],
  685. data: this.dailyTrends.map((t) =>
  686. this.preciseRound((t.displayRate || 0) * 100, 2),
  687. ),
  688. },
  689. {
  690. ...this.combinedChartData.datasets[9],
  691. data: this.dailyTrends.map((t) =>
  692. this.preciseRound((t.clickThroughRate || 0) * 100, 2),
  693. ),
  694. },
  695. {
  696. ...this.combinedChartData.datasets[10],
  697. data: this.dailyTrends.map((t) =>
  698. this.preciseRound((t.actualClickThroughRate || 0) * 100, 2),
  699. ),
  700. },
  701. ],
  702. };
  703. }
  704. refreshData(): void {
  705. this.loadAllStatistics();
  706. }
  707. navigateToStrategy(strategyName: string): void {
  708. this.router.navigate(['/message-strategy'], {
  709. queryParams: { strategyName: strategyName },
  710. });
  711. }
  712. navigateToTemplate(templateName: string): void {
  713. this.router.navigate(['/message-template'], {
  714. queryParams: { templateName: templateName },
  715. });
  716. }
  717. formatPercentage(value: number): string {
  718. return (value * 100).toFixed(2) + '%';
  719. }
  720. formatPercentageToNumber(value: number): number {
  721. return Number((value * 100).toFixed(2));
  722. }
  723. formatSeconds(seconds: number): string {
  724. if (seconds < 60) {
  725. return seconds.toFixed(2) + '秒';
  726. } else {
  727. return (seconds / 60).toFixed(2) + '分钟';
  728. }
  729. }
  730. preciseRound(num: number, decimalPlaces: number): number {
  731. if (decimalPlaces === 0) return Math.round(num);
  732. const multiplier = Math.pow(10, decimalPlaces);
  733. return Number(Math.round(num * multiplier) / multiplier);
  734. }
  735. public formatDate(date: string | null): string {
  736. if (!date) return '未知日期';
  737. return new Date(date).toLocaleDateString();
  738. }
  739. // 展开/折叠行并加载数据
  740. toggleExpand(
  741. element: any,
  742. type: 'strategy' | 'template' | 'cc' | 'image',
  743. ): void {
  744. element.expanded = !element.expanded;
  745. // 如果展开且没有加载过数据,则加载
  746. if (element.expanded && !element.dailyData && !element.loading) {
  747. this.loadDailyData(element, type);
  748. }
  749. }
  750. // 加载每日数据
  751. private loadDailyData(element: any, type: string): void {
  752. // 如果已有请求,先取消
  753. if (element.subscription) {
  754. element.subscription.unsubscribe();
  755. }
  756. element.loading = true;
  757. let url = '';
  758. const params = new HttpParams()
  759. .set(
  760. 'startDate',
  761. this.dateRange[0] ? this.formatDateForApi(this.dateRange[0]) : '',
  762. )
  763. .set(
  764. 'endDate',
  765. this.dateRange[1] ? this.formatDateForApi(this.dateRange[1]) : '',
  766. )
  767. .set('strategyName', this.selectedStrategy || '');
  768. // 根据类型构建请求URL
  769. switch (type) {
  770. case 'strategy':
  771. url = `/api/message/daily/trends/by-strategy/${encodeURIComponent(
  772. element.strategyName,
  773. )}`;
  774. break;
  775. case 'template':
  776. url = `/api/message/daily/trends/by-template/${encodeURIComponent(
  777. element.templateName,
  778. )}`;
  779. break;
  780. case 'cc':
  781. url = `/api/message/daily/trends/by-cc/${encodeURIComponent(
  782. element.cc,
  783. )}`;
  784. break;
  785. case 'image':
  786. url = `/api/message/daily/trends/by-image/${encodeURIComponent(
  787. element.image,
  788. )}`;
  789. break;
  790. default:
  791. element.loading = false;
  792. return;
  793. }
  794. // 保存订阅以便取消
  795. element.subscription = this.http
  796. .get(url, { params })
  797. .pipe(
  798. map((res: any) => res?.data || []),
  799. catchError((err) => {
  800. console.error(`Error loading daily data for ${type}:`, err);
  801. this.message.error(`加载每日数据失败`);
  802. return of([]);
  803. }),
  804. finalize(() => {
  805. element.loading = false;
  806. element.subscription = null;
  807. }),
  808. )
  809. .subscribe((data) => {
  810. element.dailyData = data;
  811. });
  812. }
  813. }