dev-manager-tauri/src/components/dashboard/ServerManagerPanel.tsx
lanrtop 9660bcbc48 feat: 服务器管理体系 + 云产品扩展 + 源码库 + API密钥管理
新增多个核心功能模块(后端 Rust 命令 + 前端 Panel 全部接线完成):

- 服务器注册表:servers.rs + ServerManagerPanel、ProjectServerPanel(项目关联服务器)
- SSH Config 自动同步:ssh_config.rs
- 服务器软件管理:server_software.rs + ServerSoftwareBlock
- 宿主机应用管理:host_apps.rs + HostAppsPanel
- 服务配置管理:service_config.rs + ServiceConfigBlock
- 项目部署信息:deploy_info.rs + DeployInfoPanel
- API密钥管理:api_keys.rs + ApiKeysPanel
- 云产品管理:cloud_products.rs + CloudProductsPanel(含 Docker应用、云数据库实例子模块)
- 源码库:source_library.rs + SourceLibraryPage(分类管理 + 项目详情)

导航栏新增「源码库」入口;蓝图 manifest 同步补录 7 个新模块
2026-04-17 22:25:06 +09:00

870 lines
39 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import {
getServers, addServer, editServer, deleteServer,
getServerProjectLinks, getProjects, getSettings, updateSettings,
checkVscodeStatus, openVscodeRemote, openServerTerminal, syncSshConfig,
parseSshConfigEntries, importSshConfigEntries,
type Server, type SaveServerInput, type QuickLink, type SshConfigEntry,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { ServiceConfigPanel } from "./ServiceConfigBlock";
import { HostAppsPanel } from "./HostAppsPanel";
function CopyBtn({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={handleCopy}
className="shrink-0 px-1.5 py-0.5 rounded text-[11px] border border-gray-200 text-gray-400 hover:text-blue-500 hover:border-blue-300 hover:bg-blue-50 transition-colors">
{copied ? "已复制" : "复制"}
</button>
);
}
function buildSshCommand(s: Server): string {
const port = s.server_port && s.server_port !== "22" ? ` -p ${s.server_port}` : "";
const key = s.ssh_key_path ? ` -i ${s.ssh_key_path}` : "";
const user = s.username ? `${s.username}@` : "";
return `ssh ${user}${s.server_host}${port}${key}`;
}
function buildDeployKeyCommand(s: Server, globalKeyPath?: string): string {
const rawKey = s.ssh_key_path || globalKeyPath || "";
const keyPub = rawKey
? rawKey.replace(/(?:\.pub)?$/, ".pub")
: "%USERPROFILE%\\.ssh\\id_ed25519.pub";
const portFlag = s.server_port && s.server_port !== "22" ? ` -p ${s.server_port}` : "";
const user = s.username ? `${s.username}@` : "";
return `type ${keyPub} | ssh${portFlag} ${user}${s.server_host} "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`;
}
// ── SSH 密钥配置区域 ────────────────────────────────────────────────────────
function SshKeySetup({ keyPath, onKeyPathChange }: {
keyPath: string;
onKeyPathChange: (path: string) => void;
}) {
const [showGuide, setShowGuide] = useState(false);
const handleBrowse = async () => {
const selected = await openFileDialog({
title: "选择 SSH 私钥文件",
defaultPath: "C:\\Users",
filters: [
{ name: "SSH Key", extensions: ["pem", "key", "*"] },
{ name: "所有文件", extensions: ["*"] },
],
multiple: false,
directory: false,
});
if (selected) {
onKeyPathChange(selected as string);
}
};
const hasKey = !!keyPath;
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<div className="flex items-center gap-3 px-5 py-3 border-b border-gray-100 bg-gray-50/60">
<span className="text-sm font-bold text-gray-800">SSH 密钥配置</span>
{hasKey ? (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-green-50 text-green-600 border border-green-200 font-medium">
已配置
</span>
) : (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-amber-50 text-amber-600 border border-amber-200 font-medium">
未配置
</span>
)}
</div>
<div className="px-5 py-4 space-y-3">
{/* 密钥路径选择 */}
<div className="flex items-center gap-3">
<label className="text-[11px] text-gray-400 font-medium shrink-0 w-16 text-right">私钥路径</label>
<input
value={keyPath}
onChange={(e) => onKeyPathChange(e.target.value)}
placeholder="例: C:\Users\你的用户名\.ssh\id_ed25519"
className="text-xs px-2.5 py-2 rounded-md border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 flex-1 font-mono"
/>
<button onClick={handleBrowse}
className="px-3 py-2 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors shrink-0">
浏览…
</button>
</div>
{hasKey && (
<div className="flex items-center gap-3">
<span className="text-[11px] text-gray-400 font-medium shrink-0 w-16 text-right">公钥路径</span>
<span className="text-xs font-mono text-gray-500">
{keyPath.replace(/(?:\.pub)?$/, ".pub")}
</span>
</div>
)}
{/* 生成教程 */}
<details className="group" open={showGuide} onToggle={(e) => setShowGuide((e.target as HTMLDetailsElement).open)}>
<summary className="text-[11px] text-blue-500 cursor-pointer hover:text-blue-700 transition-colors select-none flex items-center gap-1">
<span className="text-blue-400 group-open:rotate-90 transition-transform inline-block">&#9654;</span>
{hasKey ? "查看密钥生成教程" : "还没有密钥?点击查看如何生成"}
</summary>
<div className="mt-2 bg-gray-50 border border-gray-100 rounded-lg p-4 space-y-3">
<div>
<p className="text-xs font-semibold text-gray-700 mb-1">第 1 步:生成密钥对</p>
<p className="text-[11px] text-gray-500 mb-1.5">在本地 CMD 或 PowerShell 中执行(<code className="text-gray-600">ed25519</code> 是加密算法名称,目前最推荐的算法,安全性高且密钥短):</p>
<div className="flex items-center gap-2 px-3 py-2 bg-gray-800 rounded-md">
<span className="text-[11px] text-green-400 font-mono flex-1">ssh-keygen -t ed25519</span>
<CopyBtn value="ssh-keygen -t ed25519" />
</div>
<p className="text-[10px] text-gray-400 mt-1">连按三次回车使用默认路径和空密码。一套密钥对可用于所有服务器。生成后会得到两个文件:</p>
<ul className="text-[10px] text-gray-400 ml-3 mt-0.5 space-y-0.5">
<li>私钥:<code className="text-gray-600">C:\Users\你的用户名\.ssh\id_ed25519</code>(自己留着)</li>
<li>公钥:<code className="text-gray-600">C:\Users\你的用户名\.ssh\id_ed25519.pub</code>(放到服务器)</li>
</ul>
</div>
<div>
<p className="text-xs font-semibold text-gray-700 mb-1">第 2 步:选择私钥</p>
<p className="text-[11px] text-gray-500">点击上方「浏览…」按钮选择刚生成的私钥文件,路径会自动保存。</p>
</div>
<div>
<p className="text-xs font-semibold text-gray-700 mb-1">第 3 步:部署公钥到服务器</p>
<p className="text-[11px] text-gray-500">配置好密钥后,每台服务器卡片下方会出现「部署公钥」命令,复制到 CMD 执行一次即可免密登录。</p>
</div>
</div>
</details>
</div>
</div>
);
}
// ── 服务器表单 ──────────────────────────────────────────────────────────────
function ServerForm({ initial, onSave, onCancel, saving }: {
initial?: Server;
onSave: (input: SaveServerInput) => void;
onCancel: () => void;
saving: boolean;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [host, setHost] = useState(initial?.server_host ?? "");
const [port, setPort] = useState(initial?.server_port ?? "22");
const [username, setUsername] = useState(initial?.username ?? "");
const [password, setPassword] = useState(initial?.password ?? "");
const [sshKeyPath, setSshKeyPath] = useState(initial?.ssh_key_path ?? "");
const [notes, setNotes] = useState(initial?.notes ?? "");
const [quickLinks, setQuickLinks] = useState<QuickLink[]>(() => {
try { return initial?.quick_links ? JSON.parse(initial.quick_links) : []; } catch { return []; }
});
const addLink = () => setQuickLinks((p) => [...p, { label: "", url: "" }]);
const removeLink = (i: number) => setQuickLinks((p) => p.filter((_, idx) => idx !== i));
const updateLink = (i: number, f: "label" | "url", v: string) =>
setQuickLinks((p) => p.map((l, idx) => (idx === i ? { ...l, [f]: v } : l)));
const handleSubmit = () => {
if (!name.trim() || !host.trim()) return;
onSave({
name: name.trim(),
server_host: host.trim(),
server_port: port || undefined,
username: username || undefined,
password: password || undefined,
ssh_key_path: sshKeyPath || undefined,
quick_links: quickLinks.filter((l) => l.url.trim()).length > 0
? JSON.stringify(quickLinks.filter((l) => l.url.trim()))
: undefined,
notes: notes || undefined,
});
};
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<div className="grid grid-cols-[72px_1fr] gap-x-3 gap-y-2 items-center">
<label className="text-[11px] text-gray-400 text-right">名称 *</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="例: 生产服务器A"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
<label className="text-[11px] text-gray-400 text-right">主机 *</label>
<input value={host} onChange={(e) => setHost(e.target.value)} placeholder="例: 192.168.1.100"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
<label className="text-[11px] text-gray-400 text-right">端口</label>
<input value={port} onChange={(e) => setPort(e.target.value)} placeholder="22"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400 w-24" />
<label className="text-[11px] text-gray-400 text-right">用户名</label>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="例: root"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
<label className="text-[11px] text-gray-400 text-right">密码</label>
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" placeholder="选填"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
<label className="text-[11px] text-gray-400 text-right">SSH Key</label>
<input value={sshKeyPath} onChange={(e) => setSshKeyPath(e.target.value)} placeholder="例: ~/.ssh/id_rsa"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
<label className="text-[11px] text-gray-400 text-right">备注</label>
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="例: 阿里云 ECS"
className="text-xs px-2.5 py-1.5 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-gray-400" />
</div>
{/* 快速链接 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] text-gray-400">快速链接</span>
<button onClick={addLink}
className="text-[11px] px-1.5 py-0.5 rounded border border-dashed border-gray-300 text-gray-400 hover:border-gray-400 hover:text-gray-600 transition-colors">
+ 添加
</button>
</div>
{quickLinks.map((link, idx) => (
<div key={idx} className="flex items-center gap-1.5 mb-1">
<input value={link.label} onChange={(e) => updateLink(idx, "label", e.target.value)}
placeholder="名称" className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50 w-24 focus:outline-none focus:border-gray-400" />
<input value={link.url} onChange={(e) => updateLink(idx, "url", e.target.value)}
placeholder="https://..." className="text-xs px-2 py-1 rounded border border-gray-200 bg-gray-50 flex-1 focus:outline-none focus:border-gray-400" />
<button onClick={() => removeLink(idx)}
className="text-gray-300 hover:text-red-400 text-xs transition-colors">x</button>
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<button onClick={onCancel}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors">
取消
</button>
<button onClick={handleSubmit} disabled={saving || !name.trim() || !host.trim()}
className="px-3 py-1.5 rounded-md text-xs border border-blue-300 bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors disabled:opacity-50">
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
);
}
// ── 服务器卡片 ──────────────────────────────────────────────────────────────
function ServerCard({ server, onEdit, onDelete, globalKeyPath }: {
server: Server;
onEdit: () => void;
onDelete: () => void;
globalKeyPath?: string;
}) {
const [showPwd, setShowPwd] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [collapsed, setCollapsed] = useState(true);
const sshCmd = buildSshCommand(server);
const links: QuickLink[] = (() => {
try { return server.quick_links ? JSON.parse(server.quick_links) : []; } catch { return []; }
})();
// 关联的项目
const { data: linkedProjects = [] } = useQuery({
queryKey: ["server-projects", server.id],
queryFn: () => getServerProjectLinks(server.id),
});
const { data: allProjects = [] } = useQuery({
queryKey: ["projects"],
queryFn: () => getProjects(),
staleTime: 30000,
});
const keyPath = server.ssh_key_path || globalKeyPath;
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
{/* ── 标题栏 ── */}
<div className="flex items-center gap-3 px-6 py-3.5 bg-gradient-to-r from-gray-50 to-white border-b border-gray-100">
<div className="w-8 h-8 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center text-blue-500 text-sm shrink-0">
{server.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-800">{server.name}</span>
{server.notes && (
<span className="text-[11px] text-gray-400">{server.notes}</span>
)}
</div>
<span className="text-xs text-gray-400 font-mono">{server.server_host}{server.server_port && server.server_port !== "22" ? `:${server.server_port}` : ""}</span>
</div>
<div className="flex gap-1.5 shrink-0 items-center">
<button onClick={() => setCollapsed((v) => !v)}
className="px-2 py-1 rounded-md text-[11px] text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
title={collapsed ? "展开" : "折叠"}
>
{collapsed ? "展开 ▼" : "收起 ▲"}
</button>
<div className="w-px h-4 bg-gray-200" />
<button onClick={onEdit}
className="px-2.5 py-1 rounded-md text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors">
编辑
</button>
{!confirmDelete ? (
<button onClick={() => setConfirmDelete(true)}
className="px-2.5 py-1 rounded-md text-[11px] border border-gray-200 text-gray-400 hover:text-red-500 hover:border-red-200 transition-colors">
删除
</button>
) : (
<div className="flex gap-1">
<button onClick={onDelete}
className="px-2.5 py-1 rounded-md text-[11px] border border-red-300 bg-red-50 text-red-600 hover:bg-red-100 transition-colors">
确认
</button>
<button onClick={() => setConfirmDelete(false)}
className="px-2.5 py-1 rounded-md text-[11px] border border-gray-200 text-gray-400 transition-colors">
取消
</button>
</div>
)}
</div>
</div>
{/* ── 内容区:左右两栏布局 ── */}
{!collapsed && <div className="flex divide-x divide-gray-100">
{/* 左栏:服务器信息 + 快速链接 + 关联项目 */}
<div className="flex-1 px-6 py-4 space-y-4 min-w-0">
{/* 服务器信息 */}
<div>
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2">服务器信息</h4>
<div className="space-y-1">
<div className="flex items-center gap-3 py-1">
<span className="text-[11px] text-gray-400 w-10 shrink-0 text-right">主机</span>
<span className="text-xs font-mono text-gray-800 font-medium">{server.server_host}</span>
<CopyBtn value={server.server_host} />
</div>
{server.server_port && (
<div className="flex items-center gap-3 py-1">
<span className="text-[11px] text-gray-400 w-10 shrink-0 text-right">端口</span>
<span className="text-xs font-mono text-gray-800">{server.server_port}</span>
<CopyBtn value={server.server_port} />
</div>
)}
{server.username && (
<div className="flex items-center gap-3 py-1">
<span className="text-[11px] text-gray-400 w-10 shrink-0 text-right">用户</span>
<span className="text-xs font-mono text-gray-800">{server.username}</span>
<CopyBtn value={server.username} />
</div>
)}
</div>
</div>
{/* 快速链接 */}
{links.length > 0 && (
<div>
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2">快速链接</h4>
<div className="flex flex-wrap gap-1.5">
{links.map((link, i) => (
<div key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-blue-50 border border-blue-100">
<a href={link.url} target="_blank" rel="noopener noreferrer"
className="text-[11px] text-blue-600 hover:text-blue-800 hover:underline transition-colors font-medium">
{link.label || link.url}
</a>
<CopyBtn value={link.url} />
</div>
))}
</div>
</div>
)}
{/* 关联项目 */}
{linkedProjects.length > 0 && (
<div>
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2">关联项目</h4>
<div className="flex flex-wrap gap-1.5">
{linkedProjects.map((lp) => {
const proj = allProjects.find((p) => p.id === lp.project_id);
return (
<span key={lp.project_id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[11px] bg-gray-50 text-gray-700 border border-gray-200">
<span className="font-medium">{proj?.name ?? lp.project_id}</span>
{lp.role && (
<span className="text-[10px] text-purple-500 bg-purple-50 px-1.5 py-0.5 rounded font-medium">{lp.role}</span>
)}
</span>
);
})}
</div>
</div>
)}
</div>
{/* 右栏:连接配置 */}
<div className="w-[420px] shrink-0 px-6 py-4 space-y-4 bg-gray-50/30">
{/* SSH 登录命令 */}
<div>
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2">SSH 登录</h4>
<div className="flex items-center gap-2 px-3 py-2.5 bg-gray-900 rounded-lg">
<span className="text-[11px] text-green-400 font-mono flex-1 truncate" title={sshCmd}>$ {sshCmd}</span>
<CopyBtn value={sshCmd} />
</div>
</div>
{/* 认证方式 */}
<div className="space-y-2.5">
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">认证方式</h4>
{/* 密码认证 */}
<div className="bg-white border border-gray-200 rounded-lg px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="text-[11px] font-semibold text-gray-700">密码认证</span>
</div>
{server.password ? (
<div className="flex items-center gap-2 pl-3.5">
<span className="text-xs font-mono text-gray-700 flex-1">{showPwd ? server.password : "••••••••"}</span>
<button onClick={() => setShowPwd((v) => !v)}
className="px-1.5 py-0.5 rounded text-[11px] border border-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0">
{showPwd ? "隐藏" : "显示"}
</button>
<CopyBtn value={server.password} />
</div>
) : (
<p className="text-[11px] text-gray-300 pl-3.5">未设置密码</p>
)}
</div>
{/* 密钥认证 */}
<div className="bg-white border border-gray-200 rounded-lg px-4 py-3 space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-400 shrink-0" />
<span className="text-[11px] font-semibold text-gray-700">密钥认证</span>
<span className="text-[10px] text-green-600 bg-green-50 px-1.5 py-0.5 rounded font-medium">免密</span>
</div>
{keyPath ? (
<div className="flex items-center gap-2 pl-3.5">
<span className="text-[11px] font-mono text-gray-600 flex-1 truncate" title={keyPath}>{keyPath}</span>
{!server.ssh_key_path && globalKeyPath && (
<span className="text-[10px] text-gray-400 shrink-0">全局</span>
)}
</div>
) : (
<p className="text-[11px] text-gray-300 pl-3.5">请先在顶部配置 SSH 密钥</p>
)}
{/* 部署公钥 */}
<details className="group pl-3.5">
<summary className="text-[11px] text-blue-500 cursor-pointer hover:text-blue-700 transition-colors select-none flex items-center gap-1">
<span className="text-blue-400 group-open:rotate-90 transition-transform inline-block text-[10px]">&#9654;</span>
部署公钥到此服务器
</summary>
<div className="mt-1.5 flex items-start gap-2 px-3 py-2 bg-gray-900 rounded-md">
<span className="text-[10px] text-amber-300 font-mono flex-1 break-all leading-relaxed">
&gt; {buildDeployKeyCommand(server, globalKeyPath)}
</span>
<CopyBtn value={buildDeployKeyCommand(server, globalKeyPath)} />
</div>
<p className="text-[10px] text-gray-400 mt-1">复制到 CMD 执行一次,输入密码后即可免密登录。</p>
</details>
</div>
</div>
{/* VS Code Remote */}
<VscodeRemoteBlock serverId={server.id} serverName={server.name} />
{/* 宿主机应用 */}
<HostAppsPanel serverId={server.id} />
{/* 服务连接配置(Linux 客户端层) */}
<ServiceConfigPanel serverId={server.id} scope="system" />
{/* Docker 应用配置 */}
<ServiceConfigPanel serverId={server.id} scope="docker" />
</div>
</div>}
</div>
);
}
// ── VS Code Remote 区块 ─────────────────────────────────────────────────────
function VscodeRemoteBlock({ serverId, serverName }: { serverId: string; serverName: string }) {
const showToast = useUIStore((s) => s.showToast);
const [remotePath, setRemotePath] = useState("");
const [syncing, setSyncing] = useState(false);
const [openingTerm, setOpeningTerm] = useState(false);
const { data: vscodeStatus } = useQuery({
queryKey: ["vscode-status"],
queryFn: checkVscodeStatus,
staleTime: 60000,
});
const handleOpen = async () => {
try {
setSyncing(true);
await openVscodeRemote(serverId, remotePath || undefined);
showToast(`VS Code Remote 已打开: ${serverName}`);
} catch (e) {
showToast(`${e}`);
} finally {
setSyncing(false);
}
};
const handleOpenTerminal = async () => {
try {
setOpeningTerm(true);
await openServerTerminal(serverId);
showToast(`终端已打开: ${serverName}`);
} catch (e) {
showToast(`${e}`);
} finally {
setOpeningTerm(false);
}
};
const handleSyncOnly = async () => {
try {
setSyncing(true);
const path = await syncSshConfig();
showToast(`SSH Config 已同步: ${path}`);
} catch (e) {
showToast(`${e}`);
} finally {
setSyncing(false);
}
};
const installed = vscodeStatus?.installed ?? false;
return (
<div>
<h4 className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider mb-2">快捷操作</h4>
<div className="bg-white border border-gray-200 rounded-lg px-4 py-3 space-y-2.5">
{/* 状态提示 */}
{!installed && (
<div className="flex items-start gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-md">
<span className="text-amber-500 shrink-0 mt-0.5">!</span>
<div className="space-y-1">
<p className="text-[11px] text-amber-700 font-medium">VS Code 未检测到</p>
<p className="text-[10px] text-amber-600">请在炼境「设置」页面配置 VS Code 编辑器路径,或确保 <code className="font-mono bg-amber-100 px-1 rounded">code</code> 命令在系统 PATH 中。</p>
</div>
</div>
)}
{installed && (
<div className="flex items-start gap-2 px-3 py-2 bg-blue-50 border border-blue-100 rounded-md">
<span className="text-blue-400 shrink-0 mt-0.5 text-xs">i</span>
<p className="text-[10px] text-blue-600">需要 VS Code 扩展 <code className="font-mono bg-blue-100 px-1 rounded">Remote - SSH</code>(ms-vscode-remote.remote-ssh)。打开 VS Code → 扩展 → 搜索安装。</p>
</div>
)}
{/* 远程路径 + 打开按钮 */}
<div className="flex items-center gap-2">
<input
value={remotePath}
onChange={(e) => setRemotePath(e.target.value)}
placeholder="远程路径(默认 /root)"
className="text-xs px-2.5 py-2 rounded-md border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 flex-1 font-mono"
/>
<button
onClick={handleOpen}
disabled={!installed || syncing}
className="px-4 py-2 rounded-md text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
>
{syncing ? "连接中…" : "VS Code 打开"}
</button>
</div>
{/* CMD 打开 */}
<div className="flex items-center gap-2">
<button
onClick={handleOpenTerminal}
disabled={openingTerm}
className="px-4 py-2 rounded-md text-xs font-medium border border-gray-300 bg-gray-800 text-gray-100 hover:bg-gray-700 transition-colors disabled:opacity-40 shrink-0"
>
{openingTerm ? "打开中…" : "CMD 打开"}
</button>
<span className="text-[10px] text-gray-400">在 Windows Terminal 中 SSH 连接到此服务器</span>
</div>
{/* 同步 SSH Config */}
<div className="flex items-center justify-between">
<p className="text-[10px] text-gray-400">
炼境会自动同步服务器信息到 <code className="font-mono text-gray-500">~/.ssh/config</code>
</p>
<button onClick={handleSyncOnly} disabled={syncing}
className="text-[11px] text-gray-400 hover:text-blue-500 transition-colors disabled:opacity-50">
手动同步
</button>
</div>
</div>
</div>
);
}
// ── 主面板 ──────────────────────────────────────────────────────────────────
// ── 导入 SSH Config 弹窗 ────────────────────────────────────────────────────
function ImportSshConfigModal({ onClose, onImported }: { onClose: () => void; onImported: () => void }) {
const showToast = useUIStore((s) => s.showToast);
const [entries, setEntries] = useState<SshConfigEntry[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [importing, setImporting] = useState(false);
useEffect(() => {
parseSshConfigEntries()
.then((list) => {
setEntries(list);
setSelected(new Set(list.map((_, i) => i))); // 默认全选
})
.catch((e) => showToast(`解析失败: ${e}`))
.finally(() => setLoading(false));
}, []);
const toggleSelect = (idx: number) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(idx) ? next.delete(idx) : next.add(idx);
return next;
});
};
const toggleAll = () => {
if (selected.size === entries.length) {
setSelected(new Set());
} else {
setSelected(new Set(entries.map((_, i) => i)));
}
};
const handleImport = async () => {
const toImport = entries.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setImporting(true);
try {
const count = await importSshConfigEntries(toImport);
showToast(`已导入 ${count} 台服务器,SSH Config 已同步`);
onImported();
onClose();
} catch (e) {
showToast(`导入失败: ${e}`);
} finally {
setImporting(false);
}
};
return (
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center" onClick={onClose}>
<div className="bg-white rounded-xl border border-gray-200 shadow-xl w-[600px] max-h-[70vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
{/* 标题 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div>
<h3 className="text-sm font-bold text-gray-800">从 SSH Config 导入</h3>
<p className="text-[11px] text-gray-400 mt-0.5">检测到以下手写的 SSH 配置,勾选后导入到炼境统一管理</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg">x</button>
</div>
{/* 列表 */}
<div className="flex-1 overflow-y-auto px-6 py-3">
{loading ? (
<p className="text-xs text-gray-400 text-center py-8">解析中…</p>
) : entries.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">未检测到可导入的 SSH 配置条目</p>
) : (
<div className="space-y-1">
{/* 全选 */}
<label className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={selected.size === entries.length}
onChange={toggleAll}
className="rounded border-gray-300"
/>
<span className="text-[11px] text-gray-500 font-medium">全选 / 取消全选</span>
</label>
{entries.map((entry, idx) => (
<label
key={idx}
className={`flex items-center gap-3 py-2.5 px-3 rounded-lg border cursor-pointer transition-colors ${
selected.has(idx) ? "border-blue-200 bg-blue-50/50" : "border-gray-100 hover:bg-gray-50"
}`}
>
<input
type="checkbox"
checked={selected.has(idx)}
onChange={() => toggleSelect(idx)}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-gray-800">{entry.host_alias}</span>
<span className="text-[11px] font-mono text-gray-400">{entry.hostname}</span>
</div>
<div className="flex items-center gap-3 mt-0.5 text-[10px] text-gray-400">
{entry.user && <span>User: {entry.user}</span>}
{entry.port && entry.port !== "22" && <span>Port: {entry.port}</span>}
{entry.identity_file && <span className="truncate max-w-[200px]">Key: {entry.identity_file}</span>}
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* 底部操作 */}
<div className="flex items-center justify-between px-6 py-3 border-t border-gray-100 bg-gray-50/50">
<p className="text-[11px] text-gray-400">
导入后原 SSH Config 手写条目将由炼境接管
</p>
<div className="flex gap-2">
<button onClick={onClose}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors">
取消
</button>
<button onClick={handleImport} disabled={selected.size === 0 || importing}
className="px-4 py-1.5 rounded-md text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-40">
{importing ? "导入中…" : `导入 ${selected.size} 条`}
</button>
</div>
</div>
</div>
</div>
);
}
// ── 主面板 ──────────────────────────────────────────────────────────────────
export function ServerManagerPanel() {
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [showImport, setShowImport] = useState(false);
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [sshKeyPath, setSshKeyPath] = useState("");
// 从 settings 读取全局 SSH 密钥路径
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => getSettings(),
});
useEffect(() => {
if (settings?.ssh_key_path) {
setSshKeyPath(settings.ssh_key_path);
}
}, [settings]);
const handleKeyPathChange = async (path: string) => {
setSshKeyPath(path);
try {
await updateSettings({ ssh_key_path: path });
qc.invalidateQueries({ queryKey: ["settings"] });
} catch { /* ignore */ }
};
const { data: servers = [] } = useQuery({
queryKey: ["servers"],
queryFn: getServers,
});
const addMut = useMutation({
mutationFn: (input: SaveServerInput) => addServer(input),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["servers"] }); setShowForm(false); },
});
const editMut = useMutation({
mutationFn: ({ id, input }: { id: string; input: SaveServerInput }) => editServer(id, input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["servers"] });
setEditingServer(null);
},
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteServer(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["servers"] }),
});
return (
<div className="w-full px-8 py-8 space-y-4 pb-16">
{/* SSH 密钥配置 */}
<SshKeySetup keyPath={sshKeyPath} onKeyPathChange={handleKeyPathChange} />
{/* 顶部操作栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-gray-700">服务器管理</h2>
<span className="text-xs text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{servers.length} 台
</span>
</div>
{!showForm && !editingServer && (
<div className="flex gap-2">
<button onClick={() => setShowImport(true)}
className="px-3 py-1.5 rounded-md text-xs font-medium border border-gray-300 text-gray-600 hover:bg-gray-50 transition-colors">
从 SSH Config 导入
</button>
<button onClick={() => setShowForm(true)}
className="px-3 py-1.5 rounded-md text-xs font-medium border border-blue-300 bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors">
+ 添加服务器
</button>
</div>
)}
</div>
{/* 导入弹窗 */}
{showImport && (
<ImportSshConfigModal
onClose={() => setShowImport(false)}
onImported={() => qc.invalidateQueries({ queryKey: ["servers"] })}
/>
)}
{/* 新增表单 */}
{showForm && (
<ServerForm
onSave={(input) => addMut.mutate(input)}
onCancel={() => setShowForm(false)}
saving={addMut.isPending}
/>
)}
{/* 服务器列表 */}
{servers.length === 0 && !showForm && (
<div className="text-center py-24 text-gray-400">
<p className="text-4xl mb-4">🖥️</p>
<p className="text-sm">还没有服务器,点击上方「添加服务器」或「从 SSH Config 导入」</p>
<p className="text-xs text-gray-300 mt-1">服务器信息可被多个项目共享关联</p>
</div>
)}
{servers.map((s) =>
editingServer?.id === s.id ? (
<ServerForm
key={s.id}
initial={s}
onSave={(input) => editMut.mutate({ id: s.id, input })}
onCancel={() => setEditingServer(null)}
saving={editMut.isPending}
/>
) : (
<ServerCard
key={s.id}
server={s}
globalKeyPath={sshKeyPath}
onEdit={() => setEditingServer(s)}
onDelete={() => deleteMut.mutate(s.id)}
/>
)
)}
</div>
);
}