Compare commits

...

2 Commits

Author SHA1 Message Date
limil
1edb3474c5 优化更新表单逻辑 2026-02-07 23:49:24 +08:00
limil
098259177b 完成边的编辑窗口 2026-02-07 22:54:48 +08:00
8 changed files with 428 additions and 314 deletions

View File

@ -1,16 +1 @@
# React + Vite # Wireguard 组网可视化编辑器
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.

View File

@ -1,7 +1,7 @@
## 产品基本功能实现待办事项 ## 产品基本功能实现待办事项
- [x] 完成节点的编辑窗口 - [x] 完成节点的编辑窗口
- [ ] 完成边的编辑窗口 - [x] 完成边的编辑窗口
- [ ] 完成全局设置编辑窗口以及相关联动 - [ ] 完成全局设置编辑窗口以及相关联动
- [ ] 实现配置生成逻辑,并验证有效 - [ ] 实现配置生成逻辑,并验证有效
- [ ] 实现配置保存和加载功能 - [ ] 实现配置保存和加载功能

View File

@ -14,12 +14,14 @@ 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, Settings } from './types/graph'; import { AppNode, AppEdge, NodeData, EdgeData, 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';
@ -50,7 +52,8 @@ 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 | null>(null); const [editingNode, setEditingNode] = useState<NodeData | undefined>(undefined);
const [editingEdge, setEditingEdge] = useState<EdgeData | undefined>(undefined);
const [enableTwoWay, setEnableTwoWay] = useState(false); const [enableTwoWay, setEnableTwoWay] = useState(false);
const onNodesChange = useCallback( const onNodesChange = useCallback(
@ -63,12 +66,14 @@ 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: `e-${crypto.randomUUID()}`, id: id,
animated: !enableTwoWay, animated: !enableTwoWay,
markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed }, markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed },
data : { data : {
id: id,
isTwoWayEdge: enableTwoWay isTwoWayEdge: enableTwoWay
} }
} }
@ -80,6 +85,11 @@ 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) {
@ -117,7 +127,19 @@ function FlowContent(): ReactNode {
return node; return node;
}) })
); );
setEditingNode(null); setEditingNode(undefined);
};
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 (
@ -129,6 +151,7 @@ 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
@ -168,10 +191,19 @@ function FlowContent(): ReactNode {
<NodeEditor <NodeEditor
node={editingNode} node={editingNode}
onUpdate={handleUpdateNode} onUpdate={handleUpdateNode}
onClose={() => setEditingNode(null)} onClose={() => setEditingNode(undefined)}
settings={settings} settings={settings}
/> />
)} )}
{editingEdge && (
<EdgeEditor
edge={editingEdge}
onUpdate={handleUpdateEdge}
onClose={() => setEditingEdge(undefined)}
/>
)}
</div> </div>
); );
} }

View File

@ -12,9 +12,6 @@ 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) => (

View File

@ -0,0 +1,68 @@
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>
);
}

View File

@ -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;
} }

View File

@ -1,7 +1,7 @@
import { useState, ReactNode } from 'react'; import { useState, ReactNode } from 'react';
import { NodeData, Settings } from '../types/graph'; import { NodeData, Settings, NodeDataUpdate } from '../types/graph';
import { generateWireGuardPrivateKey } from '../utils/wireguardConfig' import { generateWireGuardPrivateKey } from '../utils/wireguardConfig'
import './NodeEditor.css'; import './FormEditor.css';
import Folder from './Folder' import Folder from './Folder'
@ -10,15 +10,10 @@ 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: NodeData) => void; onUpdate: (data: NodeDataUpdate) => void;
onClose: () => void; onClose: () => void;
} }
@ -29,36 +24,48 @@ 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 handleInputChange = (field: keyof NodeData, value: string): void => { const [label, setLabel] = useState<string>(node.label);
setFormData(prev => ({ const [privateKey, setPrivateKey] = useState<string>(node.privateKey);
...prev, const [ipv4Address, setIpv4Address] = useState(node.ipv4Address);
[field]: value const [ipv6Address, setIpv6Address] = useState(node.ipv6Address);
})); 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(formData); onUpdate({
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>: {formData.label || '新节点'}</h2> <h2>: {label}</h2>
<button className="close-btn" onClick={onClose}>×</button> <button className="close-btn" onClick={onClose}>×</button>
</div> </div>
@ -80,8 +87,8 @@ export default function NodeEditor({
<label></label> <label></label>
<input <input
type="text" type="text"
value={formData.label || ''} value={label}
onChange={(e) => handleInputChange('label', e.target.value)} onChange={e => setLabel(e.target.value)}
placeholder="例如: Node-A" placeholder="例如: Node-A"
/> />
</div> </div>
@ -90,10 +97,11 @@ export default function NodeEditor({
<label></label> <label></label>
<div className="item-group"> <div className="item-group">
<input <input
value={formData.privateKey || ''} value={privateKey}
readOnly readOnly
/> />
<button className="btn-interect" onClick={handleGenerateKey}></button> <button className="btn-interect"
onClick={_ => setPrivateKey(generateWireGuardPrivateKey())}></button>
</div> </div>
</div> </div>
@ -102,8 +110,8 @@ export default function NodeEditor({
<label>IPv4地址</label> <label>IPv4地址</label>
<input <input
type="text" type="text"
value={formData.ipv4Address || ''} value={ipv4Address || ''}
onChange={(e) => handleInputChange('ipv4Address', e.target.value)} onChange={e => setIpv4Address(e.target.value)}
/> />
</div> </div>
)} )}
@ -113,8 +121,8 @@ export default function NodeEditor({
<label>IPv6地址</label> <label>IPv6地址</label>
<input <input
type="text" type="text"
value={formData.ipv6Address || ''} value={ipv6Address || ''}
onChange={(e) => handleInputChange('ipv6Address', e.target.value)} onChange={e => setIpv6Address(e.target.value)}
/> />
</div> </div>
)} )}
@ -124,8 +132,8 @@ export default function NodeEditor({
<label></label> <label></label>
<input <input
type="text" type="text"
value={formData.disallowIPs || ''} value={disallowIPs || ''}
onChange={(e) => handleInputChange('disallowIPs', e.target.value)} onChange={e => setDisallowIPs(e.target.value)}
/> />
</div> </div>
@ -136,8 +144,11 @@ export default function NodeEditor({
min="1024" min="1024"
max="49151" max="49151"
step="1" step="1"
value={formData.listenPort || ''} value={listenPort || ''}
onChange={(e) => handleInputChange('listenPort', e.target.value)} onChange={e => {
const value = e.target.valueAsNumber;
setListenPort(isNaN(value) ? undefined : value);
}}
placeholder={`默认值:${settings.listenPort}`} placeholder={`默认值:${settings.listenPort}`}
/> />
</div> </div>
@ -148,8 +159,11 @@ export default function NodeEditor({
type="number" type="number"
min="1" min="1"
step="1" step="1"
value={formData.mtu || ''} value={mtu || ''}
onChange={(e) => handleInputChange('mtu', e.target.value)} onChange={e => {
const value = e.target.valueAsNumber;
setmtu(isNaN(value) ? undefined : value);
}}
placeholder={settings.mtu ? `默认值:${settings.mtu}` : ''} placeholder={settings.mtu ? `默认值:${settings.mtu}` : ''}
/> />
</div> </div>
@ -158,8 +172,8 @@ export default function NodeEditor({
<label>DNS服务器</label> <label>DNS服务器</label>
<input <input
type="text" type="text"
value={formData.dnsServers || ''} value={dnsServers || ''}
onChange={(e) => handleInputChange('dnsServers', e.target.value)} onChange={(e) => setdnsServers(e.target.value)}
placeholder="例如: 8.8.8.8,1.1.1.1" placeholder="例如: 8.8.8.8,1.1.1.1"
/> />
</div> </div>
@ -168,8 +182,8 @@ export default function NodeEditor({
<label>PostUp</label> <label>PostUp</label>
<textarea <textarea
rows={2} rows={2}
value={formData.postUp || ''} value={postUp || ''}
onChange={(e) => handleInputChange('postUp', e.target.value)} onChange={(e) => setPostUp(e.target.value)}
/> />
</div> </div>
@ -177,8 +191,8 @@ export default function NodeEditor({
<label>PostDown</label> <label>PostDown</label>
<textarea <textarea
rows={2} rows={2}
value={formData.postDown || ''} value={postDown || ''}
onChange={(e) => handleInputChange('postDown', e.target.value)} onChange={(e) => setPostDown(e.target.value)}
/> />
</div> </div>
@ -186,8 +200,8 @@ export default function NodeEditor({
<label></label> <label></label>
<textarea <textarea
rows={4} rows={4}
value={formData.notes || ''} value={notes || ''}
onChange={(e) => handleInputChange('notes', e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
</div> </div>
</Folder> </Folder>

View File

@ -11,7 +11,20 @@ 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;
@ -21,8 +34,13 @@ export type NodeData = {
} }
export type EdgeData = { export type EdgeData = {
readonly id: string;
isTwoWayEdge: boolean; isTwoWayEdge: boolean;
persistentKeepalive?: string; persistentKeepalive?: number;
}
export interface EdgeDataUpdate {
persistentKeepalive?: number;
} }
export class SubNetRouter { export class SubNetRouter {