# GHCS v0.2 · terminal/page.tsx · SSH Web终端前端 · xterm.js 部署路径:`~/guanghu-dev/ghcs-mono/frontend/src/app/console/terminal/page.tsx` ```tsx "use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { CommandLineIcon, ComputerDesktopIcon, ArrowPathIcon, SignalIcon, SignalSlashIcon, } from "@heroicons/react/24/outline"; type ServerInfo = { key: string; name: string; host: string; }; export default function TerminalPage() { const termRef = useRef(null); const terminalRef = useRef(null); const wsRef = useRef(null); const fitAddonRef = useRef(null); const [servers, setServers] = useState([]); const [selectedServer, setSelectedServer] = useState(""); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(""); const [xtermLoaded, setXtermLoaded] = useState(false); const API_BASE = process.env.NEXT_PUBLIC_CONSOLE_API || ""; // 加载服务器列表 useEffect(() => { fetch(`${API_BASE}/ws/terminal/servers`) .then((r) => r.json()) .then((data) => { setServers(data.servers || []); if (data.servers?.length > 0 && !selectedServer) { setSelectedServer(data.servers[0].key); } }) .catch(() => setError("无法加载服务器列表")); }, [API_BASE]); // 动态加载 xterm.js useEffect(() => { let cancelled = false; async function loadXterm() { try { const [xtermMod, fitMod, webLinksMod] = await Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-web-links"), ]); if (cancelled) return; // 加载 CSS if (!document.querySelector("#xterm-css")) { const link = document.createElement("link"); link.id = "xterm-css"; link.rel = "stylesheet"; link.href = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.css"; document.head.appendChild(link); } // 存储到 ref (window as any).__xterm = xtermMod; (window as any).__fitAddon = fitMod; setXtermLoaded(true); } catch (e) { console.error("xterm.js 加载失败", e); setError("xterm.js 加载失败,请检查网络连接"); } } loadXterm(); return () => { cancelled = true; }; }, []); // 初始化终端实例 const initTerminal = useCallback(() => { const xtermMod = (window as any).__xterm; const fitMod = (window as any).__fitAddon; if (!xtermMod || !fitMod || !termRef.current) return null; const term = new xtermMod.Terminal({ cursorBlink: true, cursorStyle: "bar", fontSize: 14, fontFamily: '"JetBrains Mono", "Fira Code", Menlo, Monaco, monospace', theme: { background: "#0d1117", foreground: "#c9d1d9", cursor: "#58a6ff", selectionBackground: "#264f78", black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922", blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4", brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364", brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff", brightCyan: "#56d4dd", brightWhite: "#f0f6fc", }, allowProposedApi: true, rows: 40, cols: 120, }); const fitAddon = new fitMod.FitAddon(); term.loadAddon(fitAddon); term.open(termRef.current); fitAddon.fit(); return { term, fitAddon }; }, []); // 连接 const connect = useCallback(() => { if (!selectedServer || !xtermLoaded) return; setConnecting(true); setError(""); const result = initTerminal(); if (!result) { setError("终端初始化失败"); setConnecting(false); return; } const { term, fitAddon } = result; terminalRef.current = term; fitAddonRef.current = fitAddon; // 构建 WebSocket URL const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const apiHost = API_BASE ? API_BASE.replace(/^https?:/, "") : `//${window.location.host}`; const wsUrl = `${wsProtocol}${apiHost}/ws/terminal/${selectedServer}`; const ws = new WebSocket(wsUrl); ws.binaryType = "arraybuffer"; wsRef.current = ws; ws.onopen = () => { setConnected(true); setConnecting(false); term.focus(); }; ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { const uint8 = new Uint8Array(event.data); term.write(uint8); } else { try { const msg = JSON.parse(event.data); if (msg.type === "error") { setError(msg.message); term.writeln(`\r\n\x1b[31m[错误] ${msg.message}\x1b[0m`); } } catch { term.write(event.data); } } }; ws.onclose = () => { setConnected(false); term.writeln("\r\n\x1b[33m[连接已断开]\x1b[0m"); }; ws.onerror = () => { setError("WebSocket 连接失败"); setConnecting(false); }; // 键盘输入 → WebSocket term.onData((data: string) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }); // 窗口大小调整 const handleResize = () => { if (fitAddonRef.current) { fitAddonRef.current.fit(); } if (term && ws.readyState === WebSocket.OPEN) { const dims = { cols: term.cols, rows: term.rows }; ws.send(JSON.stringify({ type: "resize", ...dims })); } }; // 监听终端容器大小变化 const observer = new ResizeObserver(() => { if (fitAddonRef.current) { fitAddonRef.current.fit(); } if (term && ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }) ); } }); if (termRef.current) { observer.observe(termRef.current); } // 存储清理函数 (term as any).__cleanup = () => { observer.disconnect(); }; }, [selectedServer, xtermLoaded, initTerminal, API_BASE]); // 断开 const disconnect = useCallback(() => { if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } if (terminalRef.current) { const term = terminalRef.current; if ((term as any).__cleanup) { (term as any).__cleanup(); } term.dispose(); terminalRef.current = null; } setConnected(false); }, []); // 组件卸载时清理 useEffect(() => { return () => { if (wsRef.current) { wsRef.current.close(); } if (terminalRef.current) { terminalRef.current.dispose(); } }; }, []); return (

SSH Web 终端

{/* 连接工具栏 */}
{connected ? ( <> 已连接 ) : ( <> 未连接 )}
{/* 错误提示 */} {error && (
{error}
)} {/* 终端窗口 */}
{!xtermLoaded && (
加载终端组件中...
)}
{/* 提示 */}
💡 提示:这是一个真实的 SSH 会话。所有命令直接在服务器上执行。 操作前请确认命令正确。
); } ``` --- ## 📦 依赖安装 ```bash cd ~/guanghu-dev/ghcs-mono/frontend npm install @xterm/xterm @xterm/addon-fit @xterm/addon-web-links ``` ## 🧭 侧边栏导航 在 `frontend/src/app/console/layout.tsx` 的导航项里加一项: ```tsx { name: "SSH终端", href: "/console/terminal", icon: CommandLineIcon, } ``` 并在文件顶部 import: ```tsx import { CommandLineIcon } from "@heroicons/react/24/outline"; ``` ## 🎨 终端配色 采用 GitHub Dark 配色方案(与 VS Code 深色主题一致),xterm.js 原生渲染: - 背景 `#0d1117` · 前景 `#c9d1d9` - 光标蓝色 `#58a6ff` · 选中高亮 `#264f78` - 16 色 ANSI 全部映射到 GitHub Dark palette ## ⚡ 特性 - WebSocket 二进制传输 · 低延迟 - 终端窗口自适应(ResizeObserver) - 动态加载 xterm.js(减小首屏体积) - 服务器选择器 · 多服务器可扩展 - 一键连接/断开 - 连接状态实时显示 ## 🔧 Nginx 配置(生产环境) 如果 Nginx 反代了 console-api,需要支持 WebSocket Upgrade: ``` location /ws/ { proxy_pass http://127.0.0.1:5002; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400s; proxy_send_timeout 86400s; } ```