Merge remote-tracking branch 'origin/main'

This commit is contained in:
3 2025-07-11 14:04:10 +08:00
commit 2980489b5b
4 changed files with 354 additions and 67 deletions

View File

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import {
Button,
Box,
Dialog,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
Field,
Input,
Stack,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { FiCheck } from 'react-icons/fi';
import Notification from '@/libs/system/Notification';
const MotionBox = motion(Box);
/**
* 设备配置弹窗
* @param isOpen 是否打开
* @param onClose 关闭弹窗
* @param onSave 保存修改
* @param device 当前设备
* @returns {JSX.Element}
* @constructor
*/
const DeviceConfigModal = ({ isOpen, onClose, onSave, device }) => {
const [username, setUsername] = useState(device.username || '');
const [password, setPassword] = useState(device.password || '');
const [saved, setSaved] = useState(false);
const handleSave = () => {
const updatedDevice = { ...device, username, password };
onSave(updatedDevice);
setSaved(true);
setTimeout(() => {
setSaved(false);
onClose();
}, 1200);
};
return (
<Dialog.Root open={isOpen} onClose={onClose}>
<DialogBackdrop />
<DialogPositioner>
<MotionBox
as={DialogContent}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.3 }}
bg={'whiteAlpha.100'}
backdropFilter={'blur(12px)'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<DialogHeader>交换机设备配置</DialogHeader>
<DialogBody>
<Stack gap={4}>
<Field.Root>
<Field.Label>交换机用户名</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={'请输入设备用户名'}
bg={'whiteAlpha.200'}
/>
</Field.Root>
<Field.Root>
<Field.Label>交换机密码</Field.Label>
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={'请输入设备密码'}
bg={'whiteAlpha.200'}
type={'password'}
/>
</Field.Root>
</Stack>
</DialogBody>
<DialogFooter justifyContent={'space-between'}>
<Button
variant={'outline'}
borderColor={'whiteAlpha.500'}
color={'white'}
onClick={onClose}
_hover={{ bg: 'rgba(0, 0, 255, 0.3)' }}
>
取消
</Button>
<Button
variant={'outline'}
borderColor={'whiteAlpha.500'}
color={'white'}
onClick={handleSave}
isDisabled={saved}
width={'80px'}
position={'relative'}
_hover={{ bg: 'rgba(0, 0, 255, 0.3)' }}
>
{saved ? (
<motion.div
key={'saved'}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.3 }}
>
<FiCheck size={20} color={'lightgreen'} />
</motion.div>
) : (
<motion.div
key={'save'}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.3 }}
>
保存
</motion.div>
)}
</Button>
</DialogFooter>
</MotionBox>
</DialogPositioner>
</Dialog.Root>
);
};
export default DeviceConfigModal;

View File

@ -8,10 +8,12 @@ import {
HStack,
Portal,
Select,
Spinner,
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';
@ -32,6 +34,9 @@ const ConfigPage = () => {
const [editableConfig, setEditableConfig] = useState('');
const [applying, setApplying] = useState(false);
const [hasParsed, setHasParsed] = useState(false);
const [isPeizhi, setisPeizhi] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [applyStatus, setApplyStatus] = useState([]);
const deviceCollection = createListCollection({
items: devices.map((device) => ({
@ -79,12 +84,12 @@ const ConfigPage = () => {
},
});
const result = await resultWrapper.unwrap();
if (result?.config) {
setParsedConfig(result.config);
setEditableConfig(result.config);
let result = await resultWrapper.unwrap();
if (result?.data) {
setParsedConfig(JSON.stringify(result.data));
setEditableConfig(JSON.stringify(result.data));
setHasParsed(true);
setisPeizhi(true);
}
} catch (error) {
console.error('配置解析异常:', error);
@ -105,10 +110,11 @@ const ConfigPage = () => {
}
setApplying(true);
setIsApplying(true);
try {
const applyOperation = testMode
? Common.sleep(1000).then(() => ({ success: true }))
: await api.applyConfig(selectedDevice, editableConfig);
: await api.applyConfig(selectedDevice, JSON.parse(editableConfig)?.config?.commands);
await Notification.promise({
promise: applyOperation,
@ -139,7 +145,6 @@ const ConfigPage = () => {
<Heading fontSize={'xl'} color={'teal.300'}>
交换机配置中心
</Heading>
<Field.Root>
<Field.Label fontWeight={'bold'} mb={1} fontSize="sm">
选择交换机设备
@ -175,21 +180,21 @@ const ConfigPage = () => {
</Portal>
</Select.Root>
</Field.Root>
<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访问"'}
placeholder={'例创建VLAN 10并配置IP 192.168.10.1/24并在端口1启用SSH访问"'}
value={inputText}
colorPalette={'teal'}
orientation={'vertical'}
onChange={(e) => setInputText(e.target.value)}
bg={'whiteAlpha.200'}
size={'sm'}
/>
</Field.Root>
<Button
colorScheme={'teal'}
variant={'solid'}
@ -199,29 +204,74 @@ const ConfigPage = () => {
>
解析配置
</Button>
{isPeizhi && parsedConfig && (
<FadeInWrapper delay={0.2}>
<VStack spacing={4} align={'stretch'}>
{(() => {
let parsed;
try {
parsed = JSON.parse(editableConfig);
} catch (e) {
return <Text color={'red.300'}>配置 JSON 格式错误无法解析</Text>;
}
{hasParsed && (
<FadeInWrapper>
const config = parsed.config ? [parsed.config] : parsed;
return config.map((cfg, idx) => (
<Box
key={idx}
p={4}
bg={'whiteAlpha.100'}
borderRadius={'xl'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<Text fontWeight={'bold'} mb={2} fontSize="sm">
生成配置:
<Text fontSize={'lg'} fontWeight={'bold'} mb={2}>
配置类型: {cfg.type}
</Text>
<Textarea
value={JSON.stringify(editableConfig)}
rows={12}
onChange={(e) => setEditableConfig(e.target.value)}
fontFamily={'monospace'}
size={'sm'}
bg={'blackAlpha.200'}
whiteSpace="pre-wrap"
/>
{Object.entries(cfg).map(([key, value]) => {
if (key === 'type' || key === 'commands') return null;
return (
<Field.Root
key={key}
colorPalette={'teal'}
orientation={'vertical'}
mb={3}
>
<Field.Label fontSize={'sm'}>{key}</Field.Label>
<Textarea
size={'sm'}
value={value}
onChange={(e) => {
const newVal = e.target.value;
const updated = JSON.parse(editableConfig);
updated.config[key] = newVal;
setEditableConfig(JSON.stringify(updated, null, 2));
}}
/>
</Field.Root>
);
})}
<Text fontWeight={'semibold'} mt={3} mb={2}>
配置命令:
</Text>
{cfg.commands?.map((cmd, i) => (
<Field.Root key={i} colorPalette={'teal'} orientation={'vertical'} mb={2}>
<Field.Label fontSize="sm">命令 {i + 1}</Field.Label>
<Textarea
size={'sm'}
fontFamily={'monospace'}
value={cmd}
onChange={(e) => {
const newCmd = e.target.value;
const updated = JSON.parse(editableConfig);
updated.config.commands[i] = newCmd;
setEditableConfig(JSON.stringify(updated, null, 2));
}}
/>
</Field.Root>
))}
<HStack mt={4} spacing={3} justify={'flex-end'}>
<Button
variant={'outline'}
@ -237,6 +287,21 @@ const ConfigPage = () => {
>
重置为原始配置
</Button>
<Button
size={'sm'}
variant={'outline'}
colorScheme={'gray'}
onClick={() => {
Notification.success({
title: `配置 ${cfg.type} 已保存`,
description: '修改已同步至内存配置',
});
}}
>
保存当前配置
</Button>
<Button
colorScheme={'teal'}
size={'sm'}
@ -248,6 +313,58 @@ const ConfigPage = () => {
</Button>
</HStack>
</Box>
));
})()}
{
<FadeInWrapper delay={0.2}>
<VStack spacing={4} align={'stretch'}>
<Box
p={4}
bg={'whiteAlpha.100'}
borderRadius={'xl'}
border={'1px solid'}
borderColor={'whiteAlpha.300'}
>
<Text fontSize={'lg'} fontWeight={'bold'} mb={2}>
应用配置命令
</Text>
<Box>
{JSON.parse(editableConfig).config?.commands.map((command, index) => (
<HStack key={index} mb={2}>
<Text fontSize={'sm'} flex={1}>
{command}
</Text>
<Spinner
size={'sm'}
color={applyStatus[index] === 'success' ? 'green.500' : 'red.500'}
display={
applyStatus[index] === 'pending' ||
applyStatus[index] === 'in-progress'
? 'inline-block'
: 'none'
}
/>
<Text
color={applyStatus[index] === 'success' ? 'green.500' : 'red.500'}
ml={2}
>
{applyStatus[index] === 'success'
? '成功'
: applyStatus[index] === 'failed'
? '失败'
: applyStatus[index] === 'in-progress'
? '正在应用'
: ''}
</Text>
</HStack>
))}
</Box>
</Box>
</VStack>
</FadeInWrapper>
}
</VStack>
</FadeInWrapper>
)}
</VStack>

View File

@ -19,6 +19,7 @@ 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';
import DeviceConfigModal from '@/components/pages/config/DeviceConfigModal';
/**
* 交换机管理
@ -29,6 +30,8 @@ const DevicesPage = () => {
const [devices, setDevices] = useState([]);
const [editingIndex, setEditingIndex] = useState(null);
const [editingName, setEditingName] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentDevice, setCurrentDevice] = useState(null);
useEffect(() => {
const config = ConfigTool.load();
@ -53,6 +56,23 @@ const DevicesPage = () => {
});
};
const handleOpenConfigModal = (device) => {
setCurrentDevice(device);
setIsModalOpen(true);
};
const handleSaveDeviceConfig = (updatedDevice) => {
const updatedDevices = devices.map((device) =>
device.ip === updatedDevice.ip ? updatedDevice : device
);
setDevices(updatedDevices);
ConfigTool.save({ ...ConfigTool.load(), devices: updatedDevices });
Notification.success({
title: '设备配置已保存!',
});
setIsModalOpen(false);
};
return (
<DocumentTitle title={'交换机设备'}>
<DashboardBackground />
@ -124,6 +144,15 @@ const DevicesPage = () => {
{'端口: '}
{device.ports.join(', ')}
</Text>
<Button
size={'sm'}
colorPalette={'teal'}
mt={2}
onClick={() => handleOpenConfigModal(device)}
>
配置
</Button>
</Box>
))}
</SimpleGrid>
@ -134,6 +163,15 @@ const DevicesPage = () => {
</VStack>
</FadeInWrapper>
</PageContainer>
{isModalOpen && currentDevice && (
<DeviceConfigModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveDeviceConfig}
device={currentDevice}
/>
)}
</DocumentTitle>
);
};

View File

@ -41,22 +41,16 @@ export const api = {
* @param text 文本
* @returns {Promise<axios.AxiosResponse<any>>}
*/
//parseCommand: async (text) => await axios.post(buildUrl('/api/parse_command'), { command: text }),
async parseCommand(text) {
const res = await axios.post(buildUrl('/api/parse_command', { command: text }));
if (res) {
return res;
} else return null;
},
parseCommand: (text) => axios.post(buildUrl('/api/parse_command'), { command: text }),
/**
* 应用配置
* @param switch_ip 交换机ip
* @param config 配置
* @param commands 配置,为数组[]
* @returns {Promise<axios.AxiosResponse<any>>}
*/
applyConfig: (switch_ip, config) =>
axios.post(buildUrl('/api/apply_config'), { switch_ip, config }),
applyConfig: (switch_ip, commands) =>
axios.post(buildUrl('/api/execute_cli_commands'), { switch_ip, commands }),
/**
* 更新基础URL