feat: 娣诲姞涓绘満閰嶇疆瀵煎叆瀵煎嚭鍔熻兘
This commit is contained in:
parent
a105f6c9a5
commit
97a1b3d7b0
40
README.md
40
README.md
@ -91,6 +91,7 @@
|
|||||||
| ⌨️ **快捷命令** | 内置常用命令,Ctrl+K 快速调出 |
|
| ⌨️ **快捷命令** | 内置常用命令,Ctrl+K 快速调出 |
|
||||||
| ☁️ **云端同步** | MySQL 数据库同步,多设备配置共享 |
|
| ☁️ **云端同步** | 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 主进程
|
* EasyShell - Electron 主进程
|
||||||
*/
|
*/
|
||||||
const { app, BrowserWindow, ipcMain, Menu } = require('electron');
|
const { app, BrowserWindow, ipcMain, Menu, dialog } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const Store = require('electron-store');
|
const Store = require('electron-store');
|
||||||
const databaseService = require('./src/services/database');
|
const databaseService = require('./src/services/database');
|
||||||
const sshService = require('./src/services/ssh');
|
const sshService = require('./src/services/ssh');
|
||||||
@ -187,6 +188,63 @@ ipcMain.handle('hosts:delete', async (event, id) => {
|
|||||||
return await databaseService.deleteHost(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) => {
|
ipcMain.handle('commands:search', (event, keyword) => {
|
||||||
return databaseService.searchCommands(keyword);
|
return databaseService.searchCommands(keyword);
|
||||||
|
|||||||
@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
add: (host) => ipcRenderer.invoke('hosts:add', host),
|
add: (host) => ipcRenderer.invoke('hosts:add', host),
|
||||||
update: (id, host) => ipcRenderer.invoke('hosts:update', { id, host }),
|
update: (id, host) => ipcRenderer.invoke('hosts:update', { id, host }),
|
||||||
delete: (id) => ipcRenderer.invoke('hosts:delete', id),
|
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,
|
FiEye,
|
||||||
FiEyeOff,
|
FiEyeOff,
|
||||||
FiPlay,
|
FiPlay,
|
||||||
|
FiDownload,
|
||||||
|
FiUpload,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
@ -24,6 +26,9 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState(null);
|
const [testResult, setTestResult] = useState(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importExportResult, setImportExportResult] = useState(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
host: '',
|
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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@ -150,13 +224,93 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={onClose}
|
{/* 导入按钮 */}
|
||||||
className="p-2 rounded-lg hover:bg-shell-card text-shell-text-dim hover:text-shell-text transition-colors"
|
<div className="relative group">
|
||||||
>
|
<button
|
||||||
<FiX size={20} />
|
onClick={() => handleImport('merge')}
|
||||||
</button>
|
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"
|
||||||
|
>
|
||||||
|
<FiX size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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)]">
|
<div className="flex h-[calc(85vh-130px)]">
|
||||||
{/* 主机列表 */}
|
{/* 主机列表 */}
|
||||||
|
|||||||
@ -623,6 +623,112 @@ class DatabaseService {
|
|||||||
return { success: true };
|
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() {
|
close() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user