feat: 娣诲姞涓绘満閰嶇疆瀵煎叆瀵煎嚭鍔熻兘
This commit is contained in:
parent
a105f6c9a5
commit
97a1b3d7b0
40
README.md
40
README.md
@ -91,6 +91,7 @@
|
||||
| ⌨️ **快捷命令** | 内置常用命令,Ctrl+K 快速调出 |
|
||||
| ☁️ **云端同步** | MySQL 数据库同步,多设备配置共享 |
|
||||
| 🏷️ **分组管理** | 主机分组、颜色标识、便捷管理 |
|
||||
| 📤 **导入导出** | 支持主机配置的 JSON 导入导出,方便备份迁移 |
|
||||
| 📱 **跨平台** | 桌面端和移动端统一体验 |
|
||||
|
||||
---
|
||||
@ -220,6 +221,45 @@ npm run android:run
|
||||
|
||||
---
|
||||
|
||||
## 📤 导入导出
|
||||
|
||||
### 导出主机配置
|
||||
|
||||
1. 打开「主机管理」
|
||||
2. 点击右上角「导出」按钮
|
||||
3. 选择保存位置,生成 JSON 文件
|
||||
|
||||
### 导入主机配置
|
||||
|
||||
1. 打开「主机管理」
|
||||
2. 点击「导入」按钮
|
||||
3. 选择导入模式:
|
||||
- **合并导入**:保留现有主机,更新重复的
|
||||
- **替换导入**:清空现有主机,完全替换
|
||||
4. 选择 JSON 配置文件
|
||||
|
||||
### 配置文件格式
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"appName": "EasyShell",
|
||||
"hosts": [
|
||||
{
|
||||
"name": "生产服务器",
|
||||
"host": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "root",
|
||||
"password": "******",
|
||||
"groupName": "生产环境",
|
||||
"color": "#3fb950"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 数据存储
|
||||
|
||||
60
main.js
60
main.js
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* EasyShell - Electron 主进程
|
||||
*/
|
||||
const { app, BrowserWindow, ipcMain, Menu } = require('electron');
|
||||
const { app, BrowserWindow, ipcMain, Menu, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const Store = require('electron-store');
|
||||
const databaseService = require('./src/services/database');
|
||||
const sshService = require('./src/services/ssh');
|
||||
@ -187,6 +188,63 @@ ipcMain.handle('hosts:delete', async (event, id) => {
|
||||
return await databaseService.deleteHost(id);
|
||||
});
|
||||
|
||||
// 导出主机
|
||||
ipcMain.handle('hosts:export', async () => {
|
||||
try {
|
||||
const data = databaseService.exportHosts();
|
||||
|
||||
// 打开保存对话框
|
||||
const { filePath, canceled } = await dialog.showSaveDialog(mainWindow, {
|
||||
title: '导出主机配置',
|
||||
defaultPath: `easyshell-hosts-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
filters: [
|
||||
{ name: 'JSON 文件', extensions: ['json'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
});
|
||||
|
||||
if (canceled || !filePath) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
return { success: true, filePath, count: data.hosts.length };
|
||||
} catch (error) {
|
||||
console.error('❌ 导出失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 导入主机
|
||||
ipcMain.handle('hosts:import', async (event, mode) => {
|
||||
try {
|
||||
// 打开文件选择对话框
|
||||
const { filePaths, canceled } = await dialog.showOpenDialog(mainWindow, {
|
||||
title: '导入主机配置',
|
||||
filters: [
|
||||
{ name: 'JSON 文件', extensions: ['json'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
],
|
||||
properties: ['openFile']
|
||||
});
|
||||
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
const fileContent = fs.readFileSync(filePaths[0], 'utf-8');
|
||||
const data = JSON.parse(fileContent);
|
||||
|
||||
// 导入数据
|
||||
return databaseService.importHosts(data, mode);
|
||||
} catch (error) {
|
||||
console.error('❌ 导入失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 命令
|
||||
ipcMain.handle('commands:search', (event, keyword) => {
|
||||
return databaseService.searchCommands(keyword);
|
||||
|
||||
@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
add: (host) => ipcRenderer.invoke('hosts:add', host),
|
||||
update: (id, host) => ipcRenderer.invoke('hosts:update', { id, host }),
|
||||
delete: (id) => ipcRenderer.invoke('hosts:delete', id),
|
||||
export: () => ipcRenderer.invoke('hosts:export'),
|
||||
import: (mode) => ipcRenderer.invoke('hosts:import', mode),
|
||||
},
|
||||
|
||||
// 命令
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
FiEye,
|
||||
FiEyeOff,
|
||||
FiPlay,
|
||||
FiDownload,
|
||||
FiUpload,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
const colors = [
|
||||
@ -24,6 +26,9 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importExportResult, setImportExportResult] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
host: '',
|
||||
@ -125,6 +130,75 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
}
|
||||
};
|
||||
|
||||
// 导出主机
|
||||
const handleExport = async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setExporting(true);
|
||||
setImportExportResult(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.hosts.export();
|
||||
if (result.canceled) {
|
||||
setImportExportResult(null);
|
||||
} else if (result.success) {
|
||||
setImportExportResult({
|
||||
type: 'success',
|
||||
message: `成功导出 ${result.count} 个主机配置`
|
||||
});
|
||||
} else {
|
||||
setImportExportResult({
|
||||
type: 'error',
|
||||
message: result.error || '导出失败'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setImportExportResult({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 导入主机
|
||||
const handleImport = async (mode = 'merge') => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setImporting(true);
|
||||
setImportExportResult(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.hosts.import(mode);
|
||||
if (result.canceled) {
|
||||
setImportExportResult(null);
|
||||
} else if (result.success) {
|
||||
let message = `导入完成:新增 ${result.imported} 个`;
|
||||
if (result.updated > 0) message += `,更新 ${result.updated} 个`;
|
||||
if (result.skipped > 0) message += `,跳过 ${result.skipped} 个`;
|
||||
|
||||
setImportExportResult({
|
||||
type: 'success',
|
||||
message
|
||||
});
|
||||
onUpdate(); // 刷新列表
|
||||
} else {
|
||||
setImportExportResult({
|
||||
type: 'error',
|
||||
message: result.error || '导入失败'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setImportExportResult({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@ -150,6 +224,63 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 导入按钮 */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => handleImport('merge')}
|
||||
disabled={importing}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-shell-card border border-shell-border
|
||||
rounded-lg text-sm text-shell-text-dim hover:text-shell-text
|
||||
hover:border-shell-accent/30 disabled:opacity-50 transition-all"
|
||||
title="导入主机配置"
|
||||
>
|
||||
{importing ? (
|
||||
<FiLoader className="animate-spin" size={14} />
|
||||
) : (
|
||||
<FiUpload size={14} />
|
||||
)}
|
||||
<span>导入</span>
|
||||
</button>
|
||||
{/* 下拉菜单 */}
|
||||
<div className="absolute right-0 top-full mt-1 w-40 py-1 bg-shell-surface border border-shell-border
|
||||
rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible
|
||||
transition-all z-10">
|
||||
<button
|
||||
onClick={() => handleImport('merge')}
|
||||
className="w-full px-3 py-2 text-left text-sm text-shell-text-dim hover:text-shell-text
|
||||
hover:bg-shell-card transition-colors"
|
||||
>
|
||||
合并导入
|
||||
<span className="block text-xs text-shell-text-dim/60">保留现有,更新重复</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleImport('replace')}
|
||||
className="w-full px-3 py-2 text-left text-sm text-shell-text-dim hover:text-shell-text
|
||||
hover:bg-shell-card transition-colors"
|
||||
>
|
||||
替换导入
|
||||
<span className="block text-xs text-shell-text-dim/60">清空现有,全部替换</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 导出按钮 */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || hosts.length === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-shell-card border border-shell-border
|
||||
rounded-lg text-sm text-shell-text-dim hover:text-shell-text
|
||||
hover:border-shell-accent/30 disabled:opacity-50 transition-all"
|
||||
title="导出主机配置"
|
||||
>
|
||||
{exporting ? (
|
||||
<FiLoader className="animate-spin" size={14} />
|
||||
) : (
|
||||
<FiDownload size={14} />
|
||||
)}
|
||||
<span>导出</span>
|
||||
</button>
|
||||
<div className="w-px h-6 bg-shell-border mx-1" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
|
||||
@ -157,6 +288,29 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入导出结果提示 */}
|
||||
{importExportResult && (
|
||||
<div className={`mx-6 mt-4 p-3 rounded-lg border ${
|
||||
importExportResult.type === 'success'
|
||||
? 'bg-shell-success/10 border-shell-success/30 text-shell-success'
|
||||
: 'bg-shell-error/10 border-shell-error/30 text-shell-error'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{importExportResult.type === 'success' ? <FiCheck size={16} /> : <FiX size={16} />}
|
||||
<span className="text-sm">{importExportResult.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setImportExportResult(null)}
|
||||
className="p-1 hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<FiX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-[calc(85vh-130px)]">
|
||||
{/* 主机列表 */}
|
||||
|
||||
@ -623,6 +623,112 @@ class DatabaseService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ========== 导入导出方法 ==========
|
||||
|
||||
/**
|
||||
* 导出所有主机信息为 JSON
|
||||
*/
|
||||
exportHosts() {
|
||||
const hosts = this.runQuery('SELECT * FROM hosts ORDER BY group_name, name');
|
||||
|
||||
// 移除敏感信息的内部字段,只保留必要字段
|
||||
const exportData = hosts.map(host => ({
|
||||
name: host.name,
|
||||
host: host.host,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
privateKey: host.private_key,
|
||||
groupName: host.group_name,
|
||||
color: host.color,
|
||||
description: host.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
exportTime: new Date().toISOString(),
|
||||
appName: 'EasyShell',
|
||||
hosts: exportData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入主机信息
|
||||
* @param {Object} data - 导入的 JSON 数据
|
||||
* @param {string} mode - 导入模式: 'merge'(合并) | 'replace'(替换)
|
||||
*/
|
||||
importHosts(data, mode = 'merge') {
|
||||
if (!data || !data.hosts || !Array.isArray(data.hosts)) {
|
||||
return { success: false, error: '无效的导入数据格式' };
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let updated = 0;
|
||||
|
||||
try {
|
||||
// 如果是替换模式,先清空所有主机
|
||||
if (mode === 'replace') {
|
||||
this.sqliteDb.run('DELETE FROM hosts');
|
||||
}
|
||||
|
||||
for (const host of data.hosts) {
|
||||
// 验证必要字段
|
||||
if (!host.name || !host.host || !host.username) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否已存在(以 host 地址为唯一标识)
|
||||
const existing = this.runQuerySingle('SELECT id FROM hosts WHERE host = ?', [host.host]);
|
||||
|
||||
if (existing) {
|
||||
if (mode === 'merge') {
|
||||
// 合并模式:更新已存在的记录
|
||||
this.sqliteDb.run(`
|
||||
UPDATE hosts SET
|
||||
name = ?, port = ?, username = ?, password = ?,
|
||||
private_key = ?, group_name = ?, color = ?, description = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE host = ?
|
||||
`, [
|
||||
host.name, host.port || 22, host.username, host.password || '',
|
||||
host.privateKey || '', host.groupName || '默认分组',
|
||||
host.color || '#58a6ff', host.description || '', host.host
|
||||
]);
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
// 插入新记录
|
||||
this.sqliteDb.run(`
|
||||
INSERT INTO hosts (name, host, port, username, password, private_key, group_name, color, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
host.name, host.host, host.port || 22, host.username,
|
||||
host.password || '', host.privateKey || '',
|
||||
host.groupName || '默认分组', host.color || '#58a6ff',
|
||||
host.description || ''
|
||||
]);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
this.saveDatabase();
|
||||
return {
|
||||
success: true,
|
||||
imported,
|
||||
updated,
|
||||
skipped,
|
||||
total: data.hosts.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ 导入失败:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 关闭数据库 ==========
|
||||
|
||||
close() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user