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>
此页面兼容了弹框和非弹框页面,做了两种样式处理判断