From 855d0c8c280fa9aef140ea30480b05ca6f56aaba Mon Sep 17 00:00:00 2001 From: limil Date: Wed, 18 Feb 2026 14:18:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=A4=E4=BA=92=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.css | 17 ++++++ src/App.tsx | 83 ++++++++++++--------------- src/components/SaveLoadPanel.css | 70 +++++++++++++++++++++++ src/components/SaveLoadPanel.tsx | 97 ++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 src/components/SaveLoadPanel.css create mode 100644 src/components/SaveLoadPanel.tsx diff --git a/src/App.css b/src/App.css index 3cab1cd..c15673c 100644 --- a/src/App.css +++ b/src/App.css @@ -91,4 +91,21 @@ gap: 8px; font-size: 14px; 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; } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e5cc475..5c09b17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import './App.css'; import toast, { Toaster } from 'react-hot-toast'; import SaveConfig from './types/saveConfig' import { plainToInstance, instanceToPlain } from 'class-transformer'; +import SaveLoadPanel from './components/SaveLoadPanel'; const initialNodes: AppNode[] = []; @@ -147,16 +148,15 @@ function FlowContent(): ReactNode { ); }; - const handleSaveConfig = async (): Promise => { + const handleSaveConfig = async (pass?: string): Promise => { try { - const pass = window.prompt('输入用于加密私钥的密码(留空则不加密):'); const saveConfig: SaveConfig = {settings: settings, nodes: [], edges: [], encrypted: false} if(pass) saveConfig.encrypted = true; nodes.forEach(node => { const nodeData = node.data; 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}}); }); @@ -177,35 +177,25 @@ function FlowContent(): ReactNode { } }; - const handleLoadConfig = async (): Promise => { + const handleLoadConfig = async (fileText: string, pass?: string): Promise => { try { - const input = document.createElement('input'); - input.type = 'file'; - 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) { - const pass = window.prompt('输入用于解密私钥的密码:'); - if (!pass) { toast.error('需要密码以解密私钥'); return; } - try { - for(let node of saveConfig.nodes) { - node.data.privateKey = CryptoJS.AES.decrypt(node.data.privateKey, pass).toString(CryptoJS.enc.Utf8); - } - } catch (err) { - toast.error('解密失败: ' + err); - return; + const plainObject = JSON.parse(fileText); + const saveConfig: SaveConfig = plainToInstance(SaveConfig, plainObject); + + if (saveConfig.encrypted) { + if (!pass) { toast.error('需要密码以解密私钥'); return; } + try { + for(let node of saveConfig.nodes) { + node.data.privateKey = CryptoJS.AES.decrypt(node.data.privateKey, pass).toString(CryptoJS.enc.Utf8); } + } catch (err) { + toast.error('解密失败: ' + err); + return; } - setSettings(saveConfig.settings); - setNodes(saveConfig.nodes); - setEdges(saveConfig.edges); - }; - input.click(); + } + setSettings(saveConfig.settings); + setNodes(saveConfig.nodes); + setEdges(saveConfig.edges); } catch (e) { toast.error('加载失败: ' + e); } @@ -243,27 +233,26 @@ function FlowContent(): ReactNode {
- setEnableTwoWay(checked)} >双向连接 +
+
文件操作
+ +
- +
+
图表操作
+ setEnableTwoWay(checked)} >双向连接 - - - - - + + +
diff --git a/src/components/SaveLoadPanel.css b/src/components/SaveLoadPanel.css new file mode 100644 index 0000000..a2279c9 --- /dev/null +++ b/src/components/SaveLoadPanel.css @@ -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} + diff --git a/src/components/SaveLoadPanel.tsx b/src/components/SaveLoadPanel.tsx new file mode 100644 index 0000000..74752ec --- /dev/null +++ b/src/components/SaveLoadPanel.tsx @@ -0,0 +1,97 @@ +import React, { useState, useRef } from 'react'; +import './SaveLoadPanel.css'; + +interface Props { + onSave: (pass?: string) => Promise; + onLoad: (fileText: string, pass?: string) => Promise; +} + +export default function SaveLoadPanel({ onSave, onLoad }: Props) { + const [modalType, setModalType] = useState<'save' | 'load' | null>(null); + const [password, setPassword] = useState(''); + const [pendingFileText, setPendingFileText] = useState(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 ( +
+ + + + {modalType && ( +
+
+
+
+
{modalType === 'save' ? '保存配置' : '加载配置'}
+ +
+
+ + setPassword(e.target.value)} /> + + {modalType === 'load' ? ( +
+
检测到加密文件,请输入密码后点击确认加载
+
+ +
+
+ ) : ( +
+ +
+ )} +
+
+
+ )} +
+ ); +}