Compare commits
No commits in common. "1edb3474c556c82be5c83084f7b38ba5cc7801de" and "a469133446ee93053ba0f312c16811c09f529507" have entirely different histories.
1edb3474c5
...
a469133446
17
README.md
17
README.md
@ -1 +1,16 @@
|
|||||||
# Wireguard 组网可视化编辑器
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
|
|||||||
44
src/App.tsx
44
src/App.tsx
@ -14,14 +14,12 @@ import {
|
|||||||
NodeMouseHandler,
|
NodeMouseHandler,
|
||||||
OnConnect,
|
OnConnect,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
IsValidConnection,
|
IsValidConnection
|
||||||
EdgeMouseHandler
|
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import { AppNode, AppEdge, NodeData, EdgeData, Settings } from './types/graph';
|
import { AppNode, AppEdge, NodeData, Settings } from './types/graph';
|
||||||
import CustomNode from './components/CustomNode';
|
import CustomNode from './components/CustomNode';
|
||||||
import NodeEditor from './components/NodeEditor';
|
import NodeEditor from './components/NodeEditor';
|
||||||
import EdgeEditor from './components/EdgeEditor'
|
|
||||||
import Toggle from "./components/Toggle"
|
import Toggle from "./components/Toggle"
|
||||||
import { generateWireGuardPrivateKey } from './utils/wireguardConfig';
|
import { generateWireGuardPrivateKey } from './utils/wireguardConfig';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@ -52,8 +50,7 @@ function FlowContent(): ReactNode {
|
|||||||
const [edges, setEdges] = useState<AppEdge[]>(initialEdges);
|
const [edges, setEdges] = useState<AppEdge[]>(initialEdges);
|
||||||
const [settings, setSettings] = useState<Settings>(initialSettings);
|
const [settings, setSettings] = useState<Settings>(initialSettings);
|
||||||
|
|
||||||
const [editingNode, setEditingNode] = useState<NodeData | undefined>(undefined);
|
const [editingNode, setEditingNode] = useState<NodeData | null>(null);
|
||||||
const [editingEdge, setEditingEdge] = useState<EdgeData | undefined>(undefined);
|
|
||||||
const [enableTwoWay, setEnableTwoWay] = useState(false);
|
const [enableTwoWay, setEnableTwoWay] = useState(false);
|
||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
@ -66,14 +63,12 @@ function FlowContent(): ReactNode {
|
|||||||
|
|
||||||
const onConnect = useCallback<OnConnect>(
|
const onConnect = useCallback<OnConnect>(
|
||||||
(params) => {
|
(params) => {
|
||||||
const id = `e-${crypto.randomUUID()}`
|
|
||||||
const newEdge : AppEdge = {
|
const newEdge : AppEdge = {
|
||||||
...params,
|
...params,
|
||||||
id: id,
|
id: `e-${crypto.randomUUID()}`,
|
||||||
animated: !enableTwoWay,
|
animated: !enableTwoWay,
|
||||||
markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed },
|
markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed },
|
||||||
data : {
|
data : {
|
||||||
id: id,
|
|
||||||
isTwoWayEdge: enableTwoWay
|
isTwoWayEdge: enableTwoWay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -85,11 +80,6 @@ function FlowContent(): ReactNode {
|
|||||||
(_event, node) => setEditingNode(node.data),
|
(_event, node) => setEditingNode(node.data),
|
||||||
[]);
|
[]);
|
||||||
|
|
||||||
const onEdgeClick = useCallback<EdgeMouseHandler<AppEdge>>(
|
|
||||||
(_event, edge) => setEditingEdge(edge.data),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const validateConnection = useCallback<IsValidConnection>(
|
const validateConnection = useCallback<IsValidConnection>(
|
||||||
(connection) => {
|
(connection) => {
|
||||||
if (connection.source === connection.target) {
|
if (connection.source === connection.target) {
|
||||||
@ -127,19 +117,7 @@ function FlowContent(): ReactNode {
|
|||||||
return node;
|
return node;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setEditingNode(undefined);
|
setEditingNode(null);
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateEdge = (updatedData: EdgeData): void => {
|
|
||||||
setEdges((prev) =>
|
|
||||||
prev.map((edge) => {
|
|
||||||
if (edge.data && edge.data.id === editingEdge?.id) {
|
|
||||||
return { ...edge, data: updatedData };
|
|
||||||
}
|
|
||||||
return edge;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setEditingNode(undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -151,7 +129,6 @@ function FlowContent(): ReactNode {
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeDoubleClick={onNodeClick}
|
onNodeDoubleClick={onNodeClick}
|
||||||
onEdgeDoubleClick={onEdgeClick}
|
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
deleteKeyCode={["Delete"]}
|
deleteKeyCode={["Delete"]}
|
||||||
fitView
|
fitView
|
||||||
@ -191,19 +168,10 @@ function FlowContent(): ReactNode {
|
|||||||
<NodeEditor
|
<NodeEditor
|
||||||
node={editingNode}
|
node={editingNode}
|
||||||
onUpdate={handleUpdateNode}
|
onUpdate={handleUpdateNode}
|
||||||
onClose={() => setEditingNode(undefined)}
|
onClose={() => setEditingNode(null)}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingEdge && (
|
|
||||||
<EdgeEditor
|
|
||||||
edge={editingEdge}
|
|
||||||
onUpdate={handleUpdateEdge}
|
|
||||||
onClose={() => setEditingEdge(undefined)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
## 产品基本功能实现待办事项
|
## 产品基本功能实现待办事项
|
||||||
|
|
||||||
- [x] 完成节点的编辑窗口
|
- [x] 完成节点的编辑窗口
|
||||||
- [x] 完成边的编辑窗口
|
- [ ] 完成边的编辑窗口
|
||||||
- [ ] 完成全局设置编辑窗口以及相关联动
|
- [ ] 完成全局设置编辑窗口以及相关联动
|
||||||
- [ ] 实现配置生成逻辑,并验证有效
|
- [ ] 实现配置生成逻辑,并验证有效
|
||||||
- [ ] 实现配置保存和加载功能
|
- [ ] 实现配置保存和加载功能
|
||||||
@ -12,6 +12,9 @@ export default function CustomNode({
|
|||||||
<div className="node-header">
|
<div className="node-header">
|
||||||
<span className="node-label">{data.label}</span>
|
<span className="node-label">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="node-info">
|
||||||
|
</div>
|
||||||
|
|
||||||
{[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => (
|
{[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => (
|
||||||
(["target", "source"] as const).map((type) => (
|
(["target", "source"] as const).map((type) => (
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
import { useState, ReactNode } from 'react';
|
|
||||||
import { AppNode, AppEdge, EdgeData, EdgeDataUpdate } from '../types/graph';
|
|
||||||
import { useReactFlow } from '@xyflow/react';
|
|
||||||
import './FormEditor.css';
|
|
||||||
|
|
||||||
interface EdgeEditorProps {
|
|
||||||
edge: EdgeData;
|
|
||||||
onUpdate: (data: EdgeDataUpdate) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NodeEditor({
|
|
||||||
edge,
|
|
||||||
onUpdate,
|
|
||||||
onClose
|
|
||||||
}: EdgeEditorProps): ReactNode {
|
|
||||||
|
|
||||||
const [keepalive, setKeepalive] = useState(edge.persistentKeepalive);
|
|
||||||
const { getNode, getEdge } = useReactFlow<AppNode, AppEdge>();
|
|
||||||
|
|
||||||
const handleSave = (): void => {
|
|
||||||
onUpdate({persistentKeepalive : keepalive});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const e = getEdge(edge.id)
|
|
||||||
|
|
||||||
const source = e ? getNode(e.source) : undefined
|
|
||||||
const target = e ? getNode(e.target) : undefined
|
|
||||||
|
|
||||||
const sourceName = source ? source.data.label : "error"
|
|
||||||
const targetName = target ? target.data.label : "error"
|
|
||||||
|
|
||||||
const label = `[${sourceName}] ${edge.isTwoWayEdge ? "↔" : "→"} [${targetName}]`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="node-editor-overlay">
|
|
||||||
<div className="node-editor">
|
|
||||||
<div className="editor-header">
|
|
||||||
<h2>设置边 {label} 参数</h2>
|
|
||||||
<button className="close-btn" onClick={onClose}>×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>保活频率(单位:秒)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="1"
|
|
||||||
value={keepalive || ''}
|
|
||||||
onChange={
|
|
||||||
(e) => {
|
|
||||||
const value = e.target.valueAsNumber;
|
|
||||||
setKeepalive(isNaN(value) ? undefined : value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeholder={`留空或0代表不保活`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="editor-actions">
|
|
||||||
<button className="btn-save" onClick={handleSave}>保存</button>
|
|
||||||
<button className="btn-cancel" onClick={onClose}>取消</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,240 +1,240 @@
|
|||||||
.node-editor-overlay {
|
.node-editor-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-editor {
|
.node-editor {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-header {
|
.editor-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-header h2 {
|
.editor-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #999;
|
color: #999;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-box {
|
.error-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fee;
|
background: #fee;
|
||||||
border: 1px solid #fcc;
|
border: 1px solid #fcc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
|
|
||||||
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
|
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-close-btn {
|
.error-close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
user-select: none; /* 禁止选中 */
|
user-select: none; /* 禁止选中 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-close-btn:hover {
|
.error-close-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(255, 77, 79, 0.1); /* 悬停微红 */
|
background: rgba(255, 77, 79, 0.1); /* 悬停微红 */
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||||
20%, 80% { transform: translate3d(2px, 0, 0); }
|
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||||
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||||
40%, 60% { transform: translate3d(4px, 0, 0); }
|
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
color: #c33;
|
color: #c33;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group textarea {
|
.form-group textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
.form-group input:focus,
|
||||||
.form-group textarea:focus {
|
.form-group textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #1677ff;
|
border-color: #1677ff;
|
||||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group textarea {
|
.form-group textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-group {
|
.item-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 1. 基础通用样式 - 强化了投影和圆角 */
|
/* 1. 基础通用样式 - 强化了投影和圆角 */
|
||||||
.btn-interect, .btn-save, .btn-cancel {
|
.btn-interect, .btn-save, .btn-cancel {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px; /* 稍微圆润一点,更现代 */
|
border-radius: 6px; /* 稍微圆润一点,更现代 */
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease; /* 稍微拉长过渡,显得更平滑 */
|
transition: all 0.2s ease; /* 稍微拉长过渡,显得更平滑 */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
/* 基础阴影 */
|
/* 基础阴影 */
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. 统一交互反馈:悬停增强阴影,移除缩放 */
|
/* 2. 统一交互反馈:悬停增强阴影,移除缩放 */
|
||||||
.btn-interect:hover, .btn-save:hover, .btn-cancel:hover {
|
.btn-interect:hover, .btn-save:hover, .btn-cancel:hover {
|
||||||
filter: brightness(1.05); /* 悬停微亮 */
|
filter: brightness(1.05); /* 悬停微亮 */
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); /* 悬停时投影加深,产生“浮起”感 */
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); /* 悬停时投影加深,产生“浮起”感 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移除 active 缩放,改为颜色反馈 */
|
/* 移除 active 缩放,改为颜色反馈 */
|
||||||
.btn-interect:active, .btn-save:active, .btn-cancel:active {
|
.btn-interect:active, .btn-save:active, .btn-cancel:active {
|
||||||
filter: brightness(0.95); /* 按下稍微变暗 */
|
filter: brightness(0.95); /* 按下稍微变暗 */
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
transform: none; /* 明确去掉缩放 */
|
transform: none; /* 明确去掉缩放 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-save, .btn-cancel {
|
.btn-save, .btn-cancel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. 颜色方案优化 */
|
/* 3. 颜色方案优化 */
|
||||||
.btn-interect, .btn-save {
|
.btn-interect, .btn-save {
|
||||||
background: #1677ff;
|
background: #1677ff;
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid #0958d9; /* 增加深色边框增强质感 */
|
border: 1px solid #0958d9; /* 增加深色边框增强质感 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
background: #ffffff; /* 改为白色底,配合阴影更好看 */
|
background: #ffffff; /* 改为白色底,配合阴影更好看 */
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
border: 1px solid #e5e7eb; /* 浅灰色边框 */
|
border: 1px solid #e5e7eb; /* 浅灰色边框 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-cancel:hover {
|
.btn-cancel:hover {
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 补充:调整按钮组的间距,让阴影不被遮挡 */
|
/* 补充:调整按钮组的间距,让阴影不被遮挡 */
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid #f3f4f6;
|
border-top: 1px solid #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移除所有按钮默认的 outline */
|
/* 移除所有按钮默认的 outline */
|
||||||
.btn-interect:focus,
|
.btn-interect:focus,
|
||||||
.btn-save:focus,
|
.btn-save:focus,
|
||||||
.btn-cancel:focus,
|
.btn-cancel:focus,
|
||||||
.close-btn:focus {
|
.close-btn:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 蓝色按钮的焦点效果 (Save / Interect) */
|
/* 蓝色按钮的焦点效果 (Save / Interect) */
|
||||||
.btn-interect:focus-visible,
|
.btn-interect:focus-visible,
|
||||||
.btn-save:focus-visible {
|
.btn-save:focus-visible {
|
||||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.3); /* 柔和的蓝色光晕 */
|
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.3); /* 柔和的蓝色光晕 */
|
||||||
border-color: #1677ff;
|
border-color: #1677ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 灰色按钮的焦点效果 (Cancel) */
|
/* 灰色按钮的焦点效果 (Cancel) */
|
||||||
.btn-cancel:focus-visible {
|
.btn-cancel:focus-visible {
|
||||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05); /* 极浅的灰色光晕 */
|
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05); /* 极浅的灰色光晕 */
|
||||||
border-color: #d1d5db;
|
border-color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 关闭按钮的焦点效果 */
|
/* 关闭按钮的焦点效果 */
|
||||||
.close-btn:focus-visible {
|
.close-btn:focus-visible {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, ReactNode } from 'react';
|
import { useState, ReactNode } from 'react';
|
||||||
import { NodeData, Settings, NodeDataUpdate } from '../types/graph';
|
import { NodeData, Settings } from '../types/graph';
|
||||||
import { generateWireGuardPrivateKey } from '../utils/wireguardConfig'
|
import { generateWireGuardPrivateKey } from '../utils/wireguardConfig'
|
||||||
import './FormEditor.css';
|
import './NodeEditor.css';
|
||||||
import Folder from './Folder'
|
import Folder from './Folder'
|
||||||
|
|
||||||
|
|
||||||
@ -10,10 +10,15 @@ interface Validation {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateNodeConfig(formData : NodeData) : Validation {
|
||||||
|
// todo
|
||||||
|
return {isValid : true, errors: []}
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeEditorProps {
|
interface NodeEditorProps {
|
||||||
node: NodeData;
|
node: NodeData;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
onUpdate: (data: NodeDataUpdate) => void;
|
onUpdate: (data: NodeData) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,48 +29,36 @@ export default function NodeEditor({
|
|||||||
onClose
|
onClose
|
||||||
}: NodeEditorProps): ReactNode {
|
}: NodeEditorProps): ReactNode {
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<NodeData>(node);
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
const [label, setLabel] = useState<string>(node.label);
|
const handleInputChange = (field: keyof NodeData, value: string): void => {
|
||||||
const [privateKey, setPrivateKey] = useState<string>(node.privateKey);
|
setFormData(prev => ({
|
||||||
const [ipv4Address, setIpv4Address] = useState(node.ipv4Address);
|
...prev,
|
||||||
const [ipv6Address, setIpv6Address] = useState(node.ipv6Address);
|
[field]: value
|
||||||
const [disallowIPs, setDisallowIPs] = useState(node.disallowIPs);
|
}));
|
||||||
const [listenPort, setListenPort] = useState(node.listenPort);
|
};
|
||||||
const [mtu, setmtu] = useState(node.mtu);
|
|
||||||
const [dnsServers, setdnsServers] = useState(node.dnsServers)
|
|
||||||
const [postUp, setPostUp] = useState(node.postUp)
|
|
||||||
const [postDown, setPostDown] = useState(node.postDown)
|
|
||||||
const [notes, setNotes] = useState(node.notes)
|
|
||||||
|
|
||||||
const handleSave = (): void => {
|
const handleSave = (): void => {
|
||||||
// const validation = validateNodeConfig(formData);
|
const validation = validateNodeConfig(formData);
|
||||||
// if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
// setErrors(validation.errors);
|
setErrors(validation.errors);
|
||||||
// return;
|
return;
|
||||||
// }
|
}
|
||||||
setErrors([]);
|
setErrors([]);
|
||||||
onUpdate({
|
onUpdate(formData);
|
||||||
label: label,
|
|
||||||
privateKey: privateKey,
|
|
||||||
ipv4Address: ipv4Address,
|
|
||||||
ipv6Address: ipv6Address,
|
|
||||||
disallowIPs: disallowIPs,
|
|
||||||
postUp: postUp,
|
|
||||||
postDown: postDown,
|
|
||||||
mtu: mtu,
|
|
||||||
listenPort: listenPort,
|
|
||||||
dnsServers: dnsServers,
|
|
||||||
notes: notes
|
|
||||||
});
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateKey = (): void => {
|
||||||
|
handleInputChange('privateKey', generateWireGuardPrivateKey())
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="node-editor-overlay">
|
<div className="node-editor-overlay">
|
||||||
<div className="node-editor">
|
<div className="node-editor">
|
||||||
<div className="editor-header">
|
<div className="editor-header">
|
||||||
<h2>编辑节点: {label}</h2>
|
<h2>编辑节点: {formData.label || '新节点'}</h2>
|
||||||
<button className="close-btn" onClick={onClose}>×</button>
|
<button className="close-btn" onClick={onClose}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -87,8 +80,8 @@ export default function NodeEditor({
|
|||||||
<label>节点名称</label>
|
<label>节点名称</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={label}
|
value={formData.label || ''}
|
||||||
onChange={e => setLabel(e.target.value)}
|
onChange={(e) => handleInputChange('label', e.target.value)}
|
||||||
placeholder="例如: Node-A"
|
placeholder="例如: Node-A"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -97,11 +90,10 @@ export default function NodeEditor({
|
|||||||
<label>私钥</label>
|
<label>私钥</label>
|
||||||
<div className="item-group">
|
<div className="item-group">
|
||||||
<input
|
<input
|
||||||
value={privateKey}
|
value={formData.privateKey || ''}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button className="btn-interect"
|
<button className="btn-interect" onClick={handleGenerateKey}>重新生成</button>
|
||||||
onClick={_ => setPrivateKey(generateWireGuardPrivateKey())}>重新生成</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,8 +102,8 @@ export default function NodeEditor({
|
|||||||
<label>IPv4地址</label>
|
<label>IPv4地址</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={ipv4Address || ''}
|
value={formData.ipv4Address || ''}
|
||||||
onChange={e => setIpv4Address(e.target.value)}
|
onChange={(e) => handleInputChange('ipv4Address', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -121,8 +113,8 @@ export default function NodeEditor({
|
|||||||
<label>IPv6地址</label>
|
<label>IPv6地址</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={ipv6Address || ''}
|
value={formData.ipv6Address || ''}
|
||||||
onChange={e => setIpv6Address(e.target.value)}
|
onChange={(e) => handleInputChange('ipv6Address', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -132,8 +124,8 @@ export default function NodeEditor({
|
|||||||
<label>子网黑名单</label>
|
<label>子网黑名单</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={disallowIPs || ''}
|
value={formData.disallowIPs || ''}
|
||||||
onChange={e => setDisallowIPs(e.target.value)}
|
onChange={(e) => handleInputChange('disallowIPs', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -144,11 +136,8 @@ export default function NodeEditor({
|
|||||||
min="1024"
|
min="1024"
|
||||||
max="49151"
|
max="49151"
|
||||||
step="1"
|
step="1"
|
||||||
value={listenPort || ''}
|
value={formData.listenPort || ''}
|
||||||
onChange={e => {
|
onChange={(e) => handleInputChange('listenPort', e.target.value)}
|
||||||
const value = e.target.valueAsNumber;
|
|
||||||
setListenPort(isNaN(value) ? undefined : value);
|
|
||||||
}}
|
|
||||||
placeholder={`默认值:${settings.listenPort}`}
|
placeholder={`默认值:${settings.listenPort}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -159,11 +148,8 @@ export default function NodeEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
value={mtu || ''}
|
value={formData.mtu || ''}
|
||||||
onChange={e => {
|
onChange={(e) => handleInputChange('mtu', e.target.value)}
|
||||||
const value = e.target.valueAsNumber;
|
|
||||||
setmtu(isNaN(value) ? undefined : value);
|
|
||||||
}}
|
|
||||||
placeholder={settings.mtu ? `默认值:${settings.mtu}` : ''}
|
placeholder={settings.mtu ? `默认值:${settings.mtu}` : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -172,8 +158,8 @@ export default function NodeEditor({
|
|||||||
<label>DNS服务器</label>
|
<label>DNS服务器</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={dnsServers || ''}
|
value={formData.dnsServers || ''}
|
||||||
onChange={(e) => setdnsServers(e.target.value)}
|
onChange={(e) => handleInputChange('dnsServers', e.target.value)}
|
||||||
placeholder="例如: 8.8.8.8,1.1.1.1"
|
placeholder="例如: 8.8.8.8,1.1.1.1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -182,8 +168,8 @@ export default function NodeEditor({
|
|||||||
<label>PostUp</label>
|
<label>PostUp</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
value={postUp || ''}
|
value={formData.postUp || ''}
|
||||||
onChange={(e) => setPostUp(e.target.value)}
|
onChange={(e) => handleInputChange('postUp', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -191,8 +177,8 @@ export default function NodeEditor({
|
|||||||
<label>PostDown</label>
|
<label>PostDown</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
value={postDown || ''}
|
value={formData.postDown || ''}
|
||||||
onChange={(e) => setPostDown(e.target.value)}
|
onChange={(e) => handleInputChange('postDown', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -200,8 +186,8 @@ export default function NodeEditor({
|
|||||||
<label>备注</label>
|
<label>备注</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
value={notes || ''}
|
value={formData.notes || ''}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
@ -11,20 +11,7 @@ export type NodeData = {
|
|||||||
ipv4Address?: string;
|
ipv4Address?: string;
|
||||||
ipv6Address?: string;
|
ipv6Address?: string;
|
||||||
disallowIPs?: string;
|
disallowIPs?: string;
|
||||||
postUp?: string;
|
|
||||||
postDown?: string;
|
|
||||||
mtu?: number;
|
|
||||||
listenPort?: number;
|
|
||||||
dnsServers?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeDataUpdate {
|
|
||||||
label: string;
|
|
||||||
privateKey: string;
|
|
||||||
ipv4Address?: string;
|
|
||||||
ipv6Address?: string;
|
|
||||||
disallowIPs?: string;
|
|
||||||
postUp?: string;
|
postUp?: string;
|
||||||
postDown?: string;
|
postDown?: string;
|
||||||
mtu?: number;
|
mtu?: number;
|
||||||
@ -34,13 +21,8 @@ export interface NodeDataUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type EdgeData = {
|
export type EdgeData = {
|
||||||
readonly id: string;
|
|
||||||
isTwoWayEdge: boolean;
|
isTwoWayEdge: boolean;
|
||||||
persistentKeepalive?: number;
|
persistentKeepalive?: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface EdgeDataUpdate {
|
|
||||||
persistentKeepalive?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SubNetRouter {
|
export class SubNetRouter {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user