2026-02-18 15:36:36 +08:00

333 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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