新增多个核心功能模块(后端 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 个新模块
870 lines
39 KiB
TypeScript
870 lines
39 KiB
TypeScript
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">▶</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]">▶</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">
|
||
> {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>
|
||
);
|
||
}
|