mirror of
https://github.com/Jerryplusy/AI-powered-switches.git
synced 2025-07-04 05:09:19 +00:00
重写通知模块,增加两个页面
This commit is contained in:
parent
d6787a11dd
commit
3070c676f8
@ -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()}
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
@ -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 (
|
||||
<AnimatePresence mode={'wait'}>
|
||||
@ -71,29 +86,48 @@ const GithubTransitionCard = () => {
|
||||
w={'100%'}
|
||||
px={isDashboard ? 4 : 3}
|
||||
py={isDashboard ? 3 : 2}
|
||||
noHover={isDashboard}
|
||||
disableHover={isDashboard}
|
||||
>
|
||||
{isDashboard && showNavButtons && (
|
||||
<HStack spacing={4} ml={'auto'}>
|
||||
{navItems.map((item) => (
|
||||
<Button
|
||||
key={item.path}
|
||||
size={'sm'}
|
||||
variant={'ghost'}
|
||||
color={'white'}
|
||||
_hover={{
|
||||
color: 'teal.300',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(item.path);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
<>
|
||||
<HStack spacing={4} ml={'auto'}>
|
||||
{navItems.map((item) => (
|
||||
<Button
|
||||
key={item.path}
|
||||
size={'sm'}
|
||||
variant={'ghost'}
|
||||
color={'white'}
|
||||
_hover={{
|
||||
color: 'teal.300',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(item.path);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
<HStack spacing={4} ml={'auto'}>
|
||||
<Text fontSize={'sm'} color={'white'}>
|
||||
{'测试模式'}
|
||||
</Text>
|
||||
<Switch.Root
|
||||
size={'sm'}
|
||||
checked={testMode}
|
||||
onCheckedChange={(details) => handleTestModeToggle(details.checked)}
|
||||
colorPalette={'teal'}
|
||||
zIndex={100}
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch.Root>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</MotionCard>
|
||||
</FadeInWrapper>
|
||||
@ -101,5 +135,5 @@ const GithubTransitionCard = () => {
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO 解决组件重复渲染问题
|
||||
export default GithubTransitionCard;
|
||||
|
@ -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: <Welcome /> },
|
||||
{ path: '/dashboard', element: <Dashboard /> },
|
||||
{ path: '/dashboard/scan', element: <ScanPage /> },
|
||||
{ path: '/dashboard/devices', element: <DevicesPage /> },
|
||||
{ path: '/dashboard/config', element: <ConfigPage /> },
|
||||
];
|
||||
|
||||
const buildRoutes = () =>
|
||||
|
@ -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(
|
||||
<Provider>
|
||||
<NotificationProvider>
|
||||
<App />
|
||||
</NotificationProvider>
|
||||
<App />
|
||||
</Provider>
|
||||
);
|
||||
|
21
src/frontend/src/libs/common.js
Normal file
21
src/frontend/src/libs/common.js
Normal file
@ -0,0 +1,21 @@
|
||||
const Common = {
|
||||
/**
|
||||
* 睡眠指定毫秒
|
||||
* @param {number} ms - 毫秒数,必须是非负数
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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;
|
@ -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;
|
||||
|
@ -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 (
|
||||
<NotificationContext.Provider value={notify}>
|
||||
{children}
|
||||
<Box
|
||||
position={'fixed'}
|
||||
bottom={4}
|
||||
right={4}
|
||||
zIndex={9999}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
alignItems={'flex-end'}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{notifications.map((item) => (
|
||||
<MotionBox
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 200 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 200 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
mb={3}
|
||||
width={'320px'}
|
||||
>
|
||||
<Box
|
||||
bg={item.type === 'error' ? '' : ''}
|
||||
color={'white'}
|
||||
borderRadius={'xl'}
|
||||
px={5}
|
||||
py={4}
|
||||
boxShadow={'2xl'}
|
||||
border={'1px solid rgba(255, 255, 255, 0.4)'}
|
||||
backdropFilter={'blur(8px)'}
|
||||
>
|
||||
<Box display={'flex'} alignItems={'center'}>
|
||||
<Icon
|
||||
as={item.type === 'error' ? AiFillWarning : AiOutlineInfoCircle}
|
||||
boxSize={6}
|
||||
mr={3}
|
||||
/>
|
||||
<Text fontSize={'lg'} fontWeight={'bold'}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text mt={2}>{item.description}</Text>
|
||||
{item.button && (
|
||||
<Button
|
||||
colorPalette={'whiteAlpha'}
|
||||
size={'sm'}
|
||||
mt={3}
|
||||
onClick={item.button.onClick}
|
||||
>
|
||||
{item.button.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</MotionBox>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
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: <AiOutlineInfoCircle size={24} />,
|
||||
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: <AiFillCheckCircle size={24} />,
|
||||
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: <AiFillExclamationCircle size={24} />,
|
||||
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: <AiFillWarning size={24} />,
|
||||
actionLabel: button?.label,
|
||||
action: button?.onClick,
|
||||
style: surfaceStyle,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default Notification;
|
||||
|
114
src/frontend/src/pages/ConfigPage.jsx
Normal file
114
src/frontend/src/pages/ConfigPage.jsx
Normal file
@ -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 (
|
||||
<DocumentTitle title={'交换机配置'}>
|
||||
<DashboardBackground />
|
||||
<PageContainer>
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<VStack spacing={8} align={'stretch'}>
|
||||
<Heading fontSize={'2xl'} color={'teal.300'}>
|
||||
{'交换机配置中心'}
|
||||
</Heading>
|
||||
|
||||
<Select
|
||||
placeholder={'请选择交换机设备'}
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
bg={'whiteAlpha.200'}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.ip} value={device.ip}>
|
||||
{device.name} ({device.ip})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Textarea
|
||||
rows={6}
|
||||
placeholder={'输入自然语言配置指令...'}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
bg={'whiteAlpha.200'}
|
||||
/>
|
||||
|
||||
<Button
|
||||
colorPalette={'teal'}
|
||||
variant={'solid'}
|
||||
isLoading={loading}
|
||||
onClick={handleParse}
|
||||
>
|
||||
{'解析配置'}
|
||||
</Button>
|
||||
|
||||
{parsedConfig && (
|
||||
<Box
|
||||
p={4}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
>
|
||||
<Text fontWeight={'bold'} mb={2}>
|
||||
{'已生成配置:'}
|
||||
</Text>
|
||||
<Textarea value={parsedConfig} rows={8} readOnly />
|
||||
<Button mt={4} colorPalette={'teal'} onClick={handleApply}>
|
||||
{'应用到交换机'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</FadeInWrapper>
|
||||
</PageContainer>
|
||||
</DocumentTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
@ -6,7 +6,9 @@ import DashboardBackground from '@/components/system/pages/DashboardBackground';
|
||||
import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
|
||||
import FeatureCard from '@/components/pages/dashboard/FeatureCard';
|
||||
import StatCard from '@/components/pages/dashboard/StatCard';
|
||||
import { useNotification } from '@/libs/system/Notification';
|
||||
import api from '@/services/api/api';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
import Notification from '@/libs/system/Notification';
|
||||
|
||||
/**
|
||||
* 控制台
|
||||
@ -21,24 +23,24 @@ const Dashboard = () => {
|
||||
});
|
||||
|
||||
const [networkStatus, setNetworkStatus] = useState('loading'); // loading | ok | fail
|
||||
const notify = useNotification();
|
||||
|
||||
const checkBackend = useCallback(async () => {
|
||||
setNetworkStatus('loading');
|
||||
try {
|
||||
const res = await fetch('/api/test');
|
||||
if (res.ok) {
|
||||
const res = await api.test();
|
||||
if (res) {
|
||||
setNetworkStatus('ok');
|
||||
notify.info({ title: '成功连接至后端服务!' });
|
||||
console.log(JSON.stringify(res));
|
||||
Notification.info({ title: '成功连接至后端服务!' });
|
||||
} else {
|
||||
setNetworkStatus('fail');
|
||||
notify.error({ title: '后端服务响应异常!' });
|
||||
Notification.error({ title: '后端服务响应异常!' });
|
||||
}
|
||||
} catch (err) {
|
||||
setNetworkStatus('fail');
|
||||
notify.error({ title: '无法连接到后端服务!' });
|
||||
Notification.error({ title: '无法连接到后端服务!' });
|
||||
}
|
||||
}, [notify]);
|
||||
}, [Notification]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@ -47,6 +49,14 @@ const Dashboard = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = () => {
|
||||
const stats = ConfigTool.getStats();
|
||||
setStats(stats);
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DocumentTitle title={'控制台'}>
|
||||
<DashboardBackground />
|
||||
@ -97,29 +107,17 @@ const Dashboard = () => {
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
{networkStatus === 'loading' && (
|
||||
<Badge
|
||||
colorScheme={'gray'}
|
||||
variant={'surface'}
|
||||
p={2}
|
||||
borderRadius={'lg'}
|
||||
colorPalette={'blue'}
|
||||
>
|
||||
<Badge variant={'surface'} p={2} borderRadius={'lg'} colorPalette={'blue'}>
|
||||
<Spinner size="sm" mr={2} /> {'检测网络中...'}
|
||||
</Badge>
|
||||
)}
|
||||
{networkStatus === 'ok' && (
|
||||
<Badge
|
||||
colorScheme={'green'}
|
||||
variant={'surface'}
|
||||
p={2}
|
||||
borderRadius={'lg'}
|
||||
colorPalette={'green'}
|
||||
>
|
||||
<Badge variant={'surface'} p={2} borderRadius={'lg'} colorPalette={'green'}>
|
||||
{'网络连接正常'}
|
||||
</Badge>
|
||||
)}
|
||||
{networkStatus === 'fail' && (
|
||||
<Badge colorPalette={'red'} variant={'surface'} p={2} borderRadius={'lg'}>
|
||||
<Badge color={'red'} variant={'surface'} p={2} borderRadius={'lg'}>
|
||||
{'无法连接后端'}
|
||||
</Badge>
|
||||
)}
|
||||
|
135
src/frontend/src/pages/DevicesPage.jsx
Normal file
135
src/frontend/src/pages/DevicesPage.jsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Input,
|
||||
Button,
|
||||
HStack,
|
||||
Image,
|
||||
Collapsible,
|
||||
} 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 Common from '@/libs/common';
|
||||
|
||||
/**
|
||||
* 交换机管理
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const DevicesPage = () => {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [editingIndex, setEditingIndex] = useState(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const config = ConfigTool.load();
|
||||
setDevices(config.devices || []);
|
||||
}, []);
|
||||
|
||||
const handleEdit = (idx) => {
|
||||
setEditingIndex(idx);
|
||||
setEditingName(devices[idx].name);
|
||||
};
|
||||
|
||||
const handleSave = (idx) => {
|
||||
const updated = [...devices];
|
||||
updated[idx].name = editingName;
|
||||
setDevices(updated);
|
||||
ConfigTool.save({ ...ConfigTool.load(), devices: updated });
|
||||
Common.sleep(500);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentTitle title={'交换机设备'}>
|
||||
<DashboardBackground />
|
||||
<PageContainer>
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<VStack gap={4} align={'stretch'}>
|
||||
<Heading fontSize={'2xl'} color={'teal.300'}>
|
||||
{'交换机设备管理'}
|
||||
</Heading>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} gap={3}>
|
||||
{devices.map((device, idx) => (
|
||||
<Box
|
||||
key={device.ip}
|
||||
p={4}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
transition={'all 0.3s ease'}
|
||||
_hover={{ transform: 'translateY(-8px)', bg: 'whiteAlpha.200' }}
|
||||
>
|
||||
<Image src={'/assets/switch.png'} alt={'Switch'} borderRadius={'md'} mb={3} />
|
||||
|
||||
<Collapsible.Root open={editingIndex === idx}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Box mb={3} cursor={'pointer'}>
|
||||
{editingIndex === idx ? null : (
|
||||
<HStack justify={'space-between'}>
|
||||
<Text fontSize={'lg'} fontWeight={'bold'}>
|
||||
{device.name}
|
||||
</Text>
|
||||
<Button
|
||||
size={'xs'}
|
||||
colorPalette={'teal'}
|
||||
onClick={() => handleEdit(idx)}
|
||||
>
|
||||
{'重命名'}
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<HStack mb={3}>
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
size={'sm'}
|
||||
bg={'whiteAlpha.300'}
|
||||
/>
|
||||
<Button size={'sm'} colorPalette={'teal'} onClick={() => handleSave(idx)}>
|
||||
{'保存'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<Text fontSize={'sm'}>
|
||||
{'IP: '}
|
||||
{device.ip}
|
||||
</Text>
|
||||
<Text fontSize={'sm'}>
|
||||
{'MAC: '}
|
||||
{device.mac}
|
||||
</Text>
|
||||
<Text fontSize={'sm'}>
|
||||
{'端口: '}
|
||||
{device.ports.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<Text color={'gray.400'}>{'暂无扫描记录,请先进行网络扫描'}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</FadeInWrapper>
|
||||
</PageContainer>
|
||||
</DocumentTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevicesPage;
|
@ -16,7 +16,9 @@ import PageContainer from '@/components/system/PageContainer';
|
||||
import DashboardBackground from '@/components/system/pages/DashboardBackground';
|
||||
import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
|
||||
import { api } from '@/services/api/api';
|
||||
import { useNotification } from '@/libs/system/Notification';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
import Common from '@/libs/common';
|
||||
import Notification from '@/libs/system/Notification';
|
||||
|
||||
/**
|
||||
* 网络扫描页面
|
||||
@ -28,7 +30,9 @@ const ScanPage = () => {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localIp, setLocalIp] = useState('');
|
||||
const notify = useNotification();
|
||||
|
||||
const config = ConfigTool.load();
|
||||
const testMode = config.testMode;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLocalInfo = async () => {
|
||||
@ -39,15 +43,51 @@ const ScanPage = () => {
|
||||
}
|
||||
};
|
||||
fetchLocalInfo();
|
||||
}, [subnet, notify]);
|
||||
}, [subnet, Notification]);
|
||||
|
||||
const getTestDevices = () => {
|
||||
return [
|
||||
{ ip: '192.168.1.1', mac: '00:1A:2B:3C:4D:5E', ports: [22, 23, 161] },
|
||||
{ ip: '192.168.1.2', mac: '00:1D:7D:AA:1B:01', ports: [22, 23, 161] },
|
||||
{ ip: '192.168.1.3', mac: '00:0C:29:5A:3B:11', ports: [22, 23, 161, 443] },
|
||||
{ ip: '192.168.1.4', mac: '00:23:CD:FF:10:02', ports: [22, 23] },
|
||||
{ ip: '192.168.1.5', mac: '00:04:4B:AA:BB:CC', ports: [22, 23, 80, 443] },
|
||||
{ ip: '192.168.1.6', mac: '00:11:22:33:44:55', ports: [22, 23, 161] },
|
||||
{ ip: '192.168.1.7', mac: '00:1F:45:67:89:AB', ports: [23] },
|
||||
{ ip: '192.168.1.8', mac: '00:50:56:11:22:33', ports: [22, 443, 8443] },
|
||||
];
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.scan(subnet);
|
||||
setDevices(res.devices || []);
|
||||
let scanDevices;
|
||||
if (testMode) {
|
||||
await Common.sleep(2000);
|
||||
scanDevices = getTestDevices();
|
||||
} else {
|
||||
const res = await api.scan(subnet);
|
||||
scanDevices = res.devices || [];
|
||||
}
|
||||
|
||||
scanDevices = scanDevices.map((d, idx) => ({
|
||||
...d,
|
||||
name: `交换机 ${idx + 1}`,
|
||||
}));
|
||||
setDevices(scanDevices);
|
||||
const updatedStats = {
|
||||
totalDevices: scanDevices.length,
|
||||
onlineDevices: scanDevices.length,
|
||||
lastScan: new Date().toISOString(),
|
||||
};
|
||||
|
||||
ConfigTool.save({
|
||||
...config,
|
||||
stats: updatedStats,
|
||||
devices: scanDevices,
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({ title: '扫描网络失败' });
|
||||
Notification.error({ title: '扫描网络失败' });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@ -55,10 +95,17 @@ const ScanPage = () => {
|
||||
const handleLastScan = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.listDevices();
|
||||
setDevices(res.devices || []);
|
||||
let scanDevices;
|
||||
if (testMode) {
|
||||
await Common.sleep(2000);
|
||||
scanDevices = getTestDevices();
|
||||
} else {
|
||||
const res = await api.listDevices();
|
||||
scanDevices = res.devices || [];
|
||||
}
|
||||
setDevices(scanDevices);
|
||||
} catch (err) {
|
||||
notify.error({ title: '获取上次扫描记录失败' });
|
||||
Notification.error({ title: '获取上次扫描记录失败' });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@ -122,24 +169,50 @@ const ScanPage = () => {
|
||||
)}
|
||||
|
||||
{!loading && devices.length > 0 && (
|
||||
<Table.Root variant={'outline'} striped={'true'} size={'md'} mt={4}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>{'IP 地址'}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{'MAC 地址'}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{'开放端口'}</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{devices.map((d) => (
|
||||
<Table.Row key={d.ip}>
|
||||
<Table.Cell>{d.ip}</Table.Cell>
|
||||
<Table.Cell>{d.mac}</Table.Cell>
|
||||
<Table.Cell>{d.ports.join(', ')}</Table.Cell>
|
||||
<FadeInWrapper delay={0.2} yOffset={-5}>
|
||||
<Table.Root
|
||||
variant={'outline'}
|
||||
colorPalette={'teal'}
|
||||
size={'md'}
|
||||
striped={'false'}
|
||||
showColumnBorder={'true'}
|
||||
interactive={'true'}
|
||||
stickyHeader={'true'}
|
||||
mt={4}
|
||||
borderRadius={'lg'}
|
||||
overflow={'hidden'}
|
||||
shadow={'lg'}
|
||||
>
|
||||
<Table.Header bg={'transparent'}>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader bg={'whiteAlpha.100'} backdropFilter={'blur(4px)'}>
|
||||
{'IP 地址'}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader bg={'whiteAlpha.100'} backdropFilter={'blur(4px)'}>
|
||||
{'MAC 地址'}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader bg={'whiteAlpha.100'} backdropFilter={'blur(4px)'}>
|
||||
{'开放端口'}
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{devices.map((d) => (
|
||||
<Table.Row
|
||||
key={d.ip}
|
||||
_hover={{
|
||||
bgGradient: 'linear(to-r, teal.50, blue.50)',
|
||||
transition: 'background 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<Table.Cell>{d.ip}</Table.Cell>
|
||||
<Table.Cell>{d.mac}</Table.Cell>
|
||||
<Table.Cell>{d.ports.join(', ')}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</FadeInWrapper>
|
||||
)}
|
||||
|
||||
{!loading && devices.length === 0 && (
|
||||
|
@ -2,38 +2,16 @@ import axios from 'axios';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
|
||||
/**
|
||||
* 创建带基础URL的axios实例
|
||||
* 动态拼接完整URL
|
||||
*/
|
||||
const apiClient = axios.create({
|
||||
baseURL: ConfigTool.load().backendUrl || '',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const cfg = ConfigTool.load();
|
||||
if (cfg?.authKey) {
|
||||
config.headers['Authorization'] = `Bearer ${cfg.authKey}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
const buildUrl = (path) => {
|
||||
const config = ConfigTool.load();
|
||||
let baseUrl = config.backendUrl || '';
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
console.error('API请求错误:', error);
|
||||
return Promise.reject(error.response?.data || error.message);
|
||||
}
|
||||
);
|
||||
return `${baseUrl}${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* API模块
|
||||
@ -43,27 +21,27 @@ export const api = {
|
||||
* 测试API连接
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
test: () => apiClient.get('/api/test'),
|
||||
test: () => axios.get(buildUrl('/api/test')),
|
||||
|
||||
/**
|
||||
* 扫描网络
|
||||
* @param subnet 子网地址
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
scan: (subnet) => apiClient.get(`/api/scan_network`, { params: { subnet } }),
|
||||
scan: (subnet) => axios.get(buildUrl('/api/scan_network'), { params: { subnet } }),
|
||||
|
||||
/**
|
||||
* 列出所有设备
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
listDevices: () => apiClient.get('/api/list_devices'),
|
||||
listDevices: () => axios.get(buildUrl('/api/list_devices')),
|
||||
|
||||
/**
|
||||
* 解析命令
|
||||
* @param text 文本
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
parseCommand: (text) => apiClient.post('/api/parse_command', { command: text }),
|
||||
parseCommand: (text) => axios.post(buildUrl('/api/parse_command'), { command: text }),
|
||||
|
||||
/**
|
||||
* 应用配置
|
||||
@ -71,7 +49,8 @@ export const api = {
|
||||
* @param config 配置
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
applyConfig: (switch_ip, config) => apiClient.post('/api/apply_config', { switch_ip, config }),
|
||||
applyConfig: (switch_ip, config) =>
|
||||
axios.post(buildUrl('/api/apply_config'), { switch_ip, config }),
|
||||
|
||||
/**
|
||||
* 更新基础URL
|
||||
@ -81,7 +60,6 @@ export const api = {
|
||||
const config = ConfigTool.load();
|
||||
config.backendUrl = url;
|
||||
ConfigTool.save(config);
|
||||
apiClient.defaults.baseURL = url;
|
||||
},
|
||||
};
|
||||
|
||||
@ -97,4 +75,4 @@ export const getConfig = () => ConfigTool.load();
|
||||
*/
|
||||
export const getBaseUrl = () => ConfigTool.load().backendUrl || '';
|
||||
|
||||
export default apiClient;
|
||||
export default api;
|
||||
|
Loading…
x
Reference in New Issue
Block a user