diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx index 4eb355c..1244fb4 100644 --- a/src/frontend/src/App.jsx +++ b/src/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import AppShell from '@/components/system/layout/AppShell'; import buildRoutes from '@/constants/routes/routes'; import { useEffect } from 'react'; +import { Toaster } from '@/components/ui/toaster'; const App = () => { const isProd = process.env.NODE_ENV === 'production'; @@ -13,6 +14,7 @@ const App = () => { {buildRoutes()} + ); }; diff --git a/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx b/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx index 6af0039..c2f5e31 100644 --- a/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx +++ b/src/frontend/src/components/system/layout/github/GithubTransitionCard.jsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { AnimatePresence, motion } from 'framer-motion'; -import { Box, Button, HStack } from '@chakra-ui/react'; +import { Box, Button, HStack, Switch, Text } from '@chakra-ui/react'; import web from '@/resources/icon/pages/weclome/web.svg'; import githubIcon from '@/resources/icon/pages/weclome/github.svg'; import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; import MotionCard from '@/components/ui/MotionCard'; +import ConfigTool from '@/libs/config/ConfigTool'; const navItems = [ { label: '面板', path: '/dashboard' }, @@ -25,6 +26,7 @@ const GithubTransitionCard = () => { const navigate = useNavigate(); const isDashboard = pathname.startsWith('/dashboard'); const [showNavButtons, setShowNavButtons] = useState(false); + const [testMode, setTestMode] = useState(false); useEffect(() => { setShowNavButtons(false); @@ -33,6 +35,19 @@ const GithubTransitionCard = () => { }, 400); return () => clearTimeout(timer); }, [isDashboard]); + + useEffect(() => { + const config = ConfigTool.load(); + setTestMode(config.testMode); + }, []); + + const handleTestModeToggle = (checked) => { + console.log(`切换测试模式..`); + setTestMode(checked); + const config = ConfigTool.load(); + ConfigTool.save({ ...config, testMode: checked }); + }; + const MotionBox = motion(Box); return ( @@ -71,29 +86,48 @@ const GithubTransitionCard = () => { w={'100%'} px={isDashboard ? 4 : 3} py={isDashboard ? 3 : 2} - noHover={isDashboard} + disableHover={isDashboard} > {isDashboard && showNavButtons && ( - - {navItems.map((item) => ( - - ))} - + <> + + {navItems.map((item) => ( + + ))} + + + {'测试模式'} + + handleTestModeToggle(details.checked)} + colorPalette={'teal'} + zIndex={100} + > + + + + + + + + )} @@ -101,5 +135,5 @@ const GithubTransitionCard = () => { ); }; - +// TODO 解决组件重复渲染问题 export default GithubTransitionCard; diff --git a/src/frontend/src/constants/routes/routes.jsx b/src/frontend/src/constants/routes/routes.jsx index e4ea434..f001118 100644 --- a/src/frontend/src/constants/routes/routes.jsx +++ b/src/frontend/src/constants/routes/routes.jsx @@ -2,6 +2,8 @@ import { Route } from 'react-router-dom'; import Welcome from '@/pages/Welcome'; import Dashboard from '@/pages/Dashboard'; import ScanPage from '@/pages/ScanPage'; +import DevicesPage from '@/pages/DevicesPage'; +import ConfigPage from '@/pages/ConfigPage'; /** * 路由 @@ -11,6 +13,8 @@ const routeList = [ { path: '/', element: }, { path: '/dashboard', element: }, { path: '/dashboard/scan', element: }, + { path: '/dashboard/devices', element: }, + { path: '/dashboard/config', element: }, ]; const buildRoutes = () => diff --git a/src/frontend/src/index.js b/src/frontend/src/index.js index d22913e..d021c8d 100644 --- a/src/frontend/src/index.js +++ b/src/frontend/src/index.js @@ -2,13 +2,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from '@/App'; import { Provider } from '@/components/ui/provider'; -import { NotificationProvider } from '@/libs/system/Notification'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - - - + ); diff --git a/src/frontend/src/libs/common.js b/src/frontend/src/libs/common.js new file mode 100644 index 0000000..a523457 --- /dev/null +++ b/src/frontend/src/libs/common.js @@ -0,0 +1,21 @@ +const Common = { + /** + * 睡眠指定毫秒 + * @param {number} ms - 毫秒数,必须是非负数 + * @returns {Promise} + */ + async sleep(ms) { + if (!Number.isFinite(ms)) { + return Promise.resolve(); + } + if (ms === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + return () => clearTimeout(timer); + }); + }, +}; + +export default Common; diff --git a/src/frontend/src/libs/config/ConfigTool.js b/src/frontend/src/libs/config/ConfigTool.js index adda66c..49a26fb 100644 --- a/src/frontend/src/libs/config/ConfigTool.js +++ b/src/frontend/src/libs/config/ConfigTool.js @@ -1,18 +1,26 @@ const CONFIG_KEY = 'app_config'; +/** + * 默认配置 + * @type {{backendUrl: string, authKey: string, testMode: boolean, stats: {totalDevices: number, onlineDevices: number, lastScan: string}, devices: *[]}} + */ export const defaultConfig = { backendUrl: '', authKey: '', + testMode: false, + stats: { + totalDevices: 0, + onlineDevices: 0, + lastScan: '', + }, + devices: [], }; /** - * 浏览器环境配置操作工具 + * 配置管理工具 + * @type {{load: ((function(): (any))|*), save: ((function(*): (boolean|undefined))|*), clear: ((function(): (boolean|undefined))|*), getConfigPath: (function(): string), isStorageAvailable: ((function(): (boolean|undefined))|*), getStats: (function(): *)}} */ const ConfigTool = { - /** - * 从本地存储读取配置 - * @returns {{backendUrl: string, authKey: string}} - */ load: () => { try { const stored = localStorage.getItem(CONFIG_KEY); @@ -27,15 +35,13 @@ const ConfigTool = { }, /** - * 保存配置到本地存储 - * @param {Object} config - 要保存的配置对象 - * @returns {boolean} 是否保存成功 + * 保存配置 + * @param config + * @returns {boolean} */ save: (config) => { try { localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); - //console.log(`正在保存配置:${JSON.stringify(config)}`); - //console.log(`测试读取配置:${JSON.stringify(ConfigTool.load())}`); return true; } catch (e) { console.error('保存配置失败:', e); @@ -48,7 +54,7 @@ const ConfigTool = { /** * 清除配置 - * @returns {boolean} 是否清除成功 + * @returns {boolean} */ clear: () => { try { @@ -60,16 +66,8 @@ const ConfigTool = { } }, - /** - * 获取存储标识(浏览器环境返回固定值) - * @returns {string} - */ getConfigPath: () => `localStorage:${CONFIG_KEY}`, - /** - * 检查存储是否可用 - * @returns {boolean} - */ isStorageAvailable: () => { try { const testKey = 'test'; @@ -80,6 +78,11 @@ const ConfigTool = { return false; } }, + + getStats: () => { + const config = ConfigTool.load(); + return config.stats || { totalDevices: 0, onlineDevices: 0, lastScan: '' }; + }, }; export default ConfigTool; diff --git a/src/frontend/src/libs/system/Notification.jsx b/src/frontend/src/libs/system/Notification.jsx index ae772e0..5bcd461 100644 --- a/src/frontend/src/libs/system/Notification.jsx +++ b/src/frontend/src/libs/system/Notification.jsx @@ -1,114 +1,99 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; -import { Box, Button, Icon, Text } from '@chakra-ui/react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { AiOutlineInfoCircle, AiFillWarning } from 'react-icons/ai'; +import { toaster } from '@/components/ui/toaster'; +import { + AiOutlineInfoCircle, + AiFillWarning, + AiFillCheckCircle, + AiFillExclamationCircle, +} from 'react-icons/ai'; -const NotificationContext = createContext(null); - -/** - * 通知hook - * @returns {null} - */ -export const useNotification = () => useContext(NotificationContext); - -/** - * 通知根组件 - * @param children - * @returns {JSX.Element} - * @constructor - */ -export const NotificationProvider = ({ children }) => { - const [notifications, setNotifications] = useState([]); - - const addNotification = useCallback((notification) => { - const id = Date.now() + Math.random(); - setNotifications((prev) => [...prev, { ...notification, id }]); - - setTimeout(() => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); - }, 3000); - }, []); - - const notify = { - info: ({ title, description, button }) => - addNotification({ - type: 'info', - title, - description, - button, - }), - error: ({ title, description, button }) => - addNotification({ - type: 'error', - title, - description, - button, - }), - }; - - const MotionBox = motion(Box); - - // TODO 弹窗颜色问题及重新渲染问题 - return ( - - {children} - - - {notifications.map((item) => ( - - - - - - {item.title} - - - {item.description} - {item.button && ( - - )} - - - ))} - - - - ); +const surfaceStyle = { + borderRadius: '1rem', + border: '1px solid rgba(255,255,255,0.3)', + backdropFilter: 'blur(10px)', + background: 'rgba(255, 255, 255, 0.1)', + color: 'white', }; + +/** + * 通用通知组件 + * @type {{info({title: *, description: *, button: *}): void, success({title: *, description: *, button: *}): void, warn({title: *, description: *, button: *}): void, error({title: *, description: *, button: *}): void}} + */ +const Notification = { + /** + * 信息 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + info({ title, description, button }) { + toaster.create({ + title, + description, + type: 'info', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, + + /** + * 成功信息 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + success({ title, description, button }) { + toaster.create({ + title, + description, + type: 'success', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, + + /** + * 警告 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + warn({ title, description, button }) { + toaster.create({ + title, + description, + type: 'warning', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, + + /** + * 错误提示 + * @param title 标题 + * @param description 描述 + * @param button 按钮 + */ + error({ title, description, button }) { + toaster.create({ + title, + description, + type: 'error', + duration: 3000, + icon: , + actionLabel: button?.label, + action: button?.onClick, + style: surfaceStyle, + }); + }, +}; + +export default Notification; diff --git a/src/frontend/src/pages/ConfigPage.jsx b/src/frontend/src/pages/ConfigPage.jsx new file mode 100644 index 0000000..d0704bc --- /dev/null +++ b/src/frontend/src/pages/ConfigPage.jsx @@ -0,0 +1,114 @@ +import React, { useEffect, useState } from 'react'; +import { Box, VStack, Heading, Textarea, Button, Select, Text } from '@chakra-ui/react'; +import DocumentTitle from '@/components/system/pages/DocumentTitle'; +import PageContainer from '@/components/system/PageContainer'; +import DashboardBackground from '@/components/system/pages/DashboardBackground'; +import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; +import ConfigTool from '@/libs/config/ConfigTool'; +import { api } from '@/services/api/api'; +import Notification from '@/libs/system/Notification'; + +const ConfigPage = () => { + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(''); + const [inputText, setInputText] = useState(''); + const [parsedConfig, setParsedConfig] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const config = ConfigTool.load(); + setDevices(config.devices || []); + }, []); + + const handleParse = async () => { + if (!selectedDevice || !inputText) { + Notification.error({ title: '请选择设备并输入配置指令' }); + return; + } + setLoading(true); + try { + const res = await api.parseCommand(inputText); + setParsedConfig(res.config); + } catch (e) { + Notification.error({ title: '配置解析失败' }); + } + setLoading(false); + }; + + const handleApply = async () => { + if (!parsedConfig) return; + setLoading(true); + try { + await api.applyConfig(selectedDevice, parsedConfig); + Notification.success({ title: '配置已成功应用!' }); + } catch (e) { + Notification.error({ title: '配置应用失败' }); + } + setLoading(false); + }; + + return ( + + + + + + + {'交换机配置中心'} + + + + +