📚 refactor(dashboard): modularize dashboard page into reusable hooks and components
## Overview Refactored the monolithic dashboard page (~1200 lines) into a modular architecture following the project's global layout pattern. The main `Detail/index.js` is now simplified to match other page entry files like `Midjourney/index.js`. ## Changes Made ### 🏗️ Architecture Changes - **Before**: Single large file `pages/Detail/index.js` containing all dashboard logic - **After**: Modular structure with dedicated hooks, components, and helpers ### 📁 New Files Created - `hooks/dashboard/useDashboardData.js` - Core data management and API calls - `hooks/dashboard/useDashboardStats.js` - Statistics computation and memoization - `hooks/dashboard/useDashboardCharts.js` - Chart specifications and data processing - `constants/dashboard.constants.js` - UI config, time options, and chart defaults - `helpers/dashboard.js` - Utility functions for data processing and UI helpers - `components/dashboard/index.jsx` - Main dashboard component integrating all modules - `components/dashboard/modals/SearchModal.jsx` - Search modal component ### 🔧 Updated Files - `constants/index.js` - Added dashboard constants export - `helpers/index.js` - Added dashboard helpers export - `pages/Detail/index.js` - Simplified to minimal wrapper (~20 lines) ### 🐛 Bug Fixes - Fixed SearchModal DatePicker onChange to properly convert Date objects to timestamp strings - Added missing localStorage update for `data_export_default_time` persistence - Corrected data flow between search confirmation and chart updates - Ensured proper chart data refresh after search parameter changes ### ✨ Key Improvements - **Separation of Concerns**: Data, stats, and charts logic isolated into dedicated hooks - **Reusability**: Components and hooks can be easily reused across the application - **Maintainability**: Smaller, focused files easier to understand and modify - **Consistency**: Follows established project patterns for global folder organization - **Performance**: Proper memoization and callback optimization maintained ### 🎯 Functional Verification - ✅ All dashboard panels (model analysis, resource consumption, performance metrics) update correctly - ✅ Search functionality works with proper parameter validation - ✅ Chart data refreshes properly after search/filter operations - ✅ User interface remains identical to original implementation - ✅ All existing features preserved without regression ### 🔄 Data Flow ``` User Input → SearchModal → useDashboardData → API Call → useDashboardCharts → UI Update ``` ## Breaking Changes None. All existing functionality preserved. ## Migration Notes The refactored dashboard maintains 100% API compatibility and identical user experience while providing a cleaner, more maintainable codebase structure.
This commit is contained in:
@@ -46,7 +46,7 @@ import Setup from './pages/Setup/index.js';
|
|||||||
import SetupCheck from './components/layout/SetupCheck.js';
|
import SetupCheck from './components/layout/SetupCheck.js';
|
||||||
|
|
||||||
const Home = lazy(() => import('./pages/Home'));
|
const Home = lazy(() => import('./pages/Home'));
|
||||||
const Detail = lazy(() => import('./pages/Detail'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
const About = lazy(() => import('./pages/About'));
|
const About = lazy(() => import('./pages/About'));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -214,7 +214,7 @@ function App() {
|
|||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<Detail />
|
<Dashboard />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
74
web/src/components/common/charts/TrendChart.jsx
Normal file
74
web/src/components/common/charts/TrendChart.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { VChart } from '@visactor/react-vchart';
|
||||||
|
|
||||||
|
const TrendChart = ({
|
||||||
|
data,
|
||||||
|
color,
|
||||||
|
width = 100,
|
||||||
|
height = 40,
|
||||||
|
config = { mode: 'desktop-browser' }
|
||||||
|
}) => {
|
||||||
|
const getTrendSpec = (data, color) => ({
|
||||||
|
type: 'line',
|
||||||
|
data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
|
||||||
|
xField: 'x',
|
||||||
|
yField: 'y',
|
||||||
|
height: height,
|
||||||
|
width: width,
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
orient: 'bottom',
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orient: 'left',
|
||||||
|
visible: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
padding: 0,
|
||||||
|
autoFit: false,
|
||||||
|
legends: { visible: false },
|
||||||
|
tooltip: { visible: false },
|
||||||
|
crosshair: { visible: false },
|
||||||
|
line: {
|
||||||
|
style: {
|
||||||
|
stroke: color,
|
||||||
|
lineWidth: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
fill: 'transparent'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VChart
|
||||||
|
spec={getTrendSpec(data, color)}
|
||||||
|
option={config}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrendChart;
|
||||||
107
web/src/components/dashboard/AnnouncementsPanel.jsx
Normal file
107
web/src/components/dashboard/AnnouncementsPanel.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||||
|
|
||||||
|
const AnnouncementsPanel = ({
|
||||||
|
announcementData,
|
||||||
|
announcementLegendData,
|
||||||
|
CARD_PROPS,
|
||||||
|
ILLUSTRATION_SIZE,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
{...CARD_PROPS}
|
||||||
|
className="shadow-sm !rounded-2xl lg:col-span-2"
|
||||||
|
title={
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell size={16} />
|
||||||
|
{t('系统公告')}
|
||||||
|
<Tag color="white" shape="circle">
|
||||||
|
{t('显示最新20条')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
{/* 图例 */}
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs">
|
||||||
|
{announcementLegendData.map((legend, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: legend.color === 'grey' ? '#8b9aa7' :
|
||||||
|
legend.color === 'blue' ? '#3b82f6' :
|
||||||
|
legend.color === 'green' ? '#10b981' :
|
||||||
|
legend.color === 'orange' ? '#f59e0b' :
|
||||||
|
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-600">{legend.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<ScrollableContainer maxHeight="24rem">
|
||||||
|
{announcementData.length > 0 ? (
|
||||||
|
<Timeline mode="alternate">
|
||||||
|
{announcementData.map((item, idx) => (
|
||||||
|
<Timeline.Item
|
||||||
|
key={idx}
|
||||||
|
type={item.type || 'default'}
|
||||||
|
time={item.time}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked.parse(item.content || '') }}
|
||||||
|
/>
|
||||||
|
{item.extra && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-gray-500"
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked.parse(item.extra) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||||
|
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||||
|
title={t('暂无系统公告')}
|
||||||
|
description={t('请联系管理员在系统设置中配置公告信息')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnnouncementsPanel;
|
||||||
117
web/src/components/dashboard/ApiInfoPanel.jsx
Normal file
117
web/src/components/dashboard/ApiInfoPanel.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { Server, Gauge, ExternalLink } from 'lucide-react';
|
||||||
|
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||||
|
|
||||||
|
const ApiInfoPanel = ({
|
||||||
|
apiInfoData,
|
||||||
|
handleCopyUrl,
|
||||||
|
handleSpeedTest,
|
||||||
|
CARD_PROPS,
|
||||||
|
FLEX_CENTER_GAP2,
|
||||||
|
ILLUSTRATION_SIZE,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
{...CARD_PROPS}
|
||||||
|
className="bg-gray-50 border-0 !rounded-2xl"
|
||||||
|
title={
|
||||||
|
<div className={FLEX_CENTER_GAP2}>
|
||||||
|
<Server size={16} />
|
||||||
|
{t('API信息')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<ScrollableContainer maxHeight="24rem">
|
||||||
|
{apiInfoData.length > 0 ? (
|
||||||
|
apiInfoData.map((api) => (
|
||||||
|
<React.Fragment key={api.id}>
|
||||||
|
<div className="flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
<Avatar
|
||||||
|
size="extra-small"
|
||||||
|
color={api.color}
|
||||||
|
>
|
||||||
|
{api.route.substring(0, 2)}
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap items-center justify-between mb-1 w-full gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900 !font-bold break-all">
|
||||||
|
{api.route}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 mt-1 lg:mt-0">
|
||||||
|
<Tag
|
||||||
|
prefixIcon={<Gauge size={12} />}
|
||||||
|
size="small"
|
||||||
|
color="white"
|
||||||
|
shape='circle'
|
||||||
|
onClick={() => handleSpeedTest(api.url)}
|
||||||
|
className="cursor-pointer hover:opacity-80 text-xs"
|
||||||
|
>
|
||||||
|
{t('测速')}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
prefixIcon={<ExternalLink size={12} />}
|
||||||
|
size="small"
|
||||||
|
color="white"
|
||||||
|
shape='circle'
|
||||||
|
onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="cursor-pointer hover:opacity-80 text-xs"
|
||||||
|
>
|
||||||
|
{t('跳转')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="!text-semi-color-primary break-all cursor-pointer hover:underline mb-1"
|
||||||
|
onClick={() => handleCopyUrl(api.url)}
|
||||||
|
>
|
||||||
|
{api.url}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">
|
||||||
|
{api.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||||
|
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||||
|
title={t('暂无API信息')}
|
||||||
|
description={t('请联系管理员在系统设置中配置API信息')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ApiInfoPanel;
|
||||||
117
web/src/components/dashboard/ChartsPanel.jsx
Normal file
117
web/src/components/dashboard/ChartsPanel.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||||
|
import { PieChart } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
IconHistogram,
|
||||||
|
IconPulse,
|
||||||
|
IconPieChart2Stroked
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { VChart } from '@visactor/react-vchart';
|
||||||
|
|
||||||
|
const ChartsPanel = ({
|
||||||
|
activeChartTab,
|
||||||
|
setActiveChartTab,
|
||||||
|
spec_line,
|
||||||
|
spec_model_line,
|
||||||
|
spec_pie,
|
||||||
|
spec_rank_bar,
|
||||||
|
CARD_PROPS,
|
||||||
|
CHART_CONFIG,
|
||||||
|
FLEX_CENTER_GAP2,
|
||||||
|
hasApiInfoPanel,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
{...CARD_PROPS}
|
||||||
|
className={`shadow-sm !rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
|
||||||
|
title={
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3">
|
||||||
|
<div className={FLEX_CENTER_GAP2}>
|
||||||
|
<PieChart size={16} />
|
||||||
|
{t('模型数据分析')}
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
type="button"
|
||||||
|
activeKey={activeChartTab}
|
||||||
|
onChange={setActiveChartTab}
|
||||||
|
>
|
||||||
|
<TabPane tab={
|
||||||
|
<span>
|
||||||
|
<IconHistogram />
|
||||||
|
{t('消耗分布')}
|
||||||
|
</span>
|
||||||
|
} itemKey="1" />
|
||||||
|
<TabPane tab={
|
||||||
|
<span>
|
||||||
|
<IconPulse />
|
||||||
|
{t('消耗趋势')}
|
||||||
|
</span>
|
||||||
|
} itemKey="2" />
|
||||||
|
<TabPane tab={
|
||||||
|
<span>
|
||||||
|
<IconPieChart2Stroked />
|
||||||
|
{t('调用次数分布')}
|
||||||
|
</span>
|
||||||
|
} itemKey="3" />
|
||||||
|
<TabPane tab={
|
||||||
|
<span>
|
||||||
|
<IconHistogram />
|
||||||
|
{t('调用次数排行')}
|
||||||
|
</span>
|
||||||
|
} itemKey="4" />
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<div className="h-96 p-2">
|
||||||
|
{activeChartTab === '1' && (
|
||||||
|
<VChart
|
||||||
|
spec={spec_line}
|
||||||
|
option={CHART_CONFIG}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeChartTab === '2' && (
|
||||||
|
<VChart
|
||||||
|
spec={spec_model_line}
|
||||||
|
option={CHART_CONFIG}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeChartTab === '3' && (
|
||||||
|
<VChart
|
||||||
|
spec={spec_pie}
|
||||||
|
option={CHART_CONFIG}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeChartTab === '4' && (
|
||||||
|
<VChart
|
||||||
|
spec={spec_rank_bar}
|
||||||
|
option={CHART_CONFIG}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartsPanel;
|
||||||
61
web/src/components/dashboard/DashboardHeader.jsx
Normal file
61
web/src/components/dashboard/DashboardHeader.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
|
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
const DashboardHeader = ({
|
||||||
|
getGreeting,
|
||||||
|
greetingVisible,
|
||||||
|
showSearchModal,
|
||||||
|
refresh,
|
||||||
|
loading,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out"
|
||||||
|
style={{ opacity: greetingVisible ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
{getGreeting}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
icon={<IconSearch />}
|
||||||
|
onClick={showSearchModal}
|
||||||
|
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
icon={<IconRefresh />}
|
||||||
|
onClick={refresh}
|
||||||
|
loading={loading}
|
||||||
|
className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardHeader;
|
||||||
81
web/src/components/dashboard/FaqPanel.jsx
Normal file
81
web/src/components/dashboard/FaqPanel.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { HelpCircle } from 'lucide-react';
|
||||||
|
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||||
|
|
||||||
|
const FaqPanel = ({
|
||||||
|
faqData,
|
||||||
|
CARD_PROPS,
|
||||||
|
FLEX_CENTER_GAP2,
|
||||||
|
ILLUSTRATION_SIZE,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
{...CARD_PROPS}
|
||||||
|
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||||
|
title={
|
||||||
|
<div className={FLEX_CENTER_GAP2}>
|
||||||
|
<HelpCircle size={16} />
|
||||||
|
{t('常见问答')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<ScrollableContainer maxHeight="24rem">
|
||||||
|
{faqData.length > 0 ? (
|
||||||
|
<Collapse
|
||||||
|
accordion
|
||||||
|
expandIcon={<IconPlus />}
|
||||||
|
collapseIcon={<IconMinus />}
|
||||||
|
>
|
||||||
|
{faqData.map((item, index) => (
|
||||||
|
<Collapse.Panel
|
||||||
|
key={index}
|
||||||
|
header={item.question}
|
||||||
|
itemKey={index.toString()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked.parse(item.answer || '') }}
|
||||||
|
/>
|
||||||
|
</Collapse.Panel>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||||
|
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||||
|
title={t('暂无常见问答')}
|
||||||
|
description={t('请联系管理员在系统设置中配置常见问答')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollableContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FaqPanel;
|
||||||
93
web/src/components/dashboard/StatsCards.jsx
Normal file
93
web/src/components/dashboard/StatsCards.jsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, Avatar, Skeleton } from '@douyinfe/semi-ui';
|
||||||
|
import { VChart } from '@visactor/react-vchart';
|
||||||
|
|
||||||
|
const StatsCards = ({
|
||||||
|
groupedStatsData,
|
||||||
|
loading,
|
||||||
|
getTrendSpec,
|
||||||
|
CARD_PROPS,
|
||||||
|
CHART_CONFIG
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{groupedStatsData.map((group, idx) => (
|
||||||
|
<Card
|
||||||
|
key={idx}
|
||||||
|
{...CARD_PROPS}
|
||||||
|
className={`${group.color} border-0 !rounded-2xl w-full`}
|
||||||
|
title={group.title}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{group.items.map((item, itemIdx) => (
|
||||||
|
<div
|
||||||
|
key={itemIdx}
|
||||||
|
className="flex items-center justify-between cursor-pointer"
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Avatar
|
||||||
|
className="mr-3"
|
||||||
|
size="small"
|
||||||
|
color={item.avatarColor}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">{item.title}</div>
|
||||||
|
<div className="text-lg font-semibold">
|
||||||
|
<Skeleton
|
||||||
|
loading={loading}
|
||||||
|
active
|
||||||
|
placeholder={
|
||||||
|
<Skeleton.Paragraph
|
||||||
|
active
|
||||||
|
rows={1}
|
||||||
|
style={{ width: '65px', height: '24px', marginTop: '4px' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</Skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(loading || (item.trendData && item.trendData.length > 0)) && (
|
||||||
|
<div className="w-24 h-10">
|
||||||
|
<VChart
|
||||||
|
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||||||
|
option={CHART_CONFIG}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatsCards;
|
||||||
136
web/src/components/dashboard/UptimePanel.jsx
Normal file
136
web/src/components/dashboard/UptimePanel.jsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, Button, Spin, Tabs, TabPane, Tag, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { Gauge } from 'lucide-react';
|
||||||
|
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||||
|
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||||
|
|
||||||
|
const UptimePanel = ({
|
||||||
|
uptimeData,
|
||||||
|
uptimeLoading,
|
||||||
|
activeUptimeTab,
|
||||||
|
setActiveUptimeTab,
|
||||||
|
loadUptimeData,
|
||||||
|
uptimeLegendData,
|
||||||
|
renderMonitorList,
|
||||||
|
CARD_PROPS,
|
||||||
|
ILLUSTRATION_SIZE,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
{...CARD_PROPS}
|
||||||
|
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center justify-between w-full gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gauge size={16} />
|
||||||
|
{t('服务可用性')}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={<IconRefresh />}
|
||||||
|
onClick={loadUptimeData}
|
||||||
|
loading={uptimeLoading}
|
||||||
|
size="small"
|
||||||
|
theme="borderless"
|
||||||
|
type='tertiary'
|
||||||
|
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Spin spinning={uptimeLoading}>
|
||||||
|
{uptimeData.length > 0 ? (
|
||||||
|
uptimeData.length === 1 ? (
|
||||||
|
<ScrollableContainer maxHeight="24rem">
|
||||||
|
{renderMonitorList(uptimeData[0].monitors)}
|
||||||
|
</ScrollableContainer>
|
||||||
|
) : (
|
||||||
|
<Tabs
|
||||||
|
type="card"
|
||||||
|
collapsible
|
||||||
|
activeKey={activeUptimeTab}
|
||||||
|
onChange={setActiveUptimeTab}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{uptimeData.map((group, groupIdx) => (
|
||||||
|
<TabPane
|
||||||
|
tab={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Gauge size={14} />
|
||||||
|
{group.categoryName}
|
||||||
|
<Tag
|
||||||
|
color={activeUptimeTab === group.categoryName ? 'red' : 'grey'}
|
||||||
|
size='small'
|
||||||
|
shape='circle'
|
||||||
|
>
|
||||||
|
{group.monitors ? group.monitors.length : 0}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
itemKey={group.categoryName}
|
||||||
|
key={groupIdx}
|
||||||
|
>
|
||||||
|
<ScrollableContainer maxHeight="21.5rem">
|
||||||
|
{renderMonitorList(group.monitors)}
|
||||||
|
</ScrollableContainer>
|
||||||
|
</TabPane>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||||
|
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||||
|
title={t('暂无监控数据')}
|
||||||
|
description={t('请联系管理员在系统设置中配置Uptime')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图例 */}
|
||||||
|
{uptimeData.length > 0 && (
|
||||||
|
<div className="p-3 bg-gray-50 rounded-b-2xl">
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs justify-center">
|
||||||
|
{uptimeLegendData.map((legend, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: legend.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-600">{legend.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UptimePanel;
|
||||||
247
web/src/components/dashboard/index.jsx
Normal file
247
web/src/components/dashboard/index.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useContext, useEffect } from 'react';
|
||||||
|
import { getRelativeTime } from '../../helpers';
|
||||||
|
import { UserContext } from '../../context/User/index.js';
|
||||||
|
import { StatusContext } from '../../context/Status/index.js';
|
||||||
|
|
||||||
|
import DashboardHeader from './DashboardHeader';
|
||||||
|
import StatsCards from './StatsCards';
|
||||||
|
import ChartsPanel from './ChartsPanel';
|
||||||
|
import ApiInfoPanel from './ApiInfoPanel';
|
||||||
|
import AnnouncementsPanel from './AnnouncementsPanel';
|
||||||
|
import FaqPanel from './FaqPanel';
|
||||||
|
import UptimePanel from './UptimePanel';
|
||||||
|
import SearchModal from './modals/SearchModal';
|
||||||
|
|
||||||
|
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||||
|
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
||||||
|
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHART_CONFIG,
|
||||||
|
CARD_PROPS,
|
||||||
|
FLEX_CENTER_GAP2,
|
||||||
|
ILLUSTRATION_SIZE,
|
||||||
|
ANNOUNCEMENT_LEGEND_DATA,
|
||||||
|
UPTIME_STATUS_MAP
|
||||||
|
} from '../../constants/dashboard.constants';
|
||||||
|
import {
|
||||||
|
getTrendSpec,
|
||||||
|
handleCopyUrl,
|
||||||
|
handleSpeedTest,
|
||||||
|
getUptimeStatusColor,
|
||||||
|
getUptimeStatusText,
|
||||||
|
renderMonitorList
|
||||||
|
} from '../../helpers/dashboard';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
// ========== Context ==========
|
||||||
|
const [userState, userDispatch] = useContext(UserContext);
|
||||||
|
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||||
|
|
||||||
|
// ========== 主要数据管理 ==========
|
||||||
|
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
||||||
|
|
||||||
|
// ========== 图表管理 ==========
|
||||||
|
const dashboardCharts = useDashboardCharts(
|
||||||
|
dashboardData.dataExportDefaultTime,
|
||||||
|
dashboardData.setTrendData,
|
||||||
|
dashboardData.setConsumeQuota,
|
||||||
|
dashboardData.setTimes,
|
||||||
|
dashboardData.setConsumeTokens,
|
||||||
|
dashboardData.setPieData,
|
||||||
|
dashboardData.setLineData,
|
||||||
|
dashboardData.setModelColors,
|
||||||
|
dashboardData.t
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== 统计数据 ==========
|
||||||
|
const { groupedStatsData } = useDashboardStats(
|
||||||
|
userState,
|
||||||
|
dashboardData.consumeQuota,
|
||||||
|
dashboardData.consumeTokens,
|
||||||
|
dashboardData.times,
|
||||||
|
dashboardData.trendData,
|
||||||
|
dashboardData.performanceMetrics,
|
||||||
|
dashboardData.navigate,
|
||||||
|
dashboardData.t
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== 数据处理 ==========
|
||||||
|
const initChart = async () => {
|
||||||
|
await dashboardData.loadQuotaData().then(data => {
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
dashboardCharts.updateChartData(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await dashboardData.loadUptimeData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
const data = await dashboardData.refresh();
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
dashboardCharts.updateChartData(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchConfirm = async () => {
|
||||||
|
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 数据准备 ==========
|
||||||
|
const apiInfoData = statusState?.status?.api_info || [];
|
||||||
|
const announcementData = (statusState?.status?.announcements || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
time: getRelativeTime(item.publishDate)
|
||||||
|
}));
|
||||||
|
const faqData = statusState?.status?.faq || [];
|
||||||
|
|
||||||
|
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(([status, info]) => ({
|
||||||
|
status: Number(status),
|
||||||
|
color: info.color,
|
||||||
|
label: dashboardData.t(info.label)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ========== Effects ==========
|
||||||
|
useEffect(() => {
|
||||||
|
initChart();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<DashboardHeader
|
||||||
|
getGreeting={dashboardData.getGreeting}
|
||||||
|
greetingVisible={dashboardData.greetingVisible}
|
||||||
|
showSearchModal={dashboardData.showSearchModal}
|
||||||
|
refresh={handleRefresh}
|
||||||
|
loading={dashboardData.loading}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SearchModal
|
||||||
|
searchModalVisible={dashboardData.searchModalVisible}
|
||||||
|
handleSearchConfirm={handleSearchConfirm}
|
||||||
|
handleCloseModal={dashboardData.handleCloseModal}
|
||||||
|
isMobile={dashboardData.isMobile}
|
||||||
|
isAdminUser={dashboardData.isAdminUser}
|
||||||
|
inputs={dashboardData.inputs}
|
||||||
|
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
|
||||||
|
timeOptions={dashboardData.timeOptions}
|
||||||
|
handleInputChange={dashboardData.handleInputChange}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatsCards
|
||||||
|
groupedStatsData={groupedStatsData}
|
||||||
|
loading={dashboardData.loading}
|
||||||
|
getTrendSpec={getTrendSpec}
|
||||||
|
CARD_PROPS={CARD_PROPS}
|
||||||
|
CHART_CONFIG={CHART_CONFIG}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* API信息和图表面板 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}>
|
||||||
|
<ChartsPanel
|
||||||
|
activeChartTab={dashboardData.activeChartTab}
|
||||||
|
setActiveChartTab={dashboardData.setActiveChartTab}
|
||||||
|
spec_line={dashboardCharts.spec_line}
|
||||||
|
spec_model_line={dashboardCharts.spec_model_line}
|
||||||
|
spec_pie={dashboardCharts.spec_pie}
|
||||||
|
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||||
|
CARD_PROPS={CARD_PROPS}
|
||||||
|
CHART_CONFIG={CHART_CONFIG}
|
||||||
|
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||||
|
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{dashboardData.hasApiInfoPanel && (
|
||||||
|
<ApiInfoPanel
|
||||||
|
apiInfoData={apiInfoData}
|
||||||
|
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
|
||||||
|
handleSpeedTest={handleSpeedTest}
|
||||||
|
CARD_PROPS={CARD_PROPS}
|
||||||
|
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||||
|
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 系统公告和常见问答卡片 */}
|
||||||
|
{dashboardData.hasInfoPanels && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
|
{/* 公告卡片 */}
|
||||||
|
{dashboardData.announcementsEnabled && (
|
||||||
|
<AnnouncementsPanel
|
||||||
|
announcementData={announcementData}
|
||||||
|
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(item => ({
|
||||||
|
...item,
|
||||||
|
label: dashboardData.t(item.label)
|
||||||
|
}))}
|
||||||
|
CARD_PROPS={CARD_PROPS}
|
||||||
|
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 常见问答卡片 */}
|
||||||
|
{dashboardData.faqEnabled && (
|
||||||
|
<FaqPanel
|
||||||
|
faqData={faqData}
|
||||||
|
CARD_PROPS={CARD_PROPS}
|
||||||
|
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||||
|
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 服务可用性卡片 */}
|
||||||
|
{dashboardData.uptimeEnabled && (
|
||||||
|
<UptimePanel
|
||||||
|
uptimeData={dashboardData.uptimeData}
|
||||||
|
uptimeLoading={dashboardData.uptimeLoading}
|
||||||
|
activeUptimeTab={dashboardData.activeUptimeTab}
|
||||||
|
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
||||||
|
loadUptimeData={dashboardData.loadUptimeData}
|
||||||
|
uptimeLegendData={uptimeLegendData}
|
||||||
|
renderMonitorList={(monitors) => renderMonitorList(
|
||||||
|
monitors,
|
||||||
|
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
||||||
|
(status) => getUptimeStatusText(status, UPTIME_STATUS_MAP, dashboardData.t),
|
||||||
|
dashboardData.t
|
||||||
|
)}
|
||||||
|
CARD_PROPS={CARD_PROPS}
|
||||||
|
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||||
|
t={dashboardData.t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
101
web/src/components/dashboard/modals/SearchModal.jsx
Normal file
101
web/src/components/dashboard/modals/SearchModal.jsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Modal, Form } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const SearchModal = ({
|
||||||
|
searchModalVisible,
|
||||||
|
handleSearchConfirm,
|
||||||
|
handleCloseModal,
|
||||||
|
isMobile,
|
||||||
|
isAdminUser,
|
||||||
|
inputs,
|
||||||
|
dataExportDefaultTime,
|
||||||
|
timeOptions,
|
||||||
|
handleInputChange,
|
||||||
|
t
|
||||||
|
}) => {
|
||||||
|
const formRef = useRef();
|
||||||
|
|
||||||
|
const FORM_FIELD_PROPS = {
|
||||||
|
className: "w-full mb-2 !rounded-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFormField = (Component, props) => (
|
||||||
|
<Component {...FORM_FIELD_PROPS} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const { start_timestamp, end_timestamp, username } = inputs;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('搜索条件')}
|
||||||
|
visible={searchModalVisible}
|
||||||
|
onOk={handleSearchConfirm}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
closeOnEsc={true}
|
||||||
|
size={isMobile ? 'full-width' : 'small'}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Form ref={formRef} layout='vertical' className="w-full">
|
||||||
|
{createFormField(Form.DatePicker, {
|
||||||
|
field: 'start_timestamp',
|
||||||
|
label: t('起始时间'),
|
||||||
|
initValue: start_timestamp,
|
||||||
|
value: start_timestamp,
|
||||||
|
type: 'dateTime',
|
||||||
|
name: 'start_timestamp',
|
||||||
|
onChange: (value) => handleInputChange(value, 'start_timestamp')
|
||||||
|
})}
|
||||||
|
|
||||||
|
{createFormField(Form.DatePicker, {
|
||||||
|
field: 'end_timestamp',
|
||||||
|
label: t('结束时间'),
|
||||||
|
initValue: end_timestamp,
|
||||||
|
value: end_timestamp,
|
||||||
|
type: 'dateTime',
|
||||||
|
name: 'end_timestamp',
|
||||||
|
onChange: (value) => handleInputChange(value, 'end_timestamp')
|
||||||
|
})}
|
||||||
|
|
||||||
|
{createFormField(Form.Select, {
|
||||||
|
field: 'data_export_default_time',
|
||||||
|
label: t('时间粒度'),
|
||||||
|
initValue: dataExportDefaultTime,
|
||||||
|
placeholder: t('时间粒度'),
|
||||||
|
name: 'data_export_default_time',
|
||||||
|
optionList: timeOptions,
|
||||||
|
onChange: (value) => handleInputChange(value, 'data_export_default_time')
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isAdminUser && createFormField(Form.Input, {
|
||||||
|
field: 'username',
|
||||||
|
label: t('用户名称'),
|
||||||
|
value: username,
|
||||||
|
placeholder: t('可选值'),
|
||||||
|
name: 'username',
|
||||||
|
onChange: (value) => handleInputChange(value, 'username')
|
||||||
|
})}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchModal;
|
||||||
149
web/src/constants/dashboard.constants.js
Normal file
149
web/src/constants/dashboard.constants.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========== UI 配置常量 ==========
|
||||||
|
export const CHART_CONFIG = { mode: 'desktop-browser' };
|
||||||
|
|
||||||
|
export const CARD_PROPS = {
|
||||||
|
shadows: 'always',
|
||||||
|
bordered: false,
|
||||||
|
headerLine: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FORM_FIELD_PROPS = {
|
||||||
|
className: "w-full mb-2 !rounded-lg",
|
||||||
|
size: 'large'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
|
||||||
|
export const FLEX_CENTER_GAP2 = "flex items-center gap-2";
|
||||||
|
|
||||||
|
export const ILLUSTRATION_SIZE = { width: 96, height: 96 };
|
||||||
|
|
||||||
|
// ========== 时间相关常量 ==========
|
||||||
|
export const TIME_OPTIONS = [
|
||||||
|
{ label: '小时', value: 'hour' },
|
||||||
|
{ label: '天', value: 'day' },
|
||||||
|
{ label: '周', value: 'week' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_TIME_INTERVALS = {
|
||||||
|
hour: { seconds: 3600, minutes: 60 },
|
||||||
|
day: { seconds: 86400, minutes: 1440 },
|
||||||
|
week: { seconds: 604800, minutes: 10080 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 默认时间设置 ==========
|
||||||
|
export const DEFAULT_TIME_RANGE = {
|
||||||
|
HOUR: 'hour',
|
||||||
|
DAY: 'day',
|
||||||
|
WEEK: 'week'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 图表默认配置 ==========
|
||||||
|
export const DEFAULT_CHART_SPECS = {
|
||||||
|
PIE: {
|
||||||
|
type: 'pie',
|
||||||
|
outerRadius: 0.8,
|
||||||
|
innerRadius: 0.5,
|
||||||
|
padAngle: 0.6,
|
||||||
|
valueField: 'value',
|
||||||
|
categoryField: 'type',
|
||||||
|
pie: {
|
||||||
|
style: {
|
||||||
|
cornerRadius: 10,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
outerRadius: 0.85,
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
outerRadius: 0.85,
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
orient: 'left',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
BAR: {
|
||||||
|
type: 'bar',
|
||||||
|
stack: true,
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
selectMode: 'single',
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
LINE: {
|
||||||
|
type: 'line',
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
selectMode: 'single',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 公告图例数据 ==========
|
||||||
|
export const ANNOUNCEMENT_LEGEND_DATA = [
|
||||||
|
{ color: 'grey', label: '默认', type: 'default' },
|
||||||
|
{ color: 'blue', label: '进行中', type: 'ongoing' },
|
||||||
|
{ color: 'green', label: '成功', type: 'success' },
|
||||||
|
{ color: 'orange', label: '警告', type: 'warning' },
|
||||||
|
{ color: 'red', label: '异常', type: 'error' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ========== Uptime 状态映射 ==========
|
||||||
|
export const UPTIME_STATUS_MAP = {
|
||||||
|
1: { color: '#10b981', label: '正常', text: '可用率' }, // UP
|
||||||
|
0: { color: '#ef4444', label: '异常', text: '有异常' }, // DOWN
|
||||||
|
2: { color: '#f59e0b', label: '高延迟', text: '高延迟' }, // PENDING
|
||||||
|
3: { color: '#3b82f6', label: '维护中', text: '维护中' } // MAINTENANCE
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 本地存储键名 ==========
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
DATA_EXPORT_DEFAULT_TIME: 'data_export_default_time',
|
||||||
|
MJ_NOTIFY_ENABLED: 'mj_notify_enabled'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 默认值 ==========
|
||||||
|
export const DEFAULTS = {
|
||||||
|
PAGE_SIZE: 20,
|
||||||
|
CHART_HEIGHT: 96,
|
||||||
|
MODEL_TABLE_PAGE_SIZE: 10,
|
||||||
|
MAX_TREND_POINTS: 7
|
||||||
|
};
|
||||||
@@ -21,5 +21,6 @@ export * from './channel.constants';
|
|||||||
export * from './user.constants';
|
export * from './user.constants';
|
||||||
export * from './toast.constants';
|
export * from './toast.constants';
|
||||||
export * from './common.constant';
|
export * from './common.constant';
|
||||||
|
export * from './dashboard.constants';
|
||||||
export * from './playground.constants';
|
export * from './playground.constants';
|
||||||
export * from './redemption.constants';
|
export * from './redemption.constants';
|
||||||
|
|||||||
314
web/src/helpers/dashboard.js
Normal file
314
web/src/helpers/dashboard.js
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Progress, Divider, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
|
||||||
|
import { timestamp2string, timestamp2string1, copy, showSuccess } from './utils';
|
||||||
|
import { STORAGE_KEYS, DEFAULT_TIME_INTERVALS, DEFAULTS, ILLUSTRATION_SIZE } from '../constants/dashboard.constants';
|
||||||
|
|
||||||
|
// ========== 时间相关工具函数 ==========
|
||||||
|
export const getDefaultTime = () => {
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.DATA_EXPORT_DEFAULT_TIME) || 'hour';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTimeInterval = (timeType, isSeconds = false) => {
|
||||||
|
const intervals = DEFAULT_TIME_INTERVALS[timeType] || DEFAULT_TIME_INTERVALS.hour;
|
||||||
|
return isSeconds ? intervals.seconds : intervals.minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInitialTimestamp = () => {
|
||||||
|
const defaultTime = getDefaultTime();
|
||||||
|
const now = new Date().getTime() / 1000;
|
||||||
|
|
||||||
|
switch (defaultTime) {
|
||||||
|
case 'hour':
|
||||||
|
return timestamp2string(now - 86400);
|
||||||
|
case 'week':
|
||||||
|
return timestamp2string(now - 86400 * 30);
|
||||||
|
default:
|
||||||
|
return timestamp2string(now - 86400 * 7);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 数据处理工具函数 ==========
|
||||||
|
export const updateMapValue = (map, key, value) => {
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, 0);
|
||||||
|
}
|
||||||
|
map.set(key, map.get(key) + value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initializeMaps = (key, ...maps) => {
|
||||||
|
maps.forEach(map => {
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 图表相关工具函数 ==========
|
||||||
|
export const updateChartSpec = (setterFunc, newData, subtitle, newColors, dataId) => {
|
||||||
|
setterFunc(prev => ({
|
||||||
|
...prev,
|
||||||
|
data: [{ id: dataId, values: newData }],
|
||||||
|
title: {
|
||||||
|
...prev.title,
|
||||||
|
subtext: subtitle,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: newColors,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTrendSpec = (data, color) => ({
|
||||||
|
type: 'line',
|
||||||
|
data: [{ id: 'trend', values: data.map((val, idx) => ({ x: idx, y: val })) }],
|
||||||
|
xField: 'x',
|
||||||
|
yField: 'y',
|
||||||
|
height: 40,
|
||||||
|
width: 100,
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
orient: 'bottom',
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
orient: 'left',
|
||||||
|
visible: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
padding: 0,
|
||||||
|
autoFit: false,
|
||||||
|
legends: { visible: false },
|
||||||
|
tooltip: { visible: false },
|
||||||
|
crosshair: { visible: false },
|
||||||
|
line: {
|
||||||
|
style: {
|
||||||
|
stroke: color,
|
||||||
|
lineWidth: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
visible: false
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
fill: 'transparent'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== UI 工具函数 ==========
|
||||||
|
export const createSectionTitle = (Icon, text) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon size={16} />
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createFormField = (Component, props, FORM_FIELD_PROPS) => (
|
||||||
|
<Component {...FORM_FIELD_PROPS} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== 操作处理函数 ==========
|
||||||
|
export const handleCopyUrl = async (url, t) => {
|
||||||
|
if (await copy(url)) {
|
||||||
|
showSuccess(t('复制成功'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleSpeedTest = (apiUrl) => {
|
||||||
|
const encodedUrl = encodeURIComponent(apiUrl);
|
||||||
|
const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
|
||||||
|
window.open(speedTestUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 状态映射函数 ==========
|
||||||
|
export const getUptimeStatusColor = (status, uptimeStatusMap) =>
|
||||||
|
uptimeStatusMap[status]?.color || '#8b9aa7';
|
||||||
|
|
||||||
|
export const getUptimeStatusText = (status, uptimeStatusMap, t) =>
|
||||||
|
uptimeStatusMap[status]?.text || t('未知');
|
||||||
|
|
||||||
|
// ========== 监控列表渲染函数 ==========
|
||||||
|
export const renderMonitorList = (monitors, getUptimeStatusColor, getUptimeStatusText, t) => {
|
||||||
|
if (!monitors || monitors.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center py-4">
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||||
|
darkModeImage={<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />}
|
||||||
|
title={t('暂无监控数据')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = {};
|
||||||
|
monitors.forEach((m) => {
|
||||||
|
const g = m.group || '';
|
||||||
|
if (!grouped[g]) grouped[g] = [];
|
||||||
|
grouped[g].push(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderItem = (monitor, idx) => (
|
||||||
|
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: getUptimeStatusColor(monitor.status) }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Progress
|
||||||
|
percent={(monitor.uptime || 0) * 100}
|
||||||
|
showInfo={false}
|
||||||
|
aria-label={`${monitor.name} uptime`}
|
||||||
|
stroke={getUptimeStatusColor(monitor.status)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.entries(grouped).map(([gname, list]) => (
|
||||||
|
<div key={gname || 'default'} className="mb-2">
|
||||||
|
{gname && (
|
||||||
|
<>
|
||||||
|
<div className="text-md font-semibold text-gray-500 px-2 py-1">
|
||||||
|
{gname}
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{list.map(renderItem)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 数据处理函数 ==========
|
||||||
|
export const processRawData = (data, dataExportDefaultTime, initializeMaps, updateMapValue) => {
|
||||||
|
const result = {
|
||||||
|
totalQuota: 0,
|
||||||
|
totalTimes: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
uniqueModels: new Set(),
|
||||||
|
timePoints: [],
|
||||||
|
timeQuotaMap: new Map(),
|
||||||
|
timeTokensMap: new Map(),
|
||||||
|
timeCountMap: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
result.uniqueModels.add(item.model_name);
|
||||||
|
result.totalTokens += item.token_used;
|
||||||
|
result.totalQuota += item.quota;
|
||||||
|
result.totalTimes += item.count;
|
||||||
|
|
||||||
|
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
|
||||||
|
if (!result.timePoints.includes(timeKey)) {
|
||||||
|
result.timePoints.push(timeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeMaps(timeKey, result.timeQuotaMap, result.timeTokensMap, result.timeCountMap);
|
||||||
|
updateMapValue(result.timeQuotaMap, timeKey, item.quota);
|
||||||
|
updateMapValue(result.timeTokensMap, timeKey, item.token_used);
|
||||||
|
updateMapValue(result.timeCountMap, timeKey, item.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
result.timePoints.sort();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateTrendData = (timePoints, timeQuotaMap, timeTokensMap, timeCountMap, dataExportDefaultTime) => {
|
||||||
|
const quotaTrend = timePoints.map(time => timeQuotaMap.get(time) || 0);
|
||||||
|
const tokensTrend = timePoints.map(time => timeTokensMap.get(time) || 0);
|
||||||
|
const countTrend = timePoints.map(time => timeCountMap.get(time) || 0);
|
||||||
|
|
||||||
|
const rpmTrend = [];
|
||||||
|
const tpmTrend = [];
|
||||||
|
|
||||||
|
if (timePoints.length >= 2) {
|
||||||
|
const interval = getTimeInterval(dataExportDefaultTime);
|
||||||
|
|
||||||
|
for (let i = 0; i < timePoints.length; i++) {
|
||||||
|
rpmTrend.push(timeCountMap.get(timePoints[i]) / interval);
|
||||||
|
tpmTrend.push(timeTokensMap.get(timePoints[i]) / interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: [],
|
||||||
|
usedQuota: [],
|
||||||
|
requestCount: [],
|
||||||
|
times: countTrend,
|
||||||
|
consumeQuota: quotaTrend,
|
||||||
|
tokens: tokensTrend,
|
||||||
|
rpm: rpmTrend,
|
||||||
|
tpm: tpmTrend
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => {
|
||||||
|
const aggregatedData = new Map();
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime);
|
||||||
|
const modelKey = item.model_name;
|
||||||
|
const key = `${timeKey}-${modelKey}`;
|
||||||
|
|
||||||
|
if (!aggregatedData.has(key)) {
|
||||||
|
aggregatedData.set(key, {
|
||||||
|
time: timeKey,
|
||||||
|
model: modelKey,
|
||||||
|
quota: 0,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = aggregatedData.get(key);
|
||||||
|
existing.quota += item.quota;
|
||||||
|
existing.count += item.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
return aggregatedData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateChartTimePoints = (aggregatedData, data, dataExportDefaultTime) => {
|
||||||
|
let chartTimePoints = Array.from(
|
||||||
|
new Set([...aggregatedData.values()].map((d) => d.time)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chartTimePoints.length < DEFAULTS.MAX_TREND_POINTS) {
|
||||||
|
const lastTime = Math.max(...data.map((item) => item.created_at));
|
||||||
|
const interval = getTimeInterval(dataExportDefaultTime, true);
|
||||||
|
|
||||||
|
chartTimePoints = Array.from({ length: DEFAULTS.MAX_TREND_POINTS }, (_, i) =>
|
||||||
|
timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chartTimePoints;
|
||||||
|
};
|
||||||
@@ -26,3 +26,4 @@ export * from './log';
|
|||||||
export * from './data';
|
export * from './data';
|
||||||
export * from './token';
|
export * from './token';
|
||||||
export * from './boolean';
|
export * from './boolean';
|
||||||
|
export * from './dashboard';
|
||||||
|
|||||||
437
web/src/hooks/dashboard/useDashboardCharts.js
Normal file
437
web/src/hooks/dashboard/useDashboardCharts.js
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||||||
|
import {
|
||||||
|
modelColorMap,
|
||||||
|
renderNumber,
|
||||||
|
renderQuota,
|
||||||
|
modelToColor,
|
||||||
|
getQuotaWithUnit
|
||||||
|
} from '../../helpers';
|
||||||
|
import {
|
||||||
|
processRawData,
|
||||||
|
calculateTrendData,
|
||||||
|
aggregateDataByTimeAndModel,
|
||||||
|
generateChartTimePoints,
|
||||||
|
updateChartSpec,
|
||||||
|
updateMapValue,
|
||||||
|
initializeMaps
|
||||||
|
} from '../../helpers/dashboard';
|
||||||
|
|
||||||
|
export const useDashboardCharts = (
|
||||||
|
dataExportDefaultTime,
|
||||||
|
setTrendData,
|
||||||
|
setConsumeQuota,
|
||||||
|
setTimes,
|
||||||
|
setConsumeTokens,
|
||||||
|
setPieData,
|
||||||
|
setLineData,
|
||||||
|
setModelColors,
|
||||||
|
t
|
||||||
|
) => {
|
||||||
|
// ========== 图表规格状态 ==========
|
||||||
|
const [spec_pie, setSpecPie] = useState({
|
||||||
|
type: 'pie',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'id0',
|
||||||
|
values: [{ type: 'null', value: '0' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outerRadius: 0.8,
|
||||||
|
innerRadius: 0.5,
|
||||||
|
padAngle: 0.6,
|
||||||
|
valueField: 'value',
|
||||||
|
categoryField: 'type',
|
||||||
|
pie: {
|
||||||
|
style: {
|
||||||
|
cornerRadius: 10,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
outerRadius: 0.85,
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
outerRadius: 0.85,
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
visible: true,
|
||||||
|
text: t('模型调用次数占比'),
|
||||||
|
subtext: `${t('总计')}:${renderNumber(0)}`,
|
||||||
|
},
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
orient: 'left',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum) => datum['type'],
|
||||||
|
value: (datum) => renderNumber(datum['value']),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: modelColorMap,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [spec_line, setSpecLine] = useState({
|
||||||
|
type: 'bar',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'barData',
|
||||||
|
values: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xField: 'Time',
|
||||||
|
yField: 'Usage',
|
||||||
|
seriesField: 'Model',
|
||||||
|
stack: true,
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
selectMode: 'single',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
visible: true,
|
||||||
|
text: t('模型消耗分布'),
|
||||||
|
subtext: `${t('总计')}:${renderQuota(0, 2)}`,
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum) => datum['Model'],
|
||||||
|
value: (datum) => renderQuota(datum['rawQuota'] || 0, 4),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dimension: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum) => datum['Model'],
|
||||||
|
value: (datum) => datum['rawQuota'] || 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateContent: (array) => {
|
||||||
|
array.sort((a, b) => b.value - a.value);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < array.length; i++) {
|
||||||
|
if (array[i].key == '其他') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = parseFloat(array[i].value);
|
||||||
|
if (isNaN(value)) {
|
||||||
|
value = 0;
|
||||||
|
}
|
||||||
|
if (array[i].datum && array[i].datum.TimeSum) {
|
||||||
|
sum = array[i].datum.TimeSum;
|
||||||
|
}
|
||||||
|
array[i].value = renderQuota(value, 4);
|
||||||
|
}
|
||||||
|
array.unshift({
|
||||||
|
key: t('总计'),
|
||||||
|
value: renderQuota(sum, 4),
|
||||||
|
});
|
||||||
|
return array;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: modelColorMap,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模型消耗趋势折线图
|
||||||
|
const [spec_model_line, setSpecModelLine] = useState({
|
||||||
|
type: 'line',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'lineData',
|
||||||
|
values: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xField: 'Time',
|
||||||
|
yField: 'Count',
|
||||||
|
seriesField: 'Model',
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
selectMode: 'single',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
visible: true,
|
||||||
|
text: t('模型消耗趋势'),
|
||||||
|
subtext: '',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum) => datum['Model'],
|
||||||
|
value: (datum) => renderNumber(datum['Count']),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: modelColorMap,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模型调用次数排行柱状图
|
||||||
|
const [spec_rank_bar, setSpecRankBar] = useState({
|
||||||
|
type: 'bar',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'rankData',
|
||||||
|
values: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xField: 'Model',
|
||||||
|
yField: 'Count',
|
||||||
|
seriesField: 'Model',
|
||||||
|
legends: {
|
||||||
|
visible: true,
|
||||||
|
selectMode: 'single',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
visible: true,
|
||||||
|
text: t('模型调用次数排行'),
|
||||||
|
subtext: '',
|
||||||
|
},
|
||||||
|
bar: {
|
||||||
|
state: {
|
||||||
|
hover: {
|
||||||
|
stroke: '#000',
|
||||||
|
lineWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mark: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
key: (datum) => datum['Model'],
|
||||||
|
value: (datum) => renderNumber(datum['Count']),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
specified: modelColorMap,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 数据处理函数 ==========
|
||||||
|
const generateModelColors = useCallback((uniqueModels, modelColors) => {
|
||||||
|
const newModelColors = {};
|
||||||
|
Array.from(uniqueModels).forEach((modelName) => {
|
||||||
|
newModelColors[modelName] =
|
||||||
|
modelColorMap[modelName] ||
|
||||||
|
modelColors[modelName] ||
|
||||||
|
modelToColor(modelName);
|
||||||
|
});
|
||||||
|
return newModelColors;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateChartData = useCallback((data) => {
|
||||||
|
const processedData = processRawData(
|
||||||
|
data,
|
||||||
|
dataExportDefaultTime,
|
||||||
|
initializeMaps,
|
||||||
|
updateMapValue
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
totalQuota,
|
||||||
|
totalTimes,
|
||||||
|
totalTokens,
|
||||||
|
uniqueModels,
|
||||||
|
timePoints,
|
||||||
|
timeQuotaMap,
|
||||||
|
timeTokensMap,
|
||||||
|
timeCountMap
|
||||||
|
} = processedData;
|
||||||
|
|
||||||
|
const trendDataResult = calculateTrendData(
|
||||||
|
timePoints,
|
||||||
|
timeQuotaMap,
|
||||||
|
timeTokensMap,
|
||||||
|
timeCountMap,
|
||||||
|
dataExportDefaultTime
|
||||||
|
);
|
||||||
|
setTrendData(trendDataResult);
|
||||||
|
|
||||||
|
const newModelColors = generateModelColors(uniqueModels, {});
|
||||||
|
setModelColors(newModelColors);
|
||||||
|
|
||||||
|
const aggregatedData = aggregateDataByTimeAndModel(data, dataExportDefaultTime);
|
||||||
|
|
||||||
|
const modelTotals = new Map();
|
||||||
|
for (let [_, value] of aggregatedData) {
|
||||||
|
updateMapValue(modelTotals, value.model, value.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPieData = Array.from(modelTotals).map(([model, count]) => ({
|
||||||
|
type: model,
|
||||||
|
value: count,
|
||||||
|
})).sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
const chartTimePoints = generateChartTimePoints(
|
||||||
|
aggregatedData,
|
||||||
|
data,
|
||||||
|
dataExportDefaultTime
|
||||||
|
);
|
||||||
|
|
||||||
|
let newLineData = [];
|
||||||
|
|
||||||
|
chartTimePoints.forEach((time) => {
|
||||||
|
let timeData = Array.from(uniqueModels).map((model) => {
|
||||||
|
const key = `${time}-${model}`;
|
||||||
|
const aggregated = aggregatedData.get(key);
|
||||||
|
return {
|
||||||
|
Time: time,
|
||||||
|
Model: model,
|
||||||
|
rawQuota: aggregated?.quota || 0,
|
||||||
|
Usage: aggregated?.quota ? getQuotaWithUnit(aggregated.quota, 4) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0);
|
||||||
|
timeData.sort((a, b) => b.rawQuota - a.rawQuota);
|
||||||
|
timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }));
|
||||||
|
newLineData.push(...timeData);
|
||||||
|
});
|
||||||
|
|
||||||
|
newLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||||||
|
|
||||||
|
updateChartSpec(
|
||||||
|
setSpecPie,
|
||||||
|
newPieData,
|
||||||
|
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||||
|
newModelColors,
|
||||||
|
'id0'
|
||||||
|
);
|
||||||
|
|
||||||
|
updateChartSpec(
|
||||||
|
setSpecLine,
|
||||||
|
newLineData,
|
||||||
|
`${t('总计')}:${renderQuota(totalQuota, 2)}`,
|
||||||
|
newModelColors,
|
||||||
|
'barData'
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 模型调用次数折线图 =====
|
||||||
|
let modelLineData = [];
|
||||||
|
chartTimePoints.forEach((time) => {
|
||||||
|
const timeData = Array.from(uniqueModels).map((model) => {
|
||||||
|
const key = `${time}-${model}`;
|
||||||
|
const aggregated = aggregatedData.get(key);
|
||||||
|
return {
|
||||||
|
Time: time,
|
||||||
|
Model: model,
|
||||||
|
Count: aggregated?.count || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
modelLineData.push(...timeData);
|
||||||
|
});
|
||||||
|
modelLineData.sort((a, b) => a.Time.localeCompare(b.Time));
|
||||||
|
|
||||||
|
// ===== 模型调用次数排行柱状图 =====
|
||||||
|
const rankData = Array.from(modelTotals)
|
||||||
|
.map(([model, count]) => ({
|
||||||
|
Model: model,
|
||||||
|
Count: count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.Count - a.Count);
|
||||||
|
|
||||||
|
updateChartSpec(
|
||||||
|
setSpecModelLine,
|
||||||
|
modelLineData,
|
||||||
|
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||||
|
newModelColors,
|
||||||
|
'lineData'
|
||||||
|
);
|
||||||
|
|
||||||
|
updateChartSpec(
|
||||||
|
setSpecRankBar,
|
||||||
|
rankData,
|
||||||
|
`${t('总计')}:${renderNumber(totalTimes)}`,
|
||||||
|
newModelColors,
|
||||||
|
'rankData'
|
||||||
|
);
|
||||||
|
|
||||||
|
setPieData(newPieData);
|
||||||
|
setLineData(newLineData);
|
||||||
|
setConsumeQuota(totalQuota);
|
||||||
|
setTimes(totalTimes);
|
||||||
|
setConsumeTokens(totalTokens);
|
||||||
|
}, [
|
||||||
|
dataExportDefaultTime,
|
||||||
|
setTrendData,
|
||||||
|
generateModelColors,
|
||||||
|
setModelColors,
|
||||||
|
setPieData,
|
||||||
|
setLineData,
|
||||||
|
setConsumeQuota,
|
||||||
|
setTimes,
|
||||||
|
setConsumeTokens,
|
||||||
|
t
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ========== 初始化图表主题 ==========
|
||||||
|
useEffect(() => {
|
||||||
|
initVChartSemiTheme({
|
||||||
|
isWatchingThemeSwitch: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 图表规格
|
||||||
|
spec_pie,
|
||||||
|
spec_line,
|
||||||
|
spec_model_line,
|
||||||
|
spec_rank_bar,
|
||||||
|
|
||||||
|
// 函数
|
||||||
|
updateChartData,
|
||||||
|
generateModelColors
|
||||||
|
};
|
||||||
|
};
|
||||||
313
web/src/hooks/dashboard/useDashboardData.js
Normal file
313
web/src/hooks/dashboard/useDashboardData.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { API, isAdmin, showError, timestamp2string } from '../../helpers';
|
||||||
|
import { getDefaultTime, getInitialTimestamp } from '../../helpers/dashboard';
|
||||||
|
import { TIME_OPTIONS } from '../../constants/dashboard.constants';
|
||||||
|
import { useIsMobile } from '../common/useIsMobile';
|
||||||
|
|
||||||
|
export const useDashboardData = (userState, userDispatch, statusState) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
// ========== 基础状态 ==========
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [greetingVisible, setGreetingVisible] = useState(false);
|
||||||
|
const [searchModalVisible, setSearchModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// ========== 输入状态 ==========
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
username: '',
|
||||||
|
token_name: '',
|
||||||
|
model_name: '',
|
||||||
|
start_timestamp: getInitialTimestamp(),
|
||||||
|
end_timestamp: timestamp2string(new Date().getTime() / 1000 + 3600),
|
||||||
|
channel: '',
|
||||||
|
data_export_default_time: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
|
||||||
|
|
||||||
|
// ========== 数据状态 ==========
|
||||||
|
const [quotaData, setQuotaData] = useState([]);
|
||||||
|
const [consumeQuota, setConsumeQuota] = useState(0);
|
||||||
|
const [consumeTokens, setConsumeTokens] = useState(0);
|
||||||
|
const [times, setTimes] = useState(0);
|
||||||
|
const [pieData, setPieData] = useState([{ type: 'null', value: '0' }]);
|
||||||
|
const [lineData, setLineData] = useState([]);
|
||||||
|
const [modelColors, setModelColors] = useState({});
|
||||||
|
|
||||||
|
// ========== 图表状态 ==========
|
||||||
|
const [activeChartTab, setActiveChartTab] = useState('1');
|
||||||
|
|
||||||
|
// ========== 趋势数据 ==========
|
||||||
|
const [trendData, setTrendData] = useState({
|
||||||
|
balance: [],
|
||||||
|
usedQuota: [],
|
||||||
|
requestCount: [],
|
||||||
|
times: [],
|
||||||
|
consumeQuota: [],
|
||||||
|
tokens: [],
|
||||||
|
rpm: [],
|
||||||
|
tpm: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Uptime 数据 ==========
|
||||||
|
const [uptimeData, setUptimeData] = useState([]);
|
||||||
|
const [uptimeLoading, setUptimeLoading] = useState(false);
|
||||||
|
const [activeUptimeTab, setActiveUptimeTab] = useState('');
|
||||||
|
|
||||||
|
// ========== 常量 ==========
|
||||||
|
const now = new Date();
|
||||||
|
const isAdminUser = isAdmin();
|
||||||
|
|
||||||
|
// ========== Panel enable flags ==========
|
||||||
|
const apiInfoEnabled = statusState?.status?.api_info_enabled ?? true;
|
||||||
|
const announcementsEnabled = statusState?.status?.announcements_enabled ?? true;
|
||||||
|
const faqEnabled = statusState?.status?.faq_enabled ?? true;
|
||||||
|
const uptimeEnabled = statusState?.status?.uptime_kuma_enabled ?? true;
|
||||||
|
|
||||||
|
const hasApiInfoPanel = apiInfoEnabled;
|
||||||
|
const hasInfoPanels = announcementsEnabled || faqEnabled || uptimeEnabled;
|
||||||
|
|
||||||
|
// ========== Memoized Values ==========
|
||||||
|
const timeOptions = useMemo(() => TIME_OPTIONS.map(option => ({
|
||||||
|
...option,
|
||||||
|
label: t(option.label)
|
||||||
|
})), [t]);
|
||||||
|
|
||||||
|
const performanceMetrics = useMemo(() => {
|
||||||
|
const { start_timestamp, end_timestamp } = inputs;
|
||||||
|
const timeDiff = (Date.parse(end_timestamp) - Date.parse(start_timestamp)) / 60000;
|
||||||
|
const avgRPM = isNaN(times / timeDiff) ? '0' : (times / timeDiff).toFixed(3);
|
||||||
|
const avgTPM = isNaN(consumeTokens / timeDiff) ? '0' : (consumeTokens / timeDiff).toFixed(3);
|
||||||
|
|
||||||
|
return { avgRPM, avgTPM, timeDiff };
|
||||||
|
}, [times, consumeTokens, inputs.start_timestamp, inputs.end_timestamp]);
|
||||||
|
|
||||||
|
const getGreeting = useMemo(() => {
|
||||||
|
const hours = new Date().getHours();
|
||||||
|
let greeting = '';
|
||||||
|
|
||||||
|
if (hours >= 5 && hours < 12) {
|
||||||
|
greeting = t('早上好');
|
||||||
|
} else if (hours >= 12 && hours < 14) {
|
||||||
|
greeting = t('中午好');
|
||||||
|
} else if (hours >= 14 && hours < 18) {
|
||||||
|
greeting = t('下午好');
|
||||||
|
} else {
|
||||||
|
greeting = t('晚上好');
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = userState?.user?.username || '';
|
||||||
|
return `👋${greeting},${username}`;
|
||||||
|
}, [t, userState?.user?.username]);
|
||||||
|
|
||||||
|
// ========== 回调函数 ==========
|
||||||
|
const handleInputChange = useCallback((value, name) => {
|
||||||
|
if (name === 'data_export_default_time') {
|
||||||
|
setDataExportDefaultTime(value);
|
||||||
|
localStorage.setItem('data_export_default_time', value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSearchModal = useCallback(() => {
|
||||||
|
setSearchModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseModal = useCallback(() => {
|
||||||
|
setSearchModalVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ========== API 调用函数 ==========
|
||||||
|
const loadQuotaData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
let url = '';
|
||||||
|
const { start_timestamp, end_timestamp, username } = inputs;
|
||||||
|
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
|
||||||
|
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
|
||||||
|
|
||||||
|
if (isAdminUser) {
|
||||||
|
url = `/api/data/?username=${username}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||||||
|
} else {
|
||||||
|
url = `/api/data/self/?start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&default_time=${dataExportDefaultTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await API.get(url);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setQuotaData(data);
|
||||||
|
if (data.length === 0) {
|
||||||
|
data.push({
|
||||||
|
count: 0,
|
||||||
|
model_name: '无数据',
|
||||||
|
quota: 0,
|
||||||
|
created_at: now.getTime() / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
data.sort((a, b) => a.created_at - b.created_at);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const remainingTime = Math.max(0, 500 - elapsed);
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
}, remainingTime);
|
||||||
|
}
|
||||||
|
}, [inputs, dataExportDefaultTime, isAdminUser, now]);
|
||||||
|
|
||||||
|
const loadUptimeData = useCallback(async () => {
|
||||||
|
setUptimeLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/uptime/status');
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setUptimeData(data || []);
|
||||||
|
if (data && data.length > 0 && !activeUptimeTab) {
|
||||||
|
setActiveUptimeTab(data[0].categoryName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setUptimeLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeUptimeTab]);
|
||||||
|
|
||||||
|
const getUserData = useCallback(async () => {
|
||||||
|
let res = await API.get(`/api/user/self`);
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
userDispatch({ type: 'login', payload: data });
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
}, [userDispatch]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const data = await loadQuotaData();
|
||||||
|
await loadUptimeData();
|
||||||
|
return data;
|
||||||
|
}, [loadQuotaData, loadUptimeData]);
|
||||||
|
|
||||||
|
const handleSearchConfirm = useCallback(async (updateChartDataCallback) => {
|
||||||
|
const data = await refresh();
|
||||||
|
if (data && data.length > 0 && updateChartDataCallback) {
|
||||||
|
updateChartDataCallback(data);
|
||||||
|
}
|
||||||
|
setSearchModalVisible(false);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// ========== Effects ==========
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setGreetingVisible(true);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialized.current) {
|
||||||
|
getUserData();
|
||||||
|
initialized.current = true;
|
||||||
|
}
|
||||||
|
}, [getUserData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 基础状态
|
||||||
|
loading,
|
||||||
|
greetingVisible,
|
||||||
|
searchModalVisible,
|
||||||
|
|
||||||
|
// 输入状态
|
||||||
|
inputs,
|
||||||
|
dataExportDefaultTime,
|
||||||
|
|
||||||
|
// 数据状态
|
||||||
|
quotaData,
|
||||||
|
consumeQuota,
|
||||||
|
setConsumeQuota,
|
||||||
|
consumeTokens,
|
||||||
|
setConsumeTokens,
|
||||||
|
times,
|
||||||
|
setTimes,
|
||||||
|
pieData,
|
||||||
|
setPieData,
|
||||||
|
lineData,
|
||||||
|
setLineData,
|
||||||
|
modelColors,
|
||||||
|
setModelColors,
|
||||||
|
|
||||||
|
// 图表状态
|
||||||
|
activeChartTab,
|
||||||
|
setActiveChartTab,
|
||||||
|
|
||||||
|
// 趋势数据
|
||||||
|
trendData,
|
||||||
|
setTrendData,
|
||||||
|
|
||||||
|
// Uptime 数据
|
||||||
|
uptimeData,
|
||||||
|
uptimeLoading,
|
||||||
|
activeUptimeTab,
|
||||||
|
setActiveUptimeTab,
|
||||||
|
|
||||||
|
// 计算值
|
||||||
|
timeOptions,
|
||||||
|
performanceMetrics,
|
||||||
|
getGreeting,
|
||||||
|
isAdminUser,
|
||||||
|
hasApiInfoPanel,
|
||||||
|
hasInfoPanels,
|
||||||
|
apiInfoEnabled,
|
||||||
|
announcementsEnabled,
|
||||||
|
faqEnabled,
|
||||||
|
uptimeEnabled,
|
||||||
|
|
||||||
|
// 函数
|
||||||
|
handleInputChange,
|
||||||
|
showSearchModal,
|
||||||
|
handleCloseModal,
|
||||||
|
loadQuotaData,
|
||||||
|
loadUptimeData,
|
||||||
|
getUserData,
|
||||||
|
refresh,
|
||||||
|
handleSearchConfirm,
|
||||||
|
|
||||||
|
// 导航和翻译
|
||||||
|
navigate,
|
||||||
|
t,
|
||||||
|
isMobile
|
||||||
|
};
|
||||||
|
};
|
||||||
151
web/src/hooks/dashboard/useDashboardStats.js
Normal file
151
web/src/hooks/dashboard/useDashboardStats.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Wallet, Activity, Zap, Gauge } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
IconMoneyExchangeStroked,
|
||||||
|
IconHistogram,
|
||||||
|
IconCoinMoneyStroked,
|
||||||
|
IconTextStroked,
|
||||||
|
IconPulse,
|
||||||
|
IconStopwatchStroked,
|
||||||
|
IconTypograph,
|
||||||
|
IconSend
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { renderQuota } from '../../helpers';
|
||||||
|
import { createSectionTitle } from '../../helpers/dashboard';
|
||||||
|
|
||||||
|
export const useDashboardStats = (
|
||||||
|
userState,
|
||||||
|
consumeQuota,
|
||||||
|
consumeTokens,
|
||||||
|
times,
|
||||||
|
trendData,
|
||||||
|
performanceMetrics,
|
||||||
|
navigate,
|
||||||
|
t
|
||||||
|
) => {
|
||||||
|
const groupedStatsData = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: createSectionTitle(Wallet, t('账户数据')),
|
||||||
|
color: 'bg-blue-50',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t('当前余额'),
|
||||||
|
value: renderQuota(userState?.user?.quota),
|
||||||
|
icon: <IconMoneyExchangeStroked />,
|
||||||
|
avatarColor: 'blue',
|
||||||
|
onClick: () => navigate('/console/topup'),
|
||||||
|
trendData: [],
|
||||||
|
trendColor: '#3b82f6'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('历史消耗'),
|
||||||
|
value: renderQuota(userState?.user?.used_quota),
|
||||||
|
icon: <IconHistogram />,
|
||||||
|
avatarColor: 'purple',
|
||||||
|
trendData: [],
|
||||||
|
trendColor: '#8b5cf6'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: createSectionTitle(Activity, t('使用统计')),
|
||||||
|
color: 'bg-green-50',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t('请求次数'),
|
||||||
|
value: userState.user?.request_count,
|
||||||
|
icon: <IconSend />,
|
||||||
|
avatarColor: 'green',
|
||||||
|
trendData: [],
|
||||||
|
trendColor: '#10b981'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('统计次数'),
|
||||||
|
value: times,
|
||||||
|
icon: <IconPulse />,
|
||||||
|
avatarColor: 'cyan',
|
||||||
|
trendData: trendData.times,
|
||||||
|
trendColor: '#06b6d4'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: createSectionTitle(Zap, t('资源消耗')),
|
||||||
|
color: 'bg-yellow-50',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t('统计额度'),
|
||||||
|
value: renderQuota(consumeQuota),
|
||||||
|
icon: <IconCoinMoneyStroked />,
|
||||||
|
avatarColor: 'yellow',
|
||||||
|
trendData: trendData.consumeQuota,
|
||||||
|
trendColor: '#f59e0b'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('统计Tokens'),
|
||||||
|
value: isNaN(consumeTokens) ? 0 : consumeTokens,
|
||||||
|
icon: <IconTextStroked />,
|
||||||
|
avatarColor: 'pink',
|
||||||
|
trendData: trendData.tokens,
|
||||||
|
trendColor: '#ec4899'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: createSectionTitle(Gauge, t('性能指标')),
|
||||||
|
color: 'bg-indigo-50',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: t('平均RPM'),
|
||||||
|
value: performanceMetrics.avgRPM,
|
||||||
|
icon: <IconStopwatchStroked />,
|
||||||
|
avatarColor: 'indigo',
|
||||||
|
trendData: trendData.rpm,
|
||||||
|
trendColor: '#6366f1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('平均TPM'),
|
||||||
|
value: performanceMetrics.avgTPM,
|
||||||
|
icon: <IconTypograph />,
|
||||||
|
avatarColor: 'orange',
|
||||||
|
trendData: trendData.tpm,
|
||||||
|
trendColor: '#f97316'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
], [
|
||||||
|
userState?.user?.quota,
|
||||||
|
userState?.user?.used_quota,
|
||||||
|
userState?.user?.request_count,
|
||||||
|
times,
|
||||||
|
consumeQuota,
|
||||||
|
consumeTokens,
|
||||||
|
trendData,
|
||||||
|
performanceMetrics,
|
||||||
|
navigate,
|
||||||
|
t
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groupedStatsData
|
||||||
|
};
|
||||||
|
};
|
||||||
29
web/src/pages/Dashboard/index.js
Normal file
29
web/src/pages/Dashboard/index.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
Copyright (C) 2025 QuantumNous
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as
|
||||||
|
published by the Free Software Foundation, either version 3 of the
|
||||||
|
License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Dashboard from '../../components/dashboard';
|
||||||
|
|
||||||
|
const Detail = () => (
|
||||||
|
<div className="mt-[60px] px-2">
|
||||||
|
<Dashboard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Detail;
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user