随笔 - 32,  文章 - 0,  评论 - 0,  阅读 - 38089

版本:vue2 + xterm4.18.0 + xterm-addon-fit0.5.0
先看父组件,实现多开的功能

<template>
  <div id="podTerminal" v-loading="loading">
    <header class="header"> // shell多开头部标题展示
      <div class="branding">
        <a class="nav-link" @click="tabChange(item, index)" :class="{'active': current === item.id}" v-for="(item, index) in terminalList" :key="item.id">
          <strong class="dot" :class="{'err': errorMessage}"/>
          <span class="title"> {{shellName}}-{{index + 1}} </span>
          <span v-if="terminalList.length !== 1" style="margin-left: 15px; color: red;cursor:pointer" @click="deleteTitle(item)"><i class="el-icon-close"></i></span>
        </a>
        <div class="nav-add" @click="addTerminal"><i class="el-icon-plus"></i></div>
      </div>
    </header>
    <template class="terminal-wrapper" v-for="(item) in terminalList"> // 循环多开窗口展示
      <terminal :current="current" :title="item" :key='item.id' :isDispose="item.dispose" :socketUrl="item.url" @deleteItem="deleteTerm"></terminal>
    </template>
  </div>
</template>
<script>
import "xterm/css/xterm.css";
import Terminal from './components/Terminal.vue' // shell子组件
import { openWebShell } from '@/api/webshell'  // 获取websocket链接接口
export default {
  components: { Terminal },
  data () {
    return {
      errorMessage: null, 
      terminalList: [],
      loading: false,
      current: 1, // 当前title列表选中项的id
      currentTerm: 0, // 当前title列表选中项的index
      shellName: 'master1', // 标题名称
      socketURI: '' // websocket链接
    };
  },
  mounted () {
    this.socketURI = this.$route.query.url // 从别的页面跳转过来获取到websocket链接
    this.terminalList = [{ // term列表
      id: 1, url: this.socketURI, dispose: false
    }]
  },
  methods: {
    deleteTerm(term) { // 子组件关闭连接之后删除term
      const terminalList = JSON.parse(JSON.stringify(this.terminalList))
      let termIndex = 0
      terminalList.forEach((item, index) => { // 查找删除的是第几个
        if(item.id === term.id) {
          termIndex = index
        }
      })
      if(this.currentTerm == termIndex) { // 如果要删除的term就是当前的term
        this.currentTerm = this.currentTerm === 0 ? 1 : this.currentTerm - 1
        this.current = terminalList[this.currentTerm].id;
      }
      this.terminalList.splice(termIndex, 1)
      if(this.currentTerm > termIndex) { // 如果当前term的index 大于要删除的index
        this.currentTerm = this.currentTerm - 1
      }
    },
    deleteTitle(title) { // 点击头部的删除按钮,改变term的状态,子组件接收到后关闭连接
      this.terminalList.forEach(item => {
        if(item.id === title.id) {
          item.dispose = true
        }
      })
    },
    tabChange(item, index) { // 多开后的头部点击切换
      this.current = item.id;
      this.currentTerm = index;
    },
    async addTerminal() { // 新增term
      await this.getWebShell() // 这是我自己需要的判断,如果需要重新获取websocket链接,就要做这个操作
      const newTerminal = {
        id: new Date().getTime(),
        url: this.socketURI,
        dispose: false
      }
      this.terminalList.push(newTerminal) // 添加新term到列表中
      this.current = newTerminal.id // 把当前term的title  id,设置为最新添加的term的id
      this.currentTerm = this.terminalList.length - 1 // 把当前term的index设置为新增后的最后一个
    },
    getWebShell() { // 获取websocket链接
      return new Promise((resolve, reject) => {
        openWebShell(this.containerInstanceId ? `?id=${this.containerInstanceId}` : '').then((res) => {
          this.socketURI = res.data
          resolve(res)
        })
      })
    },
  }
}

子组件:Terminal.vue 实现网页shell交互功能,我这里做的是每输入一个字符就传给后端(别问为什么,问就是后端要求的),然后接收打印后端传入的值,我的输入不会在term中打印,因为我每次给后端发完消息后,如果不是回车符,后端都会给我返回同样的东西

<template>
  <div class="new-terminal">
    <div v-show="title.id === current" :key="title.id" class="webShell-panel" :ref="`terminal${title.id}`"> // ref是后面实例化term要用到的
      <div v-if="!errorMessage" :id="`terminal${title.id}`" style="height:100%"></div> // term展示的地方
      <div v-if="errorMessage" style="color: #E03b3b; padding: 50px; text-align: center;">{{errorMessage}}</div> // term报错信息展示
    </div>
  </div>
</template>
import "xterm/css/xterm.css";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit"; // 全屏适应
import { Notification } from 'element-ui';
import 'xterm/lib/xterm.js'
export default {
  props: {
    title: { // 父组件的term列表中的项
      type: Object,
      default: {}
    },
    isDispose: { // 当前term项是否关闭
      type: Boolean,
      default: false
    },
    current: { // 当前的title中的id
      type: Number,
      default: 1
    },
    socketUrl: { // 当前项的websocket链接
      type: String,
      default: ''
    }
  },
  watch: {
    isDispose(val) { // 监听是否需要关闭链接
      if (val) { // 需要关闭
        this.$emit('deleteItem', this.title)
        this.socket && this.socket.close() // 关闭websocket
        this.term && this.term.dispose() // 关闭xterm
        window.removeEventListener('resize', this.debounce(this.resizeScreen, 500)) // 关闭后移除resize监听事件
      }
    },
    current(val) {
      if(this.title.id === val) { // tab更换后手动调用resize方法重新设置cols和rows
        let myEvent = new Event('resize');
        window.dispatchEvent(myEvent);
      }
    }
  },
  data () {
    return {
      errorMessage: null, // 报错信息
      loading: false,
      shellName: 'terminal',
      sendMessage: true, // 用于判断是否是发送消息后第一次接收消息,过滤掉后端返回的与前端发送的一样的消息
      cmd: '', // 发送的消息
      term: null,
      socket: null,
      isCanWrite: false, // 是否可以输入
      selection: '', // 被选中的复制的值
      isResize: false, // 是否更改了窗口大小
      resizeFun: null,
      ratio: 0, // 浏览器放大缩小比例
    }
  },
  mounted() {
    this.socketURI = this.$route.query.url
    this.initSocket()
  },
  methods: {
    initTerm () { // 实例化term
      const _this = this // 一定要重新定义一个this,不然this指向会出问题
      term = new Terminal({
        fontSize: 16, // 字体
        cursorBlink: true, // 光标闪烁
        disableStdin: false, // 是否应禁用输入
        convertEol: true, // 启用时,光标将设置为下一行的开头
        scrollback: 10000, // 终端中的回滚量,可以尽量设置大一点的值,以供查看之前的信息
        theme: {
          foreground: 'white', // 字体颜色
          screenKeys: true
        }
      })
      const fitAddon = new FitAddon()
      const id =`terminal${this.current}` // 找到需要存放term的id标签
      term.loadAddon(fitAddon)
      term.open(document.getElementById(id))
      fitAddon.fit()
      term.focus() // 获取焦点
      term.onKey(e => {
        const printable = !e.domEvent.altKey && !e.domEvent.altGraphKey && !e.domEvent.ctrlKey && !e.domEvent.metaKey && !(e.domEvent.keyCode >= 37 && e.domEvent.keyCode <= 40)
        if (e.domEvent.keyCode === 13 && this.isCanWrite) { // 如果允许输入并且是输入的回车字符
          this.sendMessage = false
          if(this.cmd === 'clear') { // 如果是清屏
            term.clear()
          }
          _this.cmd = ''
        } 
/***********这段可以不用写,因为打tab键的时候默认就是传入`\t`***************/
        // else if (e.domEvent.keyCode === 9) {  // tab键补全
         // _this.send('\t') // 这是后端实现的前端不需要管,后端让传什么就传什么
        // }
/***********这段可以不用写,因为打tab键的时候默认就是传入`\t`   end****************/
      })
      term.onData(key => {
          this.isResize = false
          _this.send(key)
          _this.cmd += key
      })
      // term中的选中复制
      term.onSelectionChange(function() {
        const text = term.getSelection();
           if (term.hasSelection() && text) {
             去掉复制成功提示
            _this.$copyText(text).then( // 此处使用的`vue-clipboard2`插件,直接把在term中选中的部分复制到剪贴板中,term中自带的ctrl+shift+v用于用户粘贴
              () => { },
              () => { },
            );
          }
       });
      this.resizeFun = function() {
        try {
          fitAddon.fit()  // 重新适应宽高
          term.resize(term.cols, term.rows) // 重新适应宽高后,重新设置term的cols和rows
          term.scrollToBottom() // 滚动到term的最后一行
          this.send(`stty cols ${_this.cols} \n`) // 发送消息,设置服务器的shell和目前前端窗口的列相同,解决前端因为输入过多覆盖同一行的最前端输入问题,****这个问题最好是后端解决,前端解决有缺陷****
        } catch (e) {
          console.log("e", e.message);
        }
      }
      window.addEventListener("resize", this.debounce(this.resizeFun, 500)) // 实现监听浏览器窗口改变时,改变term的窗口大小
      this.term = term
    },
    debounce(fn, delay) { // 防抖
      let timer;
      return function ()  {
        if (timer) { // 如果有正在等待触发的事件,就清除定时器防止多次触发
          clearTimeout(timer)
        }
        timer = setTimeout (() => {
          fn && fn()
        }, delay)
      }
    },
    initSocket () { // 实例化websocket
      this.socket = new WebSocket(`ws://${this.socketUrl}`)
      this.socketOnClose()
      this.socketOnOpen()
      this.socketOnError()
      this.getMessage()
    },
    socketOnOpen () {
      const _this = this
      this.socket.onopen = () => {
        // 链接成功后
        this.initTerm()
        _this.term.write('连接中...\r\n') // 这只是websocket链接成功,与后端的term链接成功可能还有等待时间,所以先打印等待中
      }
    },
    socketOnClose () {
      const _this = this
      this.socket.onclose = () => {
        console.log('socket 关闭')
        _this.socket.close()
        _this.term && _this.term.writeln('连接关闭')
      }
    },
    socketOnError () {
      const _this = this
      _this.socket.onerror = () => {
        console.log('socket 链接失败')
       Notification('连接失败')
        _this.errorMessage = '连接失败'
      }
    },
    send (order) { // 发送消息
      if(this.socket.readyState != 1) { // 如果当前websocket已关闭,则不进行发送消息
        return
      }
      console.log('发送' + order)
      _this.socket.send(JSON.stringify({"operate": "command", "command": order})) // 发送消息的格式是与后端约定的
    },
    getMessage () { // 接收后端返回的消息
      const _this = this
      this.socket.onmessage = (evt) => {
        if(!this.isCanWrite) { // 第一次链接成功后,后端会返回消息,这时才可以进行其他操作
          _ _this.term.write('连接成功\r\n')
          _this.isCanWrite = true
          _this.send(`export TERM=xterm-color \r`) // 修复使用vim编辑,退出时,无法看到vim命令之前的内容
        }
        if(evt.data.includes('export TERM=xterm-color')) {****这个问题最好是调后端接口,后端解决,前端解决有缺陷****
          _this.send(`stty cols ${this.term.cols} \r`)
        }
        if(evt.data.includes('stty cols')) {****这个问题最好是调后端接口,后端解决,前端解决有缺陷****
          _this.send(`stty rows ${this.term.rows} \r`)
        }
        if(evt.data.includes('stty rows')) {****这个问题最好是调后端接口,后端解决,前端解决有缺陷****
          this.isResize = false
        }
        if(this.isResize) { // 做shell的cols和rows设置时不重复打印开头
          this.isResize = false
          return
        }
        _this.term.write(`${evt.data}`)
        _this.term.focus()
      }
    },
  }
}

持续更新中,有问题可以交流。

posted on   打怪升级小妮子  阅读(1205)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY

< 2025年3月 >
23 24 25 26 27 28 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 1 2 3 4 5
点击右上角即可分享
微信分享提示