Merge remote-tracking branch 'origin/main'
# Conflicts: # src/backend/.envExample # src/backend/app/__init__.py # src/backend/app/api/command_parser.py # src/backend/app/api/endpoints.py # src/backend/app/api/network_config.py # src/backend/app/utils/exceptions.py # src/backend/requirements.txt
46
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: Deploy React App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'src/frontend/pnpm-lock.yaml'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: src/frontend
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
working-directory: src/frontend
|
||||
run: pnpm run build
|
||||
|
||||
- name: Copy 404.html
|
||||
working-directory: src/frontend
|
||||
run: cp build/index.html build/404.html
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
working-directory: src/frontend
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions"
|
||||
git config --global user.email "github-actions@users.noreply.github.com"
|
||||
npx gh-pages -d build -u "github-actions <github-actions@users.noreply.github.com>" --repo "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
# 大抵是没有问题了
|
4
.idea/AI-powered-switches.iml
generated
@ -2,7 +2,7 @@
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="Python" name="Python facet">
|
||||
<configuration sdkName="Python 3.12" />
|
||||
<configuration sdkName="Python 3.13" />
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager">
|
||||
@ -11,6 +11,6 @@
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (AI-powered-switches)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="Python 3.12 interpreter library" level="application" />
|
||||
<orderEntry type="library" name="Python 3.13 interpreter library" level="application" />
|
||||
</component>
|
||||
</module>
|
@ -11,7 +11,6 @@
|
||||
- Framer-motion
|
||||
- chakra-ui
|
||||
- HTML5
|
||||
|
||||
### 项目分工
|
||||
- **后端api,人工智能算法** : `3`(主要) & `log_out` & `Jerry`(maybe) 使用python
|
||||
- **前端管理后台设计**:`Jerry`使用react
|
||||
|
@ -1,22 +1,31 @@
|
||||
# 使用官方 Python 基础镜像
|
||||
FROM python:3.13-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 1. 先复制依赖文件并安装
|
||||
COPY ./requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
|
||||
# 安装系统依赖(包含 nmap 和 SSH 客户端)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
nmap \
|
||||
telnet \
|
||||
openssh-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. 复制项目代码(排除 .env 和缓存文件)
|
||||
COPY . /app
|
||||
# 复制项目文件
|
||||
COPY ./src/backend/requirements.txt .
|
||||
COPY ./src/backend /app
|
||||
|
||||
# 3. 环境变量配置
|
||||
ENV PYTHONPATH=/app \
|
||||
PORT=8000 \
|
||||
HOST=0.0.0.0
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install asyncssh telnetlib3 aiofiles
|
||||
|
||||
# 4. 安全设置
|
||||
RUN find /app -name "*.pyc" -delete && \
|
||||
find /app -name "__pycache__" -exec rm -rf {} +
|
||||
# 创建配置备份目录
|
||||
RUN mkdir -p /app/config_backups && \
|
||||
chmod 777 /app/config_backups
|
||||
|
||||
# 5. 启动命令(修正路径)
|
||||
CMD ["uvicorn", "src.backend.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 暴露 FastAPI 端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
@ -58,4 +58,7 @@ class AIService:
|
||||
return json.loads(config_str)
|
||||
raise SiliconFlowAPIException("Invalid JSON format returned from AI")
|
||||
except httpx.HTTPError as e:
|
||||
raise SiliconFlowAPIException(str(e))
|
||||
raise SiliconFlowAPIException(
|
||||
detail=f"API请求失败: {str(e)}",
|
||||
status_code=e.response.status_code if hasattr(e, "response") else 500
|
||||
)
|
36
src/backend/app/services/network_optimizer.py
Normal file
@ -0,0 +1,36 @@
|
||||
|
||||
import networkx as nx
|
||||
from scipy.optimize import minimize
|
||||
|
||||
|
||||
class NetworkOptimizer:
|
||||
def __init__(self, devices):
|
||||
"""基于图论的网络优化模型"""
|
||||
self.graph = self.build_topology_graph(devices)
|
||||
|
||||
def build_topology_graph(self, devices):
|
||||
"""构建网络拓扑图"""
|
||||
G = nx.Graph()
|
||||
for device in devices:
|
||||
G.add_node(device['ip'], type=device['type'])
|
||||
# 添加连接关系(示例)
|
||||
G.add_edge('192.168.1.1', '192.168.1.2', bandwidth=1000)
|
||||
return G
|
||||
|
||||
def optimize_path(self, source, target):
|
||||
"""计算最优路径"""
|
||||
return nx.shortest_path(self.graph, source, target)
|
||||
|
||||
def bandwidth_optimization(self):
|
||||
"""带宽优化模型"""
|
||||
|
||||
def objective(x):
|
||||
# 最小化最大链路利用率
|
||||
return max(x)
|
||||
|
||||
constraints = (
|
||||
{'type': 'eq', 'fun': lambda x: sum(x) - total_bandwidth}
|
||||
)
|
||||
|
||||
result = minimize(objective, initial_guess, constraints=constraints)
|
||||
return result.x
|
4
src/backend/batch/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .bulk_config import BulkConfigurator, BulkSwitchConfig
|
||||
from .connection_pool import SwitchConnectionPool
|
||||
|
||||
__all__ = ['BulkConfigurator', 'BulkSwitchConfig', 'SwitchConnectionPool']
|
46
src/backend/batch/bulk_config.py
Normal file
@ -0,0 +1,46 @@
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from dataclasses import dataclass
|
||||
from .connection_pool import SwitchConnectionPool
|
||||
|
||||
@dataclass
|
||||
class BulkSwitchConfig:
|
||||
vlan_id: int = None
|
||||
interface: str = None
|
||||
operation: str = "create" # 仅业务字段,无测试相关
|
||||
|
||||
class BulkConfigurator:
|
||||
"""生产环境批量配置器(无测试代码)"""
|
||||
def __init__(self, max_concurrent: int = 50):
|
||||
self.pool = SwitchConnectionPool()
|
||||
self.semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def _configure_device(self, ip: str, config: BulkSwitchConfig) -> str:
|
||||
"""核心配置方法"""
|
||||
conn = await self.pool.get_connection(ip, "admin", "admin")
|
||||
try:
|
||||
commands = self._generate_commands(config)
|
||||
results = [await conn.run(cmd) for cmd in commands]
|
||||
return "\n".join(r.stdout for r in results)
|
||||
finally:
|
||||
await self.pool.release_connection(ip, conn)
|
||||
@staticmethod
|
||||
def _generate_commands(config: BulkSwitchConfig) -> List[str]:
|
||||
"""命令生成(纯业务逻辑)"""
|
||||
commands = []
|
||||
if config.vlan_id:
|
||||
commands.append(f"vlan {config.vlan_id}")
|
||||
if config.operation == "create":
|
||||
commands.extend([
|
||||
f"name VLAN_{config.vlan_id}",
|
||||
"commit"
|
||||
])
|
||||
return commands
|
||||
|
||||
async def run_bulk(self, ip_list: List[str], config: BulkSwitchConfig) -> Dict[str, str]:
|
||||
"""批量执行入口"""
|
||||
tasks = {
|
||||
ip: asyncio.create_task(self._configure_device(ip, config))
|
||||
for ip in ip_list
|
||||
}
|
||||
return {ip: await task for ip, task in tasks.items()}
|
52
src/backend/batch/connection_pool.py
Normal file
@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
import asyncssh
|
||||
from typing import Dict
|
||||
|
||||
class SwitchConnectionPool:
|
||||
"""
|
||||
交换机连接池(支持自动重连和负载均衡)
|
||||
功能:
|
||||
- 每个IP维护动态连接池
|
||||
- 自动剔除失效连接
|
||||
- 支持空闲连接回收
|
||||
"""
|
||||
def __init__(self, max_connections_per_ip: int = 3):
|
||||
self._pools: Dict[str, asyncio.Queue] = {}
|
||||
self._max_conn = max_connections_per_ip
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_connection(self, ip: str, username: str, password: str) -> asyncssh.SSHClientConnection:
|
||||
async with self._lock:
|
||||
if ip not in self._pools:
|
||||
self._pools[ip] = asyncio.Queue(self._max_conn)
|
||||
|
||||
if not self._pools[ip].empty():
|
||||
return await self._pools[ip].get()
|
||||
|
||||
return await asyncssh.connect(
|
||||
host=ip,
|
||||
username=username,
|
||||
password=password,
|
||||
known_hosts=None,
|
||||
connect_timeout=10
|
||||
)
|
||||
|
||||
async def release_connection(self, ip: str, conn: asyncssh.SSHClientConnection):
|
||||
async with self._lock:
|
||||
if hasattr(conn, 'is_closed') and not conn.is_closed() and self._pools[ip].qsize() < self._max_conn:
|
||||
await self._pools[ip].put(conn)
|
||||
elif hasattr(conn, 'closed') and not conn.closed and self._pools[ip].qsize() < self._max_conn:
|
||||
await self._pools[ip].put(conn)
|
||||
else:
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def close_all(self):
|
||||
async with self._lock:
|
||||
for q in self._pools.values():
|
||||
while not q.empty():
|
||||
conn = await q.get()
|
||||
conn.close()
|
||||
self._pools.clear()
|
38
src/backend/docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
||||
version: '3.13'
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: switch_configurator
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- switch_backups:/app/config_backups
|
||||
environment:
|
||||
- SWITCH_USERNAME=${SWITCH_USERNAME:-admin}
|
||||
- SWITCH_PASSWORD=${SWITCH_PASSWORD:-admin}
|
||||
- SWITCH_TIMEOUT=${SWITCH_TIMEOUT:-10}
|
||||
- SILICONFLOW_API_KEY=${SILICONFLOW_API_KEY}
|
||||
- SILICONFLOW_API_URL=${SILICONFLOW_API_URL}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- backend
|
||||
|
||||
# 可选:添加 Redis 用于缓存设备扫描结果
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
switch_backups:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
backend:
|
||||
driver: bridge
|
@ -1,10 +1,34 @@
|
||||
# Network Admin Web UI
|
||||
|
||||
基于 React 和 Chakra UI
|
||||
### 基于 `React` 和 `Chakra UI`
|
||||
|
||||
- 前端框架:React
|
||||
- UI 组件库:Chakra UI
|
||||
- 网络图表:Recharts
|
||||
- 状态管理:React hooks
|
||||
- 数据通信:Axios
|
||||
- 页面路由:React Router
|
||||
#### 架构
|
||||
- 前端框架:`React`
|
||||
- UI 组件库:`Chakra UI`
|
||||
- 网络图表:`Recharts`
|
||||
- 状态管理:`React hooks`
|
||||
- 数据通信:`Axios`
|
||||
- 页面路由:`React Router`
|
||||
|
||||
#### 环境准备
|
||||
- `NodeJS>=22`
|
||||
- `Git`
|
||||
|
||||
#### 部署方法
|
||||
- `clone`项目
|
||||
- 导航到`/src/frontend/`目录
|
||||
- 使用`pnpm`管理软件包:`npm install pnpm -g`
|
||||
- 安装依赖:`pnpm install`
|
||||
- 执行`pnpm build`进行`react`服务端构建
|
||||
- 运行`pnpm start`启动服务端
|
||||
- 服务运行在本地`3000`端口
|
||||
|
||||
#### 功能(后端)
|
||||
- []监控网络流量
|
||||
- []扫描环境中的交换机
|
||||
- []自然语言解析命令
|
||||
- []下发配置到交换机
|
||||
- []流量预测
|
||||
- []图表显示
|
||||
|
||||
172.17.99.208
|
@ -2,6 +2,7 @@
|
||||
"name": "network-admin-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"homepage": "https://JerryPlsuy.github.io/AI-powered-switches",
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.19.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
@ -28,6 +29,8 @@
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject",
|
||||
"predeploy": "pnpm run build",
|
||||
"deploy": "gh-pages -d build",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\""
|
||||
},
|
||||
"eslintConfig": {
|
||||
@ -54,6 +57,7 @@
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"gh-pages": "^6.3.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react-app-rewired": "^2.2.1"
|
||||
}
|
||||
|
71
src/frontend/pnpm-lock.yaml
generated
@ -81,6 +81,9 @@ importers:
|
||||
eslint-plugin-react-hooks:
|
||||
specifier: ^4.3.0
|
||||
version: 4.6.2(eslint@8.57.0)
|
||||
gh-pages:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
prettier:
|
||||
specifier: ^3.5.3
|
||||
version: 3.5.3
|
||||
@ -2277,6 +2280,10 @@ packages:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
@ -2757,6 +2764,9 @@ packages:
|
||||
electron-to-chromium@1.5.159:
|
||||
resolution: {integrity: sha512-CEvHptWAMV5p6GJ0Lq8aheyvVbfzVrv5mmidu1D3pidoVNkB3tTBsTMVtPJ+rzRK5oV229mCLz9Zj/hNvU8GBA==}
|
||||
|
||||
email-addresses@5.0.0:
|
||||
resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==}
|
||||
|
||||
emittery@0.10.2:
|
||||
resolution: {integrity: sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==}
|
||||
engines: {node: '>=12'}
|
||||
@ -3108,6 +3118,14 @@ packages:
|
||||
filelist@1.0.4:
|
||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
||||
|
||||
filename-reserved-regex@2.0.0:
|
||||
resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
filenamify@4.3.0:
|
||||
resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
filesize@8.0.7:
|
||||
resolution: {integrity: sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@ -3214,6 +3232,10 @@ packages:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
fs-extra@11.3.0:
|
||||
resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@9.1.0:
|
||||
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -3270,6 +3292,11 @@ packages:
|
||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
gh-pages@6.3.0:
|
||||
resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@ -5547,6 +5574,10 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-outer@1.0.1:
|
||||
resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
style-loader@3.3.4:
|
||||
resolution: {integrity: sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
@ -5705,6 +5736,10 @@ packages:
|
||||
resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
trim-repeated@1.0.0:
|
||||
resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
tryer@1.0.1:
|
||||
resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==}
|
||||
|
||||
@ -9156,6 +9191,8 @@ snapshots:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
@ -9609,6 +9646,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.159: {}
|
||||
|
||||
email-addresses@5.0.0: {}
|
||||
|
||||
emittery@0.10.2: {}
|
||||
|
||||
emittery@0.8.1: {}
|
||||
@ -10135,6 +10174,14 @@ snapshots:
|
||||
dependencies:
|
||||
minimatch: 5.1.6
|
||||
|
||||
filename-reserved-regex@2.0.0: {}
|
||||
|
||||
filenamify@4.3.0:
|
||||
dependencies:
|
||||
filename-reserved-regex: 2.0.0
|
||||
strip-outer: 1.0.1
|
||||
trim-repeated: 1.0.0
|
||||
|
||||
filesize@8.0.7: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
@ -10250,6 +10297,12 @@ snapshots:
|
||||
jsonfile: 6.1.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.1.0
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@9.1.0:
|
||||
dependencies:
|
||||
at-least-node: 1.0.0
|
||||
@ -10311,6 +10364,16 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
gh-pages@6.3.0:
|
||||
dependencies:
|
||||
async: 3.2.6
|
||||
commander: 13.1.0
|
||||
email-addresses: 5.0.0
|
||||
filenamify: 4.3.0
|
||||
find-cache-dir: 3.3.2
|
||||
fs-extra: 11.3.0
|
||||
globby: 11.1.0
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@ -12976,6 +13039,10 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strip-outer@1.0.1:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
||||
style-loader@3.3.4(webpack@5.99.9):
|
||||
dependencies:
|
||||
webpack: 5.99.9
|
||||
@ -13169,6 +13236,10 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
trim-repeated@1.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
|
||||
tryer@1.0.1: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
@ -8,4 +8,4 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
@ -1,14 +1,22 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import Welcome from '@/pages/Welcome';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import AppShell from '@/components/system/layout/AppShell';
|
||||
import buildRoutes from '@/constants/routes/routes';
|
||||
import { useEffect } from 'react';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
|
||||
const App = () => {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
useEffect(() => {}, []);
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={'/'} element={<Welcome />} />
|
||||
<Route path={'/dashboard'} element={<Dashboard />} />
|
||||
</Routes>
|
||||
<BrowserRouter basename={isProd ? '/AI-powered-switches' : '/'}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppShell />}>
|
||||
{buildRoutes()}
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, Heading, Spacer, Button, Card } from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import NavButton from '@/components/ui/NavButton';
|
||||
|
||||
const Header = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Box bg={'teal.500'} px={4} py={2} color={'white'}>
|
||||
<Flex align={'center'}>
|
||||
<Heading size={'md'}>网络管理后台</Heading>
|
||||
<Spacer />
|
||||
<NavButton varint={'ghost'} color={''} onClick={() => navigate('/')}>
|
||||
返回欢迎页
|
||||
</NavButton>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
43
src/frontend/src/components/pages/dashboard/FeatureCard.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Box, Button, Text } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 特性卡片
|
||||
* @param title 标题
|
||||
* @param description 描述
|
||||
* @param buttonText 按钮文字
|
||||
* @param to 转向
|
||||
* @param disabled 按钮是否可用
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const FeatureCard = ({ title, description, buttonText, to, disabled }) => (
|
||||
<Box
|
||||
p={6}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
transition={'all 0.2s'}
|
||||
_hover={{ transform: 'translateY(-4px)' }}
|
||||
>
|
||||
<Text fontSize={'xl'} fontWeight={'bold'} color={'white'} mb={2}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text color={'gray.300'} mb={4}>
|
||||
{description}
|
||||
</Text>
|
||||
<Button
|
||||
colorPalette={'blue'}
|
||||
variant={'solid'}
|
||||
isDisabled={disabled}
|
||||
onClick={() => {
|
||||
if (to) window.location.href = to;
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default FeatureCard;
|
31
src/frontend/src/components/pages/dashboard/StatCard.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Box, Stat } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 状态卡片
|
||||
* @param title 标题
|
||||
* @param value 内容
|
||||
* @param suffix 交换机数目
|
||||
* @param isTime 扫描时间
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const StatCard = ({ title, value, suffix, isTime }) => (
|
||||
<Box
|
||||
p={6}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
transition={'all 0.2s'}
|
||||
_hover={{ transform: 'translateY(-4px)' }}
|
||||
>
|
||||
<Stat.Root colorPalette={'teal'}>
|
||||
<Stat.Label>{title}</Stat.Label>
|
||||
<Stat.ValueText>{`${value}${suffix ? ` ${suffix}` : ''}`}</Stat.ValueText>
|
||||
{isTime && <Stat.HelpText>{`上次扫描时间`}</Stat.HelpText>}
|
||||
</Stat.Root>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default StatCard;
|
29
src/frontend/src/components/pages/welcome/BackgroundBlur.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Box, Image } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import image from '@/resources/image/welcome/background.png';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 带高斯模糊的背景
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const BackgroundBlur = () => (
|
||||
<MotionBox
|
||||
position={'absolute'}
|
||||
top={0}
|
||||
left={0}
|
||||
width={'100%'}
|
||||
height={'100%'}
|
||||
filter={'blur(6px)'}
|
||||
zIndex={0}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, ease: 'easeInOut' }}
|
||||
>
|
||||
<Image src={image} objectFit={'cover'} width={'100%'} height={'100%'} />
|
||||
</MotionBox>
|
||||
);
|
||||
|
||||
export default BackgroundBlur;
|
18
src/frontend/src/components/pages/welcome/ConfigureCard.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import MotionCard from '@/components/ui/MotionCard';
|
||||
import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
|
||||
import configIcon from '@/resources/icon/pages/weclome/config.svg';
|
||||
|
||||
/**
|
||||
* 连接配置卡片组件
|
||||
* @param onClick 点击事件
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const ConfigureCard = ({ onClick }) => {
|
||||
return (
|
||||
<FadeInWrapper delay={0.4} yOffset={-5}>
|
||||
<MotionCard icon={configIcon} text={'连接配置'} onClick={onClick} />
|
||||
</FadeInWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigureCard;
|
@ -0,0 +1,190 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPositioner,
|
||||
DialogContent,
|
||||
DialogCloseTrigger,
|
||||
DialogHeader,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
Field,
|
||||
Input,
|
||||
Portal,
|
||||
Stack,
|
||||
} from '@chakra-ui/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { FiCheck } from 'react-icons/fi';
|
||||
import ConfigTool, { defaultConfig } from '@/libs/config/ConfigTool';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 连接地址配置组件
|
||||
* @param isOpen 打开状态
|
||||
* @param onClose 关闭状态
|
||||
* @param onSave 保存状态
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const ConnectionConfigModal = ({ isOpen, onClose, onSave }) => {
|
||||
const [config, setConfig] = useState(defaultConfig);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const backendRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(ConfigTool.load());
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setConfig((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
ConfigTool.save(config);
|
||||
onSave(config);
|
||||
setSaved(true);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onClose();
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
ConfigTool.clear();
|
||||
setConfig(defaultConfig);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onClose={onClose} initialFocusEl={() => backendRef.current}>
|
||||
<Portal>
|
||||
<DialogBackdrop backdropFilter={'blur(8px) hue-rotate(0deg)'} bg={'rgba(0, 0, 0, 0.3)'} />
|
||||
<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 color={'white'}>地址配置</DialogHeader>
|
||||
<DialogCloseTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
borderColor={'whiteAlpha.500'}
|
||||
color={'white'}
|
||||
size={'sm'}
|
||||
position={'absolute'}
|
||||
top={3}
|
||||
right={3}
|
||||
onClick={onClose}
|
||||
_hover={{ bg: 'rgba(255, 0, 0, 0.3)' }}
|
||||
>
|
||||
<IoClose size={20} color={'white'} />
|
||||
</Button>
|
||||
</DialogCloseTrigger>
|
||||
|
||||
<DialogBody>
|
||||
<Stack gap="4">
|
||||
<Field.Root>
|
||||
<Field.Label color={'white'}>后端连接地址</Field.Label>
|
||||
<Input
|
||||
ref={backendRef}
|
||||
name={'backendUrl'}
|
||||
value={config.backendUrl}
|
||||
onChange={handleChange}
|
||||
placeholder={'http://127.0.0.1:8000'}
|
||||
bg={'whiteAlpha.200'}
|
||||
color={'white'}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label color={'white'}>连接密钥</Field.Label>
|
||||
<Input
|
||||
name={'authKey'}
|
||||
type={'password'}
|
||||
value={config.authKey}
|
||||
onChange={handleChange}
|
||||
placeholder={'123456'}
|
||||
bg={'whiteAlpha.200'}
|
||||
color={'white'}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter justifyContent={'space-between'}>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
borderColor={'whiteAlpha.500'}
|
||||
color={'white'}
|
||||
onClick={handleClear}
|
||||
_hover={{ bg: 'rgba(0, 0, 255, 0.3)' }}
|
||||
>
|
||||
清除配置
|
||||
</Button>
|
||||
|
||||
<Stack direction={'row'} spacing={3}>
|
||||
<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)' }}
|
||||
>
|
||||
<AnimatePresence initial={false} mode={'wait'}>
|
||||
{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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
</Stack>
|
||||
</DialogFooter>
|
||||
</MotionBox>
|
||||
</DialogPositioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionConfigModal;
|
26
src/frontend/src/components/pages/welcome/DashboardCard.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
import manageIcon from '@/resources/icon/pages/weclome/setting.svg';
|
||||
import MotionCard from '@/components/ui/MotionCard';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
|
||||
|
||||
/**
|
||||
* 进入管理后台按钮组件
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const DashboardCard = ({ isConfigured }) => {
|
||||
const navigate = useNavigate();
|
||||
const handleClick = () => {
|
||||
if (isConfigured) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<FadeInWrapper delay={0.4} yOffset={-5}>
|
||||
<MotionCard icon={manageIcon} text={'管理后台'} onClick={() => handleClick()} />
|
||||
</FadeInWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardCard;
|
23
src/frontend/src/components/pages/welcome/GithubCard.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import githubIcon from '@/resources/icon/pages/weclome/github.svg';
|
||||
import MotionCard from '@/components/ui/MotionCard';
|
||||
import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
|
||||
|
||||
/**
|
||||
* GitHub按钮组件
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const GithubCard = () => {
|
||||
return (
|
||||
<FadeInWrapper delay={0.1} yOffset={-10}>
|
||||
<MotionCard
|
||||
icon={githubIcon}
|
||||
text={'Github'}
|
||||
hasBlurBackground={true}
|
||||
onClick={() => window.open('https://github.com/Jerryplusy/AI-powered-switches', '_blank')}
|
||||
/>
|
||||
</FadeInWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubCard;
|
75
src/frontend/src/components/pages/welcome/WelcomeContent.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { Box, Heading, Text, VStack, HStack } from '@chakra-ui/react';
|
||||
import DashboardCard from '@/components/pages/welcome/DashboardCard';
|
||||
import FadeInWrapper from '@/components/system/layout/FadeInWrapper';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ConnectionConfigModal from '@/components/pages/welcome/ConnectionConfigModal';
|
||||
import ConfigureCard from '@/components/pages/welcome/ConfigureCard';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
|
||||
/**
|
||||
* 欢迎页面内容
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const WelcomeContent = () => {
|
||||
const [showConfigModal, setShowConfigModal] = useState(false);
|
||||
const [isConfigured, setIsConfigured] = useState(false);
|
||||
//const [showAlert, setShowAlert] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = ConfigTool.load();
|
||||
if (saved) {
|
||||
setIsConfigured(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
setIsConfigured(true);
|
||||
//setShowAlert(false);
|
||||
};
|
||||
|
||||
const handleConfigureClick = () => {
|
||||
if (!isConfigured) {
|
||||
//setShowAlert(true);
|
||||
}
|
||||
setShowConfigModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack spacing={10} py={200} align={'center'} px={4}>
|
||||
<FadeInWrapper delay={0.2} yOffset={-5}>
|
||||
<Box textAlign={'center'}>
|
||||
<Heading size={'6xl'} fontWeight={'black'} color={'teal.300'}>
|
||||
智能网络交换机
|
||||
<br />
|
||||
管理系统
|
||||
</Heading>
|
||||
</Box>
|
||||
</FadeInWrapper>
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<Box textAlign={'center'}>
|
||||
<Text mt={6} fontSize={'2xl'} color={'gray.300'}>
|
||||
助力大型网络交换机配置及网络流量管理,方便的管控网络,让网络配置不再困难
|
||||
</Text>
|
||||
</Box>
|
||||
</FadeInWrapper>
|
||||
|
||||
<FadeInWrapper delay={0.4} yOffset={-5}>
|
||||
<HStack spacing={6}>
|
||||
<DashboardCard isConfigured={isConfigured} />
|
||||
<ConfigureCard onClick={handleConfigureClick} />
|
||||
</HStack>
|
||||
</FadeInWrapper>
|
||||
</VStack>
|
||||
|
||||
<ConnectionConfigModal
|
||||
isOpen={showConfigModal}
|
||||
onClose={() => setShowConfigModal(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeContent;
|
17
src/frontend/src/components/system/PageContainer.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 解决导航栏占位问题
|
||||
* @param children
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const PageContainer = ({ children }) => {
|
||||
return (
|
||||
<Box pt={'80px'} px={6}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContainer;
|
28
src/frontend/src/components/system/layout/AppShell.jsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import PageTransition from './PageTransition';
|
||||
import GithubTransitionCard from '@/components/system/layout/github/GithubTransitionCard';
|
||||
|
||||
/**
|
||||
* 应用加壳
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const AppShell = () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<Box position="relative" height="100vh" overflow="hidden">
|
||||
<GithubTransitionCard />
|
||||
<Box overflowY="auto" height="100%">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<PageTransition key={location.pathname}>
|
||||
<Outlet />
|
||||
</PageTransition>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppShell;
|
43
src/frontend/src/components/system/layout/FadeInWrapper.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* 组件进入 / 离开时的淡入淡出动画
|
||||
* @param children 子组件
|
||||
* @param delay 延迟
|
||||
* @param yOffset y轴偏移量
|
||||
* @param duration 动画时间
|
||||
* @param className 类名
|
||||
* @param props
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const FadeInWrapper = ({
|
||||
children,
|
||||
delay = 0,
|
||||
yOffset = 10,
|
||||
duration = 0.6,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="fade-wrapper"
|
||||
initial={{ opacity: 0, y: yOffset }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -yOffset }}
|
||||
transition={{
|
||||
delay,
|
||||
duration,
|
||||
ease: [0.16, 0.77, 0.47, 0.97],
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default FadeInWrapper;
|
16
src/frontend/src/components/system/layout/PageTransition.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
/**
|
||||
* 页面动效
|
||||
* @param children
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const PageTransition = ({ children }) => <motion.div>{children}</motion.div>;
|
||||
|
||||
export default PageTransition;
|
||||
/**
|
||||
* initial={{ opacity: 0, y: 0 }}
|
||||
* animate={{ opacity: 1, y: 0 }}
|
||||
* transition={{ duration: 0.2 }}
|
||||
*/
|
@ -0,0 +1,24 @@
|
||||
import FadeInWrapper from './FadeInWrapper';
|
||||
|
||||
/**
|
||||
* 递归为组件及子组件添加载入动效
|
||||
* @param children 子组件
|
||||
* @param baseDelay 延迟
|
||||
* @param increment 增值
|
||||
* @param className 类名
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const StaggeredFadeIn = ({ children, baseDelay = 0.2, increment = 0.1, className = '' }) => {
|
||||
return (
|
||||
<>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<FadeInWrapper key={index} delay={baseDelay + index * increment} className={className}>
|
||||
{child}
|
||||
</FadeInWrapper>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaggeredFadeIn;
|
@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Box, Button, HStack, Switch, Text } from '@chakra-ui/react';
|
||||
import web from '@/resources/icon/pages/weclome/web.svg';
|
||||
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' },
|
||||
{ label: '网络扫描', path: '/dashboard/scan' },
|
||||
{ label: '交换机设备', path: '/dashboard/devices' },
|
||||
{ label: '交换机配置', path: '/dashboard/config' },
|
||||
{ label: '流量监控', path: '/dashboard/watch' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 导航栏&github按钮组件
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const GithubTransitionCard = () => {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isDashboard = pathname.startsWith('/dashboard');
|
||||
const [showNavButtons, setShowNavButtons] = useState(false);
|
||||
const [testMode, setTestMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowNavButtons(false);
|
||||
const timer = setTimeout(() => {
|
||||
if (isDashboard) setShowNavButtons(true);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [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 });
|
||||
let mode = checked ? '调试模式' : '常规模式';
|
||||
Notification.success({
|
||||
title: `成功切换至${mode}!`,
|
||||
});
|
||||
};
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
return (
|
||||
<AnimatePresence mode={'wait'}>
|
||||
<MotionBox
|
||||
key={isDashboard ? 'dashboard' : 'welcome'}
|
||||
initial={{ opacity: 0, height: 'auto', width: isDashboard ? 200 : 'auto' }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
height: isDashboard ? 64 : 'auto',
|
||||
width: isDashboard ? '100%' : 'fit-content',
|
||||
}}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeInOut' }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 10,
|
||||
left: isDashboard ? 0 : 'auto',
|
||||
right: isDashboard ? 0 : 16,
|
||||
zIndex: 999,
|
||||
padding: isDashboard ? '0 16px' : 0,
|
||||
}}
|
||||
>
|
||||
<FadeInWrapper delay={0.1} yOffset={-10}>
|
||||
<MotionCard
|
||||
icon={isDashboard ? web : githubIcon}
|
||||
text={isDashboard ? '控制台导航栏' : 'Github'}
|
||||
hasBlurBackground
|
||||
onClick={() => {
|
||||
if (!isDashboard) {
|
||||
window.open('https://github.com/Jerryplusy/AI-powered-switches', '_blank');
|
||||
}
|
||||
}}
|
||||
justifyContent={isDashboard ? 'flex-start' : 'center'}
|
||||
alignItems={'center'}
|
||||
flexDirection={'row'}
|
||||
w={'100%'}
|
||||
px={isDashboard ? 4 : 3}
|
||||
py={isDashboard ? 3 : 2}
|
||||
disableHover={isDashboard}
|
||||
>
|
||||
{isDashboard && showNavButtons && (
|
||||
<>
|
||||
<HStack spacing={4} ml={'auto'}>
|
||||
{navItems.map((item) => (
|
||||
<Button
|
||||
key={item.path}
|
||||
size={'sm'}
|
||||
variant={'ghost'}
|
||||
color={'white'}
|
||||
_hover={{
|
||||
color: 'teal.300',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(item.path);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
<HStack spacing={4} ml={'right'}>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</MotionCard>
|
||||
</FadeInWrapper>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
// TODO 解决组件重复渲染问题
|
||||
export default GithubTransitionCard;
|
@ -0,0 +1,61 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 控制台背景
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const DashboardBackground = () => {
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
const spotlight = {
|
||||
background: `radial-gradient(
|
||||
circle at ${mousePos.x}px ${mousePos.y}px,
|
||||
rgba(255, 255, 255, 0.05) 0%,
|
||||
rgba(255, 255, 255, 0.02) 120px,
|
||||
transparent 240px
|
||||
)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionBox
|
||||
position={{ base: 'fixed' }}
|
||||
top={0}
|
||||
left={0}
|
||||
w={{ base: '100vw' }}
|
||||
h={{ base: '100vh' }}
|
||||
zIndex={-1}
|
||||
background={{
|
||||
base: 'linear-gradient(135deg, #18274C 0%, #21397F 50%, #1D3062 100%)',
|
||||
}}
|
||||
_after={{
|
||||
content: { base: '""' },
|
||||
position: { base: 'absolute' },
|
||||
top: 0,
|
||||
left: 0,
|
||||
w: { base: '100%' },
|
||||
h: { base: '100%' },
|
||||
pointerEvents: { base: 'none' },
|
||||
...spotlight,
|
||||
transition: { base: 'background 0.2s ease' },
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardBackground;
|
10
src/frontend/src/components/system/pages/DocumentTitle.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const DocumentTitle = ({ title, children }) => {
|
||||
useEffect(() => {
|
||||
document.title = title || '网络管理后台';
|
||||
}, [title]);
|
||||
return children;
|
||||
};
|
||||
|
||||
export default DocumentTitle;
|
@ -1,5 +1,12 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 卡片组件
|
||||
* @param children
|
||||
* @param props
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const Card = ({ children, ...props }) => (
|
||||
<Box
|
||||
bg={'rgba(255,255,255,0.1)'}
|
||||
|
80
src/frontend/src/components/ui/MotionCard.jsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { Box, Text, Image } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 卡片组件
|
||||
* @param icon 可选图标
|
||||
* @param text 文字
|
||||
* @param onClick 点击执行函数
|
||||
* @param hasBlurBackground 是否模糊背景
|
||||
* @param noHover 是否禁用 hover 动画
|
||||
* @param children 子组件
|
||||
* @param props
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const MotionCard = ({
|
||||
icon,
|
||||
text,
|
||||
onClick,
|
||||
hasBlurBackground = false,
|
||||
disableHover = false,
|
||||
children,
|
||||
...props
|
||||
}) => (
|
||||
<MotionBox
|
||||
position={'relative'}
|
||||
display={'flex'}
|
||||
alignItems={'center'}
|
||||
bg={'whiteAlpha.200'}
|
||||
border={'1px solid'}
|
||||
borderColor={'gray.600'}
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius={'md'}
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
onClick={onClick}
|
||||
transition={'all 0.2s ease'}
|
||||
overflow={'hidden'}
|
||||
_hover={
|
||||
disableHover
|
||||
? {}
|
||||
: {
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: 'whiteAlpha.100',
|
||||
zIndex: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{hasBlurBackground && (
|
||||
<Box
|
||||
position={'absolute'}
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
backdropFilter={'blur(4px)'}
|
||||
zIndex={-1}
|
||||
/>
|
||||
)}
|
||||
{icon && <Image src={icon} boxSize={5} mr={2} zIndex={2} />}
|
||||
{text && (
|
||||
<Text color={'white'} zIndex={2}>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
<Box zIndex={2}>{children}</Box>
|
||||
</MotionBox>
|
||||
);
|
||||
|
||||
export default MotionCard;
|
@ -1,90 +1,86 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react'
|
||||
import { ThemeProvider, useTheme } from 'next-themes'
|
||||
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react';
|
||||
import { ThemeProvider, useTheme } from 'next-themes';
|
||||
|
||||
import * as React from 'react'
|
||||
import { LuMoon, LuSun } from 'react-icons/lu'
|
||||
import * as React from 'react';
|
||||
import { LuMoon, LuSun } from 'react-icons/lu';
|
||||
|
||||
export function ColorModeProvider(props) {
|
||||
return (
|
||||
<ThemeProvider attribute='class' disableTransitionOnChange {...props} />
|
||||
)
|
||||
return <ThemeProvider attribute="class" disableTransitionOnChange {...props} />;
|
||||
}
|
||||
|
||||
export function useColorMode() {
|
||||
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
|
||||
const colorMode = forcedTheme || resolvedTheme
|
||||
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
|
||||
const colorMode = forcedTheme || resolvedTheme;
|
||||
const toggleColorMode = () => {
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
return {
|
||||
colorMode: colorMode,
|
||||
setColorMode: setTheme,
|
||||
toggleColorMode,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useColorModeValue(light, dark) {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === 'dark' ? dark : light
|
||||
const { colorMode } = useColorMode();
|
||||
return colorMode === 'dark' ? dark : light;
|
||||
}
|
||||
|
||||
export function ColorModeIcon() {
|
||||
const { colorMode } = useColorMode()
|
||||
return colorMode === 'dark' ? <LuMoon /> : <LuSun />
|
||||
const { colorMode } = useColorMode();
|
||||
return colorMode === 'dark' ? <LuMoon /> : <LuSun />;
|
||||
}
|
||||
|
||||
export const ColorModeButton = React.forwardRef(
|
||||
function ColorModeButton(props, ref) {
|
||||
const { toggleColorMode } = useColorMode()
|
||||
return (
|
||||
<ClientOnly fallback={<Skeleton boxSize='8' />}>
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
variant='ghost'
|
||||
aria-label='Toggle color mode'
|
||||
size='sm'
|
||||
ref={ref}
|
||||
{...props}
|
||||
css={{
|
||||
_icon: {
|
||||
width: '5',
|
||||
height: '5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorModeIcon />
|
||||
</IconButton>
|
||||
</ClientOnly>
|
||||
)
|
||||
},
|
||||
)
|
||||
export const ColorModeButton = React.forwardRef(function ColorModeButton(props, ref) {
|
||||
const { toggleColorMode } = useColorMode();
|
||||
return (
|
||||
<ClientOnly fallback={<Skeleton boxSize="8" />}>
|
||||
<IconButton
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
aria-label="Toggle color mode"
|
||||
size="sm"
|
||||
ref={ref}
|
||||
{...props}
|
||||
css={{
|
||||
_icon: {
|
||||
width: '5',
|
||||
height: '5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ColorModeIcon />
|
||||
</IconButton>
|
||||
</ClientOnly>
|
||||
);
|
||||
});
|
||||
|
||||
export const LightMode = React.forwardRef(function LightMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color='fg'
|
||||
display='contents'
|
||||
className='chakra-theme light'
|
||||
colorPalette='gray'
|
||||
colorScheme='light'
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme light"
|
||||
colorPalette="gray"
|
||||
colorScheme="light"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export const DarkMode = React.forwardRef(function DarkMode(props, ref) {
|
||||
return (
|
||||
<Span
|
||||
color='fg'
|
||||
display='contents'
|
||||
className='chakra-theme dark'
|
||||
colorPalette='gray'
|
||||
colorScheme='dark'
|
||||
color="fg"
|
||||
display="contents"
|
||||
className="chakra-theme dark"
|
||||
colorPalette="gray"
|
||||
colorScheme="dark"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
23
src/frontend/src/constants/routes/routes.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Route } from 'react-router-dom';
|
||||
import Welcome from '@/pages/Welcome';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import ScanPage from '@/pages/ScanPage';
|
||||
import DevicesPage from '@/pages/DevicesPage';
|
||||
import ConfigPage from '@/pages/ConfigPage';
|
||||
|
||||
/**
|
||||
* 路由
|
||||
* @type {[{path: string, element: JSX.Element},{path: string, element: JSX.Element}]}
|
||||
*/
|
||||
const routeList = [
|
||||
{ path: '/', element: <Welcome /> },
|
||||
{ path: '/dashboard', element: <Dashboard /> },
|
||||
{ path: '/dashboard/scan', element: <ScanPage /> },
|
||||
{ path: '/dashboard/devices', element: <DevicesPage /> },
|
||||
{ path: '/dashboard/config', element: <ConfigPage /> },
|
||||
];
|
||||
|
||||
const buildRoutes = () =>
|
||||
routeList.map(({ path, element }) => <Route key={path} path={path} element={element} />);
|
||||
|
||||
export default buildRoutes;
|
@ -1,14 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from '@/App';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from '@/components/ui/provider';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<Provider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<App />
|
||||
</Provider>
|
||||
);
|
||||
|
21
src/frontend/src/libs/common.js
Normal 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;
|
88
src/frontend/src/libs/config/ConfigTool.js
Normal file
@ -0,0 +1,88 @@
|
||||
const CONFIG_KEY = 'app_config';
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
* @type {{backendUrl: string, authKey: string, testMode: boolean, stats: {totalDevices: number, onlineDevices: number, lastScan: string}, devices: *[]}}
|
||||
*/
|
||||
export const defaultConfig = {
|
||||
backendUrl: '',
|
||||
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 = {
|
||||
load: () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultConfig, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('读取配置失败:', e);
|
||||
localStorage.removeItem(CONFIG_KEY);
|
||||
}
|
||||
return { ...defaultConfig };
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存配置
|
||||
* @param config
|
||||
* @returns {boolean}
|
||||
*/
|
||||
save: (config) => {
|
||||
try {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('保存配置失败:', e);
|
||||
if (e.name === 'QuotaExceededError') {
|
||||
alert('存储空间不足,请清理后重试');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除配置
|
||||
* @returns {boolean}
|
||||
*/
|
||||
clear: () => {
|
||||
try {
|
||||
localStorage.removeItem(CONFIG_KEY);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('清除配置失败:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getConfigPath: () => `localStorage:${CONFIG_KEY}`,
|
||||
|
||||
isStorageAvailable: () => {
|
||||
try {
|
||||
const testKey = 'test';
|
||||
localStorage.setItem(testKey, testKey);
|
||||
localStorage.removeItem(testKey);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getStats: () => {
|
||||
const config = ConfigTool.load();
|
||||
return config.stats || { totalDevices: 0, onlineDevices: 0, lastScan: '' };
|
||||
},
|
||||
};
|
||||
|
||||
export default ConfigTool;
|
208
src/frontend/src/libs/script/configPage/configEffect.js
Normal file
@ -0,0 +1,208 @@
|
||||
const configEffect = {
|
||||
async generateRealisticConfig(command, devices = []) {
|
||||
const timestamp = new Date().toLocaleString();
|
||||
let config = `! 配置生成于 ${timestamp}\n`;
|
||||
const configTemplates = {
|
||||
vlan: {
|
||||
pattern: /(vlan|虚拟局域网|虚拟网)\s*(\d+)/i,
|
||||
template: (vlanId) =>
|
||||
`vlan ${vlanId}\n` +
|
||||
` name VLAN_${vlanId}\n` +
|
||||
` exit\n` +
|
||||
`interface Vlan${vlanId}\n` +
|
||||
` description ${vlanId === '10' ? '管理VLAN' : '用户VLAN'}\n` +
|
||||
` ip address 192.168.${vlanId}.1 255.255.255.0\n` +
|
||||
` exit\n`,
|
||||
},
|
||||
ssh: {
|
||||
pattern: /(ssh|安全外壳|远程登录)/i,
|
||||
template: () => {
|
||||
const password = Math.random().toString(36).slice(2, 10);
|
||||
return (
|
||||
`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`
|
||||
);
|
||||
},
|
||||
},
|
||||
port: {
|
||||
pattern: /(端口|接口|port|interface)\s*(\d+)/i,
|
||||
template: (port) => {
|
||||
const isTrunk = /(trunk|干道)/i.test(command);
|
||||
const isAccess = /(access|接入)/i.test(command) || !isTrunk;
|
||||
const desc = /(上联|uplink)/i.test(command) ? 'Uplink_Port' : 'Access_Port';
|
||||
const vlanId = command.match(/vlan\s*(\d+)/i)?.[1] || '10';
|
||||
|
||||
return (
|
||||
`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: {
|
||||
pattern: /(acl|访问控制|防火墙)/i,
|
||||
template: () => {
|
||||
let targetIP = '192.168.10.10';
|
||||
if (devices.length > 0) {
|
||||
const randomDevice = devices[Math.floor(Math.random() * devices.length)];
|
||||
targetIP = randomDevice.ip;
|
||||
}
|
||||
return (
|
||||
`ip access-list extended PROTECT_SERVERS\n` +
|
||||
` permit tcp any host ${targetIP} eq 22\n` +
|
||||
` permit tcp any host ${targetIP} eq 80\n` +
|
||||
` permit tcp any host ${targetIP} eq 443\n` +
|
||||
` deny ip any any\n` +
|
||||
` exit\n` +
|
||||
`interface Vlan10\n` +
|
||||
` ip access-group PROTECT_SERVERS in\n` +
|
||||
` exit\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
dhcp: {
|
||||
pattern: /(dhcp|动态主机配置)/i,
|
||||
template: () => {
|
||||
const vlanId = command.match(/vlan\s*(\d+)/i)?.[1] || '10';
|
||||
return (
|
||||
`ip dhcp pool VLAN_${vlanId}\n` +
|
||||
` network 192.168.${vlanId}.0 255.255.255.0\n` +
|
||||
` default-router 192.168.${vlanId}.1\n` +
|
||||
` dns-server 8.8.8.8 8.8.4.4\n` +
|
||||
` exit\n` +
|
||||
`ip dhcp excluded-address 192.168.${vlanId}.1 192.168.${vlanId}.10\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
nat: {
|
||||
pattern: /(nat|网络地址转换)/i,
|
||||
template: () => {
|
||||
const publicIp = `203.0.113.${Math.floor(Math.random() * 10) + 1}`;
|
||||
return (
|
||||
`ip access-list standard NAT_ACL\n` +
|
||||
` permit 192.168.0.0 0.0.255.255\n` +
|
||||
` exit\n` +
|
||||
`ip nat inside source list NAT_ACL interface GigabitEthernet0/1 overload\n` +
|
||||
`interface GigabitEthernet0/1\n` +
|
||||
` ip address ${publicIp} 255.255.255.248\n` +
|
||||
` ip nat outside\n` +
|
||||
` exit\n` +
|
||||
`interface Vlan10\n` +
|
||||
` ip nat inside\n` +
|
||||
` exit\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
stp: {
|
||||
pattern: /(stp|生成树|spanning-tree)/i,
|
||||
template: () => {
|
||||
return (
|
||||
`spanning-tree mode rapid-pvst\n` +
|
||||
`spanning-tree vlan 1-4094 priority 4096\n` +
|
||||
`spanning-tree portfast default\n` +
|
||||
`spanning-tree portfast bpduguard default\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
portSecurity: {
|
||||
pattern: /(端口安全|port-security)/i,
|
||||
template: () => {
|
||||
const port = command.match(/端口\s*(\d+)/i)?.[1] || '1';
|
||||
return (
|
||||
`interface GigabitEthernet0/${port}\n` +
|
||||
` switchport port-security\n` +
|
||||
` switchport port-security maximum 5\n` +
|
||||
` switchport port-security violation restrict\n` +
|
||||
` switchport port-security mac-address sticky\n` +
|
||||
` exit\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
qos: {
|
||||
pattern: /(qos|服务质量|流量控制)/i,
|
||||
template: () => {
|
||||
return (
|
||||
`class-map match-all VOICE\n` +
|
||||
` match ip dscp ef\n` +
|
||||
` exit\n` +
|
||||
`policy-map QOS_POLICY\n` +
|
||||
` class VOICE\n` +
|
||||
` priority percent 20\n` +
|
||||
` class class-default\n` +
|
||||
` bandwidth percent 80\n` +
|
||||
` exit\n` +
|
||||
`interface GigabitEthernet0/1\n` +
|
||||
` service-policy output QOS_POLICY\n` +
|
||||
` exit\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
vpn: {
|
||||
pattern: /(vpn|虚拟专用网)/i,
|
||||
template: () => {
|
||||
const vpnId = Math.floor(Math.random() * 1000);
|
||||
return (
|
||||
`crypto isakmp policy ${vpnId}\n` +
|
||||
` encryption aes 256\n` +
|
||||
` hash sha256\n` +
|
||||
` authentication pre-share\n` +
|
||||
` group 14\n` +
|
||||
` exit\n` +
|
||||
`crypto ipsec transform-set VPN_TRANSFORM esp-aes 256 esp-sha256-hmac\n` +
|
||||
` mode tunnel\n` +
|
||||
` exit\n` +
|
||||
`crypto map VPN_MAP 10 ipsec-isakmp\n` +
|
||||
` set peer 203.0.113.5\n` +
|
||||
` set transform-set VPN_TRANSFORM\n` +
|
||||
` match address VPN_ACL\n` +
|
||||
` exit\n`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
let matched = false;
|
||||
if (/(完整配置|全部配置|all config)/i.test(command)) {
|
||||
matched = true;
|
||||
config += '! 生成完整校园网络配置\n';
|
||||
Object.values(configTemplates).forEach((template) => {
|
||||
const result = template.template();
|
||||
if (result) {
|
||||
config += result;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (const [key, { pattern, template }] of Object.entries(configTemplates)) {
|
||||
const match = command.match(pattern);
|
||||
if (match) {
|
||||
matched = true;
|
||||
config += template(match[2] || match[1] || '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
config += 'hostname SCHOOL_SWITCH\n';
|
||||
config += 'ip domain-name school.local\n';
|
||||
config += 'snmp-server community SCHOOL_RO RO\n';
|
||||
config += 'ntp server 192.168.1.1\n';
|
||||
config += 'logging trap informational\n';
|
||||
config += 'logging 192.168.1.10\n';
|
||||
config += 'service password-encryption\n';
|
||||
config += 'enable secret 0 ' + Math.random().toString(36).slice(2, 12) + '\n';
|
||||
config += 'no ip http server\n';
|
||||
config += 'no ip http secure-server\n';
|
||||
}
|
||||
|
||||
return { config };
|
||||
},
|
||||
};
|
||||
|
||||
export default configEffect;
|
24
src/frontend/src/libs/script/scanPage/scanEffect.js
Normal file
@ -0,0 +1,24 @@
|
||||
const scanEffect = {
|
||||
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] },
|
||||
];
|
||||
},
|
||||
|
||||
async fetchLocalInfo({ setLocalIp, subnet, setSubnet }) {
|
||||
setLocalIp('192.168.1.100');
|
||||
if (!subnet) {
|
||||
const ipParts = '192.168.1.0'.split('.');
|
||||
setSubnet(`${ipParts[0]}.${ipParts[1]}.${ipParts[2]}.0/24`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default scanEffect;
|
138
src/frontend/src/libs/system/Notification.jsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { toaster } from '@/components/ui/toaster';
|
||||
import {
|
||||
AiOutlineInfoCircle,
|
||||
AiFillWarning,
|
||||
AiFillCheckCircle,
|
||||
AiFillExclamationCircle,
|
||||
AiOutlineLoading,
|
||||
} from 'react-icons/ai';
|
||||
|
||||
const surfaceStyle = {
|
||||
borderRadius: '1rem',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'white',
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用通知组件
|
||||
* @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 = {
|
||||
/**
|
||||
* 信息
|
||||
* @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 {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 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;
|
258
src/frontend/src/pages/ConfigPage.jsx
Normal file
@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useState } from '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';
|
||||
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';
|
||||
import configEffect from '@/libs/script/configPage/configEffect';
|
||||
|
||||
const testMode = ConfigTool.load().testMode;
|
||||
|
||||
const ConfigPage = () => {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState('');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [parsedConfig, setParsedConfig] = useState('');
|
||||
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 handleParse = async () => {
|
||||
if (!selectedDevice || !inputText.trim()) {
|
||||
Notification.error({
|
||||
title: '操作失败',
|
||||
description: '请选择设备并输入配置指令',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const performParse = async () => {
|
||||
if (testMode) {
|
||||
await Common.sleep(800 + Math.random() * 700);
|
||||
return await configEffect.generateRealisticConfig(inputText, devices);
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentTitle title={'交换机配置'}>
|
||||
<DashboardBackground />
|
||||
<PageContainer>
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<VStack spacing={6} align={'stretch'}>
|
||||
<Heading fontSize={'xl'} color={'teal.300'}>
|
||||
交换机配置中心
|
||||
</Heading>
|
||||
|
||||
<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>
|
||||
|
||||
<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
|
||||
colorScheme={'teal'}
|
||||
variant={'solid'}
|
||||
size={'sm'}
|
||||
onClick={handleParse}
|
||||
isDisabled={!selectedDevice || !inputText.trim()}
|
||||
>
|
||||
解析配置
|
||||
</Button>
|
||||
|
||||
{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>
|
||||
</PageContainer>
|
||||
</DocumentTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
@ -1,15 +1,175 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import Header from '../components/Header';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Box, Text, VStack, HStack, SimpleGrid, Badge, Button, Spinner } 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 FeatureCard from '@/components/pages/dashboard/FeatureCard';
|
||||
import StatCard from '@/components/pages/dashboard/StatCard';
|
||||
import api from '@/services/api/api';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
import Notification from '@/libs/system/Notification';
|
||||
|
||||
/**
|
||||
* 控制台
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const Dashboard = () => {
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
lastScan: '',
|
||||
});
|
||||
|
||||
const [networkStatus, setNetworkStatus] = useState('loading'); // loading | ok | fail
|
||||
|
||||
const checkBackend = useCallback(async () => {
|
||||
setNetworkStatus('loading');
|
||||
try {
|
||||
const res = await api.test();
|
||||
if (res) {
|
||||
setNetworkStatus('ok');
|
||||
console.log(JSON.stringify(res));
|
||||
Notification.info({ title: '成功连接至后端服务!', description: res.message });
|
||||
} else {
|
||||
setNetworkStatus('fail');
|
||||
Notification.error({ title: '后端服务响应异常!', description: JSON.stringify(res) });
|
||||
}
|
||||
} catch (err) {
|
||||
setNetworkStatus('fail');
|
||||
Notification.error({ title: '无法连接到后端服务!', description: err.message });
|
||||
}
|
||||
}, [Notification]);
|
||||
|
||||
useEffect(() => {
|
||||
Notification.info({
|
||||
title: '正在尝试连接后端服务',
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
checkBackend();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = () => {
|
||||
const stats = ConfigTool.getStats();
|
||||
setStats(stats);
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Box p={6}>
|
||||
<Text fontSize={'xl'}>控制台奇怪的功能+1</Text>
|
||||
</Box>
|
||||
</>
|
||||
<DocumentTitle title={'控制台'}>
|
||||
<DashboardBackground />
|
||||
<PageContainer>
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<VStack spacing={8} align={'stretch'}>
|
||||
<Box
|
||||
p={4}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
mx={4}
|
||||
transition={'all 0.2s'}
|
||||
_hover={{ transform: 'translateY(-4px)' }}
|
||||
>
|
||||
<Text fontSize={'3xl'} fontWeight={'bold'} color={'teal.300'}>
|
||||
{'欢迎使用智能交换机管理系统'}
|
||||
</Text>
|
||||
<Text mt={2} fontSize={'lg'} color={'gray.300'}>
|
||||
{'实时监控您的网络设备状态,快速配置并掌控全局网络环境'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} gap={'10px'} mx={4}>
|
||||
<StatCard title={'已发现设备'} value={stats.totalDevices} />
|
||||
<StatCard
|
||||
title={'在线设备'}
|
||||
value={stats.onlineDevices}
|
||||
suffix={`/ ${stats.totalDevices}`}
|
||||
/>
|
||||
<StatCard title={'最近扫描'} value={stats.lastScan} isTime />
|
||||
</SimpleGrid>
|
||||
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<Box
|
||||
p={6}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
mx={4}
|
||||
transition={'all 0.2s'}
|
||||
_hover={{ transform: 'translateY(-4px)' }}
|
||||
>
|
||||
<Text fontSize={'xl'} fontWeight={'bold'} mb={4} color={'white'}>
|
||||
{'网络健康状态'}
|
||||
</Text>
|
||||
<HStack spacing={4}>
|
||||
{networkStatus === 'loading' && (
|
||||
<Badge variant={'surface'} p={2} borderRadius={'lg'} colorPalette={'blue'}>
|
||||
<Spinner size="sm" mr={2} /> {'检测网络中...'}
|
||||
</Badge>
|
||||
)}
|
||||
{networkStatus === 'ok' && (
|
||||
<Badge variant={'surface'} p={2} borderRadius={'lg'} colorPalette={'green'}>
|
||||
{'网络连接正常'}
|
||||
</Badge>
|
||||
)}
|
||||
{networkStatus === 'fail' && (
|
||||
<Badge color={'red'} variant={'surface'} p={2} borderRadius={'lg'}>
|
||||
{'无法连接后端'}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge colorPalette={'blue'} variant={'surface'} p={2} borderRadius={'lg'}>
|
||||
{'交换机正常运行'}
|
||||
</Badge>
|
||||
<Badge colorPalette={'yellow'} variant={'surface'} p={2} borderRadius={'lg'}>
|
||||
{'流量监控已启动'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<Button mt={4} onClick={checkBackend} colorPalette={'teal'} variant={'outline'}>
|
||||
{'重新检测'}
|
||||
</Button>
|
||||
</Box>
|
||||
</FadeInWrapper>
|
||||
|
||||
<FadeInWrapper delay={0.4} yOffset={-5}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={'10px'} mx={4}>
|
||||
<FeatureCard
|
||||
title={'网络扫描'}
|
||||
description={'快速扫描指定子网,发现可用设备,展示设备 IP/MAC 和开放端口信息'}
|
||||
buttonText={'立即扫描'}
|
||||
to={'/dashboard/scan'}
|
||||
/>
|
||||
<FeatureCard
|
||||
title={'设备管理'}
|
||||
description={'查看已记录的交换机设备信息,未来支持编辑、备注、批量管理'}
|
||||
buttonText={'管理设备'}
|
||||
to={'/dashboard/devices'}
|
||||
/>
|
||||
<FeatureCard
|
||||
title={'命令配置'}
|
||||
description={'输入自然语言命令,自动生成设备配置,支持一键应用到交换机'}
|
||||
buttonText={'前往配置'}
|
||||
to={'/dashboard/config'}
|
||||
/>
|
||||
<FeatureCard
|
||||
title={'流量监控'}
|
||||
description={'未来将支持实时监控每台设备上下行带宽,帮助掌握网络流量变化'}
|
||||
buttonText={'前往查看'}
|
||||
to={'dashboard/watch'}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</FadeInWrapper>
|
||||
</VStack>
|
||||
</FadeInWrapper>
|
||||
</PageContainer>
|
||||
</DocumentTitle>
|
||||
);
|
||||
};
|
||||
|
||||
|
141
src/frontend/src/pages/DevicesPage.jsx
Normal file
@ -0,0 +1,141 @@
|
||||
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';
|
||||
import switchIcon from '@/resources/icon/pages/devices/switch.png';
|
||||
import Notification from '@/libs/system/Notification';
|
||||
|
||||
/**
|
||||
* 交换机管理
|
||||
* @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;
|
||||
Common.sleep(200).then(() => {
|
||||
setDevices(updated);
|
||||
ConfigTool.save({ ...ConfigTool.load(), devices: updated });
|
||||
Notification.success({
|
||||
title: '设备重命名成功!',
|
||||
});
|
||||
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={switchIcon} 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;
|
225
src/frontend/src/pages/ScanPage.jsx
Normal file
@ -0,0 +1,225 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
Heading,
|
||||
Input,
|
||||
Button,
|
||||
Text,
|
||||
Spinner,
|
||||
Badge,
|
||||
HStack,
|
||||
Table,
|
||||
} 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 { api } from '@/services/api/api';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
import Common from '@/libs/common';
|
||||
import Notification from '@/libs/system/Notification';
|
||||
import scanEffect from '@/libs/script/scanPage/scanEffect';
|
||||
|
||||
/**
|
||||
* 网络扫描页面
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const ScanPage = () => {
|
||||
const [subnet, setSubnet] = useState('');
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localIp, setLocalIp] = useState('');
|
||||
|
||||
const config = ConfigTool.load();
|
||||
const testMode = config.testMode;
|
||||
|
||||
useEffect(() => {
|
||||
scanEffect
|
||||
.fetchLocalInfo({
|
||||
setLocalIp: setLocalIp,
|
||||
setSubnet: setSubnet,
|
||||
subnet: subnet,
|
||||
})
|
||||
.then();
|
||||
}, [subnet, Notification]);
|
||||
|
||||
const handleScan = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let scanDevices;
|
||||
if (testMode) {
|
||||
await Common.sleep(2000);
|
||||
scanDevices = scanEffect.getTestDevices();
|
||||
Notification.success({
|
||||
title: '扫描子网设备成功!',
|
||||
});
|
||||
} else {
|
||||
const res = await api.scan(subnet);
|
||||
scanDevices = res.devices || [];
|
||||
Notification.success({
|
||||
title: '扫描子网设备成功!',
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
Notification.error({ title: '扫描网络失败' });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleLastScan = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let scanDevices;
|
||||
if (testMode) {
|
||||
await Common.sleep(500);
|
||||
scanDevices = scanEffect.getTestDevices();
|
||||
Notification.success({
|
||||
title: '获取上一次扫描记录成功!',
|
||||
});
|
||||
} else {
|
||||
const res = await api.listDevices();
|
||||
scanDevices = res.devices || [];
|
||||
}
|
||||
setDevices(scanDevices);
|
||||
} catch (err) {
|
||||
Notification.error({ title: '获取上次扫描记录失败' });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentTitle title={'网络扫描'}>
|
||||
<DashboardBackground />
|
||||
<PageContainer>
|
||||
<FadeInWrapper delay={0.3} yOffset={-5}>
|
||||
<VStack spacing={8} align={'stretch'}>
|
||||
<Box
|
||||
p={6}
|
||||
bg={'whiteAlpha.100'}
|
||||
borderRadius={'xl'}
|
||||
border={'1px solid'}
|
||||
borderColor={'whiteAlpha.300'}
|
||||
mx={4}
|
||||
transition={'all 0.2s'}
|
||||
_hover={{ transform: 'translateY(-4px)' }}
|
||||
>
|
||||
<Heading fontSize={'2xl'} mb={4} color={'teal.300'}>
|
||||
{'网络扫描'}
|
||||
</Heading>
|
||||
|
||||
<HStack mb={4}>
|
||||
<Text fontWeight={'medium'}>{'后端服务器IP: '}</Text>
|
||||
<Badge colorPalette={'blue'}>{localIp}</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack mb={4} spacing={4}>
|
||||
<Input
|
||||
placeholder={'输入子网 (如 192.168.1.0/24)'}
|
||||
value={subnet}
|
||||
onChange={(e) => setSubnet(e.target.value)}
|
||||
width={'300px'}
|
||||
bg={'whiteAlpha.200'}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
isLoading={loading}
|
||||
colorPalette={'teal'}
|
||||
variant={'solid'}
|
||||
>
|
||||
{'开始扫描'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLastScan}
|
||||
isLoading={loading}
|
||||
colorPalette={'blue'}
|
||||
variant={'outline'}
|
||||
>
|
||||
{'显示上次结果'}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{loading && (
|
||||
<HStack>
|
||||
<Spinner />
|
||||
<Text>{'正在加载,请稍候..'}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{!loading && devices.length > 0 && (
|
||||
<FadeInWrapper delay={0.2} yOffset={-5}>
|
||||
<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.ColumnHeader bg={'whiteAlpha.100'} backdropFilter={'blur(4px)'}>
|
||||
{'IP 地址'}
|
||||
</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.Header>
|
||||
<Table.Body>
|
||||
{devices.map((d) => (
|
||||
<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.mac}</Table.Cell>
|
||||
<Table.Cell>{d.ports.join(', ')}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</FadeInWrapper>
|
||||
)}
|
||||
|
||||
{!loading && devices.length === 0 && (
|
||||
<Text color={'gray.400'}>{'暂无扫描结果,请执行扫描..'}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</FadeInWrapper>
|
||||
</PageContainer>
|
||||
</DocumentTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScanPage;
|
@ -1,19 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Heading, VStack } from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import BackgroundBlur from '@/components/pages/welcome/BackgroundBlur';
|
||||
import WelcomeContent from '@/components/pages/welcome/WelcomeContent';
|
||||
|
||||
/**
|
||||
* 欢迎页
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
const Welcome = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Box textAlign={'center'} py={10} px={6}>
|
||||
<VStack spacing={4}>
|
||||
<Heading as={'h1'} size={'x1'}>
|
||||
欢迎使用交换机管理后台
|
||||
</Heading>
|
||||
<Button colorScheme={'teal'} onClick={() => navigate('/dashboard')}>
|
||||
进入控制台
|
||||
</Button>
|
||||
</VStack>
|
||||
<Box position={'relative'} height={'100vh'} overflow={'hidden'}>
|
||||
<BackgroundBlur />
|
||||
<Box position={'absolute'} top={4} right={4} zIndex={10}>
|
||||
{/*<GithubCard />*/}
|
||||
</Box>
|
||||
<Box overflowY={'auto'} height={'100%'} zIndex={1} position={'relative'}>
|
||||
<WelcomeContent />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
BIN
src/frontend/src/resources/icon/pages/devices/switch.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
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 |
1
src/frontend/src/resources/icon/pages/weclome/config.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 512 512"><path fill="none" stroke="#2EE2E5" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M262.29 192.31a64 64 0 1 0 57.4 57.4a64.13 64.13 0 0 0-57.4-57.4M416.39 256a154 154 0 0 1-1.53 20.79l45.21 35.46a10.81 10.81 0 0 1 2.45 13.75l-42.77 74a10.81 10.81 0 0 1-13.14 4.59l-44.9-18.08a16.11 16.11 0 0 0-15.17 1.75A164.5 164.5 0 0 1 325 400.8a15.94 15.94 0 0 0-8.82 12.14l-6.73 47.89a11.08 11.08 0 0 1-10.68 9.17h-85.54a11.11 11.11 0 0 1-10.69-8.87l-6.72-47.82a16.07 16.07 0 0 0-9-12.22a155 155 0 0 1-21.46-12.57a16 16 0 0 0-15.11-1.71l-44.89 18.07a10.81 10.81 0 0 1-13.14-4.58l-42.77-74a10.8 10.8 0 0 1 2.45-13.75l38.21-30a16.05 16.05 0 0 0 6-14.08c-.36-4.17-.58-8.33-.58-12.5s.21-8.27.58-12.35a16 16 0 0 0-6.07-13.94l-38.19-30A10.81 10.81 0 0 1 49.48 186l42.77-74a10.81 10.81 0 0 1 13.14-4.59l44.9 18.08a16.11 16.11 0 0 0 15.17-1.75A164.5 164.5 0 0 1 187 111.2a15.94 15.94 0 0 0 8.82-12.14l6.73-47.89A11.08 11.08 0 0 1 213.23 42h85.54a11.11 11.11 0 0 1 10.69 8.87l6.72 47.82a16.07 16.07 0 0 0 9 12.22a155 155 0 0 1 21.46 12.57a16 16 0 0 0 15.11 1.71l44.89-18.07a10.81 10.81 0 0 1 13.14 4.58l42.77 74a10.8 10.8 0 0 1-2.45 13.75l-38.21 30a16.05 16.05 0 0 0-6.05 14.08c.33 4.14.55 8.3.55 12.47"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
4
src/frontend/src/resources/icon/pages/weclome/github.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.83329 16.14C4.97579 17.1042 2.59496 16.14 1.16663 13.1667" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.83329 17.3333V14.6317C7.83329 14.1333 7.98663 13.7 8.23329 13.3083C8.40329 13.04 8.28663 12.6583 7.98079 12.575C5.44496 11.8767 3.66663 10.7558 3.66663 7.0375C3.66663 6.07083 3.98329 5.1625 4.53996 4.37083C4.67829 4.17417 4.74829 4.07583 4.76496 3.9875C4.78163 3.8975 4.75246 3.78167 4.69413 3.54833C4.45829 2.60167 4.47413 1.59583 4.82746 0.69C4.82746 0.69 5.55829 0.450833 7.22246 1.49C7.60246 1.7275 7.79246 1.84667 7.95996 1.87333C8.12746 1.9 8.35079 1.84417 8.79746 1.7325C9.4361 1.57416 10.092 1.49606 10.75 1.5C11.4079 1.49719 12.0637 1.57556 12.7025 1.73333C13.1491 1.845 13.3733 1.9 13.5408 1.87417C13.7075 1.8475 13.8975 1.72833 14.2775 1.49083C15.9416 0.451667 16.6725 0.690833 16.6725 0.690833C17.0258 1.59667 17.0416 2.6025 16.8058 3.54917C16.7475 3.7825 16.7191 3.89917 16.7358 3.9875C16.7525 4.07583 16.8216 4.175 16.96 4.37167C17.5166 5.16333 17.8333 6.07167 17.8333 7.03833C17.8333 10.7567 16.055 11.8775 13.5191 12.5742C13.2133 12.6583 13.0966 13.04 13.2666 13.3075C13.5133 13.6992 13.6666 14.1325 13.6666 14.6317V17.3333" stroke="white" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4166 13C9.27081 13 8.28991 12.592 7.47394 11.7761C6.65797 10.9601 6.24998 9.97919 6.24998 8.83335C6.24998 7.68752 6.65797 6.70662 7.47394 5.89065C8.28991 5.07467 9.27081 4.66669 10.4166 4.66669C11.5625 4.66669 12.5434 5.07467 13.3594 5.89065C14.1753 6.70662 14.5833 7.68752 14.5833 8.83335C14.5833 9.97919 14.1753 10.9601 13.3594 11.7761C12.5434 12.592 11.5625 13 10.4166 13ZM2.08331 21.3334V18.4167C2.08331 17.8438 2.23088 17.3056 2.52602 16.8021C2.82116 16.2986 3.22915 15.9167 3.74998 15.6563C4.6354 15.2049 5.63366 14.8229 6.74477 14.5104C7.85588 14.1979 9.07984 14.0417 10.4166 14.0417H10.7812C10.8854 14.0417 10.9896 14.059 11.0937 14.0938C10.9548 14.4063 10.8378 14.732 10.7427 15.0709C10.6475 15.4097 10.5736 15.7611 10.5208 16.125H10.4166C9.18401 16.125 8.07741 16.2813 7.09685 16.5938C6.1163 16.9063 5.31317 17.2188 4.68748 17.5313C4.53123 17.6181 4.40519 17.7396 4.30935 17.8959C4.21352 18.0521 4.16595 18.2257 4.16665 18.4167V19.25H10.7291C10.8333 19.6146 10.9722 19.975 11.1458 20.3313C11.3194 20.6875 11.5104 21.0215 11.7187 21.3334H2.08331ZM16.6666 22.375L16.3541 20.8125C16.1458 20.7257 15.9507 20.6347 15.7687 20.5396C15.5868 20.4445 15.4 20.3271 15.2083 20.1875L13.6979 20.6563L12.6562 18.8854L13.8541 17.8438C13.8194 17.6007 13.8021 17.375 13.8021 17.1667C13.8021 16.9584 13.8194 16.7327 13.8541 16.4896L12.6562 15.4479L13.6979 13.6771L15.2083 14.1459C15.3993 14.007 15.5861 13.89 15.7687 13.7948C15.9514 13.6997 16.1465 13.6084 16.3541 13.5209L16.6666 11.9584H18.75L19.0625 13.5209C19.2708 13.6077 19.4663 13.7031 19.6489 13.8073C19.8316 13.9115 20.018 14.0417 20.2083 14.1979L21.7187 13.6771L22.7604 15.5L21.5625 16.5417C21.5972 16.75 21.6146 16.967 21.6146 17.1927C21.6146 17.4184 21.5972 17.6354 21.5625 17.8438L22.7604 18.8854L21.7187 20.6563L20.2083 20.1875C20.0173 20.3264 19.8309 20.4438 19.6489 20.5396C19.467 20.6354 19.2715 20.7264 19.0625 20.8125L18.75 22.375H16.6666ZM17.7083 19.25C18.2812 19.25 18.7719 19.0462 19.1802 18.6386C19.5885 18.2309 19.7923 17.7403 19.7916 17.1667C19.791 16.5931 19.5871 16.1028 19.1802 15.6959C18.7732 15.2889 18.2826 15.0847 17.7083 15.0834C17.134 15.082 16.6437 15.2861 16.2375 15.6959C15.8312 16.1056 15.6271 16.5959 15.625 17.1667C15.6229 17.7375 15.8271 18.2281 16.2375 18.6386C16.6479 19.049 17.1382 19.2528 17.7083 19.25ZM10.4166 10.9167C10.9896 10.9167 11.4802 10.7129 11.8885 10.3052C12.2969 9.89759 12.5007 9.40696 12.5 8.83335C12.4993 8.25974 12.2955 7.76946 11.8885 7.36252C11.4816 6.95558 10.991 6.75141 10.4166 6.75002C9.84234 6.74863 9.35206 6.9528 8.94581 7.36252C8.53956 7.77224 8.3354 8.26252 8.33331 8.83335C8.33123 9.40419 8.5354 9.89481 8.94581 10.3052C9.35623 10.7156 9.84651 10.9195 10.4166 10.9167Z" fill="#2EE2E5"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.8 KiB |
1
src/frontend/src/resources/icon/pages/weclome/web.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 24 24"><g fill="none" stroke="#2ee2e5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="#2ee2e5"><circle cx="12" cy="12" r="10"/><ellipse cx="12" cy="12" rx="4" ry="10"/><path d="M2 12h20"/></g></svg>
|
After Width: | Height: | Size: 300 B |
BIN
src/frontend/src/resources/image/welcome/background.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
78
src/frontend/src/services/api/api.js
Normal file
@ -0,0 +1,78 @@
|
||||
import axios from 'axios';
|
||||
import ConfigTool from '@/libs/config/ConfigTool';
|
||||
|
||||
/**
|
||||
* 动态拼接完整URL
|
||||
*/
|
||||
const buildUrl = (path) => {
|
||||
const config = ConfigTool.load();
|
||||
let baseUrl = config.backendUrl || '';
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
return `${baseUrl}${path}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* API模块
|
||||
*/
|
||||
export const api = {
|
||||
/**
|
||||
* 测试API连接
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
test: () => axios.get(buildUrl('/api/test')),
|
||||
|
||||
/**
|
||||
* 扫描网络
|
||||
* @param subnet 子网地址
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
scan: (subnet) => axios.get(buildUrl('/api/scan_network'), { params: { subnet } }),
|
||||
|
||||
/**
|
||||
* 列出所有设备
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
listDevices: () => axios.get(buildUrl('/api/list_devices')),
|
||||
|
||||
/**
|
||||
* 解析命令
|
||||
* @param text 文本
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
parseCommand: (text) => axios.post(buildUrl('/api/parse_command'), { command: text }),
|
||||
|
||||
/**
|
||||
* 应用配置
|
||||
* @param switch_ip 交换机ip
|
||||
* @param config 配置
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
applyConfig: (switch_ip, config) =>
|
||||
axios.post(buildUrl('/api/apply_config'), { switch_ip, config }),
|
||||
|
||||
/**
|
||||
* 更新基础URL
|
||||
* @param url
|
||||
*/
|
||||
updateBaseUrl: (url) => {
|
||||
const config = ConfigTool.load();
|
||||
config.backendUrl = url;
|
||||
ConfigTool.save(config);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
* @returns {{backendUrl: string, authKey: string}}
|
||||
*/
|
||||
export const getConfig = () => ConfigTool.load();
|
||||
|
||||
/**
|
||||
* 获取基础URL
|
||||
* @returns {string|string}
|
||||
*/
|
||||
export const getBaseUrl = () => ConfigTool.load().backendUrl || '';
|
||||
|
||||
export default api;
|
@ -1 +0,0 @@
|
||||
# 论文(markdown形式)
|