当前位置: 首页 > article >正文

xterm + vue3 + websocket 终端界面

 xterm.js    下载插件

// xterm
npm install --save xterm

// xterm-addon-fit    使终端适应包含元素
npm install --save xterm-addon-fit

// xterm-addon-attach   通过websocket附加到运行中的服务器进程
npm install --save xterm-addon-attach
<template>
  <div :class="props.type ? 'height305' : 'height160'">
    <el-row>
      <el-col :span="20">
        <div
          :class="['xterm', props.type ? 'heightA' : 'heightB']"
          ref="terminal"
          v-loading="loading"
          element-loading-text="拼命连接中"
        >
          <div class="terminal" id="terminal" ref="terminal"></div>
        </div>
        <div class="textarea">
          <textarea ref="textarea" v-model="quickCmd" />
          <div class="bottomOperate flexEnd">
            <el-button type="primary" @click="sendCmd" :disabled="!quickCmd"
              >发送命令</el-button
            >
          </div>
        </div>
      </el-col>
      <el-col :span="4">
        <div :class="['xtermR', props.type ? 'heightA' : 'heightBR']">
          <el-tabs
            v-model="tabActiveName"
            class="demo-tabs"
            @tab-click="handleClick"
          >
            <el-tab-pane label="常用命令" name="first">
              <div v-if="filteredGroups?.length > 0">
                <div class="marginBottom10">
                  <el-button
                    type="primary"
                    size="small"
                    @click="addCmdGroup('addGroup')"
                    >新增命令组</el-button
                  >
                  <el-button type="primary" size="small" @click="addCmd('add')"
                    >新增命令</el-button
                  >
                </div>
                <el-collapse
                  v-loading="loadingR"
                  :class="props.type ? 'listBoxA' : 'listBoxB'"
                >
                  <el-collapse-item
                    v-for="group in filteredGroups"
                    :name="group.name"
                    :key="group.name"
                    class="custom-collapse-item"
                  >
                    <template #title>
                      <div
                        class="flexSpaceBetween"
                        style="width: 100%"
                        @mouseenter="showActions(group.id, true)"
                        @mouseleave="showActions(group.id, false)"
                      >
                        <span class="collapse-title">{{ group.name }}</span>
                        <span v-show="actionStates[group.id]">
                          <el-button
                            link
                            type="primary"
                            @click="addCmdGroup('editGroup', group, $event)"
                            >编辑</el-button
                          >
                          <el-button
                            link
                            type="primary"
                            @click="del(group.id, 'group', $event)"
                            >删除</el-button
                          >
                        </span>
                      </div>
                    </template>
                    <template #default>
                      <div
                        v-for="item in group.device_command"
                        :key="item.id"
                        class="item flexSpaceBetween paddingRight20 marginBottom10"
                        @mouseenter="showActions1(item.id, true)"
                        @mouseleave="showActions1(item.id, false)"
                      >
                        <span
                          class="usualName"
                          @click="getName(item.name)"
                          :title="item.name"
                          >{{ item.name }}</span
                        >
                        <span v-show="actionStates1[item.id]" class="btns">
                          <el-button
                            link
                            type="primary"
                            @click="addCmd('edit', item, group.id)"
                            >编辑</el-button
                          >
                          <el-button link type="primary" @click="del(item.id)"
                            >删除</el-button
                          >
                        </span>
                      </div>
                    </template>
                  </el-collapse-item>
                </el-collapse>
              </div>
              <div class="flexCenter" v-else>暂无常用命令</div>
            </el-tab-pane>
            <el-tab-pane label="命令记录" name="second">
              <div
                :class="props.type ? 'listBoxA' : 'listBoxB'"
                v-if="globalStore.cmdRecordList?.length > 0"
              >
                <div
                  v-for="item in globalStore.cmdRecordList"
                  :key="item"
                  class="item flexSpaceBetween paddingRight20 marginBottom10"
                >
                  <span class="recordName" @click="getName(item)">{{
                    item
                  }}</span>
                </div>
              </div>
              <div class="flexCenter" v-else>暂无命令记录</div>
            </el-tab-pane>
          </el-tabs>
        </div>
      </el-col>
    </el-row>
  </div>
  <!-- 新增命令组 -->
  <AddTerminalGroup ref="addTerminalGroup" />
  <!-- 新增命令 -->
  <AddTerminal ref="addTerminal" />
</template>
<script setup>
import "xterm/css/xterm.css";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { debounce } from "lodash";
import { ElMessage, ElMessageBox } from "element-plus";
import {
  ref,
  reactive,
  onMounted,
  onBeforeUnmount,
  computed,
  nextTick,
  getCurrentInstance,
} from "vue";
import { useGlobalStore } from "@/stores/modules/global.js";
import AddTerminalGroup from "./AddTerminalGroup.vue";
import AddTerminal from "./AddTerminal.vue";
import {
  commandGroupIndex,
  commandGroupDel,
  commandDel,
} from "@/api/equipment";
import { WebSocketUrl } from "@/api/request";

const props = defineProps({
  type: {
    type: String,
    default: () => {
      return "";
    },
  },
  currentPathRes: {
    type: String,
    default: () => {
      return "/";
    },
  },
});
const globalStore = useGlobalStore();
const { proxy } = getCurrentInstance();
const searchTerm = ref("");
const tabActiveName = ref("first");
const cmdRecordList = ref(globalStore.cmdRecordList); // 命令历史记录
const loadingR = ref(false);
const groups = ref([]);
const quickCmd = ref("");
const actionStates = ref({});
const actionStates1 = ref({});

const filteredGroups = computed(() => {
  if (!searchTerm.value) {
    return groups.value;
  }
  return groups.value
    .map((group) => {
      const filteredItems = group.device_command.filter((item) =>
        item.includes(searchTerm.value)
      );
      return {
        ...group,
        device_command: filteredItems,
      };
    })
    .filter((group) => group.device_command.length > 0);
});

const showActions = (id, show) => {
  actionStates.value[id] = show;
};

const showActions1 = (id, show) => {
  actionStates1.value[id] = show;
};

const addCmdGroup = (type, row, event) => {
  if (event) event.stopPropagation();
  nextTick(() => {
    proxy.$refs["addTerminalGroup"].showDialog({
      type,
      row,
    });
  });
};

const addCmd = (type, row, group_id) => {
  nextTick(() => {
    proxy.$refs["addTerminal"].showDialog({
      type,
      groupList: groups.value,
      row,
      group_id,
    });
  });
};

const getName = (val) => {
  quickCmd.value = val;
};

// 发送命令
const sendCmd = () => {
  if (isWsOpen()) {
    terminalSocket.value.send(quickCmd.value);
    // 处理命令历史记录
    handleCmdRecordList(quickCmd.value);
  }
};

const handleCmdRecordList = (newCmd) => {
  if (newCmd) {
    // 对新命令进行trim处理
    const trimmedCmd = newCmd.trim();
    // 检查是否有重复值并删除
    const index = cmdRecordList.value.indexOf(trimmedCmd);
    if (index !== -1) {
      cmdRecordList.value.splice(index, 1);
    }

    // 将新命令添加到数组最前面
    cmdRecordList.value.unshift(trimmedCmd);

    globalStore.setCmdRecordList(cmdRecordList.value);
  }
};

const del = (id, group, event) => {
  if (event) event.stopPropagation();

  ElMessageBox.confirm("确认删除吗?", "删除", {
    confirmButtonText: "确定",
    cancelButtonText: "取消",
    type: "warning",
  })
    .then(() => {
      if (group) {
        commandGroupDel({ id }).then((res) => {
          if (res.status === 200) {
            ElMessage.success("删除成功");
            getTableData();
          }
        });
      } else {
        commandDel({ id }).then((res) => {
          if (res.status === 200) {
            ElMessage.success("删除成功");
            getTableData();
          }
        });
      }
    })
    .catch(() => {});
};

//获取表格数据
const getTableData = () => {
  loadingR.value = true;
  commandGroupIndex()
    .then((res) => {
      loadingR.value = false;
      if (res.status === 200) {
        groups.value = res.data?.list;
      }
    })
    .catch((error) => {
      loadingR.value = false;
    });
};
// 命令列表
getTableData();
//终端信息
const loading = ref(false);
const terminal = ref(null);
const fitAddon = new FitAddon();
let first = ref(true);
let terminalSocket = ref(null);
let term = ref(null);

// 初始化WS
const initWS = () => {
  if (!terminalSocket.value) {
    createWS();
  }

  if (terminalSocket.value && terminalSocket.value.readyState > 1) {
    terminalSocket.value.close();
    createWS();
  }
};

// 创建WS
const createWS = () => {
  loading.value = true;
  terminalSocket.value = new WebSocket(
    WebSocketUrl + globalStore.wsUrl
  );
  terminalSocket.value.onopen = runRealTerminal; //WebSocket 连接已建立
  terminalSocket.value.onmessage = onWSReceive; //收到服务器消息
  terminalSocket.value.onclose = closeRealTerminal; //WebSocket 连接已关闭
  terminalSocket.value.onerror = errorRealTerminal; //WebSocket 连接出错
};

//WebSocket 连接已建立
const runRealTerminal = () => {
  loading.value = false;
  let sendData = JSON.stringify({
    t: "conn",
  });
  terminalSocket.value.send(sendData);
};
//WebSocket收到服务器消息
const onWSReceive = (event) => {
  // 首次接收消息,发送给后端,进行同步适配尺寸
  if (first.value === true) {
    first.value = false;
    resizeRemoteTerminal();
    if (props.type === "termDia") {
      autoWriteInfo();
    }
  }
  const blob = new Blob([event.data.toString()], {
    type: "text/plain",
  });
  //将Blob 对象转换成字符串
  const reader = new FileReader();
  reader.readAsText(blob, "utf-8");
  reader.onload = (e) => {
    // 可以根据返回值判断使用何种颜色或者字体,不过返回值自带了一些字体颜色
    writeOfColor(reader.result);
  };
};

//WebSocket 连接出错
const errorRealTerminal = (ex) => {
  let message = ex.message;
  if (!message) message = "disconnected";
  term.value.write(`\x1b[31m${message}\x1b[m\r\n`);
  loading.value = false;
};
//WebSocket 连接已关闭
const closeRealTerminal = () => {
  loading.value = false;
};

// 初始化Terminal
const initTerm = () => {
  term.value = new Terminal({
    rendererType: "canvas", //渲染类型
    // rows: 50, //行数,影响最小高度
    // cols: 100, // 列数,影响最小宽度
    convertEol: true, //启用时,光标将设置为下一行的开头
    // scrollback: 50, //终端中的滚动条回滚量
    disableStdin: false, //是否应禁用输入。
    cursorStyle: "underline", //光标样式
    cursorBlink: true, //光标闪烁
    theme: {
      foreground: "#F8F8F8",
      background: "#2D2E2C",
      cursor: "help", //设置光标
      lineHeight: 16,
    },
    fontFamily: '"Cascadia Code", Menlo, monospace',
  });
  // writeDefaultInfo();
  // 弹框自动输入
  term.value.open(terminal.value); //挂载dom窗口
  term.value.loadAddon(fitAddon); //自适应尺寸
  term.value.focus();
  termData(); //Terminal 事件挂载
};

const autoWriteInfo = () => {
  let sendData = "\n" + "cd " + props.currentPathRes + "\n";
  // term.value.write(`\x1b[37m${sendData}\x1b[m`);
  // term.value.write("\r\n");
  if (isWsOpen()) {
    terminalSocket.value.send(sendData);
  }
};

const writeDefaultInfo = () => {
  let defaultInfo = [
    "┌\x1b[1m terminals \x1b[0m─────────────────────────────────────────────────────────────────┐ ",
    "│                                                                            │ ",
    "│  \x1b[1;34m 欢迎使用XS  SSH   \x1b[0m                                                       │ ",
    "│                                                                            │ ",
    "└────────────────────────────────────────────────────────────────────────────┘ ",
  ];
  term.value.write(defaultInfo.join("\n\r"));
  term.value.write("\r\n");
  // writeOfColor('我是加粗斜体红色的字呀', '1;3;', '31m')
};

const writeOfColor = (txt, fontCss = "", bgColor = "") => {
  // 在Linux脚本中以 \x1B[ 开始,中间前部分是样式+内容,以 \x1B[0m 结尾
  // 示例 \x1B[1;3;31m 内容 \x1B[0m
  // fontCss
  // 0;-4;字体样式(0;正常 1;加粗 2;变细 3;斜体 4;下划线)
  // bgColor
  // 30m-37m字体颜色(30m:黑色 31m:红色 32m:绿色 33m:棕色字 34m:蓝色 35m:洋红色/紫色 36m:蓝绿色/浅蓝色 37m:白色)
  // 40m-47m背景颜色(40m:黑色 41m:红色 42m:绿色 43m:棕色字 44m:蓝色 45m:洋红色/紫色 46m:蓝绿色/浅蓝色 47m:白色)
  // console.log("writeOfColor", term)
  term.value.write(`\x1b[37m${fontCss}${bgColor}${txt}\x1b[m`);
  // term.value.write(`\x1B[${fontCss}${bgColor}${txt}\x1B[0m`);
};

// 终端输入触发事件
const termData = () => {
  fitAddon.fit();
  // 输入与粘贴的情况,onData不能重复绑定,不然会发送多次
  term.value.onData((data) => {
    // console.log(data, "传入服务器");
    if (isWsOpen()) {
      terminalSocket.value.send(data);
    }
  });
  // 终端尺寸变化触发
  term.value.onResize(() => {
    resizeRemoteTerminal();
  });
};

//尺寸同步 发送给后端,调整后端终端大小,和前端保持一致,不然前端只是范围变大了,命令还是会换行
const resizeRemoteTerminal = () => {
  const { cols, rows } = term.value;
  if (isWsOpen()) {
    terminalSocket.value.send(
      JSON.stringify({
        t: "resize",
        width: rows,
        height: cols,
      })
    );
  }
};

// 是否连接中0 1 2 3 状态
const isWsOpen = () => {
  // console.log(terminalSocket.value, "terminalSocket.value");
  const readyState = terminalSocket.value && terminalSocket.value.readyState;
  return readyState === 1;
};

// 适应浏览器尺寸变化
const fitTerm = () => {
  fitAddon.fit();
};
const onResize = debounce(() => fitTerm(), 500);
const onTerminalResize = () => {
  window.addEventListener("resize", onResize);
};
const removeResizeListener = () => {
  window.removeEventListener("resize", onResize);
};

//*生命周期函数
onMounted(() => {
  initWS();
  initTerm();
  onTerminalResize();
});

onBeforeUnmount(() => {
  removeResizeListener();
  let sendData = JSON.stringify({
    t: "close",
  });
  if (isWsOpen()) {
    terminalSocket.value.send(sendData);
    terminalSocket.value && terminalSocket.value.close();
  }
});

// 暴露方法
defineExpose({ getTableData });
</script>
<style lang="scss" scoped>
.xterm {
  position: relative;
  width: 100%;
  background: rgb(45, 46, 44);
}

.xtermR {
  position: relative;
  width: 100%;
  background: #fff;
  padding: 10px;
  position: relative;
  // overflow: hidden;
  .listBoxA {
    overflow-y: auto;
    height: calc(100vh - 450px);
  }
  .listBoxB {
    overflow-y: auto;
    height: calc(100vh - 300px);
  }
}

.heightA {
  height: calc(100vh - 400px);
}
.heightB {
  height: calc(100vh - 235px);
}
.heightBR {
  height: calc(100vh - 155px);
}

.usualName {
  width: calc(100% - 80px);
  display: inline-block;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.btns {
  width: 80px;
}

.textarea {
  overflow: hidden;
  position: relative;
  height: 80px;
  background: #ffffff;

  textarea {
    width: 100%;
    height: 90px;
    border: 0 none;
    outline: none;
    resize: none;
    font-size: 15px;
    overflow-y: auto;
    padding: 5px;
    background: #ffffff;
  }
  .bottomOperate {
    position: absolute;
    right: 10px;
    bottom: 10px;
  }
}
.recordName {
  font-size: 13px;
  color: #303133;
  cursor: pointer;
  margin-bottom: 10px;
  width: 100%;
}
.flexCenter {
  font-size: 14px;
  padding-top: 150px;
}
</style>

 此页面兼容了弹框和非弹框页面,做了两种样式处理判断


http://www.kler.cn/a/460668.html

相关文章:

  • 黑马JavaWeb开发跟学(十五).Maven高级
  • AE RFG 1251 Generator User Manual
  • df.replace({‘b‘: r‘\s*(\.)\s*‘}, {‘b‘: r‘\1ty‘}, regex=True)
  • C++ 设计模式:备忘录模式(Memento Pattern)
  • WeNet:面向生产的流式和非流式端到端语音识别工具包
  • 批量读取pdf发票中二维码的信息
  • [2474].第04节:Activiti官方画流程图方式
  • 【开源免费】基于SpringBoot+Vue.JS安康旅游网站(JAVA毕业设计)
  • spring cloud-skywalking入门指南
  • XShell实现自动化执行脚本.sh文件)(网络安全检查)
  • 2024年的年终总结
  • vue.js 组件通信
  • HTML5实现喜庆的新年快乐网页源码
  • LiteFlow 流程引擎引入Spring boot项目集成pg数据库
  • 初始JavaEE篇 —— Maven相关配置
  • (echarts)ECharts折线图堆叠设置为不堆叠的方法
  • 华为消费级QLC SSD来了
  • TCP粘/拆包----自定义消息协议
  • Python 的 abc 模块 抽象基类(Abstract Base Classes)
  • 建造者模式详解
  • Java - 日志体系_Apache Commons Logging(JCL)日志接口库_桥接Logback 及 源码分析
  • 04、JUC并发编程之:简单概述(四)
  • pg_wal 目录下 wal 日志文件异常累积过大
  • 慧眼识词:解析TF-IDF工作原理
  • python爬虫--小白篇【selenium自动爬取文件】
  • 微信小程序自定义表格样式