From fa1931b3b6ec10f44f312f498ab2a610a7b32746 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 6 Jan 2026 10:21:11 -0800 Subject: [PATCH] =?UTF-8?q?feat(planting-service,=20admin-web):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A4=E7=A7=8D=E8=B6=8B=E5=8A=BF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端变更 (planting-service): - 添加 getTrendData API 接口支持按时间维度(日/周/月/季度/年)查询趋势数据 - 添加 TrendPeriod 类型和 TrendDataPoint 接口 - 实现 repository 层的趋势数据聚合查询 前端变更 (admin-web): - 添加趋势数据 API 端点和类型定义 - 使用 recharts 实现折线图展示认种棵数和订单数趋势 - 支持日/周/月/季度/年度时间维度切换 - 添加加载状态、错误状态和空数据状态处理 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../controllers/planting-stats.controller.ts | 44 +++++- .../dto/response/planting-stats.response.ts | 18 +++ .../services/planting-application.service.ts | 20 +++ .../planting-order.repository.interface.ts | 22 +++ .../planting-order.repository.impl.ts | 91 ++++++++++++ .../src/app/(dashboard)/statistics/page.tsx | 133 +++++++++++++++++- .../statistics/statistics.module.scss | 25 +++- .../src/infrastructure/api/endpoints.ts | 2 + .../src/services/dashboardService.ts | 13 ++ .../admin-web/src/types/dashboard.types.ts | 11 ++ 10 files changed, 367 insertions(+), 12 deletions(-) diff --git a/backend/services/planting-service/src/api/controllers/planting-stats.controller.ts b/backend/services/planting-service/src/api/controllers/planting-stats.controller.ts index 785b35ed..c52cb3bd 100644 --- a/backend/services/planting-service/src/api/controllers/planting-stats.controller.ts +++ b/backend/services/planting-service/src/api/controllers/planting-stats.controller.ts @@ -1,13 +1,14 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, + ApiQuery, } from '@nestjs/swagger'; -import { PlantingApplicationService } from '../../application/services/planting-application.service'; +import { PlantingApplicationService, TrendPeriodType } from '../../application/services/planting-application.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; -import { GlobalStatsResponseDto } from '../dto/response/planting-stats.response'; +import { GlobalStatsResponseDto, TrendDataResponseDto } from '../dto/response/planting-stats.response'; /** * 认种统计控制器 @@ -69,4 +70,41 @@ export class PlantingStatsController { data: stats, }; } + + /** + * 获取认种趋势数据(公开接口) + * [2026-01-06] 新增 + */ + @Get('trend') + @ApiOperation({ + summary: '获取认种趋势数据', + description: '按时间维度聚合认种统计数据,用于绘制趋势图表', + }) + @ApiQuery({ + name: 'period', + required: false, + enum: ['day', 'week', 'month', 'quarter', 'year'], + description: '时间维度:day(最近30天)、week(最近12周)、month(最近12月)、quarter(最近8季度)、year(最近5年)', + }) + @ApiResponse({ + status: 200, + description: '趋势数据列表', + type: [TrendDataResponseDto], + }) + async getTrendData( + @Query('period') period: TrendPeriodType = 'day', + ): Promise<{ code: number; message: string; data: TrendDataResponseDto[] }> { + const validPeriods: TrendPeriodType[] = ['day', 'week', 'month', 'quarter', 'year']; + if (!validPeriods.includes(period)) { + period = 'day'; + } + + const data = await this.plantingService.getTrendData(period); + + return { + code: 0, + message: 'success', + data, + }; + } } diff --git a/backend/services/planting-service/src/api/dto/response/planting-stats.response.ts b/backend/services/planting-service/src/api/dto/response/planting-stats.response.ts index 0d7cc1bf..a2a259fe 100644 --- a/backend/services/planting-service/src/api/dto/response/planting-stats.response.ts +++ b/backend/services/planting-service/src/api/dto/response/planting-stats.response.ts @@ -75,3 +75,21 @@ export class GlobalStatsResponseDto { @ApiProperty({ description: '统计计算时间', example: '2026-01-04T12:00:00.000Z' }) calculatedAt: string; } + +/** + * 趋势数据响应 DTO + * [2026-01-06] 新增 + */ +export class TrendDataResponseDto { + @ApiProperty({ description: '时间标签', example: '2026-01-06' }) + label: string; + + @ApiProperty({ description: '认种棵数', example: 10 }) + treeCount: number; + + @ApiProperty({ description: '订单数', example: 5 }) + orderCount: number; + + @ApiProperty({ description: '认种金额', example: '1000.00' }) + amount: string; +} diff --git a/backend/services/planting-service/src/application/services/planting-application.service.ts b/backend/services/planting-service/src/application/services/planting-application.service.ts index f087dc3f..66863152 100644 --- a/backend/services/planting-service/src/application/services/planting-application.service.ts +++ b/backend/services/planting-service/src/application/services/planting-application.service.ts @@ -535,6 +535,15 @@ export class PlantingApplicationService { calculatedAt: new Date().toISOString(), }; } + + /** + * 获取认种趋势数据 + * [2026-01-06] 新增 + */ + async getTrendData(period: TrendPeriodType): Promise { + this.logger.log(`Getting planting trend data for period: ${period}`); + return this.orderRepository.getTrendData(period); + } } /** 全局统计结果 */ @@ -568,3 +577,14 @@ export interface GlobalStatsResult { /** 统计时间 */ calculatedAt: string; } + +/** 趋势时间维度类型 [2026-01-06] 新增 */ +export type TrendPeriodType = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** 趋势数据结果 [2026-01-06] 新增 */ +export interface TrendDataResult { + label: string; + treeCount: number; + orderCount: number; + amount: string; +} diff --git a/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts b/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts index 630ef2a9..2f7db66e 100644 --- a/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts +++ b/backend/services/planting-service/src/domain/repositories/planting-order.repository.interface.ts @@ -45,6 +45,13 @@ export interface IPlantingOrderRepository { * 按状态获取订单分布统计 */ getStatusDistribution(): Promise; + + /** + * 获取趋势数据 + * [2026-01-06] 新增 + * @param period 时间维度: day(最近30天), week(最近12周), month(最近12月), quarter(最近8季度), year(最近5年) + */ + getTrendData(period: TrendPeriod): Promise; } /** 全局认种统计 */ @@ -70,4 +77,19 @@ export interface StatusDistribution { miningEnabled: number; } +/** 趋势时间维度 [2026-01-06] 新增 */ +export type TrendPeriod = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** 趋势数据点 [2026-01-06] 新增 */ +export interface TrendDataPoint { + /** 时间标签 (如 "2026-01-06", "2026-W01", "2026-01", "2026-Q1", "2026") */ + label: string; + /** 认种棵数 */ + treeCount: number; + /** 订单数 */ + orderCount: number; + /** 认种金额 */ + amount: string; +} + export const PLANTING_ORDER_REPOSITORY = Symbol('IPlantingOrderRepository'); diff --git a/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts b/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts index 8bc65684..9000839f 100644 --- a/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts +++ b/backend/services/planting-service/src/infrastructure/persistence/repositories/planting-order.repository.impl.ts @@ -6,6 +6,8 @@ import { GlobalPlantingStats, DailyPlantingStats, StatusDistribution, + TrendPeriod, + TrendDataPoint, } from '../../../domain/repositories/planting-order.repository.interface'; import { PlantingOrder } from '../../../domain/aggregates/planting-order.aggregate'; import { PlantingOrderStatus } from '../../../domain/value-objects/planting-order-status.enum'; @@ -399,4 +401,93 @@ export class PlantingOrderRepositoryImpl implements IPlantingOrderRepository { return distribution; } + + /** + * 获取趋势数据 + * [2026-01-06] 新增 + */ + async getTrendData(period: TrendPeriod): Promise { + const paidStatuses = [ + PlantingOrderStatus.PAID, + PlantingOrderStatus.FUND_ALLOCATED, + PlantingOrderStatus.POOL_SCHEDULED, + PlantingOrderStatus.POOL_INJECTED, + PlantingOrderStatus.MINING_ENABLED, + ]; + + const now = new Date(); + let startDate: Date; + let dateFormat: string; + let groupByExpression: string; + + // 根据不同维度设置时间范围和分组方式 + switch (period) { + case 'day': + // 最近30天,按天分组 + startDate = new Date(now); + startDate.setDate(startDate.getDate() - 29); + startDate.setHours(0, 0, 0, 0); + dateFormat = 'YYYY-MM-DD'; + groupByExpression = `TO_CHAR("paid_at", 'YYYY-MM-DD')`; + break; + case 'week': + // 最近12周,按周分组 + startDate = new Date(now); + startDate.setDate(startDate.getDate() - 83); // ~12周 + startDate.setHours(0, 0, 0, 0); + dateFormat = 'IYYY-IW'; + groupByExpression = `TO_CHAR("paid_at", 'IYYY-"W"IW')`; + break; + case 'month': + // 最近12个月,按月分组 + startDate = new Date(now.getFullYear(), now.getMonth() - 11, 1); + dateFormat = 'YYYY-MM'; + groupByExpression = `TO_CHAR("paid_at", 'YYYY-MM')`; + break; + case 'quarter': + // 最近8季度,按季度分组 + startDate = new Date(now.getFullYear() - 2, 0, 1); + dateFormat = 'YYYY-QQ'; + groupByExpression = `TO_CHAR("paid_at", 'YYYY-"Q"') || EXTRACT(QUARTER FROM "paid_at")`; + break; + case 'year': + // 最近5年,按年分组 + startDate = new Date(now.getFullYear() - 4, 0, 1); + dateFormat = 'YYYY'; + groupByExpression = `TO_CHAR("paid_at", 'YYYY')`; + break; + default: + throw new Error(`Invalid period: ${period}`); + } + + // 使用原生SQL进行分组查询 + const results = await this.prisma.$queryRawUnsafe< + Array<{ + label: string; + tree_count: bigint; + order_count: bigint; + total_amount: bigint | null; + }> + >(` + SELECT + ${groupByExpression} as label, + COALESCE(SUM(tree_count), 0) as tree_count, + COUNT(*) as order_count, + COALESCE(SUM(total_amount), 0) as total_amount + FROM planting_order + WHERE status IN (${paidStatuses.map(s => `'${s}'`).join(', ')}) + AND paid_at >= $1 + AND paid_at IS NOT NULL + GROUP BY ${groupByExpression} + ORDER BY label ASC + `, startDate); + + // 转换结果 + return results.map(r => ({ + label: r.label, + treeCount: Number(r.tree_count), + orderCount: Number(r.order_count), + amount: (r.total_amount ?? 0).toString(), + })); + } } diff --git a/frontend/admin-web/src/app/(dashboard)/statistics/page.tsx b/frontend/admin-web/src/app/(dashboard)/statistics/page.tsx index f8e98770..09d5afed 100644 --- a/frontend/admin-web/src/app/(dashboard)/statistics/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/statistics/page.tsx @@ -1,18 +1,28 @@ /** * 数据统计页面 * [2026-01-04] 更新:新增系统账户报表Tab - * [2026-01-06] 更新:认种统计改为真实数据,删除不相关的mock功能 + * [2026-01-06] 更新:认种统计改为真实数据,实现趋势图表 * 回滚方式:删除 SystemAccountsTab 导入及相关 mainTab state 和切换逻辑 */ 'use client'; import { useState, useEffect } from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; import { PageContainer } from '@/components/layout'; import { cn } from '@/utils/helpers'; // [2026-01-04] 新增:系统账户报表Tab import { SystemAccountsTab } from '@/components/features/system-account-report'; import { dashboardService } from '@/services/dashboardService'; -import type { PlantingGlobalStats } from '@/types'; +import type { PlantingGlobalStats, PlantingTrendPeriod, PlantingTrendDataPoint } from '@/types'; import styles from './statistics.module.scss'; /** @@ -31,22 +41,53 @@ function formatAmount(amount: string): string { return num.toLocaleString('zh-CN', { maximumFractionDigits: 2 }); } +/** + * 格式化趋势图标签 + */ +function formatTrendLabel(label: string, period: PlantingTrendPeriod): string { + if (!label) return ''; + switch (period) { + case 'day': + // 2026-01-06 -> 01-06 + return label.slice(5); + case 'week': + // 2026-W01 -> W01 + return label.slice(5); + case 'month': + // 2026-01 -> 01月 + return label.slice(5) + '月'; + case 'quarter': + // 2026-Q1 -> Q1 + return label.slice(5); + case 'year': + // 2026 -> 2026年 + return label + '年'; + default: + return label; + } +} + /** * 数据统计页面 * [2026-01-04] 更新:新增系统账户报表Tab - * [2026-01-06] 更新:认种统计改为真实数据 + * [2026-01-06] 更新:认种统计改为真实数据,实现趋势图表 */ export default function StatisticsPage() { // [2026-01-04] 新增:主Tab切换 - 数据统计 vs 系统账户 const [mainTab, setMainTab] = useState<'statistics' | 'system-accounts'>('statistics'); // 趋势图时间维度 - const [trendPeriod, setTrendPeriod] = useState<'day' | 'week' | 'month' | 'quarter' | 'year'>('day'); + const [trendPeriod, setTrendPeriod] = useState('day'); // [2026-01-06] 新增:认种统计数据状态 const [plantingStats, setPlantingStats] = useState(null); const [statsLoading, setStatsLoading] = useState(true); const [statsError, setStatsError] = useState(null); + // [2026-01-06] 新增:趋势数据状态 + const [trendData, setTrendData] = useState([]); + const [trendLoading, setTrendLoading] = useState(true); + const [trendError, setTrendError] = useState(null); + // [2026-01-06] 新增:获取认种统计数据 useEffect(() => { async function fetchPlantingStats() { @@ -69,6 +110,35 @@ export default function StatisticsPage() { fetchPlantingStats(); }, []); + // [2026-01-06] 新增:获取趋势数据 + useEffect(() => { + async function fetchTrendData() { + try { + setTrendLoading(true); + setTrendError(null); + const response = await dashboardService.getPlantingTrendData(trendPeriod); + if (response.code === 0 && response.data) { + setTrendData(response.data); + } else { + setTrendError(response.message || '获取趋势数据失败'); + } + } catch (error) { + console.error('获取趋势数据失败:', error); + setTrendError('获取趋势数据失败'); + } finally { + setTrendLoading(false); + } + } + fetchTrendData(); + }, [trendPeriod]); + + // 处理趋势数据用于图表显示 + const chartData = trendData.map(item => ({ + name: formatTrendLabel(item.label, trendPeriod), + 认种棵数: item.treeCount, + 订单数: item.orderCount, + })); + return (
@@ -152,7 +222,7 @@ export default function StatisticsPage() {
- {/* 榴莲树认种数量趋势 */} + {/* 榴莲树认种数量趋势 - [2026-01-06] 实现真实图表 */}
榴莲树认种数量趋势 @@ -168,7 +238,58 @@ export default function StatisticsPage() { ))}
-
Chart Placeholder
+
+ {trendLoading ? ( +
加载中...
+ ) : trendError ? ( +
{trendError}
+ ) : chartData.length === 0 ? ( +
暂无数据
+ ) : ( + + + + + + + + + + + + )} +
)} diff --git a/frontend/admin-web/src/app/(dashboard)/statistics/statistics.module.scss b/frontend/admin-web/src/app/(dashboard)/statistics/statistics.module.scss index 0446a8ea..4a9be68b 100644 --- a/frontend/admin-web/src/app/(dashboard)/statistics/statistics.module.scss +++ b/frontend/admin-web/src/app/(dashboard)/statistics/statistics.module.scss @@ -174,17 +174,36 @@ } } -/* 图表占位区域 */ +/* 图表区域 [2026-01-06] 更新:支持真实图表 */ .statistics__chartArea { align-self: stretch; - height: 320px; + min-height: 320px; border-radius: 8px; - background-color: #f3f4f6; + background-color: #f9fafb; display: flex; align-items: center; justify-content: center; font-size: 16px; color: #94a3b8; + padding: 16px; +} + +/* 图表加载状态 */ +.statistics__chartLoading { + color: #6b7280; + font-size: 14px; +} + +/* 图表错误状态 */ +.statistics__chartError { + color: #dc2626; + font-size: 14px; +} + +/* 图表空数据状态 */ +.statistics__chartEmpty { + color: #9ca3af; + font-size: 14px; } /* 龙虎榜与排名统计 */ diff --git a/frontend/admin-web/src/infrastructure/api/endpoints.ts b/frontend/admin-web/src/infrastructure/api/endpoints.ts index 4fc7d319..31ee371c 100644 --- a/frontend/admin-web/src/infrastructure/api/endpoints.ts +++ b/frontend/admin-web/src/infrastructure/api/endpoints.ts @@ -114,6 +114,8 @@ export const API_ENDPOINTS = { // 认种统计 (planting-service) - 从订单表实时聚合,数据可靠 PLANTING_STATS: { GLOBAL: '/v1/planting/stats/global', + // [2026-01-06] 新增:趋势数据接口 + TREND: '/v1/planting/stats/trend', }, // 通知管理 (admin-service) diff --git a/frontend/admin-web/src/services/dashboardService.ts b/frontend/admin-web/src/services/dashboardService.ts index d7107d6b..38643968 100644 --- a/frontend/admin-web/src/services/dashboardService.ts +++ b/frontend/admin-web/src/services/dashboardService.ts @@ -14,6 +14,8 @@ import type { DashboardActivity, DashboardStatItem, PlantingGlobalStats, + PlantingTrendPeriod, + PlantingTrendDataPoint, } from '@/types'; /** 仪表板概览响应 */ @@ -89,6 +91,17 @@ export const dashboardService = { async getPlantingStats(): Promise> { return apiClient.get(API_ENDPOINTS.PLANTING_STATS.GLOBAL); }, + + /** + * 获取认种趋势数据 + * [2026-01-06] 新增 + * @param period 时间维度:day(最近30天)、week(最近12周)、month(最近12月)、quarter(最近8季度)、year(最近5年) + */ + async getPlantingTrendData(period: PlantingTrendPeriod = 'day'): Promise> { + return apiClient.get(API_ENDPOINTS.PLANTING_STATS.TREND, { + params: { period }, + }); + }, }; export default dashboardService; diff --git a/frontend/admin-web/src/types/dashboard.types.ts b/frontend/admin-web/src/types/dashboard.types.ts index 928cb28d..65bee72e 100644 --- a/frontend/admin-web/src/types/dashboard.types.ts +++ b/frontend/admin-web/src/types/dashboard.types.ts @@ -116,3 +116,14 @@ export interface PlantingGlobalStats { monthStats: PlantingPeriodStats; calculatedAt: string; } + +/** 趋势时间维度 [2026-01-06] 新增 */ +export type PlantingTrendPeriod = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** 趋势数据点 [2026-01-06] 新增 */ +export interface PlantingTrendDataPoint { + label: string; + treeCount: number; + orderCount: number; + amount: string; +}