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

297 lines
8.8 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-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`