From f9e448bb072bce6092df39d997f334f9fdb4955a Mon Sep 17 00:00:00 2001
From: 2013256262li-droid <2013256262li@gmail.com>
Date: Thu, 16 Apr 2026 20:30:07 +0800
Subject: [PATCH] feat: add dashboard timeframe switch and persistence
---
src/pages/dashboard/Dashboard.jsx | 227 +++++++++++++++++++++-
src/pages/dashboard/Dashboard.module.scss | 178 ++++++++++++++++-
src/pages/dashboard/mock.js | 94 ++++++++-
3 files changed, 493 insertions(+), 6 deletions(-)
diff --git a/src/pages/dashboard/Dashboard.jsx b/src/pages/dashboard/Dashboard.jsx
index 38a8a772..a49a51ae 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
+];