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