Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
297 lines
8.8 KiB
Markdown
297 lines
8.8 KiB
Markdown
# GHCS v0.2 · terminal/page-v2.tsx · 修复版 · 直接import xterm
|
||
|
||
覆盖文件:`~/guanghu-dev/ghcs-mono/frontend/src/app/console/terminal/page.tsx`
|
||
|
||
**在 VS Code 里全选(⌘+A)旧代码 → 粘贴下面代码 → ⌘+S**
|
||
|
||
```tsx
|
||
"use client";
|
||
|
||
import { useEffect, useRef, useState, useCallback } from "react";
|
||
import { Terminal } from "@xterm/xterm";
|
||
import { FitAddon } from "@xterm/addon-fit";
|
||
import "@xterm/xterm/css/xterm.css";
|
||
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 term = useRef<Terminal | null>(null);
|
||
const fitAddon = useRef<FitAddon | null>(null);
|
||
const wsRef = useRef<WebSocket | null>(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 [termReady, setTermReady] = 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]);
|
||
|
||
// 初始化终端(页面加载时)
|
||
useEffect(() => {
|
||
if (!termRef.current) return;
|
||
|
||
const t = new 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",
|
||
},
|
||
});
|
||
|
||
const fit = new FitAddon();
|
||
t.loadAddon(fit);
|
||
t.open(termRef.current);
|
||
fit.fit();
|
||
|
||
term.current = t;
|
||
fitAddon.current = fit;
|
||
setTermReady(true);
|
||
|
||
return () => {
|
||
t.dispose();
|
||
};
|
||
}, []);
|
||
|
||
// 连接
|
||
const connect = useCallback(() => {
|
||
if (!selectedServer || !term.current) return;
|
||
|
||
setConnecting(true);
|
||
setError("");
|
||
|
||
// 先清理旧连接
|
||
if (wsRef.current) {
|
||
wsRef.current.close();
|
||
wsRef.current = null;
|
||
}
|
||
|
||
// 清屏
|
||
term.current.clear();
|
||
term.current.writeln("\x1b[32m正在连接到 " + selectedServer + "...\x1b[0m");
|
||
|
||
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.current?.focus();
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
if (event.data instanceof ArrayBuffer) {
|
||
term.current?.write(new Uint8Array(event.data));
|
||
} else {
|
||
try {
|
||
const msg = JSON.parse(event.data);
|
||
if (msg.type === "error") {
|
||
setError(msg.message);
|
||
term.current?.writeln("\r\n\x1b[31m[错误] " + msg.message + "\x1b[0m");
|
||
}
|
||
} catch {
|
||
term.current?.write(event.data);
|
||
}
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
setConnected(false);
|
||
term.current?.writeln("\r\n\x1b[33m[连接已断开]\x1b[0m");
|
||
};
|
||
|
||
ws.onerror = () => {
|
||
setError("WebSocket 连接失败");
|
||
setConnecting(false);
|
||
};
|
||
|
||
// 键盘输入
|
||
term.current.onData((data) => {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send(data);
|
||
}
|
||
});
|
||
|
||
// 窗口大小调整
|
||
const observer = new ResizeObserver(() => {
|
||
fitAddon.current?.fit();
|
||
if (term.current && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({
|
||
type: "resize",
|
||
cols: term.current.cols,
|
||
rows: term.current.rows,
|
||
}));
|
||
}
|
||
});
|
||
if (termRef.current) observer.observe(termRef.current);
|
||
}, [selectedServer, API_BASE]);
|
||
|
||
// 断开
|
||
const disconnect = useCallback(() => {
|
||
if (wsRef.current) {
|
||
wsRef.current.close();
|
||
wsRef.current = null;
|
||
}
|
||
setConnected(false);
|
||
term.current?.writeln("\r\n\x1b[33m已断开连接\x1b[0m");
|
||
}, []);
|
||
|
||
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 || !termReady}
|
||
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">
|
||
<div ref={termRef} className="w-full h-full" />
|
||
</div>
|
||
|
||
<div className="text-xs text-gray-600 flex-shrink-0">
|
||
💡 提示:点一下黑色终端区域,然后就可以输入命令了。这是一个真实的 SSH 会话。
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔑 与上一版的区别
|
||
|
||
- 用 `import { Terminal } from "@xterm/xterm"` 直接导入,不再动态加载
|
||
- 用 `import "@xterm/xterm/css/xterm.css"` 直接导入 CSS,不再加载 CDN
|
||
- 终端在页面一打开就初始化好,不需要等"加载中"
|
||
- 去掉了 `display: none/block` 切换,更简单稳定
|
||
- 去掉了 `(window as any)` 全局变量存储
|
||
|
||
---
|
||
|
||
## 部署
|
||
|
||
1. VS Code 打开 `page.tsx`(terminal 文件夹下的)
|
||
2. ⌘+A 全选 → 粘贴上面代码 → ⌘+S
|
||
3. 浏览器刷新 `http://localhost:3000/console/terminal`
|
||
4. 点连接 → 终端变黑 → 点一下黑框 → 敲 `whoami` |