Compare commits

...

2 Commits

Author SHA1 Message Date
223d43f0d9 优化vlan匹配算法 2025-06-21 17:17:46 +08:00
093c7423e5 优化通知组件,增加配置页面 2025-06-21 16:59:07 +08:00
8 changed files with 345 additions and 70 deletions

View File

@ -7,6 +7,7 @@ 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'; import ConfigTool from '@/libs/config/ConfigTool';
import Notification from '@/libs/system/Notification';
const navItems = [ const navItems = [
{ label: '面板', path: '/dashboard' }, { label: '面板', path: '/dashboard' },
@ -46,6 +47,10 @@ const GithubTransitionCard = () => {
setTestMode(checked); setTestMode(checked);
const config = ConfigTool.load(); const config = ConfigTool.load();
ConfigTool.save({ ...config, testMode: checked }); ConfigTool.save({ ...config, testMode: checked });
let mode = checked ? '调试模式' : '常规模式';
Notification.success({
title: `成功切换至${mode}!`,
});
}; };
const MotionBox = motion(Box); const MotionBox = motion(Box);
@ -109,9 +114,9 @@ const GithubTransitionCard = () => {
{item.label} {item.label}
</Button> </Button>
))} ))}
<HStack spacing={4} ml={'auto'}> <HStack spacing={4} ml={'right'}>
<Text fontSize={'sm'} color={'white'}> <Text fontSize={'sm'} color={'white'}>
{'试模式'} {'试模式'}
</Text> </Text>
<Switch.Root <Switch.Root
size={'sm'} size={'sm'}

View File

@ -4,6 +4,7 @@ import {
AiFillWarning, AiFillWarning,
AiFillCheckCircle, AiFillCheckCircle,
AiFillExclamationCircle, AiFillExclamationCircle,
AiOutlineLoading,
} from 'react-icons/ai'; } from 'react-icons/ai';
const surfaceStyle = { const surfaceStyle = {
@ -16,7 +17,13 @@ const surfaceStyle = {
/** /**
* 通用通知组件 * 通用通知组件
* @type {{info({title: *, description: *, button: *}): void, success({title: *, description: *, button: *}): void, warn({title: *, description: *, button: *}): void, error({title: *, description: *, button: *}): void}} * @type {{
* info({title: *, description: *, button: *}): void,
* success({title: *, description: *, button: *}): void,
* warn({title: *, description: *, button: *}): void,
* error({title: *, description: *, button: *}): void,
* promise({promise: *, loading: *, success: *, error: *}): void
* }}
*/ */
const Notification = { const Notification = {
/** /**
@ -57,6 +64,38 @@ const Notification = {
}); });
}, },
/**
* 异步操作通知
* @param {Object} params 参数对象
* @param {Promise} params.promise 异步操作Promise
* @param {Object} params.loading 加载中状态配置
* @param {Object} params.success 成功状态配置
* @param {Object} params.error 错误状态配置
* @returns {{id: string | undefined, unwrap: () => Promise<unknown>}}
*/
async promise({ promise, loading, success, error }) {
return toaster.promise(promise, {
loading: {
title: loading.title,
description: loading.description,
icon: <AiOutlineLoading className={'animate-spin'} size={24} />,
style: surfaceStyle,
},
success: {
title: success.title,
description: success.description,
icon: <AiFillCheckCircle size={24} />,
style: surfaceStyle,
},
error: {
title: error.title,
description: error.description,
icon: <AiFillWarning size={24} />,
style: surfaceStyle,
},
});
},
/** /**
* 警告 * 警告
* @param title 标题 * @param title 标题

View File

@ -1,5 +1,17 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Box, VStack, Heading, Textarea, Button, Select, Text } from '@chakra-ui/react'; import {
Box,
Button,
createListCollection,
Field,
Heading,
HStack,
Portal,
Select,
Text,
Textarea,
VStack,
} from '@chakra-ui/react';
import DocumentTitle from '@/components/system/pages/DocumentTitle'; import DocumentTitle from '@/components/system/pages/DocumentTitle';
import PageContainer from '@/components/system/PageContainer'; import PageContainer from '@/components/system/PageContainer';
import DashboardBackground from '@/components/system/pages/DashboardBackground'; import DashboardBackground from '@/components/system/pages/DashboardBackground';
@ -7,44 +19,184 @@ import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
import ConfigTool from '@/libs/config/ConfigTool'; import ConfigTool from '@/libs/config/ConfigTool';
import { api } from '@/services/api/api'; import { api } from '@/services/api/api';
import Notification from '@/libs/system/Notification'; import Notification from '@/libs/system/Notification';
import Common from '@/libs/common';
const testMode = ConfigTool.load().testMode;
const ConfigPage = () => { const ConfigPage = () => {
const [devices, setDevices] = useState([]); const [devices, setDevices] = useState([]);
const [selectedDevice, setSelectedDevice] = useState(''); const [selectedDevice, setSelectedDevice] = useState('');
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const [parsedConfig, setParsedConfig] = useState(''); const [parsedConfig, setParsedConfig] = useState('');
const [loading, setLoading] = useState(false); const [editableConfig, setEditableConfig] = useState('');
const [applying, setApplying] = useState(false);
const [hasParsed, setHasParsed] = useState(false);
const deviceCollection = createListCollection({
items: devices.map((device) => ({
label: `${device.name} (${device.ip})`,
value: device.ip,
})),
});
useEffect(() => { useEffect(() => {
const config = ConfigTool.load(); const config = ConfigTool.load();
setDevices(config.devices || []); setDevices(config.devices || []);
}, []); }, []);
const generateRealisticConfig = (command, devices = []) => {
const timestamp = new Date().toLocaleString();
let config = `! 配置生成于 ${timestamp}\n`;
const cmd = command.toLowerCase();
// VLAN
if (cmd.includes('vlan')) {
const vlanIdMatch = command.match(/vlan\s*(\d+)/i);
const vlanId = vlanIdMatch?.[1] || '10';
const isMgmt = cmd.includes('管理') || cmd.includes('mgmt');
config +=
`vlan ${vlanId}\n` +
` name ${isMgmt ? 'MGMT' : 'USER'}_VLAN\n` +
` exit\n` +
`interface Vlan${vlanId}\n` +
` description ${isMgmt ? 'Management VLAN' : 'User VLAN'}\n` +
` ip address 192.168.${vlanId}.1 255.255.255.0\n` +
` exit\n`;
}
// SSH
if (cmd.includes('ssh') || cmd.includes('安全') || cmd.includes('登录')) {
const password = Math.random().toString(36).slice(2, 10);
config +=
`ip ssh server\n` +
`ip ssh version 2\n` +
`username admin privilege 15 secret 0 ${password}\n` +
`line vty 0 4\n` +
` transport input ssh\n` +
` login local\n` +
` exit\n`;
}
//
if (cmd.includes('端口') || cmd.includes('接口') || cmd.includes('port')) {
const portMatch = command.match(/端口\s*(\d+)/i) || command.match(/port\s*(\d+)/i);
const port = portMatch?.[1] || '1';
const isTrunk = cmd.includes('trunk');
const isAccess = cmd.includes('access') || !isTrunk;
const desc = cmd.includes('上联') || cmd.includes('uplink') ? 'Uplink_Port' : 'Access_Port';
const vlanId = '10';
config +=
`interface GigabitEthernet0/${port}\n` +
` description ${desc}\n` +
` switchport mode ${isTrunk ? 'trunk' : 'access'}\n` +
` ${isTrunk ? 'switchport trunk allowed vlan all' : `switchport access vlan ${vlanId}`}\n` +
` no shutdown\n` +
` exit\n`;
}
// ACL
if (cmd.includes('acl') || cmd.includes('访问控制') || cmd.includes('防火墙')) {
let targetIP = '192.168.10.10';
if (devices.length > 0) {
const randomDevice = devices[Math.floor(Math.random() * devices.length)];
targetIP = randomDevice.ip;
}
config +=
`ip access-list extended PROTECT_SERVERS\n` +
` permit tcp any host ${targetIP} eq 22\n` +
` permit tcp any host ${targetIP} eq 80\n` +
` deny ip any any\n` +
` exit\n` +
`interface Vlan10\n` +
` ip access-group PROTECT_SERVERS in\n` +
` exit\n`;
}
if (config.trim() === `! 配置生成于 ${timestamp}`) {
config += '! 当前命令未识别到任何可配置项目\n';
}
return { config };
};
const handleParse = async () => { const handleParse = async () => {
if (!selectedDevice || !inputText) { if (!selectedDevice || !inputText.trim()) {
Notification.error({ title: '请选择设备并输入配置指令' }); Notification.error({
title: '操作失败',
description: '请选择设备并输入配置指令',
});
return; return;
} }
setLoading(true);
try { try {
const res = await api.parseCommand(inputText); const performParse = async () => {
setParsedConfig(res.config); if (testMode) {
} catch (e) { await Common.sleep(800 + Math.random() * 700);
Notification.error({ title: '配置解析失败' }); return generateRealisticConfig(inputText);
}
return await api.parseCommand(inputText);
};
const resultWrapper = await Notification.promise({
promise: performParse(),
loading: {
title: '正在解析配置',
description: '正在分析您的指令...',
},
success: {
title: '解析完成',
description: '已生成交换机配置',
},
error: {
title: '解析失败',
description: '请检查指令格式或网络连接',
},
});
const result = await resultWrapper.unwrap();
if (result?.config) {
setParsedConfig(result.config);
setEditableConfig(result.config);
setHasParsed(true);
}
} catch (error) {
console.error('配置解析异常:', error);
Notification.error({
title: '配置解析异常',
description: error.message,
});
} }
setLoading(false);
}; };
const handleApply = async () => { const handleApply = async () => {
if (!parsedConfig) return; if (!editableConfig.trim()) {
setLoading(true); Notification.warn({
try { title: '配置为空',
await api.applyConfig(selectedDevice, parsedConfig); description: '请先解析或编辑有效配置',
Notification.success({ title: '配置已成功应用!' }); });
} catch (e) { return;
Notification.error({ title: '配置应用失败' }); }
setApplying(true);
try {
const applyOperation = testMode
? Common.sleep(1000).then(() => ({ success: true }))
: await api.applyConfig(selectedDevice, editableConfig);
await Notification.promise({
promise: applyOperation,
loading: {
title: '配置应用中',
description: '正在推送配置到设备...',
},
success: {
title: '应用成功',
description: '配置已成功生效',
},
error: {
title: '应用失败',
description: '请检查设备连接或配置内容',
},
});
} finally {
setApplying(false);
} }
setLoading(false);
}; };
return ( return (
@ -52,42 +204,73 @@ const ConfigPage = () => {
<DashboardBackground /> <DashboardBackground />
<PageContainer> <PageContainer>
<FadeInWrapper delay={0.3} yOffset={-5}> <FadeInWrapper delay={0.3} yOffset={-5}>
<VStack spacing={8} align={'stretch'}> <VStack spacing={6} align={'stretch'}>
<Heading fontSize={'2xl'} color={'teal.300'}> <Heading fontSize={'xl'} color={'teal.300'}>
{'交换机配置中心'} 交换机配置中心
</Heading> </Heading>
<Select <Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
选择交换机设备
</Field.Label>
<Select.Root
collection={deviceCollection}
value={selectedDevice ? [selectedDevice] : []}
onValueChange={({ value }) => setSelectedDevice(value[0] ?? '')}
placeholder={'请选择交换机设备'} placeholder={'请选择交换机设备'}
value={selectedDevice} size={'sm'}
onChange={(e) => setSelectedDevice(e.target.value)} colorPalette={'teal'}
bg={'whiteAlpha.200'}
> >
{devices.map((device) => ( <Select.HiddenSelect />
<option key={device.ip} value={device.ip}> <Select.Control>
{device.name} ({device.ip}) <Select.Trigger>
</option> <Select.ValueText />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
<Select.ClearTrigger />
</Select.IndicatorGroup>
</Select.Control>
<Portal>
<Select.Positioner>
<Select.Content>
{deviceCollection.items.map((item) => (
<Select.Item key={item.value} item={item}>
{item.label}
</Select.Item>
))} ))}
</Select> </Select.Content>
</Select.Positioner>
</Portal>
</Select.Root>
</Field.Root>
<Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
配置指令输入
</Field.Label>
<Textarea <Textarea
rows={6} rows={4}
placeholder={'输入自然语言配置指令...'} placeholder={'例子创建VLAN 10并配置IP 192.168.10.1/24并在端口1启用SSH访问"'}
value={inputText} value={inputText}
onChange={(e) => setInputText(e.target.value)} onChange={(e) => setInputText(e.target.value)}
bg={'whiteAlpha.200'} bg={'whiteAlpha.200'}
size={'sm'}
/> />
</Field.Root>
<Button <Button
colorPalette={'teal'} colorScheme={'teal'}
variant={'solid'} variant={'solid'}
isLoading={loading} size={'sm'}
onClick={handleParse} onClick={handleParse}
isDisabled={!selectedDevice || !inputText.trim()}
> >
{'解析配置'} 解析配置
</Button> </Button>
{parsedConfig && ( {hasParsed && (
<FadeInWrapper>
<Box <Box
p={4} p={4}
bg={'whiteAlpha.100'} bg={'whiteAlpha.100'}
@ -95,14 +278,44 @@ const ConfigPage = () => {
border={'1px solid'} border={'1px solid'}
borderColor={'whiteAlpha.300'} borderColor={'whiteAlpha.300'}
> >
<Text fontWeight={'bold'} mb={2}> <Text fontWeight={'bold'} mb={2} fontSize="sm">
{'已生成配置:'} 生成配置:
</Text> </Text>
<Textarea value={parsedConfig} rows={8} readOnly /> <Textarea
<Button mt={4} colorPalette={'teal'} onClick={handleApply}> value={editableConfig}
{'应用到交换机'} rows={12}
onChange={(e) => setEditableConfig(e.target.value)}
fontFamily={'monospace'}
size={'sm'}
bg={'blackAlpha.200'}
/>
<HStack mt={4} spacing={3} justify={'flex-end'}>
<Button
variant={'outline'}
colorScheme={'gray'}
size={'sm'}
onClick={() => {
setEditableConfig(parsedConfig);
Notification.success({
title: '成功重置配置!',
description: '现在您可以重新审查生成的配置',
});
}}
>
重置为原始配置
</Button> </Button>
<Button
colorScheme={'teal'}
size={'sm'}
onClick={handleApply}
isLoading={applying}
isDisabled={!editableConfig.trim()}
>
应用到交换机
</Button>
</HStack>
</Box> </Box>
</FadeInWrapper>
)} )}
</VStack> </VStack>
</FadeInWrapper> </FadeInWrapper>

View File

@ -31,18 +31,21 @@ const Dashboard = () => {
if (res) { if (res) {
setNetworkStatus('ok'); setNetworkStatus('ok');
console.log(JSON.stringify(res)); console.log(JSON.stringify(res));
Notification.info({ title: '成功连接至后端服务!' }); Notification.info({ title: '成功连接至后端服务!', description: res.message });
} else { } else {
setNetworkStatus('fail'); setNetworkStatus('fail');
Notification.error({ title: '后端服务响应异常!' }); Notification.error({ title: '后端服务响应异常!', description: JSON.stringify(res) });
} }
} catch (err) { } catch (err) {
setNetworkStatus('fail'); setNetworkStatus('fail');
Notification.error({ title: '无法连接到后端服务!' }); Notification.error({ title: '无法连接到后端服务!', description: err.message });
} }
}, [Notification]); }, [Notification]);
useEffect(() => { useEffect(() => {
Notification.info({
title: '正在尝试连接后端服务',
});
const timer = setTimeout(() => { const timer = setTimeout(() => {
checkBackend(); checkBackend();
}, 3000); }, 3000);

View File

@ -17,6 +17,8 @@ import DashboardBackground from '@/components/system/pages/DashboardBackground';
import FadeInWrapper from '@/components/system/layout/FadeInWrapper'; import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
import ConfigTool from '@/libs/config/ConfigTool'; import ConfigTool from '@/libs/config/ConfigTool';
import Common from '@/libs/common'; import Common from '@/libs/common';
import switchIcon from '@/resources/icon/pages/devices/switch.png';
import Notification from '@/libs/system/Notification';
/** /**
* 交换机管理 * 交换机管理
@ -41,9 +43,12 @@ const DevicesPage = () => {
const handleSave = (idx) => { const handleSave = (idx) => {
const updated = [...devices]; const updated = [...devices];
updated[idx].name = editingName; updated[idx].name = editingName;
Common.sleep(200);
setDevices(updated); setDevices(updated);
ConfigTool.save({ ...ConfigTool.load(), devices: updated }); ConfigTool.save({ ...ConfigTool.load(), devices: updated });
Common.sleep(500); Notification.success({
title: '设备重命名成功!',
});
setEditingIndex(null); setEditingIndex(null);
}; };
@ -69,7 +74,7 @@ const DevicesPage = () => {
transition={'all 0.3s ease'} transition={'all 0.3s ease'}
_hover={{ transform: 'translateY(-8px)', bg: 'whiteAlpha.200' }} _hover={{ transform: 'translateY(-8px)', bg: 'whiteAlpha.200' }}
> >
<Image src={'/assets/switch.png'} alt={'Switch'} borderRadius={'md'} mb={3} /> <Image src={switchIcon} alt={'Switch'} borderRadius={'md'} mb={3} />
<Collapsible.Root open={editingIndex === idx}> <Collapsible.Root open={editingIndex === idx}>
<Collapsible.Trigger asChild> <Collapsible.Trigger asChild>

View File

@ -36,9 +36,9 @@ const ScanPage = () => {
useEffect(() => { useEffect(() => {
const fetchLocalInfo = async () => { const fetchLocalInfo = async () => {
setLocalIp('172.17.99.208'); setLocalIp('192.168.1.100');
if (!subnet) { if (!subnet) {
const ipParts = '172.17.99.208'.split('.'); const ipParts = '192.168.1.0'.split('.');
setSubnet(`${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.0/24`); setSubnet(`${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.0/24`);
} }
}; };
@ -65,9 +65,15 @@ const ScanPage = () => {
if (testMode) { if (testMode) {
await Common.sleep(2000); await Common.sleep(2000);
scanDevices = getTestDevices(); scanDevices = getTestDevices();
Notification.success({
title: '扫描子网设备成功!',
});
} else { } else {
const res = await api.scan(subnet); const res = await api.scan(subnet);
scanDevices = res.devices || []; scanDevices = res.devices || [];
Notification.success({
title: '扫描子网设备成功!',
});
} }
scanDevices = scanDevices.map((d, idx) => ({ scanDevices = scanDevices.map((d, idx) => ({
@ -97,8 +103,11 @@ const ScanPage = () => {
try { try {
let scanDevices; let scanDevices;
if (testMode) { if (testMode) {
await Common.sleep(2000); await Common.sleep(500);
scanDevices = getTestDevices(); scanDevices = getTestDevices();
Notification.success({
title: '获取上一次扫描记录成功!',
});
} else { } else {
const res = await api.listDevices(); const res = await api.listDevices();
scanDevices = res.devices || []; scanDevices = res.devices || [];

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 24 24"><path fill="#41d7e1" d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8M6.5 9L10 5.5L13.5 9H11v4H9V9zm11 6L14 18.5L10.5 15H13v-4h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 298 B