重写通知模块,增加两个页面

This commit is contained in:
Jerry 2025-06-21 13:31:24 +08:00
parent d6787a11dd
commit 3070c676f8
12 changed files with 588 additions and 244 deletions

View File

@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
import AppShell from '@/components/system/layout/AppShell'; import AppShell from '@/components/system/layout/AppShell';
import buildRoutes from '@/constants/routes/routes'; import buildRoutes from '@/constants/routes/routes';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Toaster } from '@/components/ui/toaster';
const App = () => { const App = () => {
const isProd = process.env.NODE_ENV === 'production'; const isProd = process.env.NODE_ENV === 'production';
@ -13,6 +14,7 @@ const App = () => {
{buildRoutes()} {buildRoutes()}
</Route> </Route>
</Routes> </Routes>
<Toaster />
</BrowserRouter> </BrowserRouter>
); );
}; };

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; 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 web from '@/resources/icon/pages/weclome/web.svg';
import githubIcon from '@/resources/icon/pages/weclome/github.svg'; import githubIcon from '@/resources/icon/pages/weclome/github.svg';
import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
import MotionCard from '@/components/ui/MotionCard'; import MotionCard from '@/components/ui/MotionCard';
import ConfigTool from '@/libs/config/ConfigTool';
const navItems = [ const navItems = [
{ label: '面板', path: '/dashboard' }, { label: '面板', path: '/dashboard' },
@ -25,6 +26,7 @@ const GithubTransitionCard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const isDashboard = pathname.startsWith('/dashboard'); const isDashboard = pathname.startsWith('/dashboard');
const [showNavButtons, setShowNavButtons] = useState(false); const [showNavButtons, setShowNavButtons] = useState(false);
const [testMode, setTestMode] = useState(false);
useEffect(() => { useEffect(() => {
setShowNavButtons(false); setShowNavButtons(false);
@ -33,6 +35,19 @@ const GithubTransitionCard = () => {
}, 400); }, 400);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isDashboard]); }, [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); const MotionBox = motion(Box);
return ( return (
<AnimatePresence mode={'wait'}> <AnimatePresence mode={'wait'}>
@ -71,9 +86,10 @@ const GithubTransitionCard = () => {
w={'100%'} w={'100%'}
px={isDashboard ? 4 : 3} px={isDashboard ? 4 : 3}
py={isDashboard ? 3 : 2} py={isDashboard ? 3 : 2}
noHover={isDashboard} disableHover={isDashboard}
> >
{isDashboard && showNavButtons && ( {isDashboard && showNavButtons && (
<>
<HStack spacing={4} ml={'auto'}> <HStack spacing={4} ml={'auto'}>
{navItems.map((item) => ( {navItems.map((item) => (
<Button <Button
@ -93,7 +109,25 @@ const GithubTransitionCard = () => {
{item.label} {item.label}
</Button> </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>
</HStack>
</>
)} )}
</MotionCard> </MotionCard>
</FadeInWrapper> </FadeInWrapper>
@ -101,5 +135,5 @@ const GithubTransitionCard = () => {
</AnimatePresence> </AnimatePresence>
); );
}; };
// TODO
export default GithubTransitionCard; export default GithubTransitionCard;

View File

@ -2,6 +2,8 @@ import { Route } from 'react-router-dom';
import Welcome from '@/pages/Welcome'; import Welcome from '@/pages/Welcome';
import Dashboard from '@/pages/Dashboard'; import Dashboard from '@/pages/Dashboard';
import ScanPage from '@/pages/ScanPage'; 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: '/', element: <Welcome /> },
{ path: '/dashboard', element: <Dashboard /> }, { path: '/dashboard', element: <Dashboard /> },
{ path: '/dashboard/scan', element: <ScanPage /> }, { path: '/dashboard/scan', element: <ScanPage /> },
{ path: '/dashboard/devices', element: <DevicesPage /> },
{ path: '/dashboard/config', element: <ConfigPage /> },
]; ];
const buildRoutes = () => const buildRoutes = () =>

View File

@ -2,13 +2,10 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from '@/App'; import App from '@/App';
import { Provider } from '@/components/ui/provider'; import { Provider } from '@/components/ui/provider';
import { NotificationProvider } from '@/libs/system/Notification';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<Provider> <Provider>
<NotificationProvider>
<App /> <App />
</NotificationProvider>
</Provider> </Provider>
); );

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

View File

@ -1,18 +1,26 @@
const CONFIG_KEY = 'app_config'; const CONFIG_KEY = 'app_config';
/**
* 默认配置
* @type {{backendUrl: string, authKey: string, testMode: boolean, stats: {totalDevices: number, onlineDevices: number, lastScan: string}, devices: *[]}}
*/
export const defaultConfig = { export const defaultConfig = {
backendUrl: '', backendUrl: '',
authKey: '', 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 = { const ConfigTool = {
/**
* 从本地存储读取配置
* @returns {{backendUrl: string, authKey: string}}
*/
load: () => { load: () => {
try { try {
const stored = localStorage.getItem(CONFIG_KEY); const stored = localStorage.getItem(CONFIG_KEY);
@ -27,15 +35,13 @@ const ConfigTool = {
}, },
/** /**
* 保存配置到本地存储 * 保存配置
* @param {Object} config - 要保存的配置对象 * @param config
* @returns {boolean} 是否保存成功 * @returns {boolean}
*/ */
save: (config) => { save: (config) => {
try { try {
localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
//console.log(`正在保存配置:${JSON.stringify(config)}`);
//console.log(`测试读取配置:${JSON.stringify(ConfigTool.load())}`);
return true; return true;
} catch (e) { } catch (e) {
console.error('保存配置失败:', e); console.error('保存配置失败:', e);
@ -48,7 +54,7 @@ const ConfigTool = {
/** /**
* 清除配置 * 清除配置
* @returns {boolean} 是否清除成功 * @returns {boolean}
*/ */
clear: () => { clear: () => {
try { try {
@ -60,16 +66,8 @@ const ConfigTool = {
} }
}, },
/**
* 获取存储标识浏览器环境返回固定值
* @returns {string}
*/
getConfigPath: () => `localStorage:${CONFIG_KEY}`, getConfigPath: () => `localStorage:${CONFIG_KEY}`,
/**
* 检查存储是否可用
* @returns {boolean}
*/
isStorageAvailable: () => { isStorageAvailable: () => {
try { try {
const testKey = 'test'; const testKey = 'test';
@ -80,6 +78,11 @@ const ConfigTool = {
return false; return false;
} }
}, },
getStats: () => {
const config = ConfigTool.load();
return config.stats || { totalDevices: 0, onlineDevices: 0, lastScan: '' };
},
}; };
export default ConfigTool; export default ConfigTool;

View File

@ -1,114 +1,99 @@
import React, { createContext, useContext, useState, useCallback } from 'react'; import { toaster } from '@/components/ui/toaster';
import { Box, Button, Icon, Text } from '@chakra-ui/react'; import {
import { AnimatePresence, motion } from 'framer-motion'; AiOutlineInfoCircle,
import { AiOutlineInfoCircle, AiFillWarning } from 'react-icons/ai'; AiFillWarning,
AiFillCheckCircle,
AiFillExclamationCircle,
} from 'react-icons/ai';
const NotificationContext = createContext(null); const surfaceStyle = {
borderRadius: '1rem',
/** border: '1px solid rgba(255,255,255,0.3)',
* 通知hook backdropFilter: 'blur(10px)',
* @returns {null} background: 'rgba(255, 255, 255, 0.1)',
*/ color: 'white',
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>
);
}; };
/**
* 通用通知组件
* @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;

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

View File

@ -6,7 +6,9 @@ import DashboardBackground from '@/components/system/pages/DashboardBackground';
import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
import FeatureCard from '@/components/pages/dashboard/FeatureCard'; import FeatureCard from '@/components/pages/dashboard/FeatureCard';
import StatCard from '@/components/pages/dashboard/StatCard'; 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 [networkStatus, setNetworkStatus] = useState('loading'); // loading | ok | fail
const notify = useNotification();
const checkBackend = useCallback(async () => { const checkBackend = useCallback(async () => {
setNetworkStatus('loading'); setNetworkStatus('loading');
try { try {
const res = await fetch('/api/test'); const res = await api.test();
if (res.ok) { if (res) {
setNetworkStatus('ok'); setNetworkStatus('ok');
notify.info({ title: '成功连接至后端服务!' }); console.log(JSON.stringify(res));
Notification.info({ title: '成功连接至后端服务!' });
} else { } else {
setNetworkStatus('fail'); setNetworkStatus('fail');
notify.error({ title: '后端服务响应异常!' }); Notification.error({ title: '后端服务响应异常!' });
} }
} catch (err) { } catch (err) {
setNetworkStatus('fail'); setNetworkStatus('fail');
notify.error({ title: '无法连接到后端服务!' }); Notification.error({ title: '无法连接到后端服务!' });
} }
}, [notify]); }, [Notification]);
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -47,6 +49,14 @@ const Dashboard = () => {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [checkBackend]); }, [checkBackend]);
useEffect(() => {
const loadStats = () => {
const stats = ConfigTool.getStats();
setStats(stats);
};
loadStats();
}, []);
return ( return (
<DocumentTitle title={'控制台'}> <DocumentTitle title={'控制台'}>
<DashboardBackground /> <DashboardBackground />
@ -97,29 +107,17 @@ const Dashboard = () => {
</Text> </Text>
<HStack spacing={4}> <HStack spacing={4}>
{networkStatus === 'loading' && ( {networkStatus === 'loading' && (
<Badge <Badge variant={'surface'} p={2} borderRadius={'lg'} colorPalette={'blue'}>
colorScheme={'gray'}
variant={'surface'}
p={2}
borderRadius={'lg'}
colorPalette={'blue'}
>
<Spinner size="sm" mr={2} /> {'检测网络中...'} <Spinner size="sm" mr={2} /> {'检测网络中...'}
</Badge> </Badge>
)} )}
{networkStatus === 'ok' && ( {networkStatus === 'ok' && (
<Badge <Badge variant={'surface'} p={2} borderRadius={'lg'} colorPalette={'green'}>
colorScheme={'green'}
variant={'surface'}
p={2}
borderRadius={'lg'}
colorPalette={'green'}
>
{'网络连接正常'} {'网络连接正常'}
</Badge> </Badge>
)} )}
{networkStatus === 'fail' && ( {networkStatus === 'fail' && (
<Badge colorPalette={'red'} variant={'surface'} p={2} borderRadius={'lg'}> <Badge color={'red'} variant={'surface'} p={2} borderRadius={'lg'}>
{'无法连接后端'} {'无法连接后端'}
</Badge> </Badge>
)} )}

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

View File

@ -16,7 +16,9 @@ import PageContainer from '@/components/system/PageContainer';
import DashboardBackground from '@/components/system/pages/DashboardBackground'; import DashboardBackground from '@/components/system/pages/DashboardBackground';
import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
import { api } from '@/services/api/api'; 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 [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [localIp, setLocalIp] = useState(''); const [localIp, setLocalIp] = useState('');
const notify = useNotification();
const config = ConfigTool.load();
const testMode = config.testMode;
useEffect(() => { useEffect(() => {
const fetchLocalInfo = async () => { const fetchLocalInfo = async () => {
@ -39,15 +43,51 @@ const ScanPage = () => {
} }
}; };
fetchLocalInfo(); 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 () => { const handleScan = async () => {
setLoading(true); setLoading(true);
try { try {
let scanDevices;
if (testMode) {
await Common.sleep(2000);
scanDevices = getTestDevices();
} else {
const res = await api.scan(subnet); const res = await api.scan(subnet);
setDevices(res.devices || []); 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) { } catch (err) {
notify.error({ title: '扫描网络失败' }); Notification.error({ title: '扫描网络失败' });
} }
setLoading(false); setLoading(false);
}; };
@ -55,10 +95,17 @@ const ScanPage = () => {
const handleLastScan = async () => { const handleLastScan = async () => {
setLoading(true); setLoading(true);
try { try {
let scanDevices;
if (testMode) {
await Common.sleep(2000);
scanDevices = getTestDevices();
} else {
const res = await api.listDevices(); const res = await api.listDevices();
setDevices(res.devices || []); scanDevices = res.devices || [];
}
setDevices(scanDevices);
} catch (err) { } catch (err) {
notify.error({ title: '获取上次扫描记录失败' }); Notification.error({ title: '获取上次扫描记录失败' });
} }
setLoading(false); setLoading(false);
}; };
@ -122,17 +169,42 @@ const ScanPage = () => {
)} )}
{!loading && devices.length > 0 && ( {!loading && devices.length > 0 && (
<Table.Root variant={'outline'} striped={'true'} size={'md'} mt={4}> <FadeInWrapper delay={0.2} yOffset={-5}>
<Table.Header> <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.Row>
<Table.ColumnHeader>{'IP 地址'}</Table.ColumnHeader> <Table.ColumnHeader bg={'whiteAlpha.100'} backdropFilter={'blur(4px)'}>
<Table.ColumnHeader>{'MAC 地址'}</Table.ColumnHeader> {'IP 地址'}
<Table.ColumnHeader>{'开放端口'}</Table.ColumnHeader> </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.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{devices.map((d) => ( {devices.map((d) => (
<Table.Row key={d.ip}> <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.ip}</Table.Cell>
<Table.Cell>{d.mac}</Table.Cell> <Table.Cell>{d.mac}</Table.Cell>
<Table.Cell>{d.ports.join(', ')}</Table.Cell> <Table.Cell>{d.ports.join(', ')}</Table.Cell>
@ -140,6 +212,7 @@ const ScanPage = () => {
))} ))}
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
</FadeInWrapper>
)} )}
{!loading && devices.length === 0 && ( {!loading && devices.length === 0 && (

View File

@ -2,38 +2,16 @@ import axios from 'axios';
import ConfigTool from '@/libs/config/ConfigTool'; import ConfigTool from '@/libs/config/ConfigTool';
/** /**
* 创建带基础URL的axios实例 * 动态拼接完整URL
*/ */
const apiClient = axios.create({ const buildUrl = (path) => {
baseURL: ConfigTool.load().backendUrl || '', const config = ConfigTool.load();
timeout: 10000, let baseUrl = config.backendUrl || '';
headers: { if (baseUrl.endsWith('/')) {
'Content-Type': 'application/json', baseUrl = baseUrl.slice(0, -1);
},
});
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
const cfg = ConfigTool.load();
if (cfg?.authKey) {
config.headers['Authorization'] = `Bearer ${cfg.authKey}`;
} }
return config; return `${baseUrl}${path}`;
}, };
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response.data,
(error) => {
console.error('API请求错误:', error);
return Promise.reject(error.response?.data || error.message);
}
);
/** /**
* API模块 * API模块
@ -43,27 +21,27 @@ export const api = {
* 测试API连接 * 测试API连接
* @returns {Promise<axios.AxiosResponse<any>>} * @returns {Promise<axios.AxiosResponse<any>>}
*/ */
test: () => apiClient.get('/api/test'), test: () => axios.get(buildUrl('/api/test')),
/** /**
* 扫描网络 * 扫描网络
* @param subnet 子网地址 * @param subnet 子网地址
* @returns {Promise<axios.AxiosResponse<any>>} * @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>>} * @returns {Promise<axios.AxiosResponse<any>>}
*/ */
listDevices: () => apiClient.get('/api/list_devices'), listDevices: () => axios.get(buildUrl('/api/list_devices')),
/** /**
* 解析命令 * 解析命令
* @param text 文本 * @param text 文本
* @returns {Promise<axios.AxiosResponse<any>>} * @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 配置 * @param config 配置
* @returns {Promise<axios.AxiosResponse<any>>} * @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 * 更新基础URL
@ -81,7 +60,6 @@ export const api = {
const config = ConfigTool.load(); const config = ConfigTool.load();
config.backendUrl = url; config.backendUrl = url;
ConfigTool.save(config); ConfigTool.save(config);
apiClient.defaults.baseURL = url;
}, },
}; };
@ -97,4 +75,4 @@ export const getConfig = () => ConfigTool.load();
*/ */
export const getBaseUrl = () => ConfigTool.load().backendUrl || ''; export const getBaseUrl = () => ConfigTool.load().backendUrl || '';
export default apiClient; export default api;