优化交互界面

This commit is contained in:
limil 2026-02-18 14:18:11 +08:00
parent 88fa8d3ad0
commit 855d0c8c28
4 changed files with 220 additions and 47 deletions

View File

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

View File

@ -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>

View 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}

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