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

421 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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>
);
}
```
---
## 📦 依赖安装
```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;
}
```