feat(admin-web): 实现 Dashboard 页面真实 API 接入

## 概述
将 admin-web Dashboard 页面从模拟数据改为真实 API 调用,
使用 React Query 实现数据获取、缓存和自动刷新。

## 新增文件
- dashboardService.ts: Dashboard API 服务封装
- useDashboard.ts: React Query hooks
- dashboard.types.ts: Dashboard 类型定义

## API 接入
- /dashboard/stats: 统计卡片(总认种量、总用户数、省/市公司数)
- /dashboard/charts: 趋势图表(支持 7d/30d/90d 周期切换)
- /dashboard/region: 区域分布
- /dashboard/activities: 最近活动

## UI 优化
- 添加加载骨架屏
- 添加错误重试机制
- 添加空数据提示
- 优化图表周期切换交互

🤖 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 2025-12-18 00:31:49 -08:00
parent 0e367d042c
commit 900db13e8d
10 changed files with 569 additions and 145 deletions

View File

@ -20,6 +20,10 @@
}
}
&__statsError {
grid-column: 1 / -1;
}
&__charts {
display: grid;
grid-template-columns: 1fr 360px;
@ -47,4 +51,89 @@
&__regionChart {
min-height: 300px;
}
// 骨架屏样式
&__skeleton {
background: $color-bg-secondary;
border-radius: $border-radius-lg;
padding: $spacing-lg;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
&__skeletonTitle {
width: 60%;
height: 16px;
background: linear-gradient(90deg, $color-border 25%, $color-bg-tertiary 50%, $color-border 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: $border-radius-sm;
}
&__skeletonValue {
width: 80%;
height: 32px;
background: linear-gradient(90deg, $color-border 25%, $color-bg-tertiary 50%, $color-border 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: $border-radius-sm;
}
&__skeletonChange {
width: 40%;
height: 14px;
background: linear-gradient(90deg, $color-border 25%, $color-bg-tertiary 50%, $color-border 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: $border-radius-sm;
}
// 错误提示样式
&__error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $spacing-md;
padding: $spacing-xl;
background: $color-bg-secondary;
border-radius: $border-radius-lg;
color: $color-text-secondary;
min-height: 120px;
}
// 空数据提示样式
&__empty {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl;
background: $color-bg-secondary;
border-radius: $border-radius-lg;
color: $color-text-tertiary;
grid-column: 1 / -1;
min-height: 120px;
}
// 加载中提示
&__activitiesLoading,
&__regionLoading {
display: flex;
align-items: center;
justify-content: center;
background: $color-bg-secondary;
border-radius: $border-radius-lg;
color: $color-text-secondary;
min-height: 100%;
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@ -1,110 +1,81 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/common';
import { PageContainer } from '@/components/layout';
import { StatCard } from '@/components/features/dashboard/StatCard';
import { TrendChart } from '@/components/features/dashboard/TrendChart';
import { RegionDistribution } from '@/components/features/dashboard/RegionDistribution';
import { RecentActivity } from '@/components/features/dashboard/RecentActivity';
import {
useDashboardStats,
useDashboardTrend,
useDashboardRegion,
useDashboardActivities,
} from '@/hooks';
import type { DashboardPeriod } from '@/types';
import styles from './dashboard.module.scss';
// 模拟统计数据
const statsData = [
{
title: '总认种量',
value: 12580,
suffix: '棵',
change: { value: 5.6, trend: 'up' as const },
color: '#1565C0',
},
{
title: '活跃用户',
value: 3240,
suffix: '人',
change: { value: 3.2, trend: 'up' as const },
color: '#4CAF50',
},
{
title: '省级公司',
value: 28,
suffix: '家',
change: { value: 2.1, trend: 'up' as const },
color: '#F5A623',
},
{
title: '市级公司',
value: 156,
suffix: '家',
change: { value: 4.8, trend: 'up' as const },
color: '#9C27B0',
},
];
// 骨架屏组件
const StatCardSkeleton = () => (
<div className={styles.dashboard__skeleton}>
<div className={styles.dashboard__skeletonTitle} />
<div className={styles.dashboard__skeletonValue} />
<div className={styles.dashboard__skeletonChange} />
</div>
);
// 模拟趋势数据
const trendData = [
{ date: '03-19', value: 150 },
{ date: '03-20', value: 280 },
{ date: '03-21', value: 320 },
{ date: '03-22', value: 250 },
{ date: '03-23', value: 380 },
{ date: '03-24', value: 420 },
{ date: '03-25', value: 390 },
];
// 错误提示组件
const ErrorMessage = ({ message, onRetry }: { message: string; onRetry?: () => void }) => (
<div className={styles.dashboard__error}>
<span>{message}</span>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
</Button>
)}
</div>
);
// 模拟区域分布数据
const regionData = [
{ region: '华东地区', percentage: 35, color: '#1565C0' },
{ region: '华南地区', percentage: 25, color: '#4CAF50' },
{ region: '华北地区', percentage: 20, color: '#F5A623' },
{ region: '华中地区', percentage: 12, color: '#9C27B0' },
{ region: '其他地区', percentage: 8, color: '#607D8B' },
];
// 模拟最近活动数据
const activityData = [
{
id: '1',
type: 'user_register' as const,
icon: '👤',
title: '新用户注册',
description: '用户 张三 完成注册',
timestamp: '5分钟前',
},
{
id: '2',
type: 'company_activity' as const,
icon: '🏢',
title: '公司授权',
description: '广东省公司完成授权',
timestamp: '15分钟前',
},
{
id: '3',
type: 'system_update' as const,
icon: '⚙️',
title: '系统更新',
description: '龙虎榜规则已更新',
timestamp: '1小时前',
},
{
id: '4',
type: 'report_generated' as const,
icon: '📊',
title: '报表生成',
description: '3月份运营报表已生成',
timestamp: '2小时前',
},
{
id: '5',
type: 'user_register' as const,
icon: '👤',
title: '新用户注册',
description: '用户 李四 完成注册',
timestamp: '3小时前',
},
];
// 空数据提示
const EmptyData = ({ message }: { message: string }) => (
<div className={styles.dashboard__empty}>
<span>{message}</span>
</div>
);
export default function DashboardPage() {
const [trendPeriod, setTrendPeriod] = useState<DashboardPeriod>('7d');
// 使用 React Query hooks 获取数据
const {
data: statsData,
isLoading: statsLoading,
error: statsError,
refetch: refetchStats,
} = useDashboardStats();
const {
data: trendData,
isLoading: trendLoading,
error: trendError,
refetch: refetchTrend,
} = useDashboardTrend(trendPeriod);
const {
data: regionData,
isLoading: regionLoading,
error: regionError,
refetch: refetchRegion,
} = useDashboardRegion();
const {
data: activitiesData,
isLoading: activitiesLoading,
error: activitiesError,
refetch: refetchActivities,
} = useDashboardActivities(5);
const headerActions = (
<>
<Button variant="outline" size="sm">
@ -128,25 +99,79 @@ export default function DashboardPage() {
<div className={styles.dashboard}>
{/* 统计卡片区 */}
<div className={styles.dashboard__stats}>
{statsData.map((stat, index) => (
<StatCard key={index} {...stat} />
))}
{statsLoading ? (
// 加载状态显示骨架屏
<>
{[1, 2, 3, 4].map((i) => (
<StatCardSkeleton key={i} />
))}
</>
) : statsError ? (
// 错误状态
<div className={styles.dashboard__statsError}>
<ErrorMessage
message="加载统计数据失败"
onRetry={() => refetchStats()}
/>
</div>
) : statsData && statsData.length > 0 ? (
// 正常显示数据
statsData.map((stat, index) => (
<StatCard key={index} {...stat} />
))
) : (
// 空数据状态
<EmptyData message="暂无统计数据" />
)}
</div>
{/* 图表区 */}
<div className={styles.dashboard__charts}>
<div className={styles.dashboard__mainChart}>
<TrendChart title="认种趋势" data={trendData} />
{trendError ? (
<ErrorMessage
message="加载趋势数据失败"
onRetry={() => refetchTrend()}
/>
) : (
<TrendChart
title="认种趋势"
data={trendData?.data ?? []}
period={trendPeriod}
onPeriodChange={setTrendPeriod}
loading={trendLoading}
/>
)}
</div>
<div className={styles.dashboard__sidePanel}>
<RecentActivity activities={activityData} />
{activitiesLoading ? (
<div className={styles.dashboard__activitiesLoading}>...</div>
) : activitiesError ? (
<ErrorMessage
message="加载活动数据失败"
onRetry={() => refetchActivities()}
/>
) : (
<RecentActivity activities={activitiesData ?? []} />
)}
</div>
</div>
{/* 底部区域 */}
<div className={styles.dashboard__bottom}>
<div className={styles.dashboard__regionChart}>
<RegionDistribution data={regionData} />
{regionLoading ? (
<div className={styles.dashboard__regionLoading}>...</div>
) : regionError ? (
<ErrorMessage
message="加载区域分布数据失败"
onRetry={() => refetchRegion()}
/>
) : regionData && regionData.length > 0 ? (
<RegionDistribution data={regionData} />
) : (
<EmptyData message="暂无区域分布数据" />
)}
</div>
</div>
</div>

View File

@ -13,4 +13,13 @@
width: 100%;
height: 300px;
}
&__loading {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 300px;
color: $color-text-secondary;
}
}

View File

@ -1,8 +1,9 @@
'use client';
import { FC, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
import { Card, Button } from '@/components/common';
import type { DashboardPeriod } from '@/types';
import styles from './TrendChart.module.scss';
interface TrendData {
@ -13,16 +14,35 @@ interface TrendData {
export interface TrendChartProps {
title: string;
data: TrendData[];
period?: DashboardPeriod;
onPeriodChange?: (period: DashboardPeriod) => void;
loading?: boolean;
}
const timeRanges = [
const timeRanges: { label: string; value: DashboardPeriod }[] = [
{ label: '7天', value: '7d' },
{ label: '30天', value: '30d' },
{ label: '90天', value: '90d' },
];
export const TrendChart: FC<TrendChartProps> = ({ title, data }) => {
const [activeRange, setActiveRange] = useState('7d');
export const TrendChart: FC<TrendChartProps> = ({
title,
data,
period: externalPeriod,
onPeriodChange,
loading = false,
}) => {
const [internalPeriod, setInternalPeriod] = useState<DashboardPeriod>('7d');
// 支持受控和非受控两种模式
const activePeriod = externalPeriod ?? internalPeriod;
const handlePeriodChange = (newPeriod: DashboardPeriod) => {
if (onPeriodChange) {
onPeriodChange(newPeriod);
} else {
setInternalPeriod(newPeriod);
}
};
return (
<Card
@ -33,8 +53,8 @@ export const TrendChart: FC<TrendChartProps> = ({ title, data }) => {
<Button
key={range.value}
size="sm"
variant={activeRange === range.value ? 'primary' : 'ghost'}
onClick={() => setActiveRange(range.value)}
variant={activePeriod === range.value ? 'primary' : 'ghost'}
onClick={() => handlePeriodChange(range.value)}
>
{range.label}
</Button>
@ -44,46 +64,52 @@ export const TrendChart: FC<TrendChartProps> = ({ title, data }) => {
className={styles.trendChart}
>
<div className={styles.trendChart__chart}>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1565C0" stopOpacity={0.2} />
<stop offset="95%" stopColor="#1565C0" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#757575' }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#757575' }}
width={40}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #E0E0E0',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
labelStyle={{ color: '#212121', fontWeight: 500, marginBottom: 4 }}
itemStyle={{ color: '#1565C0' }}
/>
<Area
type="monotone"
dataKey="value"
stroke="#1565C0"
strokeWidth={2}
fill="url(#colorValue)"
/>
</AreaChart>
</ResponsiveContainer>
{loading ? (
<div className={styles.trendChart__loading}>
<span>...</span>
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1565C0" stopOpacity={0.2} />
<stop offset="95%" stopColor="#1565C0" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#E0E0E0" vertical={false} />
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#757575' }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#757575' }}
width={40}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #E0E0E0',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
labelStyle={{ color: '#212121', fontWeight: 500, marginBottom: 4 }}
itemStyle={{ color: '#1565C0' }}
/>
<Area
type="monotone"
dataKey="value"
stroke="#1565C0"
strokeWidth={2}
fill="url(#colorValue)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</Card>
);

View File

@ -0,0 +1,3 @@
// Hooks 统一导出
export * from './useDashboard';

View File

@ -0,0 +1,96 @@
/**
* Hooks
* 使 React Query
*/
import { useQuery } from '@tanstack/react-query';
import { dashboardService } from '@/services/dashboardService';
import type { DashboardPeriod } from '@/types';
/** Query Keys */
export const dashboardKeys = {
all: ['dashboard'] as const,
overview: () => [...dashboardKeys.all, 'overview'] as const,
stats: () => [...dashboardKeys.all, 'stats'] as const,
trend: (period: DashboardPeriod) => [...dashboardKeys.all, 'trend', period] as const,
region: () => [...dashboardKeys.all, 'region'] as const,
activities: (limit: number) => [...dashboardKeys.all, 'activities', limit] as const,
};
/**
*
*/
export function useDashboardOverview() {
return useQuery({
queryKey: dashboardKeys.overview(),
queryFn: async () => {
const response = await dashboardService.getOverview();
return response.data;
},
staleTime: 30 * 1000, // 30秒后标记为过期
gcTime: 5 * 60 * 1000, // 5分钟后垃圾回收
});
}
/**
*
*/
export function useDashboardStats() {
return useQuery({
queryKey: dashboardKeys.stats(),
queryFn: async () => {
const response = await dashboardService.getStats();
return response.data.stats;
},
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
}
/**
*
* @param period
*/
export function useDashboardTrend(period: DashboardPeriod = '7d') {
return useQuery({
queryKey: dashboardKeys.trend(period),
queryFn: async () => {
const response = await dashboardService.getTrendData(period);
return response.data.trend;
},
staleTime: 60 * 1000, // 1分钟后标记为过期
gcTime: 5 * 60 * 1000,
});
}
/**
*
*/
export function useDashboardRegion() {
return useQuery({
queryKey: dashboardKeys.region(),
queryFn: async () => {
const response = await dashboardService.getRegionDistribution();
return response.data.regions;
},
staleTime: 5 * 60 * 1000, // 5分钟后标记为过期
gcTime: 10 * 60 * 1000,
});
}
/**
*
* @param limit
*/
export function useDashboardActivities(limit = 5) {
return useQuery({
queryKey: dashboardKeys.activities(limit),
queryFn: async () => {
const response = await dashboardService.getRecentActivities(limit);
return response.data.activities;
},
staleTime: 30 * 1000, // 30秒后标记为过期
gcTime: 5 * 60 * 1000,
refetchInterval: 60 * 1000, // 每分钟自动刷新
});
}

View File

@ -84,5 +84,6 @@ export const API_ENDPOINTS = {
STATS: '/dashboard/stats',
ACTIVITIES: '/dashboard/activities',
CHARTS: '/dashboard/charts',
REGION: '/dashboard/region',
},
} as const;

View File

@ -0,0 +1,85 @@
/**
*
* API调用
*/
import apiClient from '@/infrastructure/api/client';
import { API_ENDPOINTS } from '@/infrastructure/api/endpoints';
import type {
ApiResponse,
DashboardOverview,
DashboardTrendData,
DashboardPeriod,
RegionDistributionItem,
DashboardActivity,
DashboardStatItem,
} from '@/types';
/** 仪表板概览响应 */
interface DashboardOverviewResponse {
stats: DashboardStatItem[];
overview: DashboardOverview;
}
/** 仪表板趋势响应 */
interface DashboardTrendResponse {
trend: DashboardTrendData;
}
/** 仪表板区域分布响应 */
interface DashboardRegionResponse {
regions: RegionDistributionItem[];
}
/** 仪表板活动响应 */
interface DashboardActivitiesResponse {
activities: DashboardActivity[];
}
/**
*
*/
export const dashboardService = {
/**
*
*/
async getOverview(): Promise<ApiResponse<DashboardOverviewResponse>> {
return apiClient.get(API_ENDPOINTS.DASHBOARD.OVERVIEW);
},
/**
*
*/
async getStats(): Promise<ApiResponse<{ stats: DashboardStatItem[] }>> {
return apiClient.get(API_ENDPOINTS.DASHBOARD.STATS);
},
/**
*
* @param period (7d | 30d | 90d)
*/
async getTrendData(period: DashboardPeriod = '7d'): Promise<ApiResponse<DashboardTrendResponse>> {
return apiClient.get(API_ENDPOINTS.DASHBOARD.CHARTS, {
params: { period },
});
},
/**
*
*/
async getRegionDistribution(): Promise<ApiResponse<DashboardRegionResponse>> {
return apiClient.get(API_ENDPOINTS.DASHBOARD.REGION);
},
/**
*
* @param limit
*/
async getRecentActivities(limit = 5): Promise<ApiResponse<DashboardActivitiesResponse>> {
return apiClient.get(API_ENDPOINTS.DASHBOARD.ACTIVITIES, {
params: { limit },
});
},
};
export default dashboardService;

View File

@ -0,0 +1,89 @@
// 仪表板类型定义
/** 趋势变化方向 */
export type TrendDirection = 'up' | 'down';
/** 时间周期 */
export type DashboardPeriod = '7d' | '30d' | '90d';
/** 活动类型 */
export type ActivityType =
| 'user_register'
| 'company_authorization'
| 'planting_order'
| 'system_update'
| 'report_generated';
/** 统计卡片变化数据 */
export interface StatChange {
value: number;
trend: TrendDirection;
}
/** 统计卡片数据 */
export interface DashboardStatItem {
title: string;
value: number;
suffix: string;
change: StatChange;
color: string;
}
/** 仪表板概览数据 */
export interface DashboardOverview {
totalPlantingCount: number;
totalPlantingChange: StatChange;
activeUserCount: number;
activeUserChange: StatChange;
provinceCompanyCount: number;
provinceCompanyChange: StatChange;
cityCompanyCount: number;
cityCompanyChange: StatChange;
}
/** 趋势数据点 */
export interface TrendDataPoint {
date: string;
value: number;
}
/** 趋势图表数据 */
export interface DashboardTrendData {
period: DashboardPeriod;
data: TrendDataPoint[];
}
/** 区域分布数据 */
export interface RegionDistributionItem {
region: string;
percentage: number;
color: string;
}
/** 活动记录 */
export interface DashboardActivity {
id: string;
type: ActivityType;
icon: string;
title: string;
description: string;
timestamp: string;
createdAt: string;
}
/** 仪表板完整数据 */
export interface DashboardData {
overview: DashboardOverview;
trend: DashboardTrendData;
regionDistribution: RegionDistributionItem[];
recentActivities: DashboardActivity[];
}
/** 仪表板API请求参数 */
export interface DashboardTrendParams {
period: DashboardPeriod;
}
export interface DashboardActivitiesParams {
limit?: number;
}

View File

@ -5,3 +5,4 @@ export * from './user.types';
export * from './company.types';
export * from './statistics.types';
export * from './common.types';
export * from './dashboard.types';