diff --git a/web/src/App.js b/web/src/App.js
index fa935683..47304b16 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -46,7 +46,7 @@ import Setup from './pages/Setup/index.js';
import SetupCheck from './components/layout/SetupCheck.js';
const Home = lazy(() => import('./pages/Home'));
-const Detail = lazy(() => import('./pages/Detail'));
+const Dashboard = lazy(() => import('./pages/Dashboard'));
const About = lazy(() => import('./pages/About'));
function App() {
@@ -214,7 +214,7 @@ function App() {
element={
} key={location.pathname}>
-
+
}
diff --git a/web/src/components/common/charts/TrendChart.jsx b/web/src/components/common/charts/TrendChart.jsx
new file mode 100644
index 00000000..d81285ae
--- /dev/null
+++ b/web/src/components/common/charts/TrendChart.jsx
@@ -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 .
+
+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 (
+
+ );
+};
+
+export default TrendChart;
\ No newline at end of file
diff --git a/web/src/components/dashboard/AnnouncementsPanel.jsx b/web/src/components/dashboard/AnnouncementsPanel.jsx
new file mode 100644
index 00000000..89d5f335
--- /dev/null
+++ b/web/src/components/dashboard/AnnouncementsPanel.jsx
@@ -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 .
+
+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 (
+
+
+
+ {t('系统公告')}
+
+ {t('显示最新20条')}
+
+
+ {/* 图例 */}
+
+ {announcementLegendData.map((legend, index) => (
+
+ ))}
+
+
+ }
+ bodyStyle={{ padding: 0 }}
+ >
+
+ {announcementData.length > 0 ? (
+
+ {announcementData.map((item, idx) => (
+
+
+
+ {item.extra && (
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+ }
+ darkModeImage={ }
+ title={t('暂无系统公告')}
+ description={t('请联系管理员在系统设置中配置公告信息')}
+ />
+
+ )}
+
+
+ );
+};
+
+export default AnnouncementsPanel;
\ No newline at end of file
diff --git a/web/src/components/dashboard/ApiInfoPanel.jsx b/web/src/components/dashboard/ApiInfoPanel.jsx
new file mode 100644
index 00000000..5da250e6
--- /dev/null
+++ b/web/src/components/dashboard/ApiInfoPanel.jsx
@@ -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 .
+
+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 (
+
+
+ {t('API信息')}
+
+ }
+ bodyStyle={{ padding: 0 }}
+ >
+
+ {apiInfoData.length > 0 ? (
+ apiInfoData.map((api) => (
+
+
+
+
+ {api.route.substring(0, 2)}
+
+
+
+
+
+ {api.route}
+
+
+ }
+ size="small"
+ color="white"
+ shape='circle'
+ onClick={() => handleSpeedTest(api.url)}
+ className="cursor-pointer hover:opacity-80 text-xs"
+ >
+ {t('测速')}
+
+ }
+ size="small"
+ color="white"
+ shape='circle'
+ onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
+ className="cursor-pointer hover:opacity-80 text-xs"
+ >
+ {t('跳转')}
+
+
+
+
handleCopyUrl(api.url)}
+ >
+ {api.url}
+
+
+ {api.description}
+
+
+
+
+
+ ))
+ ) : (
+
+ }
+ darkModeImage={ }
+ title={t('暂无API信息')}
+ description={t('请联系管理员在系统设置中配置API信息')}
+ />
+
+ )}
+
+
+ );
+};
+
+export default ApiInfoPanel;
\ No newline at end of file
diff --git a/web/src/components/dashboard/ChartsPanel.jsx b/web/src/components/dashboard/ChartsPanel.jsx
new file mode 100644
index 00000000..86726e53
--- /dev/null
+++ b/web/src/components/dashboard/ChartsPanel.jsx
@@ -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 .
+
+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 (
+
+
+
+
+
+ {t('消耗分布')}
+
+ } itemKey="1" />
+
+
+ {t('消耗趋势')}
+
+ } itemKey="2" />
+
+
+ {t('调用次数分布')}
+
+ } itemKey="3" />
+
+
+ {t('调用次数排行')}
+
+ } itemKey="4" />
+
+
+ }
+ bodyStyle={{ padding: 0 }}
+ >
+
+ {activeChartTab === '1' && (
+
+ )}
+ {activeChartTab === '2' && (
+
+ )}
+ {activeChartTab === '3' && (
+
+ )}
+ {activeChartTab === '4' && (
+
+ )}
+
+
+ );
+};
+
+export default ChartsPanel;
\ No newline at end of file
diff --git a/web/src/components/dashboard/DashboardHeader.jsx b/web/src/components/dashboard/DashboardHeader.jsx
new file mode 100644
index 00000000..f59aa0b8
--- /dev/null
+++ b/web/src/components/dashboard/DashboardHeader.jsx
@@ -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 .
+
+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 (
+
+
+ {getGreeting}
+
+
+ }
+ onClick={showSearchModal}
+ className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
+ />
+ }
+ onClick={refresh}
+ loading={loading}
+ className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
+ />
+
+
+ );
+};
+
+export default DashboardHeader;
\ No newline at end of file
diff --git a/web/src/components/dashboard/FaqPanel.jsx b/web/src/components/dashboard/FaqPanel.jsx
new file mode 100644
index 00000000..bf09392c
--- /dev/null
+++ b/web/src/components/dashboard/FaqPanel.jsx
@@ -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 .
+
+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 (
+
+
+ {t('常见问答')}
+
+ }
+ bodyStyle={{ padding: 0 }}
+ >
+
+ {faqData.length > 0 ? (
+ }
+ collapseIcon={ }
+ >
+ {faqData.map((item, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ }
+ darkModeImage={ }
+ title={t('暂无常见问答')}
+ description={t('请联系管理员在系统设置中配置常见问答')}
+ />
+
+ )}
+
+
+ );
+};
+
+export default FaqPanel;
\ No newline at end of file
diff --git a/web/src/components/dashboard/StatsCards.jsx b/web/src/components/dashboard/StatsCards.jsx
new file mode 100644
index 00000000..ae614eb5
--- /dev/null
+++ b/web/src/components/dashboard/StatsCards.jsx
@@ -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 .
+
+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 (
+
+
+ {groupedStatsData.map((group, idx) => (
+
+
+ {group.items.map((item, itemIdx) => (
+
+
+
+ {item.icon}
+
+
+
{item.title}
+
+
+ }
+ >
+ {item.value}
+
+
+
+
+ {(loading || (item.trendData && item.trendData.length > 0)) && (
+
+
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ );
+};
+
+export default StatsCards;
\ No newline at end of file
diff --git a/web/src/components/dashboard/UptimePanel.jsx b/web/src/components/dashboard/UptimePanel.jsx
new file mode 100644
index 00000000..9c5049b8
--- /dev/null
+++ b/web/src/components/dashboard/UptimePanel.jsx
@@ -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 .
+
+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 (
+
+
+
+ {t('服务可用性')}
+
+ }
+ onClick={loadUptimeData}
+ loading={uptimeLoading}
+ size="small"
+ theme="borderless"
+ type='tertiary'
+ className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
+ />
+
+ }
+ bodyStyle={{ padding: 0 }}
+ >
+ {/* 内容区域 */}
+
+
+ {uptimeData.length > 0 ? (
+ uptimeData.length === 1 ? (
+
+ {renderMonitorList(uptimeData[0].monitors)}
+
+ ) : (
+
+ {uptimeData.map((group, groupIdx) => (
+
+
+ {group.categoryName}
+
+ {group.monitors ? group.monitors.length : 0}
+
+
+ }
+ itemKey={group.categoryName}
+ key={groupIdx}
+ >
+
+ {renderMonitorList(group.monitors)}
+
+
+ ))}
+
+ )
+ ) : (
+
+ }
+ darkModeImage={ }
+ title={t('暂无监控数据')}
+ description={t('请联系管理员在系统设置中配置Uptime')}
+ />
+
+ )}
+
+
+
+ {/* 图例 */}
+ {uptimeData.length > 0 && (
+
+
+ {uptimeLegendData.map((legend, index) => (
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default UptimePanel;
\ No newline at end of file
diff --git a/web/src/components/dashboard/index.jsx b/web/src/components/dashboard/index.jsx
new file mode 100644
index 00000000..b9588e8e
--- /dev/null
+++ b/web/src/components/dashboard/index.jsx
@@ -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 .
+
+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 (
+
+
+
+
+
+
+
+ {/* API信息和图表面板 */}
+
+
+
+
+ {dashboardData.hasApiInfoPanel && (
+
handleCopyUrl(url, dashboardData.t)}
+ handleSpeedTest={handleSpeedTest}
+ CARD_PROPS={CARD_PROPS}
+ FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
+ ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
+ t={dashboardData.t}
+ />
+ )}
+
+
+
+ {/* 系统公告和常见问答卡片 */}
+ {dashboardData.hasInfoPanels && (
+
+
+ {/* 公告卡片 */}
+ {dashboardData.announcementsEnabled && (
+
({
+ ...item,
+ label: dashboardData.t(item.label)
+ }))}
+ CARD_PROPS={CARD_PROPS}
+ ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
+ t={dashboardData.t}
+ />
+ )}
+
+ {/* 常见问答卡片 */}
+ {dashboardData.faqEnabled && (
+
+ )}
+
+ {/* 服务可用性卡片 */}
+ {dashboardData.uptimeEnabled && (
+ 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}
+ />
+ )}
+
+
+ )}
+
+ );
+};
+
+export default Dashboard;
\ No newline at end of file
diff --git a/web/src/components/dashboard/modals/SearchModal.jsx b/web/src/components/dashboard/modals/SearchModal.jsx
new file mode 100644
index 00000000..251f040c
--- /dev/null
+++ b/web/src/components/dashboard/modals/SearchModal.jsx
@@ -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 .
+
+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) => (
+
+ );
+
+ const { start_timestamp, end_timestamp, username } = inputs;
+
+ return (
+
+
+
+ );
+};
+
+export default SearchModal;
\ No newline at end of file
diff --git a/web/src/constants/dashboard.constants.js b/web/src/constants/dashboard.constants.js
new file mode 100644
index 00000000..332687e5
--- /dev/null
+++ b/web/src/constants/dashboard.constants.js
@@ -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 .
+
+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
+};
\ No newline at end of file
diff --git a/web/src/constants/index.js b/web/src/constants/index.js
index 5e81b7db..623885d4 100644
--- a/web/src/constants/index.js
+++ b/web/src/constants/index.js
@@ -21,5 +21,6 @@ export * from './channel.constants';
export * from './user.constants';
export * from './toast.constants';
export * from './common.constant';
+export * from './dashboard.constants';
export * from './playground.constants';
export * from './redemption.constants';
diff --git a/web/src/helpers/dashboard.js b/web/src/helpers/dashboard.js
new file mode 100644
index 00000000..374f1ea6
--- /dev/null
+++ b/web/src/helpers/dashboard.js
@@ -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 .
+
+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) => (
+
+
+ {text}
+
+);
+
+export const createFormField = (Component, props, FORM_FIELD_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 (
+
+ }
+ darkModeImage={ }
+ title={t('暂无监控数据')}
+ />
+
+ );
+ }
+
+ const grouped = {};
+ monitors.forEach((m) => {
+ const g = m.group || '';
+ if (!grouped[g]) grouped[g] = [];
+ grouped[g].push(m);
+ });
+
+ const renderItem = (monitor, idx) => (
+
+
+
+
{((monitor.uptime || 0) * 100).toFixed(2)}%
+
+
+
{getUptimeStatusText(monitor.status)}
+
+
+
+ );
+
+ return Object.entries(grouped).map(([gname, list]) => (
+
+ {gname && (
+ <>
+
+ {gname}
+
+
+ >
+ )}
+ {list.map(renderItem)}
+
+ ));
+};
+
+// ========== 数据处理函数 ==========
+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;
+};
\ No newline at end of file
diff --git a/web/src/helpers/index.js b/web/src/helpers/index.js
index e906e254..ecdeb20f 100644
--- a/web/src/helpers/index.js
+++ b/web/src/helpers/index.js
@@ -26,3 +26,4 @@ export * from './log';
export * from './data';
export * from './token';
export * from './boolean';
+export * from './dashboard';
diff --git a/web/src/hooks/dashboard/useDashboardCharts.js b/web/src/hooks/dashboard/useDashboardCharts.js
new file mode 100644
index 00000000..a5ce0b19
--- /dev/null
+++ b/web/src/hooks/dashboard/useDashboardCharts.js
@@ -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 .
+
+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
+ };
+};
\ No newline at end of file
diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js
new file mode 100644
index 00000000..4eaeca77
--- /dev/null
+++ b/web/src/hooks/dashboard/useDashboardData.js
@@ -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 .
+
+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
+ };
+};
\ No newline at end of file
diff --git a/web/src/hooks/dashboard/useDashboardStats.js b/web/src/hooks/dashboard/useDashboardStats.js
new file mode 100644
index 00000000..1e0a4f32
--- /dev/null
+++ b/web/src/hooks/dashboard/useDashboardStats.js
@@ -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 .
+
+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: ,
+ avatarColor: 'blue',
+ onClick: () => navigate('/console/topup'),
+ trendData: [],
+ trendColor: '#3b82f6'
+ },
+ {
+ title: t('历史消耗'),
+ value: renderQuota(userState?.user?.used_quota),
+ icon: ,
+ avatarColor: 'purple',
+ trendData: [],
+ trendColor: '#8b5cf6'
+ }
+ ]
+ },
+ {
+ title: createSectionTitle(Activity, t('使用统计')),
+ color: 'bg-green-50',
+ items: [
+ {
+ title: t('请求次数'),
+ value: userState.user?.request_count,
+ icon: ,
+ avatarColor: 'green',
+ trendData: [],
+ trendColor: '#10b981'
+ },
+ {
+ title: t('统计次数'),
+ value: times,
+ icon: ,
+ avatarColor: 'cyan',
+ trendData: trendData.times,
+ trendColor: '#06b6d4'
+ }
+ ]
+ },
+ {
+ title: createSectionTitle(Zap, t('资源消耗')),
+ color: 'bg-yellow-50',
+ items: [
+ {
+ title: t('统计额度'),
+ value: renderQuota(consumeQuota),
+ icon: ,
+ avatarColor: 'yellow',
+ trendData: trendData.consumeQuota,
+ trendColor: '#f59e0b'
+ },
+ {
+ title: t('统计Tokens'),
+ value: isNaN(consumeTokens) ? 0 : consumeTokens,
+ icon: ,
+ avatarColor: 'pink',
+ trendData: trendData.tokens,
+ trendColor: '#ec4899'
+ }
+ ]
+ },
+ {
+ title: createSectionTitle(Gauge, t('性能指标')),
+ color: 'bg-indigo-50',
+ items: [
+ {
+ title: t('平均RPM'),
+ value: performanceMetrics.avgRPM,
+ icon: ,
+ avatarColor: 'indigo',
+ trendData: trendData.rpm,
+ trendColor: '#6366f1'
+ },
+ {
+ title: t('平均TPM'),
+ value: performanceMetrics.avgTPM,
+ icon: ,
+ 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
+ };
+};
\ No newline at end of file
diff --git a/web/src/pages/Dashboard/index.js b/web/src/pages/Dashboard/index.js
new file mode 100644
index 00000000..f7f5afdd
--- /dev/null
+++ b/web/src/pages/Dashboard/index.js
@@ -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 .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import Dashboard from '../../components/dashboard';
+
+const Detail = () => (
+
+
+
+);
+
+export default Detail;
diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js
deleted file mode 100644
index 0a725209..00000000
--- a/web/src/pages/Detail/index.js
+++ /dev/null
@@ -1,1610 +0,0 @@
-/*
-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 .
-
-For commercial licensing, please contact support@quantumnous.com
-*/
-
-import React, { useContext, useEffect, useRef, useState, useMemo, useCallback } from 'react';
-import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
-import { useNavigate } from 'react-router-dom';
-import { Wallet, Activity, Zap, Gauge, PieChart, Server, Bell, HelpCircle, ExternalLink } from 'lucide-react';
-import { marked } from 'marked';
-
-import {
- Card,
- Form,
- Spin,
- Button,
- Modal,
- Avatar,
- Tabs,
- TabPane,
- Empty,
- Tag,
- Timeline,
- Collapse,
- Progress,
- Divider,
- Skeleton
-} from '@douyinfe/semi-ui';
-import ScrollableContainer from '../../components/common/ui/ScrollableContainer';
-import {
- IconRefresh,
- IconSearch,
- IconMoneyExchangeStroked,
- IconHistogram,
- IconCoinMoneyStroked,
- IconTextStroked,
- IconPulse,
- IconStopwatchStroked,
- IconTypograph,
- IconPieChart2Stroked,
- IconPlus,
- IconMinus,
- IconSend
-} from '@douyinfe/semi-icons';
-import { IllustrationConstruction, IllustrationConstructionDark } from '@douyinfe/semi-illustrations';
-import { VChart } from '@visactor/react-vchart';
-import {
- API,
- isAdmin,
- showError,
- showSuccess,
- showWarning,
- timestamp2string,
- timestamp2string1,
- getQuotaWithUnit,
- modelColorMap,
- renderNumber,
- renderQuota,
- modelToColor,
- copy,
- getRelativeTime
-} from '../../helpers';
-import { useIsMobile } from '../../hooks/common/useIsMobile.js';
-import { UserContext } from '../../context/User/index.js';
-import { StatusContext } from '../../context/Status/index.js';
-import { useTranslation } from 'react-i18next';
-
-const Detail = (props) => {
- // ========== Hooks - Context ==========
- const [userState, userDispatch] = useContext(UserContext);
- const [statusState, statusDispatch] = useContext(StatusContext);
-
- // ========== Hooks - Navigation & Translation ==========
- const { t } = useTranslation();
- const navigate = useNavigate();
- const isMobile = useIsMobile();
-
- // ========== Hooks - Refs ==========
- const formRef = useRef();
- const initialized = useRef(false);
-
- // ========== Constants & Shared Configurations ==========
- const CHART_CONFIG = { mode: 'desktop-browser' };
-
- const CARD_PROPS = {
- shadows: 'always',
- bordered: false,
- headerLine: true
- };
-
- const FORM_FIELD_PROPS = {
- className: "w-full mb-2 !rounded-lg",
- size: 'large'
- };
-
- const ICON_BUTTON_CLASS = "text-white hover:bg-opacity-80 !rounded-full";
- const FLEX_CENTER_GAP2 = "flex items-center gap-2";
-
- const ILLUSTRATION_SIZE = { width: 96, height: 96 };
-
- // ========== Constants ==========
- let 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;
-
- // ========== Helper Functions ==========
- const getDefaultTime = useCallback(() => {
- return localStorage.getItem('data_export_default_time') || 'hour';
- }, []);
-
- const getTimeInterval = useCallback((timeType, isSeconds = false) => {
- const intervals = {
- hour: isSeconds ? 3600 : 60,
- day: isSeconds ? 86400 : 1440,
- week: isSeconds ? 604800 : 10080
- };
- return intervals[timeType] || intervals.hour;
- }, []);
-
- const getInitialTimestamp = useCallback(() => {
- 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);
- }
- }, [getDefaultTime]);
-
- const updateMapValue = useCallback((map, key, value) => {
- if (!map.has(key)) {
- map.set(key, 0);
- }
- map.set(key, map.get(key) + value);
- }, []);
-
- const initializeMaps = useCallback((key, ...maps) => {
- maps.forEach(map => {
- if (!map.has(key)) {
- map.set(key, 0);
- }
- });
- }, []);
-
- const updateChartSpec = useCallback((setterFunc, newData, subtitle, newColors, dataId) => {
- setterFunc(prev => ({
- ...prev,
- data: [{ id: dataId, values: newData }],
- title: {
- ...prev.title,
- subtext: subtitle,
- },
- color: {
- specified: newColors,
- },
- }));
- }, []);
-
- const createSectionTitle = useCallback((Icon, text) => (
-
-
- {text}
-
- ), []);
-
- const createFormField = useCallback((Component, props) => (
-
- ), []);
-
- // ========== Time Options ==========
- const timeOptions = useMemo(() => [
- { label: t('小时'), value: 'hour' },
- { label: t('天'), value: 'day' },
- { label: t('周'), value: 'week' },
- ], [t]);
-
- // ========== Hooks - State ==========
- const [inputs, setInputs] = useState({
- username: '',
- token_name: '',
- model_name: '',
- start_timestamp: getInitialTimestamp(),
- end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
- channel: '',
- data_export_default_time: '',
- });
-
- const [dataExportDefaultTime, setDataExportDefaultTime] = useState(getDefaultTime());
-
- const [loading, setLoading] = useState(false);
- const [greetingVisible, setGreetingVisible] = useState(false);
- 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 [searchModalVisible, setSearchModalVisible] = useState(false);
-
- const [trendData, setTrendData] = useState({
- balance: [],
- usedQuota: [],
- requestCount: [],
- times: [],
- consumeQuota: [],
- tokens: [],
- rpm: [],
- tpm: []
- });
-
-
-
- // ========== Uptime data ==========
- const [uptimeData, setUptimeData] = useState([]);
- const [uptimeLoading, setUptimeLoading] = useState(false);
- const [activeUptimeTab, setActiveUptimeTab] = useState('');
-
- // ========== Props Destructuring ==========
- const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
-
- // ========== Chart Specs State ==========
- const [spec_pie, setSpecPie] = useState({
- type: 'pie',
- data: [
- {
- id: 'id0',
- values: pieData,
- },
- ],
- 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(times)}`,
- },
- 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: lineData,
- },
- ],
- xField: 'Time',
- yField: 'Usage',
- seriesField: 'Model',
- stack: true,
- legends: {
- visible: true,
- selectMode: 'single',
- },
- title: {
- visible: true,
- text: t('模型消耗分布'),
- subtext: `${t('总计')}:${renderQuota(consumeQuota, 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,
- },
- });
-
- // ========== Hooks - Memoized Values ==========
- const performanceMetrics = useMemo(() => {
- 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, end_timestamp, start_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]);
-
- // ========== Hooks - Callbacks ==========
- const getTrendSpec = useCallback((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'
- }
- }), []);
-
- const groupedStatsData = useMemo(() => [
- {
- title: createSectionTitle(Wallet, t('账户数据')),
- color: 'bg-blue-50',
- items: [
- {
- title: t('当前余额'),
- value: renderQuota(userState?.user?.quota),
- icon: ,
- avatarColor: 'blue',
- onClick: () => navigate('/console/topup'),
- trendData: [],
- trendColor: '#3b82f6'
- },
- {
- title: t('历史消耗'),
- value: renderQuota(userState?.user?.used_quota),
- icon: ,
- avatarColor: 'purple',
- trendData: [],
- trendColor: '#8b5cf6'
- }
- ]
- },
- {
- title: createSectionTitle(Activity, t('使用统计')),
- color: 'bg-green-50',
- items: [
- {
- title: t('请求次数'),
- value: userState.user?.request_count,
- icon: ,
- avatarColor: 'green',
- trendData: [],
- trendColor: '#10b981'
- },
- {
- title: t('统计次数'),
- value: times,
- icon: ,
- avatarColor: 'cyan',
- trendData: trendData.times,
- trendColor: '#06b6d4'
- }
- ]
- },
- {
- title: createSectionTitle(Zap, t('资源消耗')),
- color: 'bg-yellow-50',
- items: [
- {
- title: t('统计额度'),
- value: renderQuota(consumeQuota),
- icon: ,
- avatarColor: 'yellow',
- trendData: trendData.consumeQuota,
- trendColor: '#f59e0b'
- },
- {
- title: t('统计Tokens'),
- value: isNaN(consumeTokens) ? 0 : consumeTokens,
- icon: ,
- avatarColor: 'pink',
- trendData: trendData.tokens,
- trendColor: '#ec4899'
- }
- ]
- },
- {
- title: createSectionTitle(Gauge, t('性能指标')),
- color: 'bg-indigo-50',
- items: [
- {
- title: t('平均RPM'),
- value: performanceMetrics.avgRPM,
- icon: ,
- avatarColor: 'indigo',
- trendData: trendData.rpm,
- trendColor: '#6366f1'
- },
- {
- title: t('平均TPM'),
- value: performanceMetrics.avgTPM,
- icon: ,
- avatarColor: 'orange',
- trendData: trendData.tpm,
- trendColor: '#f97316'
- }
- ]
- }
- ], [
- createSectionTitle, t, userState?.user?.quota, userState?.user?.used_quota, userState?.user?.request_count,
- times, consumeQuota, consumeTokens, trendData, performanceMetrics, navigate
- ]);
-
- const handleCopyUrl = useCallback(async (url) => {
- if (await copy(url)) {
- showSuccess(t('复制成功'));
- }
- }, [t]);
-
- const handleSpeedTest = useCallback((apiUrl) => {
- const encodedUrl = encodeURIComponent(apiUrl);
- const speedTestUrl = `https://www.tcptest.cn/http/${encodedUrl}`;
- window.open(speedTestUrl, '_blank', 'noopener,noreferrer');
- }, []);
-
- const handleInputChange = useCallback((value, name) => {
- if (name === 'data_export_default_time') {
- setDataExportDefaultTime(value);
- return;
- }
- setInputs((inputs) => ({ ...inputs, [name]: value }));
- }, []);
-
- const loadQuotaData = useCallback(async () => {
- setLoading(true);
- const startTime = Date.now();
- try {
- let url = '';
- 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);
- updateChartData(data);
- } else {
- showError(message);
- }
- } finally {
- const elapsed = Date.now() - startTime;
- const remainingTime = Math.max(0, 500 - elapsed);
- setTimeout(() => {
- setLoading(false);
- }, remainingTime);
- }
- }, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
-
- 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 refresh = useCallback(async () => {
- await Promise.all([loadQuotaData(), loadUptimeData()]);
- }, [loadQuotaData, loadUptimeData]);
-
- const handleSearchConfirm = useCallback(() => {
- refresh();
- setSearchModalVisible(false);
- }, [refresh]);
-
- const initChart = useCallback(async () => {
- await loadQuotaData();
- await loadUptimeData();
- }, [loadQuotaData, loadUptimeData]);
-
- const showSearchModal = useCallback(() => {
- setSearchModalVisible(true);
- }, []);
-
- const handleCloseModal = useCallback(() => {
- setSearchModalVisible(false);
- }, []);
-
-
-
-
-
- useEffect(() => {
- const timer = setTimeout(() => {
- setGreetingVisible(true);
- }, 100);
- return () => clearTimeout(timer);
- }, []);
-
- const getUserData = 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);
- }
- };
-
- // ========== Data Processing Functions ==========
- const processRawData = useCallback((data) => {
- 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;
- }, [dataExportDefaultTime, initializeMaps, updateMapValue]);
-
- const calculateTrendData = useCallback((timePoints, timeQuotaMap, timeTokensMap, timeCountMap) => {
- 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
- };
- }, [dataExportDefaultTime, getTimeInterval]);
-
- const generateModelColors = useCallback((uniqueModels) => {
- const newModelColors = {};
- Array.from(uniqueModels).forEach((modelName) => {
- newModelColors[modelName] =
- modelColorMap[modelName] ||
- modelColors[modelName] ||
- modelToColor(modelName);
- });
- return newModelColors;
- }, [modelColors]);
-
- const aggregateDataByTimeAndModel = useCallback((data) => {
- 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;
- }, [dataExportDefaultTime]);
-
- const generateChartTimePoints = useCallback((aggregatedData, data) => {
- let chartTimePoints = Array.from(
- new Set([...aggregatedData.values()].map((d) => d.time)),
- );
-
- if (chartTimePoints.length < 7) {
- const lastTime = Math.max(...data.map((item) => item.created_at));
- const interval = getTimeInterval(dataExportDefaultTime, true);
-
- chartTimePoints = Array.from({ length: 7 }, (_, i) =>
- timestamp2string1(lastTime - (6 - i) * interval, dataExportDefaultTime),
- );
- }
-
- return chartTimePoints;
- }, [dataExportDefaultTime, getTimeInterval]);
-
- const updateChartData = useCallback((data) => {
- const processedData = processRawData(data);
- const { totalQuota, totalTimes, totalTokens, uniqueModels, timePoints, timeQuotaMap, timeTokensMap, timeCountMap } = processedData;
-
- const trendDataResult = calculateTrendData(timePoints, timeQuotaMap, timeTokensMap, timeCountMap);
- setTrendData(trendDataResult);
-
- const newModelColors = generateModelColors(uniqueModels);
- setModelColors(newModelColors);
-
- const aggregatedData = aggregateDataByTimeAndModel(data);
-
- 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);
- 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);
- }, [
- processRawData, calculateTrendData, generateModelColors, aggregateDataByTimeAndModel,
- generateChartTimePoints, updateChartSpec, updateMapValue, t
- ]);
-
- // ========== Status Data Management ==========
- const announcementLegendData = useMemo(() => [
- { color: 'grey', label: t('默认'), type: 'default' },
- { color: 'blue', label: t('进行中'), type: 'ongoing' },
- { color: 'green', label: t('成功'), type: 'success' },
- { color: 'orange', label: t('警告'), type: 'warning' },
- { color: 'red', label: t('异常'), type: 'error' }
- ], [t]);
-
- const uptimeStatusMap = useMemo(() => ({
- 1: { color: '#10b981', label: t('正常'), text: t('可用率') }, // UP
- 0: { color: '#ef4444', label: t('异常'), text: t('有异常') }, // DOWN
- 2: { color: '#f59e0b', label: t('高延迟'), text: t('高延迟') }, // PENDING
- 3: { color: '#3b82f6', label: t('维护中'), text: t('维护中') } // MAINTENANCE
- }), [t]);
-
- const uptimeLegendData = useMemo(() =>
- Object.entries(uptimeStatusMap).map(([status, info]) => ({
- status: Number(status),
- color: info.color,
- label: info.label
- })), [uptimeStatusMap]);
-
- const getUptimeStatusColor = useCallback((status) =>
- uptimeStatusMap[status]?.color || '#8b9aa7',
- [uptimeStatusMap]);
-
- const getUptimeStatusText = useCallback((status) =>
- uptimeStatusMap[status]?.text || t('未知'),
- [uptimeStatusMap, t]);
-
- const apiInfoData = useMemo(() => {
- return statusState?.status?.api_info || [];
- }, [statusState?.status?.api_info]);
-
- const announcementData = useMemo(() => {
- const announcements = statusState?.status?.announcements || [];
- return announcements.map(item => ({
- ...item,
- time: getRelativeTime(item.publishDate)
- }));
- }, [statusState?.status?.announcements]);
-
- const faqData = useMemo(() => {
- return statusState?.status?.faq || [];
- }, [statusState?.status?.faq]);
-
- const renderMonitorList = useCallback((monitors) => {
- if (!monitors || monitors.length === 0) {
- return (
-
- }
- darkModeImage={ }
- title={t('暂无监控数据')}
- />
-
- );
- }
-
- const grouped = {};
- monitors.forEach((m) => {
- const g = m.group || '';
- if (!grouped[g]) grouped[g] = [];
- grouped[g].push(m);
- });
-
- const renderItem = (monitor, idx) => (
-
-
-
-
{((monitor.uptime || 0) * 100).toFixed(2)}%
-
-
-
{getUptimeStatusText(monitor.status)}
-
-
-
- );
-
- return Object.entries(grouped).map(([gname, list]) => (
-
- {gname && (
- <>
-
- {gname}
-
-
- >
- )}
- {list.map(renderItem)}
-
- ));
- }, [t, getUptimeStatusColor, getUptimeStatusText]);
-
- // ========== Hooks - Effects ==========
- useEffect(() => {
- getUserData();
- if (!initialized.current) {
- initVChartSemiTheme({
- isWatchingThemeSwitch: true,
- });
- initialized.current = true;
- initChart();
- }
- }, []);
-
- return (
-
-
-
- {getGreeting}
-
-
- }
- onClick={showSearchModal}
- className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
- />
- }
- onClick={refresh}
- loading={loading}
- className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
- />
-
-
-
- {/* 搜索条件Modal */}
-
-
-
-
-
-
- {groupedStatsData.map((group, idx) => (
-
-
- {group.items.map((item, itemIdx) => (
-
-
-
- {item.icon}
-
-
-
{item.title}
-
-
- }
- >
- {item.value}
-
-
-
-
- {(loading || (item.trendData && item.trendData.length > 0)) && (
-
-
-
- )}
-
- ))}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
- {t('消耗分布')}
-
- } itemKey="1" />
-
-
- {t('消耗趋势')}
-
- } itemKey="2" />
-
-
- {t('调用次数分布')}
-
- } itemKey="3" />
-
-
- {t('调用次数排行')}
-
- } itemKey="4" />
-
-
- }
- bodyStyle={{ padding: 0 }}
- >
-
- {activeChartTab === '1' && (
-
- )}
- {activeChartTab === '2' && (
-
- )}
- {activeChartTab === '3' && (
-
- )}
- {activeChartTab === '4' && (
-
- )}
-
-
-
- {hasApiInfoPanel && (
-
-
- {t('API信息')}
-
- }
- bodyStyle={{ padding: 0 }}
- >
-
- {apiInfoData.length > 0 ? (
- apiInfoData.map((api) => (
- <>
-
-
-
- {api.route.substring(0, 2)}
-
-
-
-
-
- {api.route}
-
-
- }
- size="small"
- color="white"
- shape='circle'
- onClick={() => handleSpeedTest(api.url)}
- className="cursor-pointer hover:opacity-80 text-xs"
- >
- {t('测速')}
-
- }
- size="small"
- color="white"
- shape='circle'
- onClick={() => window.open(api.url, '_blank', 'noopener,noreferrer')}
- className="cursor-pointer hover:opacity-80 text-xs"
- >
- {t('跳转')}
-
-
-
-
handleCopyUrl(api.url)}
- >
- {api.url}
-
-
- {api.description}
-
-
-
-
- >
- ))
- ) : (
-
- }
- darkModeImage={ }
- title={t('暂无API信息')}
- description={t('请联系管理员在系统设置中配置API信息')}
- />
-
- )}
-
-
- )}
-
-
-
- {/* 系统公告和常见问答卡片 */}
- {
- hasInfoPanels && (
-
-
- {/* 公告卡片 */}
- {announcementsEnabled && (
-
-
-
- {t('系统公告')}
-
- {t('显示最新20条')}
-
-
- {/* 图例 */}
-
- {announcementLegendData.map((legend, index) => (
-
- ))}
-
-
- }
- bodyStyle={{ padding: 0 }}
- >
-
- {announcementData.length > 0 ? (
-
- {announcementData.map((item, idx) => (
-
-
-
- {item.extra && (
-
- )}
-
-
- ))}
-
- ) : (
-
- }
- darkModeImage={ }
- title={t('暂无系统公告')}
- description={t('请联系管理员在系统设置中配置公告信息')}
- />
-
- )}
-
-
- )}
-
- {/* 常见问答卡片 */}
- {faqEnabled && (
-
-
- {t('常见问答')}
-
- }
- bodyStyle={{ padding: 0 }}
- >
-
- {faqData.length > 0 ? (
- }
- collapseIcon={ }
- >
- {faqData.map((item, index) => (
-
-
-
- ))}
-
- ) : (
-
- }
- darkModeImage={ }
- title={t('暂无常见问答')}
- description={t('请联系管理员在系统设置中配置常见问答')}
- />
-
- )}
-
-
- )}
-
- {/* 服务可用性卡片 */}
- {uptimeEnabled && (
-
-
-
- {t('服务可用性')}
-
- }
- onClick={loadUptimeData}
- loading={uptimeLoading}
- size="small"
- theme="borderless"
- type='tertiary'
- className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
- />
-
- }
- bodyStyle={{ padding: 0 }}
- >
- {/* 内容区域 */}
-
-
- {uptimeData.length > 0 ? (
- uptimeData.length === 1 ? (
-
- {renderMonitorList(uptimeData[0].monitors)}
-
- ) : (
-
- {uptimeData.map((group, groupIdx) => (
-
-
- {group.categoryName}
-
- {group.monitors ? group.monitors.length : 0}
-
-
- }
- itemKey={group.categoryName}
- key={groupIdx}
- >
-
- {renderMonitorList(group.monitors)}
-
-
- ))}
-
- )
- ) : (
-
- }
- darkModeImage={ }
- title={t('暂无监控数据')}
- description={t('请联系管理员在系统设置中配置Uptime')}
- />
-
- )}
-
-
-
- {/* 图例 */}
- {uptimeData.length > 0 && (
-
-
- {uptimeLegendData.map((legend, index) => (
-
- ))}
-
-
- )}
-
- )}
-
-
- )
- }
-
- );
-};
-
-export default Detail;