admin管理员组

文章数量:1531256

可远程连接系统终端或开启SSH登录的路由器和交换机。 相关资料: xtermjs/xterm.js: A terminal for the web (github)

后端实现(NestJS):

1、安装依赖: npm install node-ssh @nestjs/websockets @nestjs/platform-socket.io 2、我们将创建一个名为 RemoteControlModule 的 NestJS 模块,该模块将包括 SSH 服务、WebSocket 网关和必要的配置,运行cli: nest g module remoteControl nest g service remoteControl nest g gateway remoteControl --no-spec 3、remote-control.module.ts 模块
import { Module } from '@nestjs/common';
import { RemoteControlService } from './remote-control.service';
import { RemoteControlGateway } from './remote-control.gateway';

@Module({
  providers: [RemoteControlService, RemoteControlGateway],
})
export class RemoteControlModule {}
4、remote-control.service.ts SSH服务:
import { Injectable } from '@nestjs/common'; // 导入 NestJS 的 Injectable 装饰器,用于定义依赖注入的服务
import { NodeSSH } from 'node-ssh'; // 导入 node-ssh 库,用于实现 SSH 连接和命令执行
import { Duplex } from 'stream'; // 引入 Duplex 类型

@Injectable() // 使用 @Injectable 装饰器标记该类为可注入的服务
export class RemoteControlService {
  // 定义一个私有属性 sshSessions,它是一个 Map 类型的集合,用于存储 SSH 会话信息。
  // Map 的键是一个字符串,代表客户端的唯一标识符。
  // Map 的值是一个对象,包含以下属性:
  // ssh: NodeSSH 类型,表示一个 SSH 连接实例,用于执行 SSH 命令。
  // currentDirectory: 字符串类型,表示当前工作目录的路径。
  // homeDirectory: 字符串类型,表示用户的家目录路径。
  // shellStream: 可选的 Duplex 流类型,表示一个双向流,用于与 SSH 会话进行交互。
  private sshSessions = new Map<
    string,
    {
      ssh: NodeSSH;
      currentDirectory: string;
      homeDirectory: string;
      shellStream?: Duplex;
    }
  >();

  // 定义一个设备类型的字段,用于区分不同类型的设备
  private deviceType = 'linux'; // 默认设备类型为 Linux

  // 初始化会话
  initializeSession(clientId: string) {
    try {
      // 检查是否已存在会话,避免重复初始化
      if (this.sshSessions.has(clientId)) {
        console.log(`会话已存在: ${clientId}`);
        return;
      }

      // 创建新的会话状态
      this.sshSessions.set(clientId, {
        ssh: new NodeSSH(), // 创建一个新的 NodeSSH 实例
        currentDirectory: '/', // 默认当前目录为根目录
        homeDirectory: '/', // 默认家目录为根目录
        shellStream: undefined, // 初始时没有 shellStream
      });

      console.log(`会话初始化完成: ${clientId}`);
    } catch (error) {
      console.log('初始化会话时发生错误:', error);
    }
  }

  // 定义一个异步方法 startSSHSession,用于启动一个 SSH 会话
  async startSSHSession(
    host: string, // 主机地址
    username: string, // 用户名
    password: string, // 密码
    clientId: string, // 客户端标识符
    type: string, // 接收设备类型参数
  ): Promise<string> {
    // 设置设备类型
    this.deviceType = type;

    // 检查会话是否已经初始化
    const session = this.sshSessions.get(clientId);
    if (!session) {
      // 断开连接
      this.disconnect(clientId);
      return '会话未初始化, 请先初始化会话';
    }
    try {
      // 连接到 SSH 服务器
      await session.ssh.connect({
        host, // 服务器地址
        username, // 用户名
        password, // 密码
        port: 3122, // 指定端口为3122
        // 需要了解服务器支持哪些密钥交换算法。这可以通过使用 SSH 命令行工具(如 ssh)与 -Q kex 选项来查看,或者联系你的服务器管理员获取这些信息。
        algorithms: {
          kex: [
            'ecdh-sha2-nistp256',
            'ecdh-sha2-nistp384',
            'ecdh-sha2-nistp521',
            'diffie-hellman-group-exchange-sha256',
            'diffie-hellman-group14-sha1',
            'diffie-hellman-group1-sha1', // 这是一个较旧的算法,安全性较低,最后尝试
          ],
          cipher: [
            'aes128-ctr',
            'aes192-ctr',
            'aes256-ctr',
            'aes128-gcm@openssh',
            'aes256-gcm@openssh',
            'aes128-cbc',
            'aes192-cbc',
            'aes256-cbc', // CBC 模式的算法安全性较低,建议谨慎使用
          ],
          hmac: [
            'hmac-sha2-256',
            'hmac-sha2-512',
            'hmac-sha1', // SHA1 的 HMAC 也是较旧的选择
          ],
        },
      });
      // 请求一个 shell 流,可以指定终端类型。终端类型决定了终端的行为和功能,比如字符编码、颜色支持等。
      // 常见的终端类型包括 'xterm', 'vt100', 'ansi' 等。
      // 'xterm' 是最常用的终端类型,支持颜色和鼠标事件。
      // 'vt100' 是较旧的终端类型,功能较为基础。
      // 'ansi' 也是一种常见的终端类型,支持 ANSI 转义序列。
      const shellStream = await session.ssh.requestShell({
        term: 'xterm',
      });
      // 更新会话信息
      session.shellStream = shellStream; // shell 流

      // 如果是 Linux 终端
      if (this.deviceType == 'linux') {
        // 执行命令获取用户的家目录,并去除两端的空白字符
        const homeDirectory = (
          await session.ssh.execCommand('echo $HOME')
        ).stdout.trim();
        session.currentDirectory = homeDirectory; // 当前目录设置为家目录
        session.homeDirectory = homeDirectory; // 家目录
      } else {
        // 如果是路由器或交换机,执行其他初始化设置
        // 例如:session.currentDirectory = '/';
      }

      // 返回一个字符串,表示 SSH 会话已经启动
      return `SSH 会话成功启动, 主机: ${host}, 用户: ${username}`;
    } catch (error) {
      // 如果设备类型是路由器交换机,发送退出命令
      if (this.deviceType === 'device') {
        this.sendExitCommands(clientId);
      } else {
        this.disconnect(clientId);
      }
      return 'SSH 会话启动失败,失败原因:' + error.message;
    }
  }

  /**
   * 根据客户端标识符获取对应的 SSH 会话的 shell 流。
   * 如果会话不存在或 shell 流未初始化,则返回 undefined。
   * @param clientId 客户端标识符
   * @returns 返回对应的 Duplex 流,如果会话不存在或 shell 流未初始化则返回 undefined。
   */
  getShellStream(clientId: string): Duplex | undefined {
    try {
      const session = this.sshSessions.get(clientId);
      if (!session) {
        console.error(`未找到客户端ID为 ${clientId} 的会话`);
        return undefined;
      }
      if (!session.shellStream) {
        console.error(`客户端ID为 ${clientId} 的会话中未初始化 shell 流`);
        return undefined;
      }
      return session.shellStream;
    } catch (error) {
      console.log('获取 shell 流时发生错误:', error);
      return undefined;
    }
  }

  async sendExitCommands(clientId: string): Promise<string> {
    const session = this.sshSessions.get(clientId);
    if (!session) {
      return '会话不存在';
    }
    if (!session.ssh.isConnected()) {
      return 'SSH连接已关闭';
    }
    if (session.shellStream && session.shellStream.writable) {
      try {
        // 监听错误事件
        session.shellStream.on('error', (error) => {
          console.error('Shell流错误:', error);
          this.cleanupSession(clientId);
        });
        // 监听关闭事件
        session.shellStream.on('close', () => {
          console.log('Shell 流已关闭');
          // 移除所有监听器
          session.shellStream.removeAllListeners();
          this.cleanupSession(clientId);
        });

        session.shellStream.on('data', (data) => {
          console.log('从路由器接收到的数据:', data.toString());
        });
        console.log('-----发送退出命令-----');
        // 发送退出命令
        // await session.ssh.execCommand('\x1A'); // Ctrl+Z
        // await session.ssh.execCommand('quit');
        session.shellStream.write('\x1A');
        // 等待一段时间以确保命令被处理,执行quit命令会导致Shell流关闭,从而触发 close 事件
        session.shellStream.write('quit\n');
        // 确保命令发送完成
        await new Promise((resolve) => setTimeout(resolve, 500));
        session.shellStream.end(); // 关闭写入流
        console.log('-----退出命令已发送-----');
        return '退出命令已发送';
      } catch (error) {
        console.error('设备执行退出命令时发生错误:', error);
        return '设备执行退出命令时发生错误';
      }
    } else {
      // 如果Shell流不可写或不存在,则清理会话
      this.cleanupSession(clientId);
      return 'Shell流不可写或不存在';
    }
  }

  // 只释放ssh连接,不释放shell流
  async cleanupSession(clientId: string): Promise<void> {
    const session = this.sshSessions.get(clientId);
    if (session && session.ssh.isConnected()) {
      try {
        session.ssh.dispose();
      } catch (error) {
        console.error('释放SSH连接时发生错误:', error);
      }
    }
    this.sshSessions.delete(clientId);
    console.log('cleanupSession SSH会话和资源已成功释放');
  }

  // 释放ssh连接和shell流
  async disconnect(clientId: string): Promise<string> {
    console.log(`设备断开: ${clientId}`);
    const session = this.sshSessions.get(clientId);
    if (!session) {
      return '会话不存在';
    }
    try {
      // 关闭 shell 流并清除监听器
      if (session.shellStream) {
        //监听流结束事件
        session.shellStream.end();
        session.shellStream.removeAllListeners();
      }
      // 释放 SSH 连接
      if (session.ssh.isConnected()) {
        try {
          session.ssh.dispose();
        } catch (disposeError) {
          console.error('释放 SSH 连接时发生错误:', disposeError);
        }
      }
      // 从映射中删除会话
      this.sshSessions.delete(clientId);
      console.log('disconnect SSH会话和资源已成功释放');
      return 'SSH会话和资源已成功释放';
    } catch (error) {
      console.error('断开连接时发生错误:', error);
      return '断开连接时发生错误';
    }
  }
}

5、remote-control.gateway.ts WebSocket网关:

import {
  WebSocketGateway, // 导入WebSocketGateway装饰器,用于定义WebSocket网关
  WebSocketServer, // 导入WebSocketServer装饰器,用于注入WebSocket服务器实例
  SubscribeMessage, // 导入SubscribeMessage装饰器,用于定义处理WebSocket消息的方法
  ConnectedSocket, // 导入ConnectedSocket装饰器,用于获取连接的WebSocket客户端
  MessageBody,
} from '@nestjs/websockets'; // 从NestJS的WebSocket模块中导入装饰器
import { Socket, Server } from 'socket.io'; // 导入Socket.IO的Socket类型
// 导入远程控制服务
import { RemoteControlService } from './remote-control.service';

// 使用WebSocketGateway装饰器定义一个WebSocket网关,监听8113端口
@WebSocketGateway(8113, {
  // 允许跨域
  cors: {
    origin: '*', // 允许所有来源
  },
  // 定义命名空间
  namespace: 'control', // 默认是 /,如果设置成 /control,那么客户端连接的时候,就需要使用 ws://localhost:8113/control 这种形式
})
export class RemoteControlGateway {
  // 注入WebSocket服务器实例,需要向所有客户端广播消息时使用
  @WebSocketServer() server: Server;

  // 定义一个设备类型的字段,用于区分不同类型的设备
  private deviceType = 'linux'; // 默认设备类型为 Linux

  // 构造函数,注入远程控制服务
  constructor(private remoteControlService: RemoteControlService) {}

  // 当客户端连接时触发
  handleConnection(client: Socket) {
    console.log(`客户端接入: ${client.id}`);
    // 初始化 SSH 会话
    this.remoteControlService.initializeSession(client.id);
  }

  // 当客户端断开连接时触发
  handleDisconnect(client: Socket) {
    console.log(`客户端断开: ${client.id}`);
  }

  // 处理启动终端会话的请求,传入主机地址、用户名和密码、设备类型
  @SubscribeMessage('startTerminalSession')
  async handleStartTerminalSession(
    @MessageBody()
    data: { host: string; username: string; password: string; type: string },
    @ConnectedSocket() client: Socket,
  ) {
    const clientId = client.id;
    this.deviceType = data.type;
    try {
      // 启动 SSH 会话
      const message = await this.remoteControlService.startSSHSession(
        data.host,
        data.username,
        data.password,
        clientId,
        data.type, // 传递设备类型到服务层
      );
      // 获取 SSH 会话的 shell 流
      const shellStream = this.remoteControlService.getShellStream(clientId);
      if (shellStream) {
        // 监听 shell 流的 data 事件,当主机SSH会话有输出时触发
        shellStream.on('data', (data: Buffer) => {
          // 确保发送的是字符串格式的数据
          client.emit('terminalData', data.toString('utf8'));
        });
      }
      // 发送启动终端会话的成功消息
      client.emit('terminalSessionStarted', { message, clientId });
    } catch (error) {
      // 发送启动终端会话的失败消息
      client.emit('terminalSessionError', error.message);
      // 如果设备类型是路由器交换机,发送退出命令
      if (this.deviceType === 'device') {
        await this.remoteControlService.sendExitCommands(clientId);
      } else {
        // 执行断开设备连接
        this.remoteControlService.disconnect(clientId);
      }
    }
  }

  // 处理终端输入,传入客户端ID和输入的命令
  @SubscribeMessage('input')
  async handleInput(
    @MessageBody() data: { clientId: string; input: string },
    @ConnectedSocket() client: Socket,
  ) {
    if (client.disconnected) {
      console.log(`客户端 ${client.id} 已断开连接,停止处理输入`);
      return;
    }
    try {
      // 根据客户端 ID 获取 SSH 会话的 shell 流
      const shellStream = this.remoteControlService.getShellStream(
        data.clientId,
      );
      // 如果 shell 流存在且可写,将输入写入 shell 流
      if (shellStream && shellStream.writable) {
        // 检查输入是否为退格键,并发送正确的字符
        shellStream.write(data.input);
      } else {
        // 如果 shell 流不存在或不可写,发送错误消息
        client.emit(
          'terminalSessionError',
          'Shell终端不可用,请检查是否已启动终端会话.',
        );
        // 如果设备类型是路由器交换机,发送退出命令
        if (this.deviceType === 'device') {
          await this.remoteControlService.sendExitCommands(data.clientId);
        } else {
          // 执行断开设备连接
          this.remoteControlService.disconnect(data.clientId);
        }
      }
    } catch (error) {
      console.log('处理终端输入时发生错误:', error);
    }
  }

  // 处理断开终端连接的请求,传入客户端ID
  @SubscribeMessage('disconnectTerminal')
  async handleDisconnect1(
    @MessageBody() clientId: string,
    @ConnectedSocket() client: Socket,
  ) {
    console.log('进入 sendExitCommands 断开设备的方法:', clientId);
    // 如果设备类型是路由器交换机,发送退出命令
    if (this.deviceType == 'device') {
      client.emit('terminalDisconnected', '设备终端已断开');
      const message = await this.remoteControlService.sendExitCommands(
        clientId,
      );
      console.log('执行 sendExitCommands 设备命令之后:', message);
    } else {
      client.emit('terminalDisconnected', '系统终端已断开');
      // 执行断开设备连接
      this.remoteControlService.disconnect(clientId);
    }
  }
}

6、main.ts 未捕获异常进行捕获(如果连接的是路由器终端就需要这个配置):

import { NestFactory} from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';

async function bootstrap() {
  // 使用NestFactory.create()方法创建一个Nest应用实例
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  // 启用 CORS 跨域
  app.enableCors();
  // 为所有路由设置前缀
  app.setGlobalPrefix('api');

  // 在程序的入口点或适当的位置添加全局未捕获异常的监听器
  process.on('uncaughtException', (error) => {
    console.error('未捕获的异常:', error);
  });

  // 未处理的 Promise 拒绝的监听
  process.on('unhandledRejection', (reason, promise) => {
    console.error('未处理的拒绝:', promise, '原因:', reason);
  });

  // 使用app.listen()方法启动应用
  await app.listen(3000);
}
bootstrap();

前端实现(VUE3+xterm.js):

1、安装依赖: npm install @xterm/xterm @xterm/addon-fit @xterm/addon-attach socket.io-client 2、xterm终端实现:
<template>
  <div ref="terminalRef" class="terminal"></div>
</template>

<script setup>
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
// import { AttachAddon } from '@xterm/addon-attach'
import { io } from 'socket.io-client';
import { useEventListener } from '@vueuse/core';

const terminalRef = ref(null);
// 创建终端实例
const terminal = new Terminal({
  disableStdin: false, // 是否禁用输入
  fontSize: 16, // 字体大小
  fontFamily: 'Consolas, "Courier New", monospace', // 字体
  fontWeight: 'normal', // 字体粗细
  fontWeightBold: 'bold', // 粗体字体
  letterSpacing: 0, // 字符间距
  lineHeight: 1.0, // 行高
  scrollback: 1000, // 设置终端可以回溯的最大行数为1000行
  scrollSensitivity: 1, // 设置滚动条滚动时的灵敏度,数值越大滚动越快
  fastScrollModifier: 'Shift', // 设置快速滚动的修饰键,按住Shift键滚动滚动条时滚动速度会加快
  fastScrollSensitivity: 5, // 设置快速滚动的灵敏度,数值越大滚动越快
  logLevel: 'info', // 日志级别
  allowTransparency: true, // 背景是否应支持非不透明颜色,开启后支持 theme中使用 rgba
  theme: {
    cursor: '#ffffff', // 光标颜色
    cursorAccent: '#000000', // 光标的强调色
    foreground: '#ffffff', // 默认的前景色,即文本颜色
    background: '#1e1e1e', // 背景颜色
    selection: '#ffffff40', // 选择文本的颜色
  },
  convertEol: true, // 是否将回车符转换为换行符
  cursorBlink: true, // 光标闪烁
  cursorStyle: 'block', // 光标样式
  cursorWidth: 1, // 光标宽度
  altClickMovesCursor: true, // 如果启用,alt+click会将提示光标移动到鼠标下方的位置。默认值为true
  rightClickSelectsWord: true, // 右键点击选择单词
  windowsMode: true, // 是否启用Windows模式,如果启用,将使用Windows快捷键
});
// 创建适应容器的插件实例
const fitAddon = new FitAddon();
// 加载适应容器的插件
terminal.loadAddon(fitAddon);

// 创建socket连接
const socket = io('http://localhost:8113/control', {
  autoConnect: false, // 禁止自动连接
});
// const socket = io('wss://121.36.55.244:30446/ws?id=140035370511824')

// 存储从后端接收的 clientId
let clientId = null;

// 创建一个变量来存储累积的输入
let inputBuffer = '';

onMounted(() => {
  // 在页面上打开终端实例
  terminal.open(terminalRef.value);

  // 在下一个事件循环中调整终端以适应容器
  nextTick(() => {
    // 调整终端尺寸以适应容器
    fitAddon.fit();
    const charWidth = 14; // 字符的大概宽度,单位为像素
    const charHeight = 19; // 字符的大概高度,单位为像素

    const containerWidth = terminalRef.value.offsetWidth; // 终端容器的宽度
    const containerHeight = terminalRef.value.offsetHeight; // 终端容器的高度

    const cols = Math.floor(containerWidth / charWidth);
    const rows = Math.floor(containerHeight / charHeight);
    console.log('cols:', cols, 'rows:', rows);
    // 手动调整终端的列数和行数
    terminal.resize(cols, rows);

    // 明确调用 connect 方法连接服务器
    setTimeout(() => {
      socket.connect();
    }, 1000);
  });

  // 监听 socket 连接事件,会触发后端的 handleConnect 事件
  socket.on('connect', () => {
    console.log('connect:Socket 链接成功');
    // 向终端写入文本并换行
    terminal.writeln('connect:Socket 链接成功');
    // 创建 AttachAddon 实例,用于将终端连接到服务器的 shell
    // const attachAddon = new AttachAddon(socket)
    // 加载 AttachAddon 插件
    // terminal.loadAddon(attachAddon)

    // 连接到远程主机
    // const sshInfo = {
    //   host: '172.16.250.103',
    //   username: 'root',
    //   password: 'huawei@123',
    //   type: 'linux' // 连接的设备类型
    // }
    // 连接到路由器或交换机,3122
    const sshInfo = {
      host: '172.16.250.203',
      username: 'liuwenzhao',
      password: '1234@abcd',
      type: 'device',
    };
    // 发送开始终端会话的请求
    socket.emit('startTerminalSession', sshInfo);
  });

  // 监听 socket 断开连接事件,会触发后端的 handleDisconnect 事件
  socket.on('disconnect', () => {
    console.log('disconnect:Socket 断开连接. 请检查您的连接.');
    terminal.writeln('disconnect:Socket 断开连接. 请检查您的连接.');
  });

  // 监听 terminalSessionStarted 事件, 用于显示终端会话已经开始
  socket.on('terminalSessionStarted', (data) => {
    console.log('terminalSessionStarted:' + data.message);
    terminal.writeln('terminalSessionStarted:' + data.message);
    // 存储从后端接收的 clientId
    clientId = data.clientId;
  });
  // 监听 terminalSessionError 事件, 用于显示终端会话错误
  socket.on('terminalSessionError', (error) => {
    console.error('terminalSessionError:连接到主机失败:', error);
    terminal.writeln('terminalSessionError:连接到主机失败:', error);
  });
  // 监听 terminalDisconnected 事件, 用于显示终端已经断开连接
  socket.on('terminalDisconnected', (message) => {
    console.log('terminalDisconnected:' + message); // 可以在这里处理断开连接后的逻辑,如显示消息、清理资源等
    terminal.writeln('terminalDisconnected:' + message);
  });

  // 监听从服务器发送的消息
  socket.on('terminalData', (data) => {
    // 将从服务器接收到的数据写入终端
    terminal.write(data);
  });

  // onData 事件用于监听用户输入的命令
  terminal.onData((data) => {
    if (socket.connected && clientId) {
      socket.emit('input', { clientId, input: data });
    } else {
      console.error('Socket 未连接.');
      terminal.writeln('Socket 未连接.');
    }

    // 检测到回车键
    if (data == '\r') {
      // 如果用户输入的命令是 'clear',则清除终端内容
      if (inputBuffer.trim() == 'clear') {
        // 检查用户是否输入了 'clear' 命令
        terminal.clear(); // 清除终端内容
      }
      // 清空积累的输入
      inputBuffer = '';
    } else {
      // 累积输入到缓冲区
      inputBuffer += data;
    }
  });

  // onKey(callback({ key: string, domEvent: KeyboardEvent })) // key: 键盘按键的值,domEvent: 键盘事件
  // onData(callback(key: String)) // 类似于input的oninput事件,key代表的是输入的字符
  // onCursorMove(callback()) // 输入光标位置变动会触发,比如输入,换行等
  // onLineFeed(callback()) // 操作回车按钮换行时触发,自然输入换行不会触发
  // onScroll(callback(scrollLineNumber: number)) // 当输入的行数超过设定的行数后会触发内容的滚动,输入换行以及回车换行均会触发
  // onSelectionChange(callback()) // 操作鼠标左键选中/取消选中会触发
  // onRender(callback({start: number, end: number})) // 鼠标移出点击,移入点击以及输入模式下键盘按下都会触发,范围从“0”到“Terminal.rows-1”
  // onResize(callback({cols: number, rows: number})) // 在 open() 之后如果调用 resize 设置行列会触发改事件,返回新的行列数值
  // onTitleChange(callback()) // 标题更改触发,未找到对应的触发条件
  // onBell(callback()) // 为触发铃声时添加事件侦听器
});

// 断开终端连接
function disconnectTerminal() {
  // 发送断开终端连接的请求
  socket.emit('disconnectTerminal', clientId);
}

// 页面刷新或关闭时断开连接
useEventListener(window, 'beforeunload', (event) => {
  // 在这里执行你需要的操作
  console.log('页面即将刷新或关闭');
  disconnectTerminal();
  if (terminal) {
    // 销毁终端实例
    terminal.dispose();
  }
  // 如果你需要阻止页面关闭,可以使用以下代码
  event.preventDefault();
  event.returnValue = '';
});

// onUnmounted 组件卸载时断开连接,会触发 socket 的 disconnect 事件和后端的 handleDisconnect 事件
// onUnmounted(() => {
//   disconnectTerminal()
//   if (terminal) {
//     // 销毁终端实例
//     terminal.dispose()
//   }
// })
</script>

<style scoped lang="scss">
.terminal {
  width: 60%;
  height: 600px;
  overflow: hidden;
}
</style>

本文标签: 终端路由器交换机xtermnestjs