Compare commits

..

2 Commits

Author SHA1 Message Date
limil
cf66dfec5a 添加Toast 2026-02-11 00:54:33 +08:00
limil
653b20ed61 优化节点外观 2026-02-11 00:46:46 +08:00
10 changed files with 135 additions and 78 deletions

View File

@ -12,4 +12,4 @@
- [ ] 使用Reducer 和 Immer重构代码 - [ ] 使用Reducer 和 Immer重构代码
- [ ] 实现加密功能(完全加密和只加密私钥) - [ ] 实现加密功能(完全加密和只加密私钥)
- [ ] 添加测试用例 - [ ] 添加测试用例
- [ ] 完成! - [ ] 体验调优:生成配置完成后弹出预览窗口可以下载或者复制

31
package-lock.json generated
View File

@ -12,6 +12,7 @@
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"tweetnacl": "^1.0.3" "tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {
@ -1811,8 +1812,8 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "license": "MIT",
"license": "MIT" "peer": true
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
@ -2343,6 +2344,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -2790,6 +2800,23 @@
"react": "^19.2.3" "react": "^19.2.3"
} }
}, },
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@ -14,6 +14,7 @@
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"tweetnacl": "^1.0.3" "tweetnacl": "^1.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

@ -18,7 +18,7 @@ import {
EdgeMouseHandler EdgeMouseHandler
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { AppNode, AppEdge, NodeData, EdgeData, NodeDataUpdate, EdgeDataUpdate, Settings } from './types/graph'; import { AppNode, AppEdge, NodeData, EdgeData, NodeDataUpdate, EdgeDataUpdate, Settings, initialSettings, SettingsContext } 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 EdgeEditor from './components/EdgeEditor'
@ -26,13 +26,10 @@ import SettingsEditor from './components/SettingsEditor'
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';
import { Toaster } from 'react-hot-toast';
const initialNodes: AppNode[] = []; const initialNodes: AppNode[] = [];
const initialEdges: AppEdge[] = []; const initialEdges: AppEdge[] = [];
const initialSettings : Settings = {
listenPort: 38894,
mtu: 1420,
};
const nodeTypes : NodeTypes = { const nodeTypes : NodeTypes = {
custom: CustomNode, custom: CustomNode,
@ -147,30 +144,33 @@ function FlowContent(): ReactNode {
return ( return (
<div style={{ width: '100vw', height: '100vh' }}> <div style={{ width: '100vw', height: '100vh' }}>
<ReactFlow <Toaster/>
nodes={nodes} <SettingsContext value={settings}>
edges={edges} <ReactFlow
onNodesChange={onNodesChange} nodes={nodes}
onEdgesChange={onEdgesChange} edges={edges}
onConnect={onConnect} onNodesChange={onNodesChange}
onNodeDoubleClick={onNodeClick} onEdgesChange={onEdgesChange}
onEdgeDoubleClick={onEdgeClick} onConnect={onConnect}
nodeTypes={nodeTypes} onNodeDoubleClick={onNodeClick}
deleteKeyCode={["Delete"]} onEdgeDoubleClick={onEdgeClick}
fitView nodeTypes={nodeTypes}
isValidConnection={validateConnection} deleteKeyCode={["Delete"]}
> fitView
<Background /> isValidConnection={validateConnection}
<Controls /> >
<MiniMap <Background />
nodeColor={(n) => { <Controls />
if (n.type === 'input') return 'blue'; <MiniMap
return '#eee'; nodeColor={(n) => {
}} if (n.type === 'input') return 'blue';
maskColor="rgba(0, 0, 0, 0.1)" return '#eee';
position="bottom-right" // 也可以是 top-right 等 }}
/> maskColor="rgba(0, 0, 0, 0.1)"
</ReactFlow> position="bottom-right" // 也可以是 top-right 等
/>
</ReactFlow>
</SettingsContext>
<div className="toolbar"> <div className="toolbar">
<div className="toolbar-group"> <div className="toolbar-group">

View File

@ -2,9 +2,9 @@
background: white; background: white;
border: 2px solid #ddd; border: 2px solid #ddd;
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 10px;
min-width: 150px; min-width: 150px;
max-width: 240px; /* 限制节点不要太长 */ max-width: 300px; /* 限制节点不要太长 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@ -30,26 +30,43 @@
.node-header { .node-header {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
align-items: center; margin-bottom: 4px;
margin-bottom: 8px;
gap: 8px;
} }
.node-label { .node-label {
font-weight: 600; font-weight: 600;
color: #333; color: #333;
font-size: 12px; /* smaller title font */ font-size: 12px; /* smaller title font */
flex: 0 0 100px; /* 固定宽度 100px给按钮留更多空间 */ flex: 0 0 120px; /* 固定宽度 100px给按钮留更多空间 */
min-width: 0; /* allow truncation inside flex */ min-width: 0; /* allow truncation inside flex */
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: center;
} }
.node-info { .node-info {
font-size: 12px; font-size: 10px;
color: #666; border-bottom: 1px solid #eee;
margin-bottom: 4px;
}
.info-item {
margin-bottom: 4px;
}
.info-item .label {
color: #999;
}
.info-item .value {
color: #333;
}
.node-actions {
display: flex;
gap: 10px;
} }
.gen-btn { .gen-btn {
@ -59,10 +76,11 @@
border-radius: 4px; border-radius: 4px;
padding: 1px 6px; /* 更小的按钮内边距 */ padding: 1px 6px; /* 更小的按钮内边距 */
cursor: pointer; cursor: pointer;
font-size: 10px; /* 更小的文字 */ font-size: 8px; /* 更小的文字 */
line-height: 1; line-height: 1;
height: 20px; height: 20px;
flex-shrink: 0; flex-shrink: 0;
flex: 1;
opacity: 0.95; opacity: 0.95;
transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease, opacity 120ms ease; transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease, opacity 120ms ease;
outline: none; /* remove default focus outline */ outline: none; /* remove default focus outline */
@ -72,7 +90,6 @@
opacity: 1; opacity: 1;
} }
.gen-btn:focus { .gen-btn:focus {
outline: none; outline: none;
} }
@ -82,19 +99,3 @@
background-color: #0f62d6; /* slightly darker on press */ background-color: #0f62d6; /* slightly darker on press */
box-shadow: inset 0 1px 2px rgba(0,0,0,0.12); box-shadow: inset 0 1px 2px rgba(0,0,0,0.12);
} }
.info-item {
display: flex;
gap: 4px;
margin-bottom: 2px;
}
.info-item .label {
font-weight: 500;
color: #999;
}
.info-item .value {
color: #333;
font-family: monospace;
}

View File

@ -1,7 +1,8 @@
import { ReactNode } from 'react'; import { ReactNode, useContext } from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react'; import { Handle, Position, NodeProps } from '@xyflow/react';
import { AppNode } from '../types/graph'; import { AppNode, SettingsContext } from '../types/graph';
import './CustomNode.css'; import './CustomNode.css';
import toast from 'react-hot-toast';
export default function CustomNode({ export default function CustomNode({
data, data,
@ -9,13 +10,41 @@ export default function CustomNode({
}: NodeProps<AppNode>): ReactNode { }: NodeProps<AppNode>): ReactNode {
const handleGenerate = (e: React.MouseEvent) => { const handleGenerate = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
toast.success('保存成功!');
}; };
const settings = useContext(SettingsContext);
return ( return (
<div className={`custom-node ${selected ? 'selected' : ''}`}> <div className={`custom-node ${selected ? 'selected' : ''}`}>
<div className="node-header"> <div className="node-header">
<span className="node-label" title={data.label}>{data.label}</span> <label className="node-label">{data.label}</label>
<button className="gen-btn" onClick={handleGenerate} onDoubleClick={e => e.stopPropagation()}></button> </div>
<div className='node-info'>
{settings.ipv4Subnet && (
<div className="info-item">
<span className="label">IPv4地址</span>
<span className="value">{data.ipv4Address || "未设置"}</span>
</div>
)}
{settings.ipv6Subnet && (
<div className="info-item">
<span className="label">IPv6地址</span>
<span className="value">{data.ipv6Address || "未设置"}</span>
</div>
)}
{(!settings.ipv4Subnet && !settings.ipv6Subnet) && (
<div className="info-item">
<span className="label"></span>
</div>
)}
</div>
<div className="node-actions">
<button className="gen-btn" onClick={handleGenerate} onDoubleClick={e => e.stopPropagation()}></button>
</div> </div>
{[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => ( {[Position.Top, Position.Bottom, Position.Right, Position.Left].map((position) => (

View File

@ -160,10 +160,6 @@
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; /* 稍微拉长过渡,显得更平滑 */ transition: all 0.2s ease; /* 稍微拉长过渡,显得更平滑 */
display: flex;
align-items: 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);
} }

View File

@ -27,18 +27,14 @@ function Validate(updateData : NodeDataUpdate, settings : Settings) : string[] {
if(ipv4Subnet) { if(ipv4Subnet) {
const cidr = IPNetwork.parse(ipv4Subnet); const cidr = IPNetwork.parse(ipv4Subnet);
if(!ipv4Address) { if(ipv4Address && !cidr.contains(IPNetwork.parse(`${ipv4Address}/32`))) {
errors.push("需要设置IPv4地址");
} else if(!cidr.contains(IPNetwork.parse(`${ipv4Address}/32`))) {
errors.push("IPv4不在子网范围中"); errors.push("IPv4不在子网范围中");
} }
} }
if(ipv6Subnet) { if(ipv6Subnet) {
const cidr = IPNetwork.parse(ipv6Subnet); const cidr = IPNetwork.parse(ipv6Subnet);
if(!ipv6Address) { if(ipv6Address && !cidr.contains(IPNetwork.parse(`${ipv6Address}/128`))) {
errors.push("需要设置IPv6地址");
} else if(!cidr.contains(IPNetwork.parse(`${ipv6Address}/128`))) {
errors.push("IPv6不在子网范围中"); errors.push("IPv6不在子网范围中");
} }
} }

View File

@ -17,7 +17,7 @@ function Validate(updateData: Settings) : string[] {
if(ipv4Subnet) { if(ipv4Subnet) {
const result = IPNetwork.parse(ipv4Subnet) const result = IPNetwork.parse(ipv4Subnet)
if(!result.isValid) { if(!result.isValid) {
errors.push("IPv4子网" + (result.error ?? "ipv4子网不合法")) errors.push("IPv4子网" + (result.error || "ipv4子网不合法"))
} else if(result.version != 'IPv4') { } else if(result.version != 'IPv4') {
errors.push("IPv4子网" + "非IPv4 CIDR"); errors.push("IPv4子网" + "非IPv4 CIDR");
} }
@ -26,7 +26,7 @@ function Validate(updateData: Settings) : string[] {
if(ipv6Subnet) { if(ipv6Subnet) {
const result = IPNetwork.parse(ipv6Subnet) const result = IPNetwork.parse(ipv6Subnet)
if(!result.isValid) { if(!result.isValid) {
errors.push("IPv6子网" + (result.error ?? "子网不合法")); errors.push("IPv6子网" + (result.error || "子网不合法"));
} else if(result.version != 'IPv6') { } else if(result.version != 'IPv6') {
errors.push("IPv6子网" + "非IPv6 CIDR"); errors.push("IPv6子网" + "非IPv6 CIDR");
} }

View File

@ -39,3 +39,10 @@ export interface Settings {
ipv4Subnet?: string; ipv4Subnet?: string;
ipv6Subnet?: string; ipv6Subnet?: string;
} }
export const initialSettings : Settings = {
listenPort: 38894,
mtu: 1420,
};
export const SettingsContext = createContext<Settings>(initialSettings);