mirror of
https://github.com/Jerryplusy/AI-powered-switches.git
synced 2025-07-04 05:09:19 +00:00
Compare commits
2 Commits
3070c676f8
...
223d43f0d9
Author | SHA1 | Date | |
---|---|---|---|
223d43f0d9 | |||
093c7423e5 |
@ -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'}
|
||||
|
@ -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 标题
|
||||
|
@ -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,184 @@ 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, 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 () => {
|
||||
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 +204,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>
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -36,9 +36,9 @@ const ScanPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLocalInfo = async () => {
|
||||
setLocalIp('172.17.99.208');
|
||||
setLocalIp('192.168.1.100');
|
||||
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`);
|
||||
}
|
||||
};
|
||||
@ -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 || [];
|
||||
|
BIN
src/frontend/src/resources/icon/pages/devices/switch.png
Normal file
BIN
src/frontend/src/resources/icon/pages/devices/switch.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
1
src/frontend/src/resources/icon/pages/devices/switch.svg
Normal file
1
src/frontend/src/resources/icon/pages/devices/switch.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user