优化通知组件,增加配置页面

This commit is contained in:
Jerry 2025-06-21 16:59:07 +08:00
parent 3070c676f8
commit 093c7423e5
8 changed files with 326 additions and 68 deletions

View File

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

View File

@ -4,6 +4,7 @@ import {
AiFillWarning,
AiFillCheckCircle,
AiFillExclamationCircle,
AiOutlineLoading,
} from 'react-icons/ai';
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 = {
/**
@ -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 标题

View File

@ -1,5 +1,17 @@
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 PageContainer from '@/components/system/PageContainer';
import DashboardBackground from '@/components/system/pages/DashboardBackground';
@ -7,44 +19,167 @@ 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';
import Common from '@/libs/common';
const testMode = ConfigTool.load().testMode;
const ConfigPage = () => {
const [devices, setDevices] = useState([]);
const [selectedDevice, setSelectedDevice] = useState('');
const [inputText, setInputText] = 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(() => {
const config = ConfigTool.load();
setDevices(config.devices || []);
}, []);
const generateRealisticConfig = (command) => {
const timestamp = new Date().toLocaleString();
let config = `! 配置生成于 ${timestamp}\n`;
if (command.includes('VLAN')) {
const vlanId = command.match(/VLAN\s*(\d+)/)?.[1] || '10';
config +=
`vlan ${vlanId}\n` +
` name ${command.includes('管理') ? 'MGMT' : 'USER'}_VLAN\n` +
` exit\n` +
`interface Vlan${vlanId}\n` +
` description ${command.includes('管理') ? 'Management' : 'User'} VLAN\n` +
` ip address 192.168.${vlanId}.1 255.255.255.0\n` +
` exit\n`;
}
if (command.includes('SSH') || command.includes('安全')) {
config +=
`ip ssh server\n` +
`ip ssh version 2\n` +
`username admin privilege 15 secret 0 ${Math.random().toString(36).slice(2, 10)}\n` +
`line vty 0 4\n` +
` transport input ssh\n` +
` login local\n` +
` exit\n`;
}
if (command.includes('端口') || command.includes('接口')) {
const port = command.match(/端口\s*(\d+)/)?.[1] || '1';
config +=
`interface GigabitEthernet0/${port}\n` +
` description ${command.includes('接入') ? 'Access_Port' : 'Uplink_Port'}\n` +
` switchport mode ${command.includes('trunk') ? 'trunk' : 'access'}\n` +
` ${command.includes('trunk') ? 'switchport trunk allowed vlan all' : 'switchport access vlan 10'}\n` +
` no shutdown\n` +
` exit\n`;
}
if (command.includes('ACL') || command.includes('访问控制')) {
config +=
`ip access-list extended PROTECT_SERVERS\n` +
` permit tcp any host 192.168.10.10 eq 22\n` +
` permit tcp any host 192.168.10.10 eq 80\n` +
` deny ip any any\n` +
` exit\n` +
`interface Vlan10\n` +
` ip access-group PROTECT_SERVERS in\n` +
` exit\n`;
}
return { config };
};
const handleParse = async () => {
if (!selectedDevice || !inputText) {
Notification.error({ title: '请选择设备并输入配置指令' });
if (!selectedDevice || !inputText.trim()) {
Notification.error({
title: '操作失败',
description: '请选择设备并输入配置指令',
});
return;
}
setLoading(true);
try {
const res = await api.parseCommand(inputText);
setParsedConfig(res.config);
} catch (e) {
Notification.error({ title: '配置解析失败' });
const performParse = async () => {
if (testMode) {
await Common.sleep(800 + Math.random() * 700);
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 () => {
if (!parsedConfig) return;
setLoading(true);
try {
await api.applyConfig(selectedDevice, parsedConfig);
Notification.success({ title: '配置已成功应用!' });
} catch (e) {
Notification.error({ title: '配置应用失败' });
if (!editableConfig.trim()) {
Notification.warn({
title: '配置为空',
description: '请先解析或编辑有效配置',
});
return;
}
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 (
@ -52,57 +187,118 @@ const ConfigPage = () => {
<DashboardBackground />
<PageContainer>
<FadeInWrapper delay={0.3} yOffset={-5}>
<VStack spacing={8} align={'stretch'}>
<Heading fontSize={'2xl'} color={'teal.300'}>
{'交换机配置中心'}
<VStack spacing={6} align={'stretch'}>
<Heading fontSize={'xl'} 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>
<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={'请选择交换机设备'}
size={'sm'}
colorPalette={'teal'}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<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.Content>
</Select.Positioner>
</Portal>
</Select.Root>
</Field.Root>
<Textarea
rows={6}
placeholder={'输入自然语言配置指令...'}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
bg={'whiteAlpha.200'}
/>
<Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
配置指令输入
</Field.Label>
<Textarea
rows={4}
placeholder={'例子创建VLAN 10并配置IP 192.168.10.1/24并在端口1启用SSH访问"'}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
bg={'whiteAlpha.200'}
size={'sm'}
/>
</Field.Root>
<Button
colorPalette={'teal'}
colorScheme={'teal'}
variant={'solid'}
isLoading={loading}
size={'sm'}
onClick={handleParse}
isDisabled={!selectedDevice || !inputText.trim()}
>
{'解析配置'}
解析配置
</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>
{hasParsed && (
<FadeInWrapper>
<Box
p={4}
bg={'whiteAlpha.100'}
borderRadius={'xl'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<Text fontWeight={'bold'} mb={2} fontSize="sm">
生成配置:
</Text>
<Textarea
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
colorScheme={'teal'}
size={'sm'}
onClick={handleApply}
isLoading={applying}
isDisabled={!editableConfig.trim()}
>
应用到交换机
</Button>
</HStack>
</Box>
</FadeInWrapper>
)}
</VStack>
</FadeInWrapper>

View File

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

View File

@ -17,6 +17,8 @@ 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';
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 updated = [...devices];
updated[idx].name = editingName;
Common.sleep(200);
setDevices(updated);
ConfigTool.save({ ...ConfigTool.load(), devices: updated });
Common.sleep(500);
Notification.success({
title: '设备重命名成功!',
});
setEditingIndex(null);
};
@ -69,7 +74,7 @@ const DevicesPage = () => {
transition={'all 0.3s ease'}
_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.Trigger asChild>

View File

@ -65,9 +65,15 @@ const ScanPage = () => {
if (testMode) {
await Common.sleep(2000);
scanDevices = getTestDevices();
Notification.success({
title: '扫描子网设备成功!',
});
} else {
const res = await api.scan(subnet);
scanDevices = res.devices || [];
Notification.success({
title: '扫描子网设备成功!',
});
}
scanDevices = scanDevices.map((d, idx) => ({
@ -97,8 +103,11 @@ const ScanPage = () => {
try {
let scanDevices;
if (testMode) {
await Common.sleep(2000);
await Common.sleep(500);
scanDevices = getTestDevices();
Notification.success({
title: '获取上一次扫描记录成功!',
});
} else {
const res = await api.listDevices();
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