优化交互界面
This commit is contained in:
parent
88fa8d3ad0
commit
855d0c8c28
17
src/App.css
17
src/App.css
@ -92,3 +92,20 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-right{
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section{
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:8px;
|
||||||
|
padding:8px 12px;
|
||||||
|
}
|
||||||
|
.section-title{
|
||||||
|
font-size:12px;
|
||||||
|
color:#666;
|
||||||
|
font-weight:600;
|
||||||
|
margin-bottom:4px;
|
||||||
|
}
|
||||||
81
src/App.tsx
81
src/App.tsx
@ -31,6 +31,7 @@ import './App.css';
|
|||||||
import toast, { Toaster } from 'react-hot-toast';
|
import toast, { Toaster } from 'react-hot-toast';
|
||||||
import SaveConfig from './types/saveConfig'
|
import SaveConfig from './types/saveConfig'
|
||||||
import { plainToInstance, instanceToPlain } from 'class-transformer';
|
import { plainToInstance, instanceToPlain } from 'class-transformer';
|
||||||
|
import SaveLoadPanel from './components/SaveLoadPanel';
|
||||||
|
|
||||||
|
|
||||||
const initialNodes: AppNode[] = [];
|
const initialNodes: AppNode[] = [];
|
||||||
@ -147,16 +148,15 @@ function FlowContent(): ReactNode {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConfig = async (): Promise<void> => {
|
const handleSaveConfig = async (pass?: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const pass = window.prompt('输入用于加密私钥的密码(留空则不加密):');
|
|
||||||
const saveConfig: SaveConfig = {settings: settings, nodes: [], edges: [], encrypted: false}
|
const saveConfig: SaveConfig = {settings: settings, nodes: [], edges: [], encrypted: false}
|
||||||
if(pass) saveConfig.encrypted = true;
|
if(pass) saveConfig.encrypted = true;
|
||||||
|
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const nodeData = node.data;
|
const nodeData = node.data;
|
||||||
let privateKey = nodeData.privateKey;
|
let privateKey = nodeData.privateKey;
|
||||||
if(pass) privateKey = CryptoJS.AES.encrypt(privateKey, pass).toString();
|
if(pass && typeof privateKey === 'string') privateKey = CryptoJS.AES.encrypt(privateKey, pass).toString();
|
||||||
saveConfig.nodes.push({...node, data: {...nodeData, privateKey: privateKey}});
|
saveConfig.nodes.push({...node, data: {...nodeData, privateKey: privateKey}});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -177,35 +177,25 @@ function FlowContent(): ReactNode {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadConfig = async (): Promise<void> => {
|
const handleLoadConfig = async (fileText: string, pass?: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const input = document.createElement('input');
|
const plainObject = JSON.parse(fileText);
|
||||||
input.type = 'file';
|
const saveConfig: SaveConfig = plainToInstance(SaveConfig, plainObject);
|
||||||
input.accept = 'application/json';
|
|
||||||
input.onchange = async () => {
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
const jsonString = await file.text();
|
|
||||||
const plainObject = JSON.parse(jsonString);
|
|
||||||
const saveConfig: SaveConfig = plainToInstance(SaveConfig, plainObject);
|
|
||||||
|
|
||||||
if (saveConfig.encrypted) {
|
if (saveConfig.encrypted) {
|
||||||
const pass = window.prompt('输入用于解密私钥的密码:');
|
if (!pass) { toast.error('需要密码以解密私钥'); return; }
|
||||||
if (!pass) { toast.error('需要密码以解密私钥'); return; }
|
try {
|
||||||
try {
|
for(let node of saveConfig.nodes) {
|
||||||
for(let node of saveConfig.nodes) {
|
node.data.privateKey = CryptoJS.AES.decrypt(node.data.privateKey, pass).toString(CryptoJS.enc.Utf8);
|
||||||
node.data.privateKey = CryptoJS.AES.decrypt(node.data.privateKey, pass).toString(CryptoJS.enc.Utf8);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('解密失败: ' + err);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('解密失败: ' + err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setSettings(saveConfig.settings);
|
}
|
||||||
setNodes(saveConfig.nodes);
|
setSettings(saveConfig.settings);
|
||||||
setEdges(saveConfig.edges);
|
setNodes(saveConfig.nodes);
|
||||||
};
|
setEdges(saveConfig.edges);
|
||||||
input.click();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('加载失败: ' + e);
|
toast.error('加载失败: ' + e);
|
||||||
}
|
}
|
||||||
@ -243,27 +233,26 @@ function FlowContent(): ReactNode {
|
|||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<Toggle
|
<div className="action-section">
|
||||||
className="toolbar-item"
|
<div className="section-title">文件操作</div>
|
||||||
checked = {enableTwoWay}
|
<SaveLoadPanel onSave={handleSaveConfig} onLoad={handleLoadConfig} />
|
||||||
onChange={checked => setEnableTwoWay(checked)} >双向连接</Toggle>
|
</div>
|
||||||
|
|
||||||
<button className="toolbar-btn" onClick={handleAddNode} title="添加新节点">
|
<div className="action-section">
|
||||||
➕ 添加节点
|
<div className="section-title">图表操作</div>
|
||||||
</button>
|
<Toggle
|
||||||
|
className="toolbar-item"
|
||||||
|
checked = {enableTwoWay}
|
||||||
|
onChange={checked => setEnableTwoWay(checked)} >双向连接</Toggle>
|
||||||
|
|
||||||
<button className="toolbar-btn" onClick={() => {setEditSettings(true);}} title="设置">
|
<button className="toolbar-btn" onClick={handleAddNode} title="添加新节点">
|
||||||
📋 设置
|
➕ 添加节点
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="toolbar-btn" onClick={handleSaveConfig} title="保存配置">
|
|
||||||
💾 保存配置
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="toolbar-btn" onClick={handleLoadConfig} title="加载配置">
|
|
||||||
📂 加载配置
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
<button className="toolbar-btn" onClick={() => {setEditSettings(true);}} title="设置">
|
||||||
|
📋 设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
70
src/components/SaveLoadPanel.css
Normal file
70
src/components/SaveLoadPanel.css
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
.save-load-panel{
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
align-items:stretch;
|
||||||
|
gap:8px;
|
||||||
|
padding:8px 0;
|
||||||
|
width:auto;
|
||||||
|
}
|
||||||
|
.panel-section{
|
||||||
|
display:flex;
|
||||||
|
flex-direction:column;
|
||||||
|
gap:6px;
|
||||||
|
padding:8px;
|
||||||
|
border:1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius:6px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
.panel-title{
|
||||||
|
font-size:12px;
|
||||||
|
color:#666;
|
||||||
|
}
|
||||||
|
.panel-input{
|
||||||
|
padding:6px 8px;
|
||||||
|
font-size:13px;
|
||||||
|
border:1px solid #ddd;
|
||||||
|
border-radius:4px;
|
||||||
|
}
|
||||||
|
/* Use .toolbar-btn for button styles so Save/Load matches other toolbar actions */
|
||||||
|
.save-load-panel .toolbar-btn{width:100%;text-align:left;padding:8px 16px;border-radius:4px}
|
||||||
|
/* divider removed to match toolbar style */
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.sl-modal{
|
||||||
|
position:fixed;
|
||||||
|
inset:0;
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:center;
|
||||||
|
z-index:200;
|
||||||
|
}
|
||||||
|
.sl-modal-backdrop{
|
||||||
|
position:absolute;
|
||||||
|
inset:0;
|
||||||
|
background:rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.sl-modal-content{
|
||||||
|
position:relative;
|
||||||
|
width:360px;
|
||||||
|
background:#fff;
|
||||||
|
border-radius:8px;
|
||||||
|
box-shadow:0 8px 24px rgba(0,0,0,0.2);
|
||||||
|
z-index:210;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.sl-modal-header{
|
||||||
|
display:flex;
|
||||||
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
padding:12px 16px;
|
||||||
|
border-bottom:1px solid #eee;
|
||||||
|
}
|
||||||
|
.sl-modal-title{font-weight:600}
|
||||||
|
.sl-close{background:none;border:none;cursor:pointer;font-size:16px}
|
||||||
|
.sl-modal-body{padding:12px 16px;display:flex;flex-direction:column;gap:8px}
|
||||||
|
.sl-label{font-size:12px;color:#555}
|
||||||
|
.sl-input{padding:8px;border:1px solid #ddd;border-radius:4px}
|
||||||
|
.sl-file-input{padding:6px}
|
||||||
|
.sl-hint{font-size:12px;color:#777;margin-top:6px}
|
||||||
|
.sl-actions{display:flex;gap:8px;justify-content:flex-end}
|
||||||
|
|
||||||
97
src/components/SaveLoadPanel.tsx
Normal file
97
src/components/SaveLoadPanel.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import './SaveLoadPanel.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSave: (pass?: string) => Promise<void>;
|
||||||
|
onLoad: (fileText: string, pass?: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SaveLoadPanel({ onSave, onLoad }: Props) {
|
||||||
|
const [modalType, setModalType] = useState<'save' | 'load' | null>(null);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [pendingFileText, setPendingFileText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openSave = () => {
|
||||||
|
setPassword('');
|
||||||
|
setModalType('save');
|
||||||
|
};
|
||||||
|
const openLoad = async () => {
|
||||||
|
// immediately open file selector
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'application/json';
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(text);
|
||||||
|
if (obj && obj.encrypted) {
|
||||||
|
// need password to decrypt
|
||||||
|
setPendingFileText(text);
|
||||||
|
setPassword('');
|
||||||
|
setModalType('load');
|
||||||
|
} else {
|
||||||
|
await onLoad(text, undefined);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// if invalid json, still try to load
|
||||||
|
await onLoad(text, undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalType(null);
|
||||||
|
setPassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSave = async () => {
|
||||||
|
await onSave(password || undefined);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmLoad = async () => {
|
||||||
|
if (!pendingFileText) return;
|
||||||
|
await onLoad(pendingFileText, password || undefined);
|
||||||
|
setPendingFileText(null);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="save-load-panel">
|
||||||
|
<button className="toolbar-btn" onClick={openSave}>💾 保存</button>
|
||||||
|
<button className="toolbar-btn" onClick={openLoad}>📂 加载</button>
|
||||||
|
|
||||||
|
{modalType && (
|
||||||
|
<div className="sl-modal">
|
||||||
|
<div className="sl-modal-backdrop" onClick={closeModal} />
|
||||||
|
<div className="sl-modal-content">
|
||||||
|
<div className="sl-modal-header">
|
||||||
|
<div className="sl-modal-title">{modalType === 'save' ? '保存配置' : '加载配置'}</div>
|
||||||
|
<button className="sl-close" onClick={closeModal}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="sl-modal-body">
|
||||||
|
<label className="sl-label">加密密码(可选)</label>
|
||||||
|
<input type="password" className="sl-input" value={password} onChange={e => setPassword(e.target.value)} />
|
||||||
|
|
||||||
|
{modalType === 'load' ? (
|
||||||
|
<div className="sl-load-row">
|
||||||
|
<div className="sl-hint">检测到加密文件,请输入密码后点击确认加载</div>
|
||||||
|
<div className="sl-actions">
|
||||||
|
<button className="toolbar-btn" onClick={confirmLoad}>确认加载</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sl-actions">
|
||||||
|
<button className="toolbar-btn" onClick={confirmSave}>确认保存</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user