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) => ( +
+
+ {legend.label} +
+ ))} +
+
+ } + 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('模型数据分析')} +
+ + + + {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} +

+
+
+
+ ); +}; + +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('服务可用性')} +
+
+ } + 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) => ( +
+
+ {legend.label} +
+ ))} +
+
+ )} + + ); +}; + +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 ( + +
+ {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') + })} +
+
+ ); +}; + +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.name} +
+ {((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.name} -
- {((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} -

-
-
-
- - {/* 搜索条件Modal */} - -
- {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') - })} -
-
- -
-
- {groupedStatsData.map((group, idx) => ( - -
- {group.items.map((item, itemIdx) => ( -
-
- - {item.icon} - -
-
{item.title}
-
- - } - > - {item.value} - -
-
-
- {(loading || (item.trendData && item.trendData.length > 0)) && ( -
- -
- )} -
- ))} -
-
- ))} -
-
- -
-
- -
- - {t('模型数据分析')} -
- - - - {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) => ( -
-
- {legend.label} -
- ))} -
-
- } - 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('服务可用性')} -
-
- } - 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) => ( -
-
- {legend.label} -
- ))} -
-
- )} - - )} -
-
- ) - } -
- ); -}; - -export default Detail;