Files
aiops/frontend/src/components/server/TerminalDialog.tsx
T
attacker c6e0d6ca57 refactor(i18n): U42 残余硬编码中文彻底清零 — 33文件54处→0, zh/en 7847→7898对齐
全量 JSX children + toast/confirm + sr-only 描述硬编码中文清零:
- Kubernetes模块: UpdateStrategyCard maxSurge/maxUnavailable说明 + IngressCreateForm host域名/Service端口 + ServiceCreateForm 端口配置/selector + DeploymentEditService 无selector/个关联/暂无Service/Ingress + ClusterOverviewPage 核 + KubernetesClusterDialog 已配置 + KubectlTerminal/ResourceVisualEditor/CreateHPADialog/DeploymentEditContainers/Storage + PodInstancesTable/UpdateStrategySelectorCards
- AI模块: PredictiveCapacityTab AI深度解读/紧急/警告/7天预测/预计N天 + PredictiveCostTab AI深度解读 + event-handler.ts 高危操作确认toast
- Deploy模块: DeployCredentials 启用/禁用 + DeployRBAC 删除角色confirm + WorkspaceTermDialog + CredFormDialog Windows用户
- Monitor模块: AlertRulesTab 删除confirm/分钟 + PrometheusDatasourceManager/LogDatasourcePage 删除confirm + QueryHistoryPanel 条
- System模块: ServerFileBrowser/TerminalDialog/RdpAuditViewer sr-only + SystemManager/CredentialManager/TerminalSessionsTab 删除confirm + SecuritySettingsTab 30分钟/2小时
- Task模块: CronJobViewDialog 次

新增 61 翻译键 (k8s.editAdvanced 6 + k8s.editService 11 + k8s.cluster 2 + k8s.misc 9 + aiops.capacity 9 + aiops.cost 1 + aiops.cli 2 + deploy 4 + monitor 5 + system 10 + task 1 + common 2)
tsc -b=0, npm build✓ (17.71s), ESLint=0/43/any=0, go build=0, 17/17 测试通过
2026-07-14 00:51:36 +08:00

319 lines
10 KiB
TypeScript

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<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [hasOutput, setHasOutput] = useState(false);
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
const [selectedCredId, setSelectedCredId] = useState<string>("");
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
// 禁用 Radix Dialog 默认的 ESC 关闭:终端内 vi/top 等会用到 ESC,关闭只能点头部 X 按钮 // Logan.Li
onEscapeKeyDown={(e) => 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" }
}
>
<DialogTitle className="sr-only">{t("common.podLogs")} - {serverName}</DialogTitle>
<DialogDescription className="sr-only">{t("system.sshTerminalDesc")}</DialogDescription>
{/* 终端头部 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm">
<div
className={cn(
"w-2 h-2 rounded-full",
connected ? "bg-emerald-400" : "bg-red-400",
)}
/>
<span className="text-[var(--dg-text-primary)] font-medium">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* 凭据选择器(无认证凭据时显示) */}
{!authReady && (
<div
className="p-6 flex flex-col items-center justify-center gap-4"
style={{ height: "480px" }}
>
<Shield className="h-12 w-12 dg-text-yellow" />
<div className="text-center">
<p className="text-[var(--dg-text-primary)] font-medium mb-1">
{t("server.noAuthCred")}
</p>
<p className="text-sm text-[var(--dg-text-muted)]">
{t("server.selectCredHint")}
</p>
</div>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">
{t("server.noCredAvailable")}
</p>
) : (
<select
value={selectedCredId}
onChange={(e) => setSelectedCredId(e.target.value)}
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value="">{t("server.selectCred")}</option>
{credentials.map((c: JsonData) => (
<option key={c.id} value={String(c.id)}>
{c.type === "ssh_key" ? "🔑" : "🔒"} {c.name}
</option>
))}
</select>
)}
<Button
onClick={handleAssignCredential}
disabled={!selectedCredId || assigning}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{assigning ? t("server.linking") : t("server.confirmConnect")}
</Button>
</div>
)}
{/* 终端容器 */}
{authReady && (
<div
className="relative bg-[#0a0e14]"
style={{ height: fullscreen ? "calc(100vh - 49px)" : "520px" }}
>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<SessionWatermark className="opacity-60" />
<TerminalLoadingOverlay
show={!hasOutput}
label={t("server.terminalLoggingIn")}
/>
</div>
)}
</DialogContent>
</Dialog>
);
}