feat: 娣诲姞涓绘満閰嶇疆瀵煎叆瀵煎嚭鍔熻兘

This commit is contained in:
Ethanfly 2025-12-29 14:38:13 +08:00
parent a105f6c9a5
commit 97a1b3d7b0
5 changed files with 367 additions and 7 deletions

View File

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

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

View File

@ -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),
},
// 命令

View File

@ -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,14 +224,94 @@ function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
</span>
)}
</div>
<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 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"
>
<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)]">
{/* 主机列表 */}
<div className="w-80 border-r border-shell-border overflow-y-auto custom-scrollbar">

View File

@ -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() {