版本: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()
}
},
}
}
持续更新中,有问题可以交流。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY