easyshell/src/components/HostManager.js
Ethanfly c0fe5b3321 Implement SFTP functionality and enhance UI/UX
- Added SFTP file management capabilities including list, upload, download, delete, and directory operations.
- Integrated SFTP progress callbacks to provide real-time feedback during file transfers.
- Updated the UI to include a dedicated SFTP browser and host information panel.
- Enhanced the sidebar and title bar with improved styling and animations for a cyberpunk theme.
- Refactored host management to support editing and connecting to hosts with a more intuitive interface.
- Updated package dependencies to support new features and improve performance.
2025-12-29 13:50:23 +08:00

494 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
FiX,
FiPlus,
FiTrash2,
FiServer,
FiCheck,
FiLoader,
FiKey,
FiEye,
FiEyeOff,
FiPlay,
} from 'react-icons/fi';
const colors = [
'#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff',
'#56d4dd', '#ffa657', '#ff7b72', '#d2a8ff', '#76e3ea',
];
function HostManager({ hosts, initialEditHost, onClose, onConnect, onUpdate }) {
const [isEditing, setIsEditing] = useState(!!initialEditHost);
const [editingHost, setEditingHost] = useState(initialEditHost || null);
const [showPassword, setShowPassword] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [formData, setFormData] = useState({
name: '',
host: '',
port: 22,
username: '',
password: '',
privateKey: '',
groupName: '默认分组',
color: '#58a6ff',
description: '',
});
// 初始化编辑状态
useEffect(() => {
if (initialEditHost) {
setEditingHost(initialEditHost);
setIsEditing(true);
}
}, [initialEditHost]);
useEffect(() => {
if (editingHost) {
setFormData({
name: editingHost.name || '',
host: editingHost.host || '',
port: editingHost.port || 22,
username: editingHost.username || '',
password: editingHost.password || '',
privateKey: editingHost.private_key || '',
groupName: editingHost.group_name || '默认分组',
color: editingHost.color || '#58a6ff',
description: editingHost.description || '',
});
}
}, [editingHost]);
const resetForm = () => {
setFormData({
name: '',
host: '',
port: 22,
username: '',
password: '',
privateKey: '',
groupName: '默认分组',
color: '#58a6ff',
description: '',
});
setEditingHost(null);
setIsEditing(false);
setTestResult(null);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!window.electronAPI) return;
try {
if (editingHost) {
await window.electronAPI.hosts.update(editingHost.id, formData);
} else {
await window.electronAPI.hosts.add(formData);
}
onUpdate();
resetForm();
} catch (error) {
console.error('保存失败:', error);
}
};
const handleDelete = async (id) => {
if (!window.electronAPI) return;
if (window.confirm('确定要删除这个主机吗?')) {
await window.electronAPI.hosts.delete(id);
onUpdate();
}
};
const handleTest = async () => {
if (!window.electronAPI) return;
setTesting(true);
setTestResult(null);
try {
const result = await window.electronAPI.ssh.test({
host: formData.host,
port: formData.port,
username: formData.username,
password: formData.password,
privateKey: formData.privateKey,
});
setTestResult(result);
} catch (error) {
setTestResult({ success: false, message: error.message });
} finally {
setTesting(false);
}
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-shell-surface border border-shell-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] overflow-hidden"
>
{/* 头部 */}
<div className="px-6 py-4 border-b border-shell-border flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-shell-text font-display">主机管理</h2>
{isEditing && (
<span className="px-2 py-0.5 bg-shell-accent/20 border border-shell-accent/30
rounded text-xs text-shell-accent font-medium">
{editingHost?.id ? '编辑中' : '新建'}
</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>
<div className="flex h-[calc(85vh-130px)]">
{/* 主机列表 */}
<div className="w-80 border-r border-shell-border overflow-y-auto custom-scrollbar">
<div className="p-4">
<button
onClick={() => {
resetForm();
setIsEditing(true);
}}
className="w-full flex items-center justify-center gap-2 px-4 py-3
bg-shell-accent/20 border border-shell-accent/30 rounded-lg
text-shell-accent hover:bg-shell-accent/30 transition-all btn-glow"
>
<FiPlus size={18} />
<span>添加新主机</span>
</button>
</div>
<div className="px-4 pb-4 space-y-2">
{hosts.map((host) => {
const isSelected = editingHost?.id === host.id;
return (
<div
key={host.id}
onClick={() => {
setEditingHost(host);
setIsEditing(true);
setTestResult(null);
}}
className={`group p-3 rounded-lg border transition-all cursor-pointer
${isSelected
? 'bg-shell-accent/10 border-shell-accent/50'
: 'bg-shell-card/50 border-shell-border hover:border-shell-accent/30 hover:bg-shell-card'
}`}
>
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: `${host.color}20` }}
>
<FiServer size={18} style={{ color: host.color }} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-shell-text truncate">
{host.name}
</div>
<div className="text-xs text-shell-text-dim truncate">
{host.username}@{host.host}:{host.port}
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-shell-border/50">
<button
onClick={(e) => {
e.stopPropagation();
onConnect(host);
}}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5
bg-shell-accent/20 text-shell-accent text-sm
rounded-md hover:bg-shell-accent/30 transition-colors"
>
<FiPlay size={12} />
连接
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(host.id);
}}
className="p-1.5 rounded-md hover:bg-shell-error/20 text-shell-text-dim
hover:text-shell-error transition-colors"
title="删除主机"
>
<FiTrash2 size={14} />
</button>
</div>
</div>
);
})}
{hosts.length === 0 && (
<div className="text-center py-8 text-shell-text-dim">
暂无主机点击上方按钮添加
</div>
)}
</div>
</div>
{/* 编辑表单 */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
{isEditing ? (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
{/* 名称 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
名称 *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
placeholder="生产服务器"
/>
</div>
{/* 分组 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
分组
</label>
<input
type="text"
value={formData.groupName}
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
placeholder="默认分组"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{/* 主机 */}
<div className="col-span-2">
<label className="block text-sm font-medium text-shell-text-dim mb-2">
主机地址 *
</label>
<input
type="text"
required
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
placeholder="192.168.1.100 或 example.com"
/>
</div>
{/* 端口 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
端口
</label>
<input
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 22 })}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
/>
</div>
</div>
{/* 用户名 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
用户名 *
</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
placeholder="root"
/>
</div>
{/* 密码 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
密码
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-2.5 pr-12 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-shell-text-dim
hover:text-shell-text transition-colors"
>
{showPassword ? <FiEyeOff size={18} /> : <FiEye size={18} />}
</button>
</div>
</div>
{/* 私钥 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
<span className="flex items-center gap-2">
<FiKey size={14} />
SSH 私钥 (可选)
</span>
</label>
<textarea
value={formData.privateKey}
onChange={(e) => setFormData({ ...formData, privateKey: e.target.value })}
rows={4}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50 font-mono text-sm
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 resize-none"
placeholder="-----BEGIN RSA PRIVATE KEY-----..."
/>
</div>
{/* 颜色选择 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
标识颜色
</label>
<div className="flex gap-2">
{colors.map((color) => (
<button
key={color}
type="button"
onClick={() => setFormData({ ...formData, color })}
className={`w-8 h-8 rounded-lg transition-all ${
formData.color === color
? 'ring-2 ring-offset-2 ring-offset-shell-surface ring-white/50 scale-110'
: 'hover:scale-105'
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
{/* 描述 */}
<div>
<label className="block text-sm font-medium text-shell-text-dim mb-2">
备注说明
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-4 py-2.5 bg-shell-bg border border-shell-border rounded-lg
text-shell-text placeholder-shell-text-dim/50
focus:border-shell-accent focus:ring-1 focus:ring-shell-accent/50 resize-none"
placeholder="关于这台服务器的备注..."
/>
</div>
{/* 测试结果 */}
{testResult && (
<div
className={`p-4 rounded-lg border ${
testResult.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 gap-2">
{testResult.success ? <FiCheck size={18} /> : <FiX size={18} />}
<span>{testResult.message}</span>
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex items-center justify-between pt-4 border-t border-shell-border">
<button
type="button"
onClick={handleTest}
disabled={testing || !formData.host || !formData.username}
className="flex items-center gap-2 px-4 py-2 bg-shell-card border border-shell-border
rounded-lg text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30
disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{testing ? (
<FiLoader className="animate-spin" size={16} />
) : (
<FiCheck size={16} />
)}
<span>测试连接</span>
</button>
<div className="flex gap-3">
<button
type="button"
onClick={resetForm}
className="px-4 py-2 border border-shell-border rounded-lg text-shell-text-dim
hover:text-shell-text hover:bg-shell-card transition-all"
>
取消
</button>
<button
type="submit"
className="px-6 py-2 bg-shell-accent rounded-lg text-white font-medium
hover:bg-shell-accent/80 transition-all btn-glow"
>
{editingHost ? '保存修改' : '添加主机'}
</button>
</div>
</div>
</form>
) : (
<div className="h-full flex items-center justify-center text-shell-text-dim">
<div className="text-center">
<FiServer className="mx-auto text-5xl mb-4 opacity-30" />
<p>选择一个主机进行编辑</p>
<p className="text-sm mt-1">或点击"添加新主机"创建</p>
</div>
</div>
)}
</div>
</div>
</motion.div>
</motion.div>
);
}
export default HostManager;