import { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import "xterm/css/xterm.css"; import { Maximize2, Minimize2, X, Shield } from "lucide-react"; import { cn } from "@/lib/utils"; import { attachTerminalResize, fitTerminal, mountXterm, } from "@/lib/xterm-utils"; import { TerminalLoadingOverlay } from "@/components/common/TerminalLoadingOverlay"; import { SessionWatermark } from "@/components/common/SessionWatermark"; import { useUserStore } from "@/store"; import { credentialApi, serverCredentialApi } from "@/api"; import { toast } from "sonner"; import type { JsonData } from "@/types/common"; interface TerminalDialogProps { open: boolean; onOpenChange: (open: boolean) => void; serverId: number | null; serverName: string; serverIp: string; /** 服务器是否有可用的认证凭据(密码或SSH密钥) */ hasAuth?: boolean; } /** * 终端 Dialog — WebSocket SSH 终端 // Logan.Li * 打开时自动连接,关闭时自动断开 */ export function TerminalDialog({ open, onOpenChange, serverId, serverName, serverIp, hasAuth = true, }: TerminalDialogProps) { const { t } = useTranslation(); const containerRef = useRef(null); const termRef = useRef(null); const wsRef = useRef(null); const fitRef = useRef(null); const [connected, setConnected] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [hasOutput, setHasOutput] = useState(false); // 凭据选择状态(hasAuth=false 时显示) // Logan.Li const [credentials, setCredentials] = useState([]); const [selectedCredId, setSelectedCredId] = useState(""); const [assigning, setAssigning] = useState(false); const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志 // 加载凭据列表 useEffect(() => { if (!open || hasAuth) return; credentialApi .list() .then((res: JsonData) => setCredentials(res?.list || [])) .catch(() => {}); }, [open, hasAuth]); // 关联凭据 const handleAssignCredential = async () => { if (!selectedCredId || !serverId) return; try { setAssigning(true); await serverCredentialApi.assign(serverId, Number(selectedCredId)); toast.success(t("common.operationSuccess")); setAuthReady(true); } catch (e) { toast.error(e?.message || t("common.operationFailed")); } finally { setAssigning(false); } }; // 重置状态 useEffect(() => { if (!open) { setAuthReady(hasAuth); setSelectedCredId(""); } }, [open, hasAuth]); // 连接终端(authReady=true 时才执行) useEffect(() => { if (!open || !serverId || !authReady) return; setHasOutput(false); // 创建终端 const term = new XTerm({ cursorBlink: true, fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', allowProposedApi: true, theme: { background: "#0a0e14", foreground: "#d6d6d6", cursor: "#d6d6d6", selectionBackground: "rgba(255,255,255,0.2)", }, }); const fit = new FitAddon(); term.loadAddon(fit); termRef.current = term; fitRef.current = fit; // WebSocket 连接 const proto = location.protocol === "https:" ? "wss" : "ws"; const token = useUserStore.getState().token || ""; const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24&token=${encodeURIComponent(token)}`; const ws = new WebSocket(wsUrl); wsRef.current = ws; term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ status: 0, data })); } }); ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.status === 0 && typeof msg.data === "string") { setHasOutput(true); term.write(msg.data); } } catch { setHasOutput(true); term.write(typeof event.data === "string" ? event.data : ""); } }; ws.onopen = () => { setConnected(true); // 挂载后 fit + 同步 PTY 尺寸,避免右侧黑边 // Logan.Li mountXterm(term, fit, () => containerRef.current, ws); }; ws.onclose = () => { setConnected(false); setHasOutput(true); term.write(`\r\n\x1b[31m[${t("server.connectionClosed")}]\r\n`); }; ws.onerror = () => { setHasOutput(true); term.write(`\r\n\x1b[31m[${t("server.connectionError")}]\r\n`); }; return () => { try { ws.close(); term.dispose(); } catch { /* noop */ } termRef.current = null; wsRef.current = null; setConnected(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, serverId, authReady]); // 容器尺寸变化时 fit 并同步远端 PTY // Logan.Li useEffect(() => { if (!open || !authReady) return; let cleanup: (() => void) | undefined; let retry = 0; const attach = () => { const container = containerRef.current; const fit = fitRef.current; const term = termRef.current; const ws = wsRef.current; if (!container || !fit || !term) { if (retry++ < 30) requestAnimationFrame(attach); return; } cleanup = attachTerminalResize(container, fit, term, ws); try { fitTerminal(fit, term, ws); } catch { /* noop */ } }; attach(); return () => cleanup?.(); }, [open, authReady, fullscreen]); return ( e.preventDefault()} className={cn( "bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0", // 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画 "!duration-0 !transition-none", fullscreen ? "!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0" : "", )} style={ fullscreen ? { maxWidth: "100vw", width: "100vw", height: "100vh" } : { maxWidth: "960px", width: "92vw" } } > {t("common.podLogs")} - {serverName} {t("system.sshTerminalDesc")} {/* 终端头部 */}
{serverName} ({serverIp})
{/* 凭据选择器(无认证凭据时显示) */} {!authReady && (

{t("server.noAuthCred")}

{t("server.selectCredHint")}

{credentials.length === 0 ? (

{t("server.noCredAvailable")}

) : ( )}
)} {/* 终端容器 */} {authReady && (
)}
); }