dashboard.component.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import { Component, OnInit } from '@angular/core';
  2. import { CommonModule, NgFor, NgIf, DatePipe } from '@angular/common';
  3. import { DashboardService, NewArtworkTab } from '../services/dashboard.service';
  4. import { ArtDoneRateComponent } from './art-done-rate.component';
  5. import { NzCardModule } from 'ng-zorro-antd/card';
  6. import { NzIconModule } from 'ng-zorro-antd/icon';
  7. import { NzStatisticModule } from 'ng-zorro-antd/statistic';
  8. import { NzSpinModule } from 'ng-zorro-antd/spin';
  9. import { NzTableModule } from 'ng-zorro-antd/table';
  10. import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
  11. import { NzTabsModule } from 'ng-zorro-antd/tabs';
  12. import { NzMessageService } from 'ng-zorro-antd/message';
  13. @Component({
  14. selector: 'app-dashboard',
  15. standalone: true,
  16. imports: [
  17. CommonModule,
  18. NgFor,
  19. NgIf,
  20. DatePipe,
  21. ArtDoneRateComponent,
  22. NzCardModule,
  23. NzIconModule,
  24. NzStatisticModule,
  25. NzSpinModule,
  26. NzTableModule,
  27. NzPageHeaderModule,
  28. NzTabsModule,
  29. ],
  30. providers: [NzMessageService],
  31. template: `
  32. <div class="dashboard-container">
  33. <nz-page-header [nzGhost]="false">
  34. <nz-page-header-title>数据看板</nz-page-header-title>
  35. <nz-page-header-extra>
  36. <span nz-icon nzType="sync" nzTheme="outline" (click)="refreshData()"></span>
  37. </nz-page-header-extra>
  38. <nz-page-header-content>
  39. <p>最后更新时间:{{ lastUpdateTime | date: 'yyyy-MM-dd HH:mm:ss' }}</p>
  40. </nz-page-header-content>
  41. </nz-page-header>
  42. <nz-spin [nzSpinning]="isLoading" nzTip="数据加载中...">
  43. <nz-card nzTitle="用户卡片:当日日活 + 日活曲线" nzHoverable class="stack-card">
  44. <nz-statistic
  45. [nzTitle]="'当日日活(DAU)'"
  46. [nzValue]="activeUsersToday"
  47. [nzPrefix]="userIcon"
  48. [nzSuffix]="'人'"
  49. [nzValueStyle]="{ color: '#1677ff' }"
  50. ></nz-statistic>
  51. <ng-template #userIcon>
  52. <span nz-icon nzType="user" nzTheme="outline"></span>
  53. </ng-template>
  54. <div class="range-buttons">
  55. <button type="button" class="range-btn" [class.active]="selectedDauRange === 7" (click)="changeDauRange(7)">7天</button>
  56. <button type="button" class="range-btn" [class.active]="selectedDauRange === 14" (click)="changeDauRange(14)">14天</button>
  57. <button type="button" class="range-btn" [class.active]="selectedDauRange === 30" (click)="changeDauRange(30)">30天</button>
  58. </div>
  59. <div class="chart-wrapper">
  60. <svg viewBox="0 0 600 180" class="line-chart" preserveAspectRatio="none">
  61. <polyline
  62. *ngIf="dauChartPoints"
  63. [attr.points]="dauChartPoints"
  64. fill="none"
  65. stroke="#1677ff"
  66. stroke-width="3"
  67. stroke-linecap="round"
  68. stroke-linejoin="round"
  69. ></polyline>
  70. <line
  71. *ngIf="hoveredDauIndex >= 0"
  72. [attr.x1]="hoveredDauX"
  73. [attr.y1]="16"
  74. [attr.x2]="hoveredDauX"
  75. [attr.y2]="164"
  76. stroke="#91caff"
  77. stroke-width="1"
  78. stroke-dasharray="3 3"
  79. ></line>
  80. <circle
  81. *ngIf="hoveredDauIndex >= 0"
  82. [attr.cx]="hoveredDauX"
  83. [attr.cy]="hoveredDauY"
  84. r="4"
  85. fill="#1677ff"
  86. ></circle>
  87. <rect
  88. x="0"
  89. y="0"
  90. width="600"
  91. height="180"
  92. fill="transparent"
  93. (mousemove)="onChartMouseMove($event)"
  94. (mouseleave)="onChartMouseLeave()"
  95. ></rect>
  96. </svg>
  97. </div>
  98. <div class="chart-hover-tip" *ngIf="hoveredDauIndex >= 0">
  99. {{ hoveredDauDate }}:{{ hoveredDauValue }}
  100. </div>
  101. <div class="chart-meta">
  102. <span>{{ dauTrendStartDate }}</span>
  103. <span>Max: {{ dauMax }}</span>
  104. <span>Min: {{ dauMin }}</span>
  105. <span>{{ dauTrendEndDate }}</span>
  106. </div>
  107. </nz-card>
  108. <nz-card nzTitle="作品卡片:最近7天上新作品表现" nzHoverable class="stack-card">
  109. <nz-tabset [nzSelectedIndex]="activeArtworkTabIndex" (nzSelectedIndexChange)="activeArtworkTabIndex = $event">
  110. <nz-tab *ngFor="let tab of artworkTabs" [nzTitle]="tab.label">
  111. <div class="tab-subtitle">{{ tab.date }} · 当日DAU {{ tab.dau }}</div>
  112. <nz-table
  113. #worksTable
  114. [nzData]="tab.artworks"
  115. [nzFrontPagination]="false"
  116. [nzBordered]="true"
  117. [nzSize]="'small'"
  118. >
  119. <thead>
  120. <tr>
  121. <th>作品</th>
  122. <th>名称</th>
  123. <th>区块数</th>
  124. <th>点击率</th>
  125. <th>完成率</th>
  126. <th>点击数</th>
  127. <th>完成数</th>
  128. <th>道具使用数</th>
  129. </tr>
  130. </thead>
  131. <tbody>
  132. <tr *ngFor="let work of worksTable.data">
  133. <td>
  134. <img
  135. [src]="work.thumbnail"
  136. class="work-thumbnail"
  137. alt="作品缩略图"
  138. (click)="openArtworkImage(work.thumbnail)"
  139. />
  140. </td>
  141. <td>
  142. <a [href]="getArtworkDetailUrl(work.resId)" target="_blank" rel="noopener noreferrer">
  143. {{ work.name }}
  144. </a>
  145. </td>
  146. <td>{{ work.areaCount || 0 }}</td>
  147. <td>{{ work.clickRate * 100 | number: '1.1-2' }}%</td>
  148. <td>
  149. <art-done-rate
  150. [data]="{
  151. completionRate: work.completionRate,
  152. areaCountFloor: work.areaCountFloor,
  153. totalStartCount: work.clickCount,
  154. totalDoneCount: work.completionCount
  155. }"
  156. ></art-done-rate>
  157. </td>
  158. <td>{{ work.clickCount }}</td>
  159. <td>{{ work.completionCount }}</td>
  160. <td>{{ work.tipCount || 0 }}</td>
  161. </tr>
  162. </tbody>
  163. </nz-table>
  164. <div class="empty-tip" *ngIf="tab.artworks.length === 0">当日无上新作品</div>
  165. </nz-tab>
  166. </nz-tabset>
  167. </nz-card>
  168. </nz-spin>
  169. </div>
  170. `,
  171. styles: [
  172. `
  173. .dashboard-container {
  174. padding: 24px;
  175. background: #f5f7fa;
  176. min-height: 100%;
  177. }
  178. .stack-card {
  179. margin-bottom: 16px;
  180. }
  181. .range-buttons {
  182. margin: 12px 0 8px;
  183. display: flex;
  184. gap: 8px;
  185. }
  186. .range-btn {
  187. border: 1px solid #d9d9d9;
  188. background: #fff;
  189. border-radius: 6px;
  190. padding: 4px 10px;
  191. cursor: pointer;
  192. }
  193. .range-btn.active {
  194. border-color: #1677ff;
  195. color: #1677ff;
  196. background: #e6f4ff;
  197. }
  198. .chart-wrapper {
  199. margin-top: 8px;
  200. border: 1px solid #f0f0f0;
  201. border-radius: 8px;
  202. background: #fcfdff;
  203. }
  204. .line-chart {
  205. width: 100%;
  206. height: 180px;
  207. }
  208. .chart-meta {
  209. margin-top: 8px;
  210. display: flex;
  211. justify-content: space-between;
  212. color: #8c8c8c;
  213. font-size: 12px;
  214. }
  215. .chart-hover-tip {
  216. margin-top: 6px;
  217. color: #262626;
  218. font-size: 12px;
  219. }
  220. .tab-subtitle {
  221. margin-bottom: 10px;
  222. color: #595959;
  223. font-size: 12px;
  224. }
  225. .work-thumbnail {
  226. width: 52px;
  227. height: 52px;
  228. border-radius: 4px;
  229. object-fit: cover;
  230. border: 1px solid #f0f0f0;
  231. cursor: zoom-in;
  232. }
  233. .empty-tip {
  234. margin-top: 10px;
  235. color: #8c8c8c;
  236. font-size: 12px;
  237. }
  238. `,
  239. ],
  240. })
  241. export class DashboardComponent implements OnInit {
  242. isLoading = true;
  243. lastUpdateTime = new Date();
  244. activeUsersToday = 0;
  245. selectedDauRange = 30;
  246. dauTrendData: number[] = [];
  247. dauTrendLabels: string[] = [];
  248. dauChartPoints = '';
  249. dauMin = 0;
  250. dauMax = 0;
  251. dauTrendStartDate = '';
  252. dauTrendEndDate = '';
  253. hoveredDauIndex = -1;
  254. hoveredDauX = 0;
  255. hoveredDauY = 0;
  256. hoveredDauDate = '';
  257. hoveredDauValue = 0;
  258. artworkTabs: NewArtworkTab[] = [];
  259. activeArtworkTabIndex = 0;
  260. private readonly chartWidth = 600;
  261. private readonly chartHeight = 180;
  262. private readonly chartPadding = 16;
  263. constructor(
  264. private message: NzMessageService,
  265. private dashboardService: DashboardService,
  266. ) {}
  267. ngOnInit(): void {
  268. this.loadDashboardData();
  269. }
  270. refreshData(): void {
  271. this.loadDashboardData();
  272. }
  273. changeDauRange(days: number): void {
  274. if (this.selectedDauRange === days) return;
  275. this.selectedDauRange = days;
  276. this.loadUserCardData();
  277. }
  278. private buildDauChartPoints(values: number[]): string {
  279. if (!values.length) return '';
  280. const width = 600;
  281. const height = 180;
  282. const padding = 16;
  283. const minVal = Math.min(...values);
  284. const maxVal = Math.max(...values);
  285. const span = Math.max(1, maxVal - minVal);
  286. const stepX =
  287. values.length > 1 ? (width - padding * 2) / (values.length - 1) : 0;
  288. return values
  289. .map((value, idx) => {
  290. const x = padding + idx * stepX;
  291. const y =
  292. height - padding - ((value - minVal) / span) * (height - padding * 2);
  293. return `${x},${y}`;
  294. })
  295. .join(' ');
  296. }
  297. private getDauY(value: number): number {
  298. const minVal = this.dauMin;
  299. const maxVal = this.dauMax;
  300. const span = Math.max(1, maxVal - minVal);
  301. return this.chartHeight - this.chartPadding - ((value - minVal) / span) * (this.chartHeight - this.chartPadding * 2);
  302. }
  303. onChartMouseMove(event: MouseEvent): void {
  304. if (this.dauTrendData.length === 0) return;
  305. const rect = (event.target as SVGRectElement).getBoundingClientRect();
  306. const localX = ((event.clientX - rect.left) / rect.width) * this.chartWidth;
  307. let index = 0;
  308. if (this.dauTrendData.length > 1) {
  309. const stepX = (this.chartWidth - this.chartPadding * 2) / (this.dauTrendData.length - 1);
  310. index = Math.round((localX - this.chartPadding) / stepX);
  311. index = Math.max(0, Math.min(this.dauTrendData.length - 1, index));
  312. this.hoveredDauX = this.chartPadding + index * stepX;
  313. } else {
  314. this.hoveredDauX = this.chartWidth / 2;
  315. }
  316. this.hoveredDauIndex = index;
  317. this.hoveredDauDate = this.dauTrendLabels[index] || '';
  318. this.hoveredDauValue = this.dauTrendData[index] || 0;
  319. this.hoveredDauY = this.getDauY(this.hoveredDauValue);
  320. }
  321. onChartMouseLeave(): void {
  322. this.hoveredDauIndex = -1;
  323. }
  324. getArtworkDetailUrl(resId: string): string {
  325. return `https://color2.jccytech.cn/app/zh/pages/detail/${resId}`;
  326. }
  327. openArtworkImage(imageUrl: string): void {
  328. if (!imageUrl) return;
  329. window.open(imageUrl, '_blank', 'noopener,noreferrer');
  330. }
  331. private applyDauData(dau: { today: number; trend: Array<{ date: string; dau: number }> }): void {
  332. this.activeUsersToday = dau.today;
  333. this.dauTrendLabels = dau.trend.map((item) => item.date);
  334. this.dauTrendData = dau.trend.map((item) => item.dau);
  335. this.dauMin = this.dauTrendData.length
  336. ? Math.min(...this.dauTrendData)
  337. : 0;
  338. this.dauMax = this.dauTrendData.length
  339. ? Math.max(...this.dauTrendData)
  340. : 0;
  341. this.dauTrendStartDate = this.dauTrendLabels[0] || '';
  342. this.dauTrendEndDate =
  343. this.dauTrendLabels[this.dauTrendLabels.length - 1] || '';
  344. this.dauChartPoints = this.buildDauChartPoints(this.dauTrendData);
  345. }
  346. private loadUserCardData(): void {
  347. this.isLoading = true;
  348. this.dashboardService.getKpi(this.selectedDauRange).subscribe({
  349. next: (response) => {
  350. if (!response.success) {
  351. this.message.error('获取日活数据失败');
  352. this.isLoading = false;
  353. return;
  354. }
  355. this.applyDauData(response.data.dau);
  356. this.lastUpdateTime = new Date();
  357. this.isLoading = false;
  358. },
  359. error: (error) => {
  360. console.error('Failed to load DAU data:', error);
  361. this.message.error('加载日活数据出错');
  362. this.isLoading = false;
  363. },
  364. });
  365. }
  366. private loadArtworkTabsData(): Promise<void> {
  367. return new Promise((resolve) => {
  368. this.dashboardService.getNewArtworkTabs(7, 20).subscribe({
  369. next: (response) => {
  370. if (response.success) {
  371. this.artworkTabs = response.data.tabs || [];
  372. this.activeArtworkTabIndex = 0;
  373. } else {
  374. this.message.error('获取上新作品数据失败');
  375. }
  376. resolve();
  377. },
  378. error: (error) => {
  379. console.error('Failed to load artwork tabs:', error);
  380. this.message.error('加载上新作品数据出错');
  381. resolve();
  382. },
  383. });
  384. });
  385. }
  386. loadDashboardData(): void {
  387. this.isLoading = true;
  388. this.dashboardService.getKpi(this.selectedDauRange).subscribe({
  389. next: async (kpiResponse) => {
  390. if (!kpiResponse.success) {
  391. this.message.error('获取日活数据失败');
  392. this.isLoading = false;
  393. return;
  394. }
  395. this.applyDauData(kpiResponse.data.dau);
  396. await this.loadArtworkTabsData();
  397. this.lastUpdateTime = new Date();
  398. this.isLoading = false;
  399. },
  400. error: (error) => {
  401. console.error('Failed to load dashboard data:', error);
  402. this.message.error('加载看板数据出错');
  403. this.isLoading = false;
  404. },
  405. });
  406. }
  407. }