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:
hailin 2026-01-06 10:21:11 -08:00
parent 4f3660f05e
commit fa1931b3b6
10 changed files with 367 additions and 12 deletions

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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');

View File

@ -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(),
}));
}
}

View File

@ -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>
</>
)}

View File

@ -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;
}
/* 龙虎榜与排名统计 */

View File

@ -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)

View File

@ -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;

View File

@ -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;
}