| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- import { Component, OnInit } from '@angular/core';
- import { CommonModule, NgFor, NgIf, DatePipe } from '@angular/common';
- import { DashboardService, NewArtworkTab } from '../services/dashboard.service';
- import { ArtDoneRateComponent } from './art-done-rate.component';
- import { NzCardModule } from 'ng-zorro-antd/card';
- import { NzIconModule } from 'ng-zorro-antd/icon';
- import { NzStatisticModule } from 'ng-zorro-antd/statistic';
- import { NzSpinModule } from 'ng-zorro-antd/spin';
- import { NzTableModule } from 'ng-zorro-antd/table';
- import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
- import { NzTabsModule } from 'ng-zorro-antd/tabs';
- import { NzMessageService } from 'ng-zorro-antd/message';
- @Component({
- selector: 'app-dashboard',
- standalone: true,
- imports: [
- CommonModule,
- NgFor,
- NgIf,
- DatePipe,
- ArtDoneRateComponent,
- NzCardModule,
- NzIconModule,
- NzStatisticModule,
- NzSpinModule,
- NzTableModule,
- NzPageHeaderModule,
- NzTabsModule,
- ],
- providers: [NzMessageService],
- template: `
- <div class="dashboard-container">
- <nz-page-header [nzGhost]="false">
- <nz-page-header-title>数据看板</nz-page-header-title>
- <nz-page-header-extra>
- <span nz-icon nzType="sync" nzTheme="outline" (click)="refreshData()"></span>
- </nz-page-header-extra>
- <nz-page-header-content>
- <p>最后更新时间:{{ lastUpdateTime | date: 'yyyy-MM-dd HH:mm:ss' }}</p>
- </nz-page-header-content>
- </nz-page-header>
- <nz-spin [nzSpinning]="isLoading" nzTip="数据加载中...">
- <nz-card nzTitle="用户卡片:当日日活 + 日活曲线" nzHoverable class="stack-card">
- <nz-statistic
- [nzTitle]="'当日日活(DAU)'"
- [nzValue]="activeUsersToday"
- [nzPrefix]="userIcon"
- [nzSuffix]="'人'"
- [nzValueStyle]="{ color: '#1677ff' }"
- ></nz-statistic>
- <ng-template #userIcon>
- <span nz-icon nzType="user" nzTheme="outline"></span>
- </ng-template>
- <div class="range-buttons">
- <button type="button" class="range-btn" [class.active]="selectedDauRange === 7" (click)="changeDauRange(7)">7天</button>
- <button type="button" class="range-btn" [class.active]="selectedDauRange === 14" (click)="changeDauRange(14)">14天</button>
- <button type="button" class="range-btn" [class.active]="selectedDauRange === 30" (click)="changeDauRange(30)">30天</button>
- </div>
- <div class="chart-wrapper">
- <svg viewBox="0 0 600 180" class="line-chart" preserveAspectRatio="none">
- <polyline
- *ngIf="dauChartPoints"
- [attr.points]="dauChartPoints"
- fill="none"
- stroke="#1677ff"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- ></polyline>
- <line
- *ngIf="hoveredDauIndex >= 0"
- [attr.x1]="hoveredDauX"
- [attr.y1]="16"
- [attr.x2]="hoveredDauX"
- [attr.y2]="164"
- stroke="#91caff"
- stroke-width="1"
- stroke-dasharray="3 3"
- ></line>
- <circle
- *ngIf="hoveredDauIndex >= 0"
- [attr.cx]="hoveredDauX"
- [attr.cy]="hoveredDauY"
- r="4"
- fill="#1677ff"
- ></circle>
- <rect
- x="0"
- y="0"
- width="600"
- height="180"
- fill="transparent"
- (mousemove)="onChartMouseMove($event)"
- (mouseleave)="onChartMouseLeave()"
- ></rect>
- </svg>
- </div>
- <div class="chart-hover-tip" *ngIf="hoveredDauIndex >= 0">
- {{ hoveredDauDate }}:{{ hoveredDauValue }}
- </div>
- <div class="chart-meta">
- <span>{{ dauTrendStartDate }}</span>
- <span>Max: {{ dauMax }}</span>
- <span>Min: {{ dauMin }}</span>
- <span>{{ dauTrendEndDate }}</span>
- </div>
- </nz-card>
- <nz-card nzTitle="作品卡片:最近7天上新作品表现" nzHoverable class="stack-card">
- <nz-tabset [nzSelectedIndex]="activeArtworkTabIndex" (nzSelectedIndexChange)="activeArtworkTabIndex = $event">
- <nz-tab *ngFor="let tab of artworkTabs" [nzTitle]="tab.label">
- <div class="tab-subtitle">{{ tab.date }} · 当日DAU {{ tab.dau }}</div>
- <nz-table
- #worksTable
- [nzData]="tab.artworks"
- [nzFrontPagination]="false"
- [nzBordered]="true"
- [nzSize]="'small'"
- >
- <thead>
- <tr>
- <th>作品</th>
- <th>名称</th>
- <th>区块数</th>
- <th>点击率</th>
- <th>完成率</th>
- <th>点击数</th>
- <th>完成数</th>
- <th>道具使用数</th>
- </tr>
- </thead>
- <tbody>
- <tr *ngFor="let work of worksTable.data">
- <td>
- <img
- [src]="work.thumbnail"
- class="work-thumbnail"
- alt="作品缩略图"
- (click)="openArtworkImage(work.thumbnail)"
- />
- </td>
- <td>
- <a [href]="getArtworkDetailUrl(work.resId)" target="_blank" rel="noopener noreferrer">
- {{ work.name }}
- </a>
- </td>
- <td>{{ work.areaCount || 0 }}</td>
- <td>{{ work.clickRate * 100 | number: '1.1-2' }}%</td>
- <td>
- <art-done-rate
- [data]="{
- completionRate: work.completionRate,
- areaCountFloor: work.areaCountFloor,
- totalStartCount: work.clickCount,
- totalDoneCount: work.completionCount
- }"
- ></art-done-rate>
- </td>
- <td>{{ work.clickCount }}</td>
- <td>{{ work.completionCount }}</td>
- <td>{{ work.tipCount || 0 }}</td>
- </tr>
- </tbody>
- </nz-table>
- <div class="empty-tip" *ngIf="tab.artworks.length === 0">当日无上新作品</div>
- </nz-tab>
- </nz-tabset>
- </nz-card>
- </nz-spin>
- </div>
- `,
- styles: [
- `
- .dashboard-container {
- padding: 24px;
- background: #f5f7fa;
- min-height: 100%;
- }
- .stack-card {
- margin-bottom: 16px;
- }
- .range-buttons {
- margin: 12px 0 8px;
- display: flex;
- gap: 8px;
- }
- .range-btn {
- border: 1px solid #d9d9d9;
- background: #fff;
- border-radius: 6px;
- padding: 4px 10px;
- cursor: pointer;
- }
- .range-btn.active {
- border-color: #1677ff;
- color: #1677ff;
- background: #e6f4ff;
- }
- .chart-wrapper {
- margin-top: 8px;
- border: 1px solid #f0f0f0;
- border-radius: 8px;
- background: #fcfdff;
- }
- .line-chart {
- width: 100%;
- height: 180px;
- }
- .chart-meta {
- margin-top: 8px;
- display: flex;
- justify-content: space-between;
- color: #8c8c8c;
- font-size: 12px;
- }
- .chart-hover-tip {
- margin-top: 6px;
- color: #262626;
- font-size: 12px;
- }
- .tab-subtitle {
- margin-bottom: 10px;
- color: #595959;
- font-size: 12px;
- }
- .work-thumbnail {
- width: 52px;
- height: 52px;
- border-radius: 4px;
- object-fit: cover;
- border: 1px solid #f0f0f0;
- cursor: zoom-in;
- }
- .empty-tip {
- margin-top: 10px;
- color: #8c8c8c;
- font-size: 12px;
- }
- `,
- ],
- })
- export class DashboardComponent implements OnInit {
- isLoading = true;
- lastUpdateTime = new Date();
- activeUsersToday = 0;
- selectedDauRange = 30;
- dauTrendData: number[] = [];
- dauTrendLabels: string[] = [];
- dauChartPoints = '';
- dauMin = 0;
- dauMax = 0;
- dauTrendStartDate = '';
- dauTrendEndDate = '';
- hoveredDauIndex = -1;
- hoveredDauX = 0;
- hoveredDauY = 0;
- hoveredDauDate = '';
- hoveredDauValue = 0;
- artworkTabs: NewArtworkTab[] = [];
- activeArtworkTabIndex = 0;
- private readonly chartWidth = 600;
- private readonly chartHeight = 180;
- private readonly chartPadding = 16;
- constructor(
- private message: NzMessageService,
- private dashboardService: DashboardService,
- ) {}
- ngOnInit(): void {
- this.loadDashboardData();
- }
- refreshData(): void {
- this.loadDashboardData();
- }
- changeDauRange(days: number): void {
- if (this.selectedDauRange === days) return;
- this.selectedDauRange = days;
- this.loadUserCardData();
- }
- private buildDauChartPoints(values: number[]): string {
- if (!values.length) return '';
- const width = 600;
- const height = 180;
- const padding = 16;
- const minVal = Math.min(...values);
- const maxVal = Math.max(...values);
- const span = Math.max(1, maxVal - minVal);
- const stepX =
- values.length > 1 ? (width - padding * 2) / (values.length - 1) : 0;
- return values
- .map((value, idx) => {
- const x = padding + idx * stepX;
- const y =
- height - padding - ((value - minVal) / span) * (height - padding * 2);
- return `${x},${y}`;
- })
- .join(' ');
- }
- private getDauY(value: number): number {
- const minVal = this.dauMin;
- const maxVal = this.dauMax;
- const span = Math.max(1, maxVal - minVal);
- return this.chartHeight - this.chartPadding - ((value - minVal) / span) * (this.chartHeight - this.chartPadding * 2);
- }
- onChartMouseMove(event: MouseEvent): void {
- if (this.dauTrendData.length === 0) return;
- const rect = (event.target as SVGRectElement).getBoundingClientRect();
- const localX = ((event.clientX - rect.left) / rect.width) * this.chartWidth;
- let index = 0;
- if (this.dauTrendData.length > 1) {
- const stepX = (this.chartWidth - this.chartPadding * 2) / (this.dauTrendData.length - 1);
- index = Math.round((localX - this.chartPadding) / stepX);
- index = Math.max(0, Math.min(this.dauTrendData.length - 1, index));
- this.hoveredDauX = this.chartPadding + index * stepX;
- } else {
- this.hoveredDauX = this.chartWidth / 2;
- }
- this.hoveredDauIndex = index;
- this.hoveredDauDate = this.dauTrendLabels[index] || '';
- this.hoveredDauValue = this.dauTrendData[index] || 0;
- this.hoveredDauY = this.getDauY(this.hoveredDauValue);
- }
- onChartMouseLeave(): void {
- this.hoveredDauIndex = -1;
- }
- getArtworkDetailUrl(resId: string): string {
- return `https://color2.jccytech.cn/app/zh/pages/detail/${resId}`;
- }
- openArtworkImage(imageUrl: string): void {
- if (!imageUrl) return;
- window.open(imageUrl, '_blank', 'noopener,noreferrer');
- }
- private applyDauData(dau: { today: number; trend: Array<{ date: string; dau: number }> }): void {
- this.activeUsersToday = dau.today;
- this.dauTrendLabels = dau.trend.map((item) => item.date);
- this.dauTrendData = dau.trend.map((item) => item.dau);
- this.dauMin = this.dauTrendData.length
- ? Math.min(...this.dauTrendData)
- : 0;
- this.dauMax = this.dauTrendData.length
- ? Math.max(...this.dauTrendData)
- : 0;
- this.dauTrendStartDate = this.dauTrendLabels[0] || '';
- this.dauTrendEndDate =
- this.dauTrendLabels[this.dauTrendLabels.length - 1] || '';
- this.dauChartPoints = this.buildDauChartPoints(this.dauTrendData);
- }
- private loadUserCardData(): void {
- this.isLoading = true;
- this.dashboardService.getKpi(this.selectedDauRange).subscribe({
- next: (response) => {
- if (!response.success) {
- this.message.error('获取日活数据失败');
- this.isLoading = false;
- return;
- }
- this.applyDauData(response.data.dau);
- this.lastUpdateTime = new Date();
- this.isLoading = false;
- },
- error: (error) => {
- console.error('Failed to load DAU data:', error);
- this.message.error('加载日活数据出错');
- this.isLoading = false;
- },
- });
- }
- private loadArtworkTabsData(): Promise<void> {
- return new Promise((resolve) => {
- this.dashboardService.getNewArtworkTabs(7, 20).subscribe({
- next: (response) => {
- if (response.success) {
- this.artworkTabs = response.data.tabs || [];
- this.activeArtworkTabIndex = 0;
- } else {
- this.message.error('获取上新作品数据失败');
- }
- resolve();
- },
- error: (error) => {
- console.error('Failed to load artwork tabs:', error);
- this.message.error('加载上新作品数据出错');
- resolve();
- },
- });
- });
- }
- loadDashboardData(): void {
- this.isLoading = true;
- this.dashboardService.getKpi(this.selectedDauRange).subscribe({
- next: async (kpiResponse) => {
- if (!kpiResponse.success) {
- this.message.error('获取日活数据失败');
- this.isLoading = false;
- return;
- }
- this.applyDauData(kpiResponse.data.dau);
- await this.loadArtworkTabsData();
- this.lastUpdateTime = new Date();
- this.isLoading = false;
- },
- error: (error) => {
- console.error('Failed to load dashboard data:', error);
- this.message.error('加载看板数据出错');
- this.isLoading = false;
- },
- });
- }
- }
|