feat(planting-service, admin-web): 实现认种趋势图表功能
后端变更 (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 <noreply@anthropic.com>
This commit is contained in:
parent
4f3660f05e
commit
fa1931b3b6
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -535,6 +535,15 @@ export class PlantingApplicationService {
|
|||
calculatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认种趋势数据
|
||||
* [2026-01-06] 新增
|
||||
*/
|
||||
async getTrendData(period: TrendPeriodType): Promise<TrendDataResult[]> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,13 @@ export interface IPlantingOrderRepository {
|
|||
* 按状态获取订单分布统计
|
||||
*/
|
||||
getStatusDistribution(): Promise<StatusDistribution>;
|
||||
|
||||
/**
|
||||
* 获取趋势数据
|
||||
* [2026-01-06] 新增
|
||||
* @param period 时间维度: day(最近30天), week(最近12周), month(最近12月), quarter(最近8季度), year(最近5年)
|
||||
*/
|
||||
getTrendData(period: TrendPeriod): Promise<TrendDataPoint[]>;
|
||||
}
|
||||
|
||||
/** 全局认种统计 */
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<TrendDataPoint[]> {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlantingTrendPeriod>('day');
|
||||
|
||||
// [2026-01-06] 新增:认种统计数据状态
|
||||
const [plantingStats, setPlantingStats] = useState<PlantingGlobalStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const [statsError, setStatsError] = useState<string | null>(null);
|
||||
|
||||
// [2026-01-06] 新增:趋势数据状态
|
||||
const [trendData, setTrendData] = useState<PlantingTrendDataPoint[]>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(true);
|
||||
const [trendError, setTrendError] = useState<string | null>(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 (
|
||||
<PageContainer title="数据统计">
|
||||
<div className={styles.statistics}>
|
||||
|
|
@ -152,7 +222,7 @@ export default function StatisticsPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* 榴莲树认种数量趋势 */}
|
||||
{/* 榴莲树认种数量趋势 - [2026-01-06] 实现真实图表 */}
|
||||
<section className={styles.statistics__card}>
|
||||
<div className={styles.statistics__cardHeader}>
|
||||
<b className={styles.statistics__cardTitle}>榴莲树认种数量趋势</b>
|
||||
|
|
@ -168,7 +238,58 @@ export default function StatisticsPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.statistics__chartArea}>Chart Placeholder</div>
|
||||
<div className={styles.statistics__chartArea}>
|
||||
{trendLoading ? (
|
||||
<div className={styles.statistics__chartLoading}>加载中...</div>
|
||||
) : trendError ? (
|
||||
<div className={styles.statistics__chartError}>{trendError}</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className={styles.statistics__chartEmpty}>暂无数据</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
tickLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#6b7280' }}
|
||||
tickLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="认种棵数"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="订单数"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/* 龙虎榜与排名统计 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import type {
|
|||
DashboardActivity,
|
||||
DashboardStatItem,
|
||||
PlantingGlobalStats,
|
||||
PlantingTrendPeriod,
|
||||
PlantingTrendDataPoint,
|
||||
} from '@/types';
|
||||
|
||||
/** 仪表板概览响应 */
|
||||
|
|
@ -89,6 +91,17 @@ export const dashboardService = {
|
|||
async getPlantingStats(): Promise<ApiResponse<PlantingGlobalStats>> {
|
||||
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<ApiResponse<PlantingTrendDataPoint[]>> {
|
||||
return apiClient.get(API_ENDPOINTS.PLANTING_STATS.TREND, {
|
||||
params: { period },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default dashboardService;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue