📚 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:
t0ng7u
2025-07-20 15:47:02 +08:00
parent d74a5bd507
commit 0eaeef5723
20 changed files with 2531 additions and 1612 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;