message-dashboard.component.ts 23 KB

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