优化交互和外观

This commit is contained in:
limil 2026-01-30 12:13:09 +08:00
parent 97dd6e6900
commit 6a45efffe9
5 changed files with 102 additions and 58 deletions

View File

@ -1,8 +1,10 @@
#root { #root {
max-width: 1280px; width: 100vw;
margin: 0 auto; height: 100vh;
padding: 2rem; margin: 0;
text-align: center; padding: 0;
max-width: none; /* 必须取消最大宽度限制 */
text-align: left; /* 取消居中对齐,否则 toolbar 里的文字可能会乱 */
} }
.logo { .logo {
@ -53,6 +55,7 @@
.toolbar-group { .toolbar-group {
display: flex; display: flex;
flex-direction: column;
gap: 8px; gap: 8px;
background: white; background: white;
padding: 10px; padding: 10px;
@ -82,3 +85,10 @@
transform: scale(0.95); transform: scale(0.95);
} }
.toolbar-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #333;
}

View File

@ -9,9 +9,12 @@ import {
ReactFlowProvider, ReactFlowProvider,
NodeTypes, NodeTypes,
NodeChange, NodeChange,
OnEdgesChange, EdgeChange,
MarkerType, MarkerType,
NodeMouseHandler,
OnConnect, OnConnect,
MiniMap,
IsValidConnection
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { AppNode, AppEdge, NodeData } from './types/graph'; import { AppNode, AppEdge, NodeData } from './types/graph';
@ -21,23 +24,9 @@ import ConfigViewer from './components/ConfigViewer';
import Toggle from "./components/Toggle" import Toggle from "./components/Toggle"
import './App.css'; import './App.css';
const initialNodes: AppNode[] = [ const initialNodes: AppNode[] = [];
{
id: 'n1',
position: { x: 250, y: 50 },
data: {
label: 'Node-A',
ipAddress: '10.0.0.1',
listenPort: '51820',
privateKey: '',
publicKey: '',
},
type: 'custom',
},
];
const initialEdges: AppEdge[] = []; const initialEdges: AppEdge[] = [];
const nodeTypes : NodeTypes = { const nodeTypes : NodeTypes = {
custom: CustomNode, custom: CustomNode,
}; };
@ -47,31 +36,55 @@ function FlowContent(): ReactNode {
const [edges, setEdges] = useState<AppEdge[]>(initialEdges); const [edges, setEdges] = useState<AppEdge[]>(initialEdges);
const [editingNode, setEditingNode] = useState<NodeData | null>(null); const [editingNode, setEditingNode] = useState<NodeData | null>(null);
const [showConfigViewer, setShowConfigViewer] = useState(false); const [showConfigViewer, setShowConfigViewer] = useState(false);
const [enableTwoWay, setEnableTwoWay] = useState(false);
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange<AppNode>[]) => setNodes((nds) => applyNodeChanges<AppNode>(changes, nds)), (changes: NodeChange<AppNode>[]) => setNodes((nds) => applyNodeChanges<AppNode>(changes, nds)),
[], []);
);
const onEdgesChange = useCallback<OnEdgesChange>( const onEdgesChange = useCallback(
(changes) => setEdges((edgesSnapshot) => applyEdgeChanges(changes, edgesSnapshot)), (changes : EdgeChange<AppEdge>[]) => setEdges((eds) => applyEdgeChanges<AppEdge>(changes, eds)),
[], []);
);
const onConnect = useCallback<OnConnect>( const onConnect = useCallback<OnConnect>(
(params) => setEdges((edgesSnapshot) => addEdge(params, edgesSnapshot)), (params) => {
[], const newEdge : AppEdge = {
); ...params,
id: `e-${Date.now()}`,
animated: !enableTwoWay,
markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed },
data : {
isTwoWayEdge: enableTwoWay
}
}
return setEdges((eds) => addEdge<AppEdge>(newEdge, eds));
},
[enableTwoWay]);
const onNodeClick = useCallback((_event: React.MouseEvent, node: AppNode) => { const onNodeClick = useCallback<NodeMouseHandler<AppNode>>(
setEditingNode(node.data); (_event, node) => setEditingNode(node.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 handleAddNode = (): void => {
const newNodeId = `n${Date.now()}`; const newNodeId = `n${Date.now()}`;
const newNode: AppNode = { const newNode: AppNode = {
id: newNodeId, id: newNodeId,
position: { x: Math.random() * 500, y: Math.random() * 500 }, position: { x: 0, y: 0 },
data: { data: {
label: `Node-${String.fromCharCode(65 + (nodes.length % 26))}`, label: `Node-${String.fromCharCode(65 + (nodes.length % 26))}`,
ipAddress: `10.0.0.${nodes.length + 2}`, ipAddress: `10.0.0.${nodes.length + 2}`,
@ -80,8 +93,9 @@ function FlowContent(): ReactNode {
publicKey: '', publicKey: '',
}, },
type: 'custom', type: 'custom',
selected: true
}; };
setNodes((prev) => [...prev, newNode]); setNodes((prev) => [...prev.map(node => ({ ...node, selected: false })), newNode]);
}; };
const handleUpdateNode = (updatedData: NodeData): void => { const handleUpdateNode = (updatedData: NodeData): void => {
@ -107,28 +121,36 @@ function FlowContent(): ReactNode {
onNodeDoubleClick={onNodeClick} onNodeDoubleClick={onNodeClick}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
deleteKeyCode={["Delete"]} deleteKeyCode={["Delete"]}
defaultEdgeOptions={{
animated: true,
markerEnd: { type: MarkerType.ArrowClosed }
}}
fitView fitView
isValidConnection={validateConnection}
> >
<Background /> <Background />
<Controls /> <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> </ReactFlow>
<div className="toolbar"> <div className="toolbar">
<div className="toolbar-group"> <div className="toolbar-group">
<Toggle
className="toolbar-item"
checked = {enableTwoWay}
onChange={checked => setEnableTwoWay(checked)} ></Toggle>
<button className="toolbar-btn" onClick={handleAddNode} title="添加新节点"> <button className="toolbar-btn" onClick={handleAddNode} title="添加新节点">
</button> </button>
<button
className="toolbar-btn" <button className="toolbar-btn" onClick={() => setShowConfigViewer(true)} title="查看配置">
onClick={() => setShowConfigViewer(true)}
title="查看和下载WireGuard配置"
>
📋 📋
</button> </button>
</div> </div>
</div> </div>

View File

@ -30,7 +30,7 @@ export default function CustomNode({
{[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) => (
<Handle type={type} position={position} id={position} key={position} className="node-handle"/> <Handle type={type} position={position} id={position} key={`${type}-${position}`} className="node-handle"/>
)) ))
))} ))}
</div> </div>

View File

@ -3,19 +3,26 @@ import "./Toggle.css"
interface ToggleProps { interface ToggleProps {
checked: boolean; checked: boolean;
onChange: (checked: boolean) => void; onChange?: (checked: boolean) => void;
name?: string; name?: string;
children?: React.ReactNode;
className?: string,
} }
const Toggle : React.FC<ToggleProps> = ({ const Toggle : React.FC<ToggleProps> = ({
checked, checked,
onChange, onChange,
name name,
children,
className = ""
}) => { }) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.checked); onChange?.(e.target.checked);
}; };
return ( return (
<div className={className}>
{children && <span>{children}</span>}
<label className="switch"> <label className="switch">
<input <input
type="checkbox" type="checkbox"
@ -25,6 +32,7 @@ const Toggle : React.FC<ToggleProps> = ({
/> />
<span className="slider round"></span> <span className="slider round"></span>
</label> </label>
</div>
) )
} }

View File

@ -2,7 +2,7 @@ import { Node, Edge } from '@xyflow/react';
export type AppNode = Node<NodeData>; export type AppNode = Node<NodeData>;
export type AppEdge = Edge; export type AppEdge = Edge<EdgeData>;
export type NodeData = { export type NodeData = {
label: string; label: string;
@ -14,3 +14,7 @@ export type NodeData = {
dnsServers?: string; dnsServers?: string;
persistentKeepalive?: string; persistentKeepalive?: string;
} }
export type EdgeData = {
isTwoWayEdge: boolean
}