feat: basic overview is done

This commit is contained in:
JustSong
2025-01-31 22:18:02 +08:00
parent b4e69df802
commit dc8c3bc69e
5 changed files with 412 additions and 31 deletions

View File

@@ -13,6 +13,7 @@
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-toastify": "^9.0.8", "react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5", "react-turnstile": "^1.0.5",
"recharts": "^2.15.1",
"semantic-ui-css": "^2.5.0", "semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3" "semantic-ui-react": "^2.1.3"
}, },

View File

@@ -25,6 +25,7 @@ import TopUp from './pages/TopUp';
import Log from './pages/Log'; import Log from './pages/Log';
import Chat from './pages/Chat'; import Chat from './pages/Chat';
import LarkOAuth from './components/LarkOAuth'; import LarkOAuth from './components/LarkOAuth';
import Dashboard from './pages/Dashboard';
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About')); const About = lazy(() => import('./pages/About'));
@@ -292,9 +293,15 @@ function App() {
</Suspense> </Suspense>
} }
/> />
<Route path='*' element={ <Route
<NotFound /> path='/dashboard'
} /> element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path='*' element={<NotFound />} />
</Routes> </Routes>
); );
} }

View File

@@ -2,8 +2,22 @@ import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User'; import { UserContext } from '../context/User';
import { Button, Container, Dropdown, Icon, Menu, Segment } from 'semantic-ui-react'; import {
import { API, getLogo, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers'; Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import {
API,
getLogo,
getSystemName,
isAdmin,
isMobile,
showSuccess,
} from '../helpers';
import '../index.css'; import '../index.css';
// Header Buttons // Header Buttons
@@ -11,58 +25,63 @@ let headerButtons = [
{ {
name: '首页', name: '首页',
to: '/', to: '/',
icon: 'home' icon: 'home',
}, },
{ {
name: '渠道', name: '渠道',
to: '/channel', to: '/channel',
icon: 'sitemap', icon: 'sitemap',
admin: true admin: true,
}, },
{ {
name: '令牌', name: '令牌',
to: '/token', to: '/token',
icon: 'key' icon: 'key',
}, },
{ {
name: '兑换', name: '兑换',
to: '/redemption', to: '/redemption',
icon: 'dollar sign', icon: 'dollar sign',
admin: true admin: true,
}, },
{ {
name: '充值', name: '充值',
to: '/topup', to: '/topup',
icon: 'cart' icon: 'cart',
}, },
{ {
name: '用户', name: '用户',
to: '/user', to: '/user',
icon: 'user', icon: 'user',
admin: true admin: true,
},
{
name: '总览',
to: '/dashboard',
icon: 'chart bar',
}, },
{ {
name: '日志', name: '日志',
to: '/log', to: '/log',
icon: 'book' icon: 'book',
}, },
{ {
name: '设置', name: '设置',
to: '/setting', to: '/setting',
icon: 'setting' icon: 'setting',
}, },
{ {
name: '关于', name: '关于',
to: '/about', to: '/about',
icon: 'info circle' icon: 'info circle',
} },
]; ];
if (localStorage.getItem('chat_link')) { if (localStorage.getItem('chat_link')) {
headerButtons.splice(1, 0, { headerButtons.splice(1, 0, {
name: '聊天', name: '聊天',
to: '/chat', to: '/chat',
icon: 'comments' icon: 'comments',
}); });
} }
@@ -123,18 +142,14 @@ const Header = () => {
borderBottom: 'none', borderBottom: 'none',
marginBottom: '0', marginBottom: '0',
borderTop: 'none', borderTop: 'none',
height: '51px' height: '51px',
} }
: { borderTop: 'none', height: '52px' } : { borderTop: 'none', height: '52px' }
} }
> >
<Container> <Container>
<Menu.Item as={Link} to='/'> <Menu.Item as={Link} to='/'>
<img <img src={logo} alt='logo' style={{ marginRight: '0.75em' }} />
src={logo}
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}> <div style={{ fontSize: '20px' }}>
<b>{systemName}</b> <b>{systemName}</b>
</div> </div>

View File

@@ -0,0 +1,63 @@
.dashboard-container {
padding: 20px;
background-color: #f7f9fc;
}
.stat-card {
background: linear-gradient(135deg, #2185d0 0%, #1678c2 100%) !important;
color: white !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
transition: transform 0.2s ease !important;
margin-bottom: 1rem !important;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-card .statistic {
color: white !important;
}
.charts-grid {
margin-top: 1rem !important;
margin-bottom: 1rem !important;
}
.charts-grid .column {
padding: 0.5rem !important;
}
.chart-card {
margin: 0 !important;
height: 100%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05) !important;
}
.chart-container {
margin-top: 20px;
padding: 10px;
background-color: white;
border-radius: 4px;
}
.ui.card > .content > .header {
color: #1a1a1a;
font-size: 1.2em;
margin-bottom: 15px;
}
/* 优化图表响应式布局 */
@media (max-width: 768px) {
.dashboard-container {
padding: 10px;
}
.chart-container {
padding: 5px;
}
.charts-grid .column {
padding: 0.25rem !important;
}
}

View File

@@ -0,0 +1,295 @@
import React, { useEffect, useState } from 'react';
import { Card, Grid, Statistic } from 'semantic-ui-react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from 'recharts';
import axios from 'axios';
import './Dashboard.css';
const Dashboard = () => {
const [data, setData] = useState([]);
const [summaryData, setSummaryData] = useState({
todayRequests: 0,
todayQuota: 0,
todayTokens: 0,
});
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
try {
const response = await axios.get('/api/user/dashboard');
if (response.data.success) {
const dashboardData = response.data.data;
setData(dashboardData);
calculateSummary(dashboardData);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
}
};
const calculateSummary = (dashboardData) => {
const today = new Date().toISOString().split('T')[0];
const todayData = dashboardData.filter((item) => item.Day === today);
const summary = {
todayRequests: todayData.reduce(
(sum, item) => sum + item.RequestCount,
0
),
todayQuota:
todayData.reduce((sum, item) => sum + item.Quota, 0) / 1000000, // 转换为美元
todayTokens: todayData.reduce(
(sum, item) => sum + item.PromptTokens + item.CompletionTokens,
0
),
};
setSummaryData(summary);
};
// 处理数据以供折线图使用,补充缺失的日期
const processTimeSeriesData = () => {
const dailyData = {};
// 获取日期范围
const dates = data.map((item) => item.Day);
const minDate = new Date(Math.min(...dates.map((d) => new Date(d))));
const maxDate = new Date(Math.max(...dates.map((d) => new Date(d))));
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
dailyData[dateStr] = {
date: dateStr,
requests: 0,
quota: 0,
tokens: 0,
};
}
// 填充实际数据
data.forEach((item) => {
dailyData[item.Day].requests += item.RequestCount;
dailyData[item.Day].quota += item.Quota / 1000000;
dailyData[item.Day].tokens += item.PromptTokens + item.CompletionTokens;
});
return Object.values(dailyData).sort((a, b) =>
a.date.localeCompare(b.date)
);
};
// 处理数据以供堆叠柱状图使用
const processModelData = () => {
const timeData = {};
// 获取日期范围
const dates = data.map((item) => item.Day);
const minDate = new Date(Math.min(...dates.map((d) => new Date(d))));
const maxDate = new Date(Math.max(...dates.map((d) => new Date(d))));
// 生成所有日期
for (let d = new Date(minDate); d <= maxDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split('T')[0];
timeData[dateStr] = {
date: dateStr,
};
// 初始化所有模型的数据为0
const models = [...new Set(data.map((item) => item.ModelName))];
models.forEach((model) => {
timeData[dateStr][model] = 0;
});
}
// 填充实际数据
data.forEach((item) => {
timeData[item.Day][item.ModelName] =
item.PromptTokens + item.CompletionTokens;
});
return Object.values(timeData).sort((a, b) => a.date.localeCompare(b.date));
};
// 获取所有唯一的模型名称
const getUniqueModels = () => {
return [...new Set(data.map((item) => item.ModelName))];
};
const timeSeriesData = processTimeSeriesData();
const modelData = processModelData();
const models = getUniqueModels();
// 生成随机颜色
const getRandomColor = (index) => {
const colors = [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
];
return colors[index % colors.length];
};
return (
<div className='dashboard-container'>
<Grid columns={3} stackable>
<Grid.Column>
<Card fluid className='stat-card'>
<Card.Content>
<Statistic>
<Statistic.Value>{summaryData.todayRequests}</Statistic.Value>
<Statistic.Label>今日请求量</Statistic.Label>
</Statistic>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='stat-card'>
<Card.Content>
<Statistic>
<Statistic.Value>
${summaryData.todayQuota.toFixed(3)}
</Statistic.Value>
<Statistic.Label>今日消费</Statistic.Label>
</Statistic>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='stat-card'>
<Card.Content>
<Statistic>
<Statistic.Value>{summaryData.todayTokens}</Statistic.Value>
<Statistic.Label>今日 token</Statistic.Label>
</Statistic>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
{/* 三个并排的折线图 */}
<Grid columns={3} stackable className='charts-grid'>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>今日请求量</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<LineChart data={timeSeriesData}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip />
<Line
type='monotone'
dataKey='requests'
stroke='#2185d0'
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>今日消费</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<LineChart data={timeSeriesData}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip />
<Line
type='monotone'
dataKey='quota'
stroke='#21ba45'
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>今日 token</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={120}>
<LineChart data={timeSeriesData}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip />
<Line
type='monotone'
dataKey='tokens'
stroke='#f2711c'
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
{/* 模型使用统计 */}
<Card fluid className='chart-card'>
<Card.Content>
<Card.Header>统计</Card.Header>
<div className='chart-container'>
<ResponsiveContainer width='100%' height={300}>
<BarChart data={modelData}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis />
<Tooltip />
<Legend />
{models.map((model, index) => (
<Bar
key={model}
dataKey={model}
stackId='a'
fill={getRandomColor(index)}
name={model}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</Card.Content>
</Card>
</div>
);
};
export default Dashboard;