diff --git a/src/pages/dashboard/Dashboard.jsx b/src/pages/dashboard/Dashboard.jsx index 38a8a77..a49a51a 100644 --- a/src/pages/dashboard/Dashboard.jsx +++ b/src/pages/dashboard/Dashboard.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, useCallback } from 'react'; import { Alert, Badge, @@ -23,14 +23,34 @@ import { Eye, Person, Telephone, + ArrowUp, + ArrowDown, + People, + Cart, + CurrencyDollar, + Activity, } from 'react-bootstrap-icons'; import { Link } from 'react-router-dom'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; import { useAppDispatch, useAppSelector } from '../../app/hooks'; import { fetchPosts } from '../../features/posts/postsSlice'; import Widget from '../../components/Widget'; +import { getDashboardData, timeframeOptions } from './mock'; import s from './Dashboard.module.scss'; +const STORAGE_KEY = 'dashboard_timeframe'; +const DEFAULT_TIMEFRAME = '7d'; + const formatDate = (value) => new Intl.DateTimeFormat('en', { month: 'short', @@ -38,6 +58,25 @@ const formatDate = (value) => year: 'numeric', }).format(new Date(value)); +const formatNumber = (num) => { + if (num >= 10000) { + return (num / 10000).toFixed(1) + '万'; + } + return num.toLocaleString(); +}; + +const getStoredTimeframe = () => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && timeframeOptions.some(opt => opt.value === stored)) { + return stored; + } + } catch (e) { + console.warn('Failed to read timeframe from localStorage:', e); + } + return DEFAULT_TIMEFRAME; +}; + const quickLinks = [ { to: '/app/main', @@ -69,11 +108,121 @@ const quickLinks = [ }, ]; +const TimeframeSelector = ({ value, onChange }) => ( + + {timeframeOptions.map((option) => ( + + ))} + +); + +const StatCard = ({ icon: Icon, title, value, growth, color }) => { + const isPositive = growth >= 0; + return ( + +
+
+ +
+
+
{title}
+
{formatNumber(value)}
+
+ {isPositive ? : } + {Math.abs(growth)}% + vs 上期 +
+
+
+
+ ); +}; + +const EmptyState = ({ title, message, icon: Icon }) => ( +
+
+ {Icon ? : null} +
+
{title}
+

{message}

+
+); + +const MainChart = ({ data, hasData }) => { + if (!hasData || data.length === 0) { + return ( + + ); + } + + return ( + + + + + `${value >= 1000 ? (value / 1000).toFixed(0) + 'k' : value}`} + /> + + + + + + + ); +}; + const Dashboard = () => { const dispatch = useAppDispatch(); const posts = useAppSelector((state) => state.posts.items); const fetchStatus = useAppSelector((state) => state.posts.fetchStatus); const [isDropdownOpened, setIsDropdownOpened] = useState(false); + const [timeframe, setTimeframe] = useState(getStoredTimeframe); useEffect(() => { if (fetchStatus === 'idle' && posts.length === 0) { @@ -81,15 +230,89 @@ const Dashboard = () => { } }, [dispatch, fetchStatus, posts.length]); + const handleTimeframeChange = useCallback((newTimeframe) => { + setTimeframe(newTimeframe); + try { + localStorage.setItem(STORAGE_KEY, newTimeframe); + } catch (e) { + console.warn('Failed to save timeframe to localStorage:', e); + } + }, []); + + const dashboardData = useMemo( + () => getDashboardData(timeframe), + [timeframe] + ); + + const { stats, chartData, hasData } = dashboardData; + const recentPosts = useMemo(() => posts.slice(0, 5), [posts]); + const statCards = [ + { + icon: People, + title: '总用户数', + value: stats.totalUsers, + growth: stats.userGrowth, + color: '#3754a5', + }, + { + icon: Activity, + title: '活跃用户', + value: stats.activeUsers, + growth: stats.userGrowth, + color: '#1ab394', + }, + { + icon: Cart, + title: '新订单', + value: stats.newOrders, + growth: stats.orderGrowth, + color: '#f3c363', + }, + { + icon: CurrencyDollar, + title: '总收入', + value: stats.revenue, + growth: stats.orderGrowth, + color: '#eb3349', + }, + ]; + return (
YOU ARE HERE Dashboard -

Dashboard

+
+

Dashboard

+ +
+ + + {statCards.map((card, index) => ( + + + + ))} + + + +
+ + 数据趋势 +
+ 基于当前选择的时间段 +
+ } + className="mb-lg" + > + + + { + const dates = []; + const today = new Date(); + for (let i = days - 1; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + dates.push(date); + } + return dates; +}; + +const formatDateLabel = (date, days) => { + if (days <= 7) { + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); + } else if (days <= 30) { + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }); + } else { + return date.toLocaleDateString('zh-CN', { month: 'short' }); + } +}; + +const generateChartData = (days, hasData = true) => { + if (!hasData) return []; + + const dates = generateDateRange(days); + const data = []; + let pv = Math.floor(Math.random() * 2000) + 1000; + let uv = Math.floor(Math.random() * 1500) + 500; + + const sampleRate = days <= 7 ? 1 : days <= 30 ? 2 : 7; + + for (let i = 0; i < dates.length; i++) { + if (i % sampleRate === 0) { + pv = Math.max(500, pv + Math.floor(Math.random() * 600) - 300); + uv = Math.max(300, uv + Math.floor(Math.random() * 400) - 200); + data.push({ + name: formatDateLabel(dates[i], days), + pv, + uv, + }); + } + } + return data; +}; + +const generateStats = (days, hasData = true) => { + if (!hasData) { + return { + totalUsers: 0, + activeUsers: 0, + newOrders: 0, + revenue: 0, + userGrowth: 0, + orderGrowth: 0, + }; + } + + const baseMultiplier = days / 7; + return { + totalUsers: Math.floor((1240 + Math.random() * 500) * baseMultiplier), + activeUsers: Math.floor((850 + Math.random() * 300) * baseMultiplier), + newOrders: Math.floor((320 + Math.random() * 200) * baseMultiplier), + revenue: Math.floor((15680 + Math.random() * 10000) * baseMultiplier), + userGrowth: Math.floor(5 + Math.random() * 15), + orderGrowth: Math.floor(-5 + Math.random() * 20), + }; +}; + +export const getDashboardData = (timeframe) => { + const daysMap = { + '7d': 7, + '30d': 30, + '90d': 90, + }; + + const days = daysMap[timeframe] || 7; + + const hasData = Math.random() > 0.1; + + return { + stats: generateStats(days, hasData), + chartData: generateChartData(days, hasData), + hasData, + }; +}; + +export const timeframeOptions = [ + { value: '7d', label: '7天' }, + { value: '30d', label: '30天' }, + { value: '90d', label: '90天' }, +]; + export const mock = [ { id: 123325, @@ -24,4 +116,4 @@ export const mock = [ updatedAt: '2019-11-14', title: 'Light Blue Vue Node.js - update version 3.0.5' }, -] \ No newline at end of file +];