dev-manager-tauri/src/components/dashboard/ProjectServerPanel.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

348 lines
15 KiB
TypeScript
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 { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getProjectServerLinks, getServers, linkServer, unlinkServer,
type ProjectServerLink, type QuickLink,
} from "../../lib/commands";
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 buildSshCmd(link: ProjectServerLink): string {
const port = link.server_port && link.server_port !== "22" ? ` -p ${link.server_port}` : "";
const key = link.ssh_key_path ? ` -i ${link.ssh_key_path}` : "";
const user = link.username ? `${link.username}@` : "";
return `ssh ${user}${link.server_host}${port}${key}`;
}
function buildDeployKeyCmd(link: ProjectServerLink): string {
const keyPub = link.ssh_key_path
? link.ssh_key_path.replace(/(?:\.pub)?$/, ".pub")
: "%USERPROFILE%\\.ssh\\id_ed25519.pub";
const portFlag = link.server_port && link.server_port !== "22" ? ` -p ${link.server_port}` : "";
const user = link.username ? `${link.username}@` : "";
return `type ${keyPub} | ssh${portFlag} ${user}${link.server_host} "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"`;
}
// ── 已关联服务器条目 ────────────────────────────────────────────────────────
function LinkedServerItem({ link, projectId }: { link: ProjectServerLink; projectId: string }) {
const qc = useQueryClient();
const [showPwd, setShowPwd] = useState(false);
const unlinkMut = useMutation({
mutationFn: () => unlinkServer(projectId, link.server_id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["project-servers", projectId] });
qc.invalidateQueries({ queryKey: ["server-projects", link.server_id] });
},
});
const sshCmd = buildSshCmd(link);
const links: QuickLink[] = (() => {
try { return link.quick_links ? JSON.parse(link.quick_links) : []; } catch { return []; }
})();
return (
<div className="bg-gray-50 border border-gray-100 rounded-lg px-3 py-2.5 space-y-1.5">
{/* 标题行 */}
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-gray-700">{link.server_name}</span>
<span className="text-[11px] text-gray-400 font-mono">{link.server_host}</span>
{link.role && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-50 text-purple-600 border border-purple-100 font-medium">
{link.role}
</span>
)}
<button onClick={() => unlinkMut.mutate()}
className="ml-auto text-[11px] text-gray-300 hover:text-red-400 transition-colors">
取消关联
</button>
</div>
{/* 信息行 */}
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[11px]">
{link.deploy_type && (
<div className="flex items-center gap-1">
<span className={`px-1.5 py-0.5 rounded-full font-medium border ${
link.deploy_type === "docker"
? "bg-sky-50 text-sky-600 border-sky-200"
: "bg-emerald-50 text-emerald-600 border-emerald-200"
}`}>
{link.deploy_type === "docker" ? "Docker" : "裸机"}
</span>
</div>
)}
{link.username && (
<div className="flex items-center gap-1">
<span className="text-gray-400">用户:</span>
<span className="font-mono text-gray-600">{link.username}</span>
<CopyBtn value={link.username} />
</div>
)}
{link.password && (
<div className="flex items-center gap-1">
<span className="text-gray-400">密码:</span>
<span className="font-mono text-gray-600">{showPwd ? link.password : "••••"}</span>
<button onClick={() => setShowPwd((v) => !v)}
className="px-1 py-0.5 rounded text-[10px] border border-gray-200 text-gray-400 hover:text-gray-600 transition-colors">
{showPwd ? "隐藏" : "显示"}
</button>
<CopyBtn value={link.password} />
</div>
)}
{link.deploy_path && (
<div className="flex items-center gap-1">
<span className="text-gray-400">{link.deploy_type === "docker" ? "Compose:" : "路径:"}</span>
<span className="font-mono text-gray-600">{link.deploy_path}</span>
<CopyBtn value={link.deploy_path} />
</div>
)}
{link.container_name && (
<div className="flex items-center gap-1">
<span className="text-gray-400">容器:</span>
<span className="font-mono text-gray-600">{link.container_name}</span>
<CopyBtn value={link.container_name} />
</div>
)}
{link.start_command && (
<div className="flex items-center gap-1">
<span className="text-gray-400">启动:</span>
<span className="font-mono text-gray-600">{link.start_command}</span>
<CopyBtn value={link.start_command} />
</div>
)}
</div>
{/* SSH 命令 */}
<div className="flex items-center gap-2 px-2.5 py-1.5 bg-gray-800 rounded-md">
<span className="text-[11px] text-green-400 font-mono flex-1 truncate">$ {sshCmd}</span>
<CopyBtn value={sshCmd} />
</div>
{/* 部署公钥命令 */}
<details className="group">
<summary className="text-[11px] text-gray-400 cursor-pointer hover:text-gray-600 transition-colors select-none flex items-center gap-1">
<span className="text-gray-300 group-open:rotate-90 transition-transform inline-block">&#9654;</span>
部署公钥(CMD)
</summary>
<div className="mt-1 flex items-start gap-2 px-2.5 py-1.5 bg-amber-950 rounded-md">
<span className="text-[10px] text-amber-300 font-mono flex-1 break-all leading-relaxed">
&gt; {buildDeployKeyCmd(link)}
</span>
<CopyBtn value={buildDeployKeyCmd(link)} />
</div>
</details>
{/* 快速链接 */}
{links.length > 0 && (
<div className="flex flex-wrap gap-2">
{links.map((l, i) => (
<div key={i} className="flex items-center gap-1">
<a href={l.url} target="_blank" rel="noopener noreferrer"
className="text-[11px] text-blue-500 hover:text-blue-700 hover:underline transition-colors">
{l.label || l.url}
</a>
<CopyBtn value={l.url} />
</div>
))}
</div>
)}
</div>
);
}
// ── 关联选择器 ──────────────────────────────────────────────────────────────
function LinkSelector({ projectId, linkedIds }: { projectId: string; linkedIds: Set<string> }) {
const qc = useQueryClient();
const [selectedServerId, setSelectedServerId] = useState("");
const [role, setRole] = useState("production");
const [deployType, setDeployType] = useState<"bare" | "docker">("bare");
const [deployPath, setDeployPath] = useState("");
const [containerName, setContainerName] = useState("");
const [startCommand, setStartCommand] = useState("");
const { data: allServers = [] } = useQuery({
queryKey: ["servers"],
queryFn: getServers,
staleTime: 30000,
});
const available = allServers.filter((s) => !linkedIds.has(s.id));
const linkMut = useMutation({
mutationFn: () => linkServer(projectId, selectedServerId, {
role: role || undefined,
deploy_type: deployType,
deploy_path: deployPath || undefined,
container_name: containerName || undefined,
start_command: startCommand || undefined,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["project-servers", projectId] });
qc.invalidateQueries({ queryKey: ["server-projects", selectedServerId] });
setSelectedServerId("");
setRole("production");
setDeployType("bare");
setDeployPath("");
setContainerName("");
setStartCommand("");
},
});
if (available.length === 0) {
return (
<p className="text-[11px] text-gray-300 text-center py-1">
{allServers.length === 0 ? "请先在「服务器」Tab 中添加服务器" : "所有服务器已关联"}
</p>
);
}
const inputCls = "text-xs px-2.5 py-2 rounded-md border border-gray-200 bg-white focus:outline-none focus:border-blue-300 focus:ring-1 focus:ring-blue-100";
return (
<div className="bg-gray-50 border border-gray-100 rounded-lg p-4 space-y-3">
{/* 第一行:服务器 + 角色 + 部署方式 */}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">服务器</label>
<select value={selectedServerId} onChange={(e) => setSelectedServerId(e.target.value)} className={inputCls}>
<option value="">选择服务器…</option>
{available.map((s) => (
<option key={s.id} value={s.id}>{s.name} ({s.server_host})</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">角色</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className={inputCls}>
<option value="production">production</option>
<option value="staging">staging</option>
<option value="development">development</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">部署方式</label>
<div className="flex rounded-md border border-gray-200 overflow-hidden h-[34px]">
<button
onClick={() => setDeployType("bare")}
className={`flex-1 text-xs font-medium transition-colors ${
deployType === "bare" ? "bg-emerald-50 text-emerald-700 border-r border-gray-200" : "bg-white text-gray-400 border-r border-gray-200 hover:bg-gray-50"
}`}
>
裸机
</button>
<button
onClick={() => setDeployType("docker")}
className={`flex-1 text-xs font-medium transition-colors ${
deployType === "docker" ? "bg-sky-50 text-sky-700" : "bg-white text-gray-400 hover:bg-gray-50"
}`}
>
Docker
</button>
</div>
</div>
</div>
{/* 第二行:根据部署类型显示不同字段 */}
<div className="grid grid-cols-2 gap-3">
{deployType === "bare" ? (
<>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">部署路径</label>
<input value={deployPath} onChange={(e) => setDeployPath(e.target.value)}
placeholder="/var/www/app" className={inputCls} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">启动/重启命令</label>
<input value={startCommand} onChange={(e) => setStartCommand(e.target.value)}
placeholder="pm2 restart app" className={inputCls} />
</div>
</>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">容器/服务名</label>
<input value={containerName} onChange={(e) => setContainerName(e.target.value)}
placeholder="my-app" className={inputCls} />
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">Compose 路径</label>
<input value={deployPath} onChange={(e) => setDeployPath(e.target.value)}
placeholder="/opt/docker/compose.yml" className={inputCls} />
</div>
</>
)}
</div>
{/* Docker 额外行:启动命令 */}
{deployType === "docker" && (
<div className="flex flex-col gap-1">
<label className="text-[11px] text-gray-400 font-medium">启动命令</label>
<input value={startCommand} onChange={(e) => setStartCommand(e.target.value)}
placeholder="docker compose up -d" className={inputCls} />
</div>
)}
<div className="flex justify-end">
<button onClick={() => linkMut.mutate()} disabled={!selectedServerId || linkMut.isPending}
className="px-4 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 disabled:opacity-50">
关联服务器
</button>
</div>
</div>
);
}
// ── 主面板(嵌入 ProjectCard) ──────────────────────────────────────────────
export function ProjectServerPanel({ projectId }: { projectId: string }) {
const [showLink, setShowLink] = useState(false);
const { data: links = [] } = useQuery({
queryKey: ["project-servers", projectId],
queryFn: () => getProjectServerLinks(projectId),
});
const linkedIds = new Set(links.map((l) => l.server_id));
return (
<div className="mt-3 mx-1 border border-gray-100 rounded-lg bg-white p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-gray-600">关联服务器</span>
<button onClick={() => setShowLink((v) => !v)}
className={`text-[11px] px-2 py-0.5 rounded border transition-colors ${
showLink
? "border-blue-300 bg-blue-50 text-blue-600"
: "border-gray-200 text-gray-400 hover:text-gray-600"
}`}>
{showLink ? "收起" : "+ 关联"}
</button>
</div>
{showLink && <LinkSelector projectId={projectId} linkedIds={linkedIds} />}
{links.length === 0 && !showLink && (
<p className="text-[11px] text-gray-300 text-center py-2">暂未关联服务器</p>
)}
{links.map((link) => (
<LinkedServerItem key={link.server_id} link={link} projectId={projectId} />
))}
</div>
);
}