421 lines
12 KiB
Markdown
421 lines
12 KiB
Markdown
|
|
# 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;
|
|||
|
|
}
|
|||
|
|
```
|