333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
import { useState, useCallback, ReactNode } from 'react';
|
||
import {
|
||
ReactFlow,
|
||
applyNodeChanges,
|
||
applyEdgeChanges,
|
||
addEdge,
|
||
Background,
|
||
Controls,
|
||
ReactFlowProvider,
|
||
NodeTypes,
|
||
NodeChange,
|
||
EdgeChange,
|
||
MarkerType,
|
||
NodeMouseHandler,
|
||
OnConnect,
|
||
MiniMap,
|
||
IsValidConnection,
|
||
EdgeMouseHandler
|
||
} from '@xyflow/react';
|
||
import '@xyflow/react/dist/style.css';
|
||
import { AppNode, AppEdge, NodeData, EdgeData, NodeDataUpdate, EdgeDataUpdate } from './types/graph';
|
||
import {Settings, SettingsContext} from './types/settings'
|
||
import CustomNode from './components/CustomNode';
|
||
import NodeEditor from './components/NodeEditor';
|
||
import EdgeEditor from './components/EdgeEditor'
|
||
import SettingsEditor from './components/SettingsEditor'
|
||
import Toggle from "./components/Toggle"
|
||
import { generateWireGuardPrivateKey } from './utils/wireguardConfig';
|
||
import CryptoJS from 'crypto-js';
|
||
import './App.css';
|
||
import toast, { Toaster } from 'react-hot-toast';
|
||
import SaveConfig from './types/saveConfig'
|
||
import { plainToInstance, instanceToPlain } from 'class-transformer';
|
||
import SaveLoadPanel from './components/SaveLoadPanel';
|
||
|
||
|
||
const initialNodes: AppNode[] = [];
|
||
const initialEdges: AppEdge[] = [];
|
||
|
||
const nodeTypes : NodeTypes = {
|
||
custom: CustomNode,
|
||
};
|
||
|
||
function generateNodeData(count: number) : NodeData | null {
|
||
const privateKey = generateWireGuardPrivateKey();
|
||
const node : NodeData = {
|
||
id: `n-${crypto.randomUUID()}`,
|
||
label: `Node-${count + 1}`,
|
||
privateKey: privateKey
|
||
}
|
||
return node
|
||
}
|
||
|
||
function FlowContent(): ReactNode {
|
||
const [nodes, setNodes] = useState<AppNode[]>(initialNodes);
|
||
const [edges, setEdges] = useState<AppEdge[]>(initialEdges);
|
||
const [settings, setSettings] = useState<Settings>(new Settings());
|
||
|
||
const [editingNode, setEditingNode] = useState<NodeData | undefined>(undefined);
|
||
const [editingEdge, setEditingEdge] = useState<EdgeData | undefined>(undefined);
|
||
const [enableTwoWay, setEnableTwoWay] = useState(false);
|
||
const [editSettings, setEditSettings] = useState<boolean>(false);
|
||
|
||
const onNodesChange = useCallback(
|
||
(changes: NodeChange<AppNode>[]) => setNodes((nds) => applyNodeChanges<AppNode>(changes, nds)),
|
||
[]);
|
||
|
||
const onEdgesChange = useCallback(
|
||
(changes : EdgeChange<AppEdge>[]) => setEdges((eds) => applyEdgeChanges<AppEdge>(changes, eds)),
|
||
[]);
|
||
|
||
const onConnect = useCallback<OnConnect>(
|
||
(params) => {
|
||
const id = `e-${crypto.randomUUID()}`
|
||
const newEdge : AppEdge = {
|
||
...params,
|
||
id: id,
|
||
animated: !enableTwoWay,
|
||
markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed },
|
||
data : {
|
||
id: id,
|
||
isTwoWayEdge: enableTwoWay
|
||
}
|
||
}
|
||
return setEdges((eds) => addEdge<AppEdge>(newEdge, eds));
|
||
},
|
||
[enableTwoWay]);
|
||
|
||
const onNodeClick = useCallback<NodeMouseHandler<AppNode>>(
|
||
(_event, node) => setEditingNode(node.data),
|
||
[]);
|
||
|
||
const onEdgeClick = useCallback<EdgeMouseHandler<AppEdge>>(
|
||
(_event, edge) => setEditingEdge(edge.data),
|
||
[]
|
||
)
|
||
|
||
const validateConnection = useCallback<IsValidConnection>(
|
||
(connection) => {
|
||
if (connection.source === connection.target) {
|
||
return false;
|
||
}
|
||
const isDuplicate = edges.some(
|
||
(edge) => (
|
||
(edge.source === connection.source && edge.target === connection.target) ||
|
||
(edge.source === connection.target && edge.target === connection.source)
|
||
)
|
||
);
|
||
return !isDuplicate;
|
||
},
|
||
[edges]);
|
||
|
||
const handleAddNode = (): void => {
|
||
const result = generateNodeData(nodes.length);
|
||
if(!result) return;
|
||
const newNode: AppNode = {
|
||
id: result.id,
|
||
position: { x: 0, y: 0 },
|
||
data: result,
|
||
type: 'custom',
|
||
selected: true
|
||
};
|
||
setNodes((prev) => [...prev.map(node => ({ ...node, selected: false })), newNode]);
|
||
};
|
||
|
||
const handleUpdateNode = (updatedData: NodeDataUpdate): void => {
|
||
setNodes((prev) =>
|
||
prev.map((node) => {
|
||
if (node.data.id === editingNode?.id) {
|
||
return {
|
||
...node,
|
||
data: {...node.data, ...updatedData}
|
||
}
|
||
}
|
||
return node;
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleUpdateEdge = (updatedData: EdgeDataUpdate): void => {
|
||
setEdges((prev) =>
|
||
prev.map((edge) => {
|
||
if (edge.data && edge.data.id === editingEdge?.id) {
|
||
edge.data = {...edge.data, ...updatedData}
|
||
}
|
||
return edge;
|
||
})
|
||
);
|
||
};
|
||
|
||
const handleSaveConfig = async (pass?: string): Promise<void> => {
|
||
try {
|
||
const saveConfig: SaveConfig = {settings: settings, nodes: [], edges: [], encrypted: false}
|
||
|
||
nodes.forEach(node => {
|
||
const nodeData = node.data;
|
||
let privateKey: string = nodeData.privateKey;
|
||
if(pass) {
|
||
privateKey = CryptoJS.AES.encrypt(privateKey, pass).toString();
|
||
saveConfig.encrypted = true;
|
||
}
|
||
saveConfig.nodes.push({...node, data: {...nodeData, privateKey: privateKey}});
|
||
});
|
||
|
||
edges.forEach(edge => {
|
||
saveConfig.edges.push(edge);
|
||
});
|
||
|
||
const json = instanceToPlain(saveConfig);
|
||
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
|
||
const lastNameKey = 'wg-last-filename';
|
||
const suggestedName = (localStorage.getItem(lastNameKey) || 'wg-config.json');
|
||
|
||
// Use File System Access API when available to show save file picker
|
||
if ((window as any).showSaveFilePicker) {
|
||
try {
|
||
const handle = await (window as any).showSaveFilePicker({
|
||
suggestedName: suggestedName,
|
||
types: [{
|
||
description: 'WireGuard Config',
|
||
accept: { 'application/json': ['.json'] }
|
||
}]
|
||
});
|
||
const writable = await handle.createWritable();
|
||
await writable.write(blob);
|
||
await writable.close();
|
||
// remember chosen name if available
|
||
try {
|
||
const name = handle.name || suggestedName;
|
||
localStorage.setItem(lastNameKey, name);
|
||
} catch (e) {
|
||
localStorage.setItem(lastNameKey, suggestedName);
|
||
}
|
||
} catch (e) {
|
||
// If user cancels or error, fallback to anchor download
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = suggestedName;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
} else {
|
||
// Fallback: create an anchor and trigger download, using last used filename
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = suggestedName;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
localStorage.setItem(lastNameKey, suggestedName);
|
||
}
|
||
} catch (e) {
|
||
toast.error('保存失败: ' + e);
|
||
}
|
||
};
|
||
|
||
const handleLoadConfig = async (fileText: string, pass?: string): Promise<void> => {
|
||
try {
|
||
const plainObject = JSON.parse(fileText);
|
||
const saveConfig: SaveConfig = plainToInstance(SaveConfig, plainObject);
|
||
|
||
if (saveConfig.encrypted) {
|
||
if (!pass) { toast.error('需要密码以解密私钥'); return; }
|
||
for(let node of saveConfig.nodes) {
|
||
const privateKey = CryptoJS.AES.decrypt(node.data.privateKey, pass).toString(CryptoJS.enc.Utf8);
|
||
if(!privateKey) {
|
||
toast.error('密码错误');
|
||
return;
|
||
}
|
||
node.data.privateKey = privateKey;
|
||
}
|
||
}
|
||
setSettings(saveConfig.settings);
|
||
setNodes(saveConfig.nodes);
|
||
setEdges(saveConfig.edges);
|
||
} catch (e) {
|
||
toast.error('加载失败: ' + e);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ width: '100vw', height: '100vh' }}>
|
||
<Toaster/>
|
||
<SettingsContext value={settings}>
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
onConnect={onConnect}
|
||
onNodeDoubleClick={onNodeClick}
|
||
onEdgeDoubleClick={onEdgeClick}
|
||
nodeTypes={nodeTypes}
|
||
deleteKeyCode={["Delete"]}
|
||
fitView
|
||
isValidConnection={validateConnection}
|
||
>
|
||
<Background />
|
||
<Controls />
|
||
<MiniMap
|
||
nodeColor={(n) => {
|
||
if (n.type === 'input') return 'blue';
|
||
return '#eee';
|
||
}}
|
||
maskColor="rgba(0, 0, 0, 0.1)"
|
||
position="bottom-right" // 也可以是 top-right 等
|
||
/>
|
||
</ReactFlow>
|
||
</SettingsContext>
|
||
|
||
<div className="toolbar">
|
||
<div className="toolbar-group">
|
||
<div className="action-section">
|
||
<div className="section-title">图表操作</div>
|
||
<Toggle
|
||
className="toolbar-item"
|
||
checked = {enableTwoWay}
|
||
onChange={checked => setEnableTwoWay(checked)} >双向连接</Toggle>
|
||
|
||
<button className="toolbar-btn" onClick={handleAddNode} title="添加新节点">
|
||
➕ 添加节点
|
||
</button>
|
||
|
||
<button className="toolbar-btn" onClick={() => {setEditSettings(true);}} title="设置">
|
||
📋 设置
|
||
</button>
|
||
</div>
|
||
|
||
<div className="action-section">
|
||
<div className="section-title">文件操作</div>
|
||
<SaveLoadPanel onSave={handleSaveConfig} onLoad={handleLoadConfig} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{editingNode && (
|
||
<NodeEditor
|
||
node={editingNode}
|
||
onUpdate={handleUpdateNode}
|
||
onClose={() => setEditingNode(undefined)}
|
||
settings={settings}
|
||
/>
|
||
)}
|
||
|
||
{editingEdge && (
|
||
<EdgeEditor
|
||
edge={editingEdge}
|
||
onUpdate={handleUpdateEdge}
|
||
onClose={() => setEditingEdge(undefined)}
|
||
/>
|
||
)}
|
||
|
||
{editSettings && (
|
||
<SettingsEditor
|
||
settings={settings}
|
||
onUpdate={settingsUpdate => {setSettings(settingsUpdate)}}
|
||
onClose={() => setEditSettings(false)}
|
||
/>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function App(): ReactNode {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<FlowContent />
|
||
</ReactFlowProvider>
|
||
);
|
||
}
|