message-record.component.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. import { Component, OnInit, OnDestroy } from '@angular/core';
  2. import { CommonModule, DatePipe } from '@angular/common';
  3. import {
  4. FormsModule,
  5. ReactiveFormsModule,
  6. FormBuilder,
  7. FormGroup,
  8. Validators,
  9. } from '@angular/forms';
  10. import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
  11. import { Subscription } from 'rxjs';
  12. import { Router, ActivatedRoute } from '@angular/router';
  13. // NG-ZORRO 组件
  14. import { NzTableModule } from 'ng-zorro-antd/table';
  15. import { NzDividerModule } from 'ng-zorro-antd/divider';
  16. import { NzButtonModule } from 'ng-zorro-antd/button';
  17. import { NzIconModule } from 'ng-zorro-antd/icon';
  18. import { NzFormModule } from 'ng-zorro-antd/form';
  19. import { NzInputModule } from 'ng-zorro-antd/input';
  20. import { NzSelectModule } from 'ng-zorro-antd/select';
  21. import { NzDatePickerModule } from 'ng-zorro-antd/date-picker';
  22. import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
  23. import { NzCardModule } from 'ng-zorro-antd/card';
  24. import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
  25. import { NzTagModule } from 'ng-zorro-antd/tag';
  26. import { NzMessageService } from 'ng-zorro-antd/message';
  27. import { NzSpinModule } from 'ng-zorro-antd/spin';
  28. import { NzEmptyModule } from 'ng-zorro-antd/empty';
  29. import { NzPaginationModule } from 'ng-zorro-antd/pagination';
  30. import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal';
  31. import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
  32. import { MessageService, IMessageRecord } from '../services/message.service';
  33. import { MessageRecordDetailComponent } from './message-record-detail.component';
  34. import { UserDetailModalComponent } from './user-detail-modal.component';
  35. @Component({
  36. selector: 'app-message-record',
  37. standalone: true,
  38. imports: [
  39. CommonModule,
  40. FormsModule,
  41. ReactiveFormsModule,
  42. NzTableModule,
  43. NzDividerModule,
  44. NzButtonModule,
  45. NzIconModule,
  46. NzFormModule,
  47. NzInputModule,
  48. NzSelectModule,
  49. NzDatePickerModule,
  50. NzPageHeaderModule,
  51. NzCardModule,
  52. NzPopconfirmModule,
  53. NzTagModule,
  54. NzSpinModule,
  55. NzEmptyModule,
  56. NzPaginationModule,
  57. NzDescriptionsModule,
  58. NzModalModule,
  59. DatePipe,
  60. ],
  61. template: `
  62. <nz-page-header [nzGhost]="false">
  63. <nz-page-header-title>消息推送记录</nz-page-header-title>
  64. <nz-page-header-content>
  65. 查看和管理所有消息推送记录
  66. </nz-page-header-content>
  67. </nz-page-header>
  68. <nz-card>
  69. <form nz-form [formGroup]="filterForm" class="filter-form">
  70. <div nz-row [nzGutter]="16">
  71. <!-- 基本筛选条件 -->
  72. <div nz-col [nzSpan]="4">
  73. <nz-form-item>
  74. <nz-input-group nzPrefixIcon="user">
  75. <input nz-input placeholder="用户ID" formControlName="uid" />
  76. </nz-input-group>
  77. </nz-form-item>
  78. </div>
  79. <div nz-col [nzSpan]="4">
  80. <nz-form-item>
  81. <nz-input-group nzPrefixIcon="project">
  82. <input
  83. nz-input
  84. placeholder="活动"
  85. formControlName="activityName"
  86. />
  87. </nz-input-group>
  88. </nz-form-item>
  89. </div>
  90. <div nz-col [nzSpan]="4">
  91. <nz-form-item>
  92. <nz-input-group nzPrefixIcon="ant-design">
  93. <input
  94. nz-input
  95. placeholder="策略"
  96. formControlName="strategyName"
  97. />
  98. </nz-input-group>
  99. </nz-form-item>
  100. </div>
  101. <div nz-col [nzSpan]="4">
  102. <nz-form-item>
  103. <nz-input-group nzPrefixIcon="cluster">
  104. <input
  105. nz-input
  106. placeholder="模板"
  107. formControlName="templateName"
  108. />
  109. </nz-input-group>
  110. </nz-form-item>
  111. </div>
  112. <div nz-col [nzSpan]="4">
  113. <nz-form-item>
  114. <nz-select
  115. nzPlaceHolder="状态"
  116. formControlName="status"
  117. nzAllowClear
  118. >
  119. <nz-option nzValue="0" nzLabel="未发送"></nz-option>
  120. <nz-option nzValue="1" nzLabel="发送成功"></nz-option>
  121. <nz-option nzValue="2" nzLabel="已送达"></nz-option>
  122. <nz-option nzValue="3" nzLabel="已打开"></nz-option>
  123. <nz-option nzValue="-1" nzLabel="发送失败"></nz-option>
  124. </nz-select>
  125. </nz-form-item>
  126. </div>
  127. <div nz-col [nzSpan]="4" class="button-col">
  128. <button nz-button (click)="resetFilters()">重置</button>
  129. </div>
  130. </div>
  131. <!-- 日期筛选区域 -->
  132. <div nz-row [nzGutter]="16" style="margin-top: 16px;">
  133. <div nz-col [nzSpan]="6">
  134. <nz-form-item>
  135. <nz-range-picker
  136. formControlName="plannedSendAt"
  137. nzPlaceHolder="计划发送时间"
  138. style="width: 100%;"
  139. ></nz-range-picker>
  140. </nz-form-item>
  141. </div>
  142. <div nz-col [nzSpan]="6">
  143. <nz-form-item>
  144. <nz-range-picker
  145. formControlName="actualSendAt"
  146. nzPlaceHolder="实际发送时间"
  147. style="width: 100%;"
  148. ></nz-range-picker>
  149. </nz-form-item>
  150. </div>
  151. <div nz-col [nzSpan]="6">
  152. <nz-form-item>
  153. <nz-range-picker
  154. formControlName="deliveredAt"
  155. nzPlaceHolder="送达时间"
  156. style="width: 100%;"
  157. ></nz-range-picker>
  158. </nz-form-item>
  159. </div>
  160. <div nz-col [nzSpan]="6">
  161. <nz-form-item>
  162. <nz-range-picker
  163. formControlName="openedAt"
  164. nzPlaceHolder="打开时间"
  165. style="width: 100%;"
  166. ></nz-range-picker>
  167. </nz-form-item>
  168. </div>
  169. </div>
  170. </form>
  171. <nz-spin [nzSpinning]="isLoading">
  172. <nz-table
  173. #recordsTable
  174. [nzData]="records"
  175. [nzLoading]="isLoading"
  176. [nzFrontPagination]="false"
  177. [nzBordered]="true"
  178. [nzSize]="'small'"
  179. [nzShowPagination]="false"
  180. >
  181. <thead>
  182. <tr>
  183. <th>用户ID</th>
  184. <th>CC</th>
  185. <th>消息</th>
  186. <th>来源</th>
  187. <th>状态</th>
  188. <th>计划发送时间</th>
  189. <th>实际发送时间</th>
  190. <th>送达时间</th>
  191. <th>打开时间</th>
  192. <th>创建时间</th>
  193. <th>操作</th>
  194. </tr>
  195. </thead>
  196. <tbody>
  197. <tr *ngFor="let record of records">
  198. <td>
  199. <a (click)="showUserDetail(record.uid)">{{ record.uid }}</a>
  200. </td>
  201. <td>{{ record.cc }}</td>
  202. <td class="message-content-cell">
  203. <div class="message-title">
  204. {{ record.title }}
  205. </div>
  206. <div class="message-content">
  207. {{ record.content }}
  208. </div>
  209. </td>
  210. <td class="source-cell">
  211. @if(record.activityName) {
  212. <div class="source-item">
  213. <span class="source-label">活动:</span>
  214. <span class="source-value">{{ record.activityName }}</span>
  215. </div>
  216. } @if(record.strategyName) {
  217. <div class="source-item">
  218. <span class="source-label">策略:</span>
  219. <span class="source-value">{{ record.strategyName }}</span>
  220. </div>
  221. } @if(record.templateName) {
  222. <div class="source-item">
  223. <span class="source-label">模板:</span>
  224. <span class="source-value">{{ record.templateName }}</span>
  225. </div>
  226. }
  227. </td>
  228. <td>
  229. <nz-tag [nzColor]="getStatusColor(record.status)">
  230. {{ getStatusName(record.status) }}
  231. </nz-tag>
  232. </td>
  233. <td>{{ record.plannedSendAt | date : 'yyyy-MM-dd HH:mm' }}</td>
  234. <td>{{ record.actualSendAt | date : 'yyyy-MM-dd HH:mm' }}</td>
  235. <td>{{ record.deliveredAt | date : 'yyyy-MM-dd HH:mm' }}</td>
  236. <td>{{ record.openedAt | date : 'yyyy-MM-dd HH:mm' }}</td>
  237. <td>{{ record.createdAt | date : 'yyyy-MM-dd HH:mm' }}</td>
  238. <td>
  239. <a (click)="showDetail(record)">详情</a>
  240. </td>
  241. </tr>
  242. </tbody>
  243. </nz-table>
  244. <nz-empty
  245. *ngIf="records.length === 0 && !isLoading"
  246. nzNotFoundContent="暂无消息记录"
  247. ></nz-empty>
  248. </nz-spin>
  249. <div class="pagination-container">
  250. <nz-pagination
  251. [nzPageIndex]="pagination.page"
  252. [nzTotal]="pagination.total"
  253. [nzPageSize]="pagination.limit"
  254. [nzShowTotal]="totalTemplate"
  255. (nzPageIndexChange)="onPageChange($event)"
  256. (nzPageSizeChange)="onPageSizeChange($event)"
  257. nzShowSizeChanger
  258. nzShowQuickJumper
  259. ></nz-pagination>
  260. <ng-template #totalTemplate let-total>
  261. <span>共 {{ total }} 条记录</span>
  262. </ng-template>
  263. </div>
  264. </nz-card>
  265. `,
  266. styles: [
  267. `
  268. nz-select {
  269. width: 100%;
  270. }
  271. .filter-form {
  272. margin-bottom: 16px;
  273. nz-range-picker {
  274. width: 100%;
  275. }
  276. }
  277. .button-col {
  278. display: flex;
  279. gap: 8px;
  280. justify-content: flex-end;
  281. }
  282. nz-table {
  283. margin-top: 16px;
  284. }
  285. .pagination-container {
  286. display: flex;
  287. justify-content: center;
  288. margin-top: 16px;
  289. }
  290. nz-tag {
  291. margin-right: 0;
  292. }
  293. /* 详情按钮样式 */
  294. a[nz-button] {
  295. padding: 0 8px;
  296. }
  297. @media (max-width: 768px) {
  298. nz-col {
  299. margin-bottom: 8px;
  300. }
  301. .button-col {
  302. justify-content: flex-start;
  303. }
  304. }
  305. /* 消息内容单元格样式 */
  306. .message-content-cell {
  307. max-width: 300px;
  308. min-width: 200px;
  309. }
  310. /* 消息标题样式 */
  311. .message-title {
  312. font-weight: bold;
  313. white-space: nowrap;
  314. overflow: hidden;
  315. text-overflow: ellipsis;
  316. margin-bottom: 4px;
  317. }
  318. /* 消息内容样式 */
  319. .message-content {
  320. display: -webkit-box;
  321. -webkit-line-clamp: 2;
  322. -webkit-box-orient: vertical;
  323. overflow: hidden;
  324. text-overflow: ellipsis;
  325. line-height: 1.4;
  326. color: #666;
  327. }
  328. /* 来源单元格样式 */
  329. .source-cell {
  330. min-width: 200px;
  331. max-width: 300px;
  332. }
  333. .source-item {
  334. display: flex;
  335. margin-bottom: 4px;
  336. line-height: 1.5;
  337. }
  338. .source-label {
  339. font-weight: 500;
  340. color: #666;
  341. min-width: 40px;
  342. margin-right: 8px;
  343. }
  344. .source-value {
  345. flex: 1;
  346. overflow: hidden;
  347. text-overflow: ellipsis;
  348. white-space: nowrap;
  349. }
  350. /* 响应式调整 */
  351. @media (max-width: 1200px) {
  352. .source-cell {
  353. min-width: 150px;
  354. max-width: 200px;
  355. }
  356. }
  357. `,
  358. ],
  359. })
  360. export class MessageRecordComponent implements OnInit, OnDestroy {
  361. records: IMessageRecord[] = [];
  362. isLoading = false;
  363. private queryParamsSubscription!: Subscription;
  364. // 使用 FormGroup 管理所有筛选字段
  365. filterForm: FormGroup;
  366. // 分页参数
  367. pagination = {
  368. page: 1,
  369. limit: 30,
  370. total: 0,
  371. };
  372. constructor(
  373. private messageService: MessageService,
  374. private message: NzMessageService,
  375. private modalService: NzModalService,
  376. private fb: FormBuilder,
  377. private router: Router,
  378. private route: ActivatedRoute
  379. ) {
  380. this.filterForm = this.fb.group({
  381. uid: [null],
  382. activityName: [null],
  383. templateName: [null],
  384. strategyName: [null],
  385. status: [null],
  386. plannedSendAt: [null],
  387. actualSendAt: [null],
  388. deliveredAt: [null],
  389. openedAt: [null],
  390. });
  391. }
  392. ngOnInit(): void {
  393. // 订阅 URL 查询参数的变化
  394. this.queryParamsSubscription = this.route.queryParams.subscribe(
  395. (params) => {
  396. // 更新分页状态
  397. this.pagination.page = params['page']
  398. ? parseInt(params['page'], 10)
  399. : 1;
  400. this.pagination.limit = params['limit']
  401. ? parseInt(params['limit'], 10)
  402. : 30;
  403. // 更新表单控件的值,日期需要特殊处理
  404. const formValue: any = {};
  405. for (const key in this.filterForm.controls) {
  406. if (params[key]) {
  407. // 日期范围参数格式: "2023-01-01T00:00:00Z,2023-01-31T23:59:59Z"
  408. if (key.includes('At')) {
  409. const dateRange = params[key].split(',');
  410. if (dateRange.length === 2) {
  411. formValue[key] = [
  412. new Date(dateRange[0]),
  413. new Date(dateRange[1]),
  414. ];
  415. }
  416. } else {
  417. formValue[key] = params[key];
  418. }
  419. }
  420. }
  421. // 使用 patchValue 来避免未提供的控件报错
  422. this.filterForm.patchValue(formValue, { emitEvent: false });
  423. this.loadRecords();
  424. }
  425. );
  426. // 监听表单变化,自动触发查询
  427. this.filterForm.valueChanges
  428. .pipe(
  429. debounceTime(500),
  430. distinctUntilChanged(
  431. (prev, curr) => JSON.stringify(prev) === JSON.stringify(curr)
  432. )
  433. )
  434. .subscribe(() => {
  435. this.onFilter();
  436. });
  437. }
  438. ngOnDestroy(): void {
  439. if (this.queryParamsSubscription) {
  440. this.queryParamsSubscription.unsubscribe();
  441. }
  442. }
  443. loadRecords(): void {
  444. this.isLoading = true;
  445. // 准备查询参数
  446. const params: any = this.prepareFilters();
  447. params['page'] = this.pagination.page;
  448. params['limit'] = this.pagination.limit;
  449. this.messageService.getPaginatedRecords(params).subscribe({
  450. next: (response) => {
  451. this.records = response.data;
  452. this.pagination.total = response.pagination.total;
  453. this.isLoading = false;
  454. },
  455. error: (err) => {
  456. this.message.error('加载记录失败: ' + (err.message || '未知错误'));
  457. this.isLoading = false;
  458. },
  459. });
  460. }
  461. private prepareFilters(): any {
  462. const formValue = this.filterForm.value;
  463. const filters: any = {};
  464. for (const key in formValue) {
  465. if (
  466. formValue.hasOwnProperty(key) &&
  467. formValue[key] !== null &&
  468. formValue[key] !== ''
  469. ) {
  470. if (Array.isArray(formValue[key])) {
  471. if (formValue[key].length === 2) {
  472. const start = formValue[key][0].toISOString();
  473. const end = formValue[key][1].toISOString();
  474. filters[key] = `${start},${end}`;
  475. }
  476. } else {
  477. filters[key] = formValue[key];
  478. }
  479. }
  480. }
  481. return filters;
  482. }
  483. // 重构为私有方法,用于导航到新 URL
  484. private navigateToUrl(): void {
  485. const filters = this.prepareFilters();
  486. const queryParams = {
  487. page: this.pagination.page,
  488. limit: this.pagination.limit,
  489. ...filters,
  490. };
  491. // FIX: Removed queryParamsHandling: 'merge' to allow empty filters to be removed from the URL
  492. this.router.navigate([], {
  493. relativeTo: this.route,
  494. queryParams,
  495. });
  496. }
  497. onFilter(): void {
  498. this.pagination.page = 1; // 筛选时重置到第一页
  499. this.navigateToUrl();
  500. }
  501. resetFilters(): void {
  502. this.filterForm.reset();
  503. this.onFilter();
  504. }
  505. // 分页变更处理
  506. onPageChange(page: number): void {
  507. this.pagination.page = page;
  508. this.navigateToUrl();
  509. }
  510. onPageSizeChange(size: number): void {
  511. this.pagination.limit = size;
  512. this.pagination.page = 1;
  513. this.navigateToUrl();
  514. }
  515. // 显示详情模态框
  516. showDetail(record: IMessageRecord): void {
  517. this.modalService.create({
  518. nzTitle: `消息记录详情 - ${record.uid}`,
  519. nzWidth: '800px',
  520. nzContent: MessageRecordDetailComponent,
  521. nzData: { record },
  522. nzFooter: null,
  523. });
  524. }
  525. // 获取状态名称
  526. getStatusName(status: number): string {
  527. const statuses: Record<number, string> = {
  528. 0: '未发送',
  529. 1: '发送成功',
  530. 2: '已送达',
  531. 3: '已打开',
  532. [-1]: '发送失败',
  533. };
  534. return statuses[status] || '未知状态';
  535. }
  536. // 获取状态颜色
  537. getStatusColor(status: number): string {
  538. const colors: Record<number, string> = {
  539. 0: 'default',
  540. 1: 'processing',
  541. 2: 'success',
  542. 3: 'green',
  543. [-1]: 'error',
  544. };
  545. return colors[status] || 'default';
  546. }
  547. showUserDetail(uid: string): void {
  548. this.modalService.create({
  549. nzTitle: `用户详情 - ${uid}`,
  550. nzContent: UserDetailModalComponent,
  551. nzWidth: '800px',
  552. nzData: { uid }, // 传递用户UID
  553. nzFooter: null,
  554. nzBodyStyle: {
  555. 'max-height': '70vh',
  556. 'overflow-y': 'auto',
  557. },
  558. });
  559. }
  560. }