Guanghu Domestic Migration a27e87cb99 chore: import sanitized domestic snapshot for REPO-007
Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc

[SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
2026-07-17 15:59:55 +08:00

12 KiB
Raw Blame History

GHCS v0.2 · terminal/page.tsx · SSH Web终端前端 · xterm.js

部署路径:~/guanghu-dev/ghcs-mono/frontend/src/app/console/terminal/page.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<HTMLDivElement>(null);
  const terminalRef = useRef<any>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const fitAddonRef = useRef<any>(null);

  const [servers, setServers] = useState<ServerInfo[]>([]);
  const [selectedServer, setSelectedServer] = useState<string>("");
  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 (
    <div className="space-y-4 h-[calc(100vh-8rem)] flex flex-col">
      <div className="flex items-center justify-between flex-shrink-0">
        <h1 className="text-2xl font-bold text-gray-100 flex items-center gap-3">
          <CommandLineIcon className="w-7 h-7 text-green-400" />
          SSH Web 终端
        </h1>
      </div>

      {/* 连接工具栏 */}
      <div className="flex items-center gap-4 flex-shrink-0">
        <div className="flex items-center gap-2">
          <ComputerDesktopIcon className="w-5 h-5 text-gray-400" />
          <select
            value={selectedServer}
            onChange={(e) => setSelectedServer(e.target.value)}
            disabled={connected}
            className="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-green-500 disabled:opacity-50"
          >
            {servers.map((s) => (
              <option key={s.key} value={s.key}>
                {s.name} ({s.host})
              </option>
            ))}
          </select>
        </div>

        <div className="flex items-center gap-2">
          {connected ? (
            <>
              <span className="flex items-center gap-1.5 text-green-400 text-sm">
                <SignalIcon className="w-4 h-4" />
                已连接
              </span>
              <button
                onClick={disconnect}
                className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-600/30 rounded-lg text-sm transition flex items-center gap-1.5"
              >
                <SignalSlashIcon className="w-4 h-4" />
                断开
              </button>
            </>
          ) : (
            <>
              <span className="text-gray-500 text-sm flex items-center gap-1.5">
                <SignalSlashIcon className="w-4 h-4" />
                未连接
              </span>
              <button
                onClick={connect}
                disabled={connecting || !xtermLoaded}
                className="px-4 py-1.5 bg-green-600 hover:bg-green-500 text-white rounded-lg text-sm transition flex items-center gap-1.5 disabled:opacity-50"
              >
                {connecting ? (
                  <>
                    <ArrowPathIcon className="w-4 h-4 animate-spin" />
                    连接中...
                  </>
                ) : (
                  <>
                    <SignalIcon className="w-4 h-4" />
                    连接
                  </>
                )}
              </button>
            </>
          )}
        </div>
      </div>

      {/* 错误提示 */}
      {error && (
        <div className="p-3 bg-red-900/30 border border-red-700/50 rounded-lg text-red-300 text-sm flex-shrink-0">
          {error}
        </div>
      )}

      {/* 终端窗口 */}
      <div className="flex-1 bg-[#0d1117] rounded-xl border border-gray-700/50 overflow-hidden min-h-0">
        {!xtermLoaded && (
          <div className="flex items-center justify-center h-full text-gray-500">
            <ArrowPathIcon className="w-6 h-6 animate-spin mr-2" />
            加载终端组件中...
          </div>
        )}
        <div
          ref={termRef}
          className="w-full h-full p-2"
          style= display: xtermLoaded ? "block" : "none" 
        />
      </div>

      {/* 提示 */}
      <div className="text-xs text-gray-600 flex-shrink-0">
        💡 提示:这是一个真实的 SSH 会话。所有命令直接在服务器上执行。
        操作前请确认命令正确。
      </div>
    </div>
  );
}

📦 依赖安装

cd ~/guanghu-dev/ghcs-mono/frontend
npm install @xterm/xterm @xterm/addon-fit @xterm/addon-web-links

🧭 侧边栏导航

frontend/src/app/console/layout.tsx 的导航项里加一项:

{
  name: "SSH终端",
  href: "/console/terminal",
  icon: CommandLineIcon,
}

并在文件顶部 import

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;
}