1.需求背景
后台管理类项目中经常有一种需求场景—在前端页面使用终端工具操作远程服务器。伴随此功能的往往还有内置命令,快捷键操作等附加需求。由于浏览器安全策略,想在前端项目中通过 js 或者是其他的方式唤起操作系统的终端程序,基本上没有这种可能。前端可以实现一个伪终端输入窗口,配合后端服务支持实现类似终端程序的效果。
2.技术调研
插件 |
问题 |
推荐指数 |
Xterm |
主流方案 |
⭐️⭐️⭐️⭐️ |
3.技术实现
- 依赖安装
1
| pnpm add @xterm/xterm @xterm/addon-fit
|
- 实例化 terminal
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <template> <div class="terminal-containet"> <div ref="terminalDom" class="terminal"></div> </div> </template>
<script setup> import { ref, onMounted, onBeforeUnmount } from "vue"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css";
onMounted(() => { initTerminal(); });
let terminalDom = ref(null); let terminalInstance = ref(null); let fitAddon = new FitAddon();
//初始化Terminal工具 const initTerminal = () => { terminalInstance.value = new Terminal({ fontSize: 14, fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace", theme: { background: "rgba(0,0,0,0)", }, cursorBlink: true, cursorStyle: "underline", scrollback: 100, tabStopWidth: 4, }); terminalInstance.value.open(terminalDom.value); terminalInstance.value.loadAddon(fitAddon); //加载自适应宽高插件 terminalInstance.value.focus(); //聚焦 }; </script>
|
- 页面视口变化监听:此步骤用于给后端服务创建的终端插件实时同步宽高,保证两端输入输出效果一致
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <script setup> import { useResizeObserver } from "@vueuse/core"; //terminal尺寸变化执行事件 const terminalSizeChange = () => { fitAddon.fit(); const { cols, rows } = terminalInstance.value; send( JSON.stringify({ event: "changeSize", data: { cols, rows }, }) ); }; //监听DOM元素尺寸变化重置Terminal useResizeObserver(terminalDom, terminalSizeChange); </script>
|
- websocket 信息交互:此步骤用于将键盘事件通过 websocket 同步到后端终端,模拟实时输入效果。与此同时前端根据后端返回的数据,实时将信息流赋值到 terminal 终端,显示真正的命令行交互效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| <script setup> ... import { useWebSocket} from "@vueuse/core"; ...
const { send, close } = useWebSocket("ws://127.0.0.1:8888", { onMessage: (ws, e) => { handleOnMessage(e); //onMessage事件监听 }, autoReconnect: { retries: 5, delay: 1000, onFailed() { console.error("websocket链接失败"); }, }, });
//监听键盘输入发送websocket通知 const handleOnData = (e) => { send(JSON.stringify({ event: "keyInput", data: e })); }; //处理websocket响应事件 const handleOnMessage = (e) => { const { event, data } = JSON.parse(e.data); // if (data == "\r") terminalInstance.value.writeln(""); if (event == "streamOutput") { terminalInstance.value.write(data); } }; </script>
|
Node 后端服务实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const { WebSocketServer } = require("ws"); const utf8 = require("utf8"); const SSHClient = require("ssh2").Client;
let wsIns; const wss = new WebSocketServer({ port: 8888 }); console.log("websocket服务已开启:ws://127.0.0.1:8888"); wss.on("connection", (ws) => { ws.sendData = (event, data) => { ws.send(JSON.stringify({ event, data })); }; ws.on("error", console.error); ws.on("message", (msg) => { const { event, data } = JSON.parse(msg); if (event == "createNewServer") { createNewServer(data); } }); ws.on("close", () => { clearTimeout(ws._closeTimeout, ws.send); }); ws._closeTimeout = setTimeout(() => { ws.terminate(); }, 600000); wsIns = ws; });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| const createNewServer = (config) => { const { cols, rows, host, username, password } = config; let ssh = new SSHClient(); ssh .on("ready", function () { wsIns.sendData("streamOutput", "SSH CONNECTION SUCCESS\r\n"); ssh.shell({ cols, rows }, (err, stream) => { if (err) { return wsIns.sendData( "streamOutput", "\r\n*** SSH SHELL ERROR: " + err.message + " ***\r\n" ); } wsIns.on("message", (msg) => { const { event, data } = JSON.parse(msg); if (event == "keyInput") { stream.write(data); } else if (event == "changeSize") { stream.setWindow(data.rows, data.cols); } }); stream .on("data", (d) => { wsIns.sendData( "streamOutput", utf8.decode(d.toString("binary")) ); }) .on("close", () => { ssh.end(); }); }); }) .on("close", function () { wsIns.sendData("streamOutput", "SSH CONNECTION CLOSED\r\n"); }) .on("error", function () { wsIns.sendData("streamOutput", "SSH CONNECTION ERROR\r\n"); }) .connect({ host, port: 22, username, password, }); };
|
4. 注意事项
- 前端键盘事件无需本地保存字符串后统一传递给后端,实时传输即可。中心思想,前端只展示后端返回数据。
- 后端服务注意 websocket 及 SSH 服务实现即可。中心思想:后端只做数据转发。
5.效果预览