优化节点编辑的外观
This commit is contained in:
parent
0c5668838f
commit
6cf0af4528
38
src/App.tsx
38
src/App.tsx
@ -27,42 +27,20 @@ import './App.css';
|
||||
const initialNodes: AppNode[] = [];
|
||||
const initialEdges: AppEdge[] = [];
|
||||
const initialSettings : Settings = {
|
||||
v4SubNetPrefix: [172, 29],
|
||||
listenPort: 38894,
|
||||
mtu: 1420,
|
||||
};
|
||||
|
||||
export function generateEdgeId() : string {
|
||||
return `e-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export function generateNodeId() : string {
|
||||
return `n-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const nodeTypes : NodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
function getFirstAvailableId(ids: number[]) : number {
|
||||
const idSet = new Set(ids);
|
||||
let id = 1;
|
||||
while (idSet.has(id)) {
|
||||
id++;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function generateNodeData(nodes : NodeData[]) : NodeData | null {
|
||||
const hostId = getFirstAvailableId(nodes.filter(n => n.groupId == 0).map(n => n.hostId))
|
||||
if(hostId > 255) return null
|
||||
|
||||
function generateNodeData(count: number) : NodeData | null {
|
||||
const privateKey = generateWireGuardPrivateKey();
|
||||
|
||||
const node : NodeData = {
|
||||
label: `Node-${hostId}`,
|
||||
privateKey: privateKey,
|
||||
groupId: 0,
|
||||
hostId: hostId,
|
||||
id: `n-${crypto.randomUUID()}`,
|
||||
label: `Node-${count + 1}`,
|
||||
privateKey: privateKey
|
||||
}
|
||||
return node
|
||||
}
|
||||
@ -87,7 +65,7 @@ function FlowContent(): ReactNode {
|
||||
(params) => {
|
||||
const newEdge : AppEdge = {
|
||||
...params,
|
||||
id: `e-${Date.now()}`,
|
||||
id: `e-${crypto.randomUUID()}`,
|
||||
animated: !enableTwoWay,
|
||||
markerEnd: enableTwoWay ? undefined : { type: MarkerType.ArrowClosed },
|
||||
data : {
|
||||
@ -118,10 +96,10 @@ function FlowContent(): ReactNode {
|
||||
[edges]);
|
||||
|
||||
const handleAddNode = (): void => {
|
||||
const result = generateNodeData(nodes.map(n => n.data));
|
||||
const result = generateNodeData(nodes.length);
|
||||
if(result == null) return;
|
||||
const newNode: AppNode = {
|
||||
id: generateNodeId(),
|
||||
id: result.id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: result,
|
||||
type: 'custom',
|
||||
|
||||
66
src/components/Folder.css
Normal file
66
src/components/Folder.css
Normal file
@ -0,0 +1,66 @@
|
||||
.folder-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #ffffff;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* 悬停微阴影 */
|
||||
.folder-item:hover {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.folder-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: #f9fafb;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
|
||||
/* 核心修改:禁止选中标题文字 */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.folder-header:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 箭头旋转动画 */
|
||||
.arrow {
|
||||
font-size: 12px;
|
||||
transition: transform 0.3s ease;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.show-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 核心动画逻辑 */
|
||||
.folder-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.folder-content.show {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.content-inner {
|
||||
min-height: 0;
|
||||
padding: 0 16px; /* 默认隐藏时 padding 也是 0 */
|
||||
transition: padding 0.3s;
|
||||
}
|
||||
|
||||
.show .content-inner {
|
||||
padding: 12px 16px; /* 展开后增加内边距 */
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
27
src/components/Folder.tsx
Normal file
27
src/components/Folder.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useState, ReactNode } from 'react';
|
||||
import './Folder.css';
|
||||
|
||||
interface FolderProps {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Folder({ title, children }: FolderProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="folder-item">
|
||||
<div className="folder-header" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span>{title}</span>
|
||||
{/* 使用同一个字符配合旋转动画,视觉更连贯 */}
|
||||
<span className={`arrow ${isOpen ? 'show-arrow' : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className={`folder-content ${isOpen ? 'show' : ''}`}>
|
||||
<div className="content-inner">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -56,11 +56,44 @@
|
||||
}
|
||||
|
||||
.error-box {
|
||||
position: relative;
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
|
||||
animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both;
|
||||
}
|
||||
|
||||
.error-close-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #ff4d4f;
|
||||
font-size: 16px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
user-select: none; /* 禁止选中 */
|
||||
}
|
||||
|
||||
.error-close-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 77, 79, 0.1); /* 悬停微红 */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@ -118,41 +151,90 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* 1. 基础通用样式 */
|
||||
/* 1. 基础通用样式 - 强化了投影和圆角 */
|
||||
.btn-interect, .btn-save, .btn-cancel {
|
||||
padding: 8px 16px; /* 补充了间距,确保按钮有厚度 */
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px; /* 稍微圆润一点,更现代 */
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease-in-out;
|
||||
transition: all 0.2s ease; /* 稍微拉长过渡,显得更平滑 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* 基础阴影 */
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 2. 统一交互反馈:悬停变深,点下缩放 */
|
||||
/* 2. 统一交互反馈:悬停增强阴影,移除缩放 */
|
||||
.btn-interect:hover, .btn-save:hover, .btn-cancel:hover {
|
||||
filter: brightness(0.9); /* 自动变深,无需指定具体颜色 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
filter: brightness(1.05); /* 悬停微亮 */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); /* 悬停时投影加深,产生“浮起”感 */
|
||||
}
|
||||
|
||||
/* 移除 active 缩放,改为颜色反馈 */
|
||||
.btn-interect:active, .btn-save:active, .btn-cancel:active {
|
||||
transform: scale(0.96);
|
||||
filter: brightness(0.95); /* 按下稍微变暗 */
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transform: none; /* 明确去掉缩放 */
|
||||
}
|
||||
|
||||
.btn-save, .btn-cancel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 3. 颜色方案优化 */
|
||||
.btn-interect, .btn-save {
|
||||
background: #1677ff;
|
||||
color: white;
|
||||
border: 1px solid #0958d9; /* 增加深色边框增强质感 */
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
background: #ffffff; /* 改为白色底,配合阴影更好看 */
|
||||
color: #4b5563;
|
||||
border: 1px solid #e5e7eb; /* 浅灰色边框 */
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #f9fafb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 补充:调整按钮组的间距,让阴影不被遮挡 */
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* 移除所有按钮默认的 outline */
|
||||
.btn-interect:focus,
|
||||
.btn-save:focus,
|
||||
.btn-cancel:focus,
|
||||
.close-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 蓝色按钮的焦点效果 (Save / Interect) */
|
||||
.btn-interect:focus-visible,
|
||||
.btn-save:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.3); /* 柔和的蓝色光晕 */
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
/* 灰色按钮的焦点效果 (Cancel) */
|
||||
.btn-cancel:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.05); /* 极浅的灰色光晕 */
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 关闭按钮的焦点效果 */
|
||||
.close-btn:focus-visible {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@ -2,8 +2,8 @@ import { useState, ReactNode } from 'react';
|
||||
import { NodeData, Settings } from '../types/graph';
|
||||
import { generateWireGuardPrivateKey } from '../utils/wireguardConfig'
|
||||
import './NodeEditor.css';
|
||||
|
||||
|
||||
import Folder from './Folder'
|
||||
import { randomBytes } from 'tweetnacl';
|
||||
|
||||
interface NodeEditorProps {
|
||||
node: NodeData;
|
||||
@ -18,6 +18,7 @@ export default function NodeEditor({
|
||||
onUpdate,
|
||||
onClose
|
||||
}: NodeEditorProps): ReactNode {
|
||||
|
||||
const [formData, setFormData] = useState<NodeData>(node);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
@ -29,6 +30,7 @@ export default function NodeEditor({
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
setErrors(["123"])
|
||||
// const validation = validateNodeConfig(formData);
|
||||
// if (!validation.isValid) {
|
||||
// setErrors(validation.errors);
|
||||
@ -54,6 +56,12 @@ export default function NodeEditor({
|
||||
|
||||
{errors.length > 0 && (
|
||||
<div className="error-box">
|
||||
<div
|
||||
className="error-close-btn"
|
||||
onClick={() => setErrors([])} // 点击清空错误数组
|
||||
title="关闭提示"
|
||||
>×</div>
|
||||
|
||||
{errors.map((error, idx) => (
|
||||
<p key={idx} className="error-message">• {error}</p>
|
||||
))}
|
||||
@ -81,95 +89,100 @@ export default function NodeEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>主机ID</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
step="1"
|
||||
value={formData.hostId}
|
||||
onChange={(e) => handleInputChange('hostId', e.target.value)}
|
||||
placeholder="同一子网ID下主机ID不能重复,不得超过255"
|
||||
/>
|
||||
</div>
|
||||
{settings.ipv4Subnet && (
|
||||
<div className="form-group">
|
||||
<label>IPv4地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.ipv4Address || ''}
|
||||
onChange={(e) => handleInputChange('ipv4Address', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>子网ID</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="255"
|
||||
step="1"
|
||||
value={formData.groupId}
|
||||
onChange={(e) => handleInputChange('groupId', e.target.value)}
|
||||
placeholder="不得超过255"
|
||||
/>
|
||||
</div>
|
||||
{settings.ipv6Subnet && (
|
||||
<div className="form-group">
|
||||
<label>IPv6地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.ipv6Address || ''}
|
||||
onChange={(e) => handleInputChange('ipv6Address', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* host options */}
|
||||
<Folder title='高级'>
|
||||
<div className="form-group">
|
||||
<label>子网黑名单</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.disallowIPs || ''}
|
||||
onChange={(e) => handleInputChange('disallowIPs', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>PostUp (可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.postUp || ''}
|
||||
onChange={(e) => handleInputChange('postUp', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>侦听端口</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1024"
|
||||
max="49151"
|
||||
step="1"
|
||||
value={formData.listenPort || ''}
|
||||
onChange={(e) => handleInputChange('listenPort', e.target.value)}
|
||||
placeholder={`默认值:${settings.listenPort}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>PostDown (可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.postDown || ''}
|
||||
onChange={(e) => handleInputChange('postDown', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>mtu</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={formData.mtu || ''}
|
||||
onChange={(e) => handleInputChange('mtu', e.target.value)}
|
||||
placeholder={settings.mtu ? `默认值:${settings.mtu}` : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>mtu (可选)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={formData.mtu || ''}
|
||||
onChange={(e) => handleInputChange('mtu', e.target.value)}
|
||||
placeholder={settings.mtu ? `默认值:${settings.mtu}` : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>DNS服务器</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dnsServers || ''}
|
||||
onChange={(e) => handleInputChange('dnsServers', e.target.value)}
|
||||
placeholder="例如: 8.8.8.8,1.1.1.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>PostUp</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.postUp || ''}
|
||||
onChange={(e) => handleInputChange('postUp', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>侦听端口 (可选)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1024"
|
||||
max="49151"
|
||||
step="1"
|
||||
value={formData.listenPort || ''}
|
||||
onChange={(e) => handleInputChange('listenPort', e.target.value)}
|
||||
placeholder={`默认值:${settings.listenPort}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>PostDown</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={formData.postDown || ''}
|
||||
onChange={(e) => handleInputChange('postDown', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>DNS服务器 (可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.dnsServers || ''}
|
||||
onChange={(e) => handleInputChange('dnsServers', e.target.value)}
|
||||
placeholder="例如: 8.8.8.8,1.1.1.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>备注 (可选)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>备注</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Folder>
|
||||
|
||||
<div className="editor-actions">
|
||||
<button className="btn-save" onClick={handleSave}>保存</button>
|
||||
|
||||
@ -4,16 +4,8 @@ export type AppNode = Node<NodeData>;
|
||||
|
||||
export type AppEdge = Edge<EdgeData>;
|
||||
|
||||
export class SubNetRouter {
|
||||
private _nodes : Record<string, string | undefined> = {};
|
||||
|
||||
constructor(
|
||||
public subnet: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export type NodeData = {
|
||||
id: string;
|
||||
readonly id: string;
|
||||
label: string;
|
||||
privateKey: string;
|
||||
ipv4Address?: string;
|
||||
@ -28,18 +20,27 @@ export type NodeData = {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type EdgeData = {
|
||||
isTwoWayEdge: boolean;
|
||||
persistentKeepalive?: string;
|
||||
}
|
||||
|
||||
export class SubNetRouter {
|
||||
private _nodes : Record<string, string | undefined> = {};
|
||||
|
||||
constructor(
|
||||
public subnet: string,
|
||||
public readonly kind: 'ipv4' | 'ipv6'
|
||||
) {}
|
||||
}
|
||||
|
||||
export type Settings = {
|
||||
listenPort: number;
|
||||
mtu: number;
|
||||
|
||||
ipv4Subnet?: string;
|
||||
ipv4SubnetRouter?: SubNetRouter;
|
||||
ipv4SubNetRouter?: SubNetRouter;
|
||||
|
||||
ipv6Subnet?: string;
|
||||
ipv6SubnetRouter?: SubNetRouter;
|
||||
}
|
||||
|
||||
export type EdgeData = {
|
||||
isTwoWayEdge: boolean;
|
||||
persistentKeepalive?: string;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user