mirror of
https://gitee.com/dromara/mayfly-go
synced 2025-11-07 01:40:25 +08:00
328 lines
8.1 KiB
Vue
328 lines
8.1 KiB
Vue
<template>
|
||
<div id="terminal-body" :style="{ height }">
|
||
<div ref="terminalRef" class="terminal" />
|
||
|
||
<TerminalSearch ref="terminalSearchRef" :search-addon="state.addon.search" @close="focus" />
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import '@xterm/xterm/css/xterm.css';
|
||
import { Terminal, ITheme } from '@xterm/xterm';
|
||
import { FitAddon } from '@xterm/addon-fit';
|
||
import { SearchAddon } from '@xterm/addon-search';
|
||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||
|
||
import { storeToRefs } from 'pinia';
|
||
import { useThemeConfig } from '@/store/themeConfig';
|
||
import { ref, nextTick, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
|
||
import TerminalSearch from './TerminalSearch.vue';
|
||
import { debounce } from 'lodash';
|
||
import { TerminalStatus } from './common';
|
||
import { useEventListener } from '@vueuse/core';
|
||
import themes from './themes';
|
||
import { TrzszFilter } from 'trzsz';
|
||
import { useI18n } from 'vue-i18n';
|
||
|
||
const { t } = useI18n();
|
||
|
||
const props = defineProps({
|
||
// mounted时,是否执行init方法
|
||
mountInit: {
|
||
type: Boolean,
|
||
default: true,
|
||
},
|
||
/**
|
||
* 初始化执行命令
|
||
*/
|
||
cmd: { type: String },
|
||
/**
|
||
* 连接url
|
||
*/
|
||
socketUrl: {
|
||
type: String,
|
||
},
|
||
/**
|
||
* 高度
|
||
*/
|
||
height: {
|
||
type: [String, Number],
|
||
default: '100%',
|
||
},
|
||
});
|
||
|
||
const emit = defineEmits(['statusChange']);
|
||
|
||
const terminalRef: any = ref(null);
|
||
const terminalSearchRef: any = ref(null);
|
||
|
||
const { themeConfig } = storeToRefs(useThemeConfig());
|
||
|
||
// 终端实例
|
||
let term: Terminal;
|
||
let socket: WebSocket;
|
||
let pingInterval: any;
|
||
|
||
const state = reactive({
|
||
// 插件
|
||
addon: {
|
||
fit: null as any,
|
||
search: null as any,
|
||
weblinks: null as any,
|
||
},
|
||
status: -11,
|
||
});
|
||
|
||
onMounted(() => {
|
||
if (props.mountInit) {
|
||
init();
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => state.status,
|
||
() => {
|
||
emit('statusChange', state.status);
|
||
}
|
||
);
|
||
|
||
// 监听 themeConfig terminalTheme配置的变化
|
||
watch(
|
||
() => themeConfig.value.terminalTheme,
|
||
() => {
|
||
term.options.theme = getTerminalTheme();
|
||
}
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
close();
|
||
});
|
||
|
||
function init() {
|
||
state.status = TerminalStatus.NoConnected;
|
||
if (term) {
|
||
console.log('重新连接...');
|
||
close();
|
||
}
|
||
nextTick(() => {
|
||
initTerm();
|
||
});
|
||
}
|
||
|
||
async function initTerm() {
|
||
term = new Terminal({
|
||
fontSize: themeConfig.value.terminalFontSize || 15,
|
||
fontWeight: themeConfig.value.terminalFontWeight || 'normal',
|
||
fontFamily: 'JetBrainsMono, monaco, Consolas, Lucida Console, monospace',
|
||
cursorBlink: true,
|
||
disableStdin: false,
|
||
allowProposedApi: true,
|
||
fastScrollModifier: 'ctrl',
|
||
theme: getTerminalTheme(),
|
||
});
|
||
|
||
term.open(terminalRef.value);
|
||
|
||
// 注册自适应组件
|
||
const fitAddon = new FitAddon();
|
||
state.addon.fit = fitAddon;
|
||
term.loadAddon(fitAddon);
|
||
fitTerminal();
|
||
// 注册窗口大小监听器
|
||
useEventListener('resize', debounce(fitTerminal, 400));
|
||
|
||
initSocket();
|
||
// 注册其他插件
|
||
loadAddon();
|
||
|
||
// 注册自定义快捷键
|
||
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
|
||
// 注册搜索键 ctrl + f
|
||
if (event.key === 'f' && (event.ctrlKey || event.metaKey) && event.type === 'keydown') {
|
||
event.preventDefault();
|
||
terminalSearchRef.value.open();
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function initSocket() {
|
||
if (!props.socketUrl) {
|
||
return;
|
||
}
|
||
socket = new WebSocket(`${props.socketUrl}&rows=${term?.rows}&cols=${term?.cols}`);
|
||
// 监听socket连接
|
||
socket.onopen = () => {
|
||
// 注册心跳
|
||
pingInterval = setInterval(sendPing, 15000);
|
||
state.status = TerminalStatus.Connected;
|
||
|
||
focus();
|
||
fitTerminal();
|
||
|
||
// 如果有初始要执行的命令,则发送执行命令
|
||
if (props.cmd) {
|
||
sendCmd(props.cmd + ' \r');
|
||
}
|
||
};
|
||
|
||
// 监听socket错误信息
|
||
socket.onerror = (e: Event) => {
|
||
term.writeln(`\r\n\x1b[31m${t('components.terminal.connErrMsg')}`);
|
||
state.status = TerminalStatus.Error;
|
||
console.log('连接错误', e);
|
||
};
|
||
|
||
socket.onclose = (e: CloseEvent) => {
|
||
console.log('terminal socket close...', e.reason);
|
||
state.status = TerminalStatus.Disconnected;
|
||
};
|
||
}
|
||
|
||
function loadAddon() {
|
||
// 注册搜索组件
|
||
const searchAddon = new SearchAddon();
|
||
state.addon.search = searchAddon;
|
||
term.loadAddon(searchAddon);
|
||
|
||
// 注册 url link组件
|
||
const weblinks = new WebLinksAddon();
|
||
state.addon.weblinks = weblinks;
|
||
term.loadAddon(weblinks);
|
||
|
||
// 注册 trzsz
|
||
// initialize trzsz filter
|
||
const trzsz = new TrzszFilter({
|
||
// write the server output to the terminal
|
||
writeToTerminal: (data: any) => term.write(typeof data === 'string' ? data : new Uint8Array(data)),
|
||
// send the user input to the server
|
||
sendToServer: sendCmd,
|
||
// the terminal columns
|
||
terminalColumns: term.cols,
|
||
// there is a windows shell
|
||
isWindowsShell: false,
|
||
});
|
||
|
||
// let trzsz process the server output
|
||
socket?.addEventListener('message', (e) => trzsz.processServerOutput(e.data));
|
||
// let trzsz process the user input
|
||
term.onData((data) => trzsz.processTerminalInput(data));
|
||
term.onBinary((data) => trzsz.processBinaryInput(data));
|
||
term.onResize((size) => {
|
||
sendResize(size.cols, size.rows);
|
||
// tell trzsz the terminal columns has been changed
|
||
trzsz.setTerminalColumns(size.cols);
|
||
});
|
||
// enable drag files or directories to upload
|
||
terminalRef.value.addEventListener('dragover', (event: Event) => event.preventDefault());
|
||
terminalRef.value.addEventListener('drop', (event: any) => {
|
||
event.preventDefault();
|
||
trzsz
|
||
.uploadFiles(event.dataTransfer.items)
|
||
.then(() => console.log('upload success'))
|
||
.catch((err: any) => console.log(err));
|
||
});
|
||
}
|
||
|
||
// 写入内容至终端
|
||
const write2Term = (data: any) => {
|
||
term.write(data);
|
||
};
|
||
|
||
const writeln2Term = (data: any) => {
|
||
term.writeln(data);
|
||
};
|
||
|
||
const getTerminalTheme = () => {
|
||
const terminalTheme = themeConfig.value.terminalTheme;
|
||
// 如果不是自定义主题,则返回内置主题
|
||
if (terminalTheme != 'custom') {
|
||
return (themes as any)[terminalTheme];
|
||
}
|
||
|
||
// 自定义主题
|
||
return {
|
||
foreground: themeConfig.value.terminalForeground || '#7e9192', //字体
|
||
background: themeConfig.value.terminalBackground || '#002833', //背景色
|
||
cursor: themeConfig.value.terminalCursor || '#268F81', //设置光标
|
||
// cursorAccent: "red", // 光标停止颜色
|
||
} as ITheme;
|
||
};
|
||
|
||
// 自适应终端
|
||
const fitTerminal = () => {
|
||
state.addon.fit.fit();
|
||
};
|
||
|
||
const focus = () => {
|
||
setTimeout(() => term.focus(), 300);
|
||
};
|
||
|
||
const clear = () => {
|
||
term.clear();
|
||
term.clearSelection();
|
||
term.focus();
|
||
};
|
||
|
||
enum MsgType {
|
||
Resize = 1,
|
||
Data = 2,
|
||
Ping = 3,
|
||
}
|
||
|
||
const send = (msg: any) => {
|
||
state.status == TerminalStatus.Connected && socket?.send(msg);
|
||
};
|
||
|
||
const sendResize = (cols: number, rows: number) => {
|
||
send(`${MsgType.Resize}|${rows}|${cols}`);
|
||
};
|
||
|
||
const sendPing = () => {
|
||
send(`${MsgType.Ping}|ping`);
|
||
};
|
||
|
||
function sendCmd(key: any) {
|
||
send(`${MsgType.Data}|${key}`);
|
||
}
|
||
|
||
function closeSocket() {
|
||
// 关闭 websocket
|
||
socket && socket.readyState === 1 && socket.close();
|
||
// 清除 ping
|
||
pingInterval && clearInterval(pingInterval);
|
||
}
|
||
|
||
function close() {
|
||
console.log('in terminal body close');
|
||
closeSocket();
|
||
if (term) {
|
||
state.addon.search.dispose();
|
||
state.addon.fit.dispose();
|
||
state.addon.weblinks.dispose();
|
||
term.dispose();
|
||
}
|
||
}
|
||
|
||
const getStatus = (): TerminalStatus => {
|
||
return state.status;
|
||
};
|
||
|
||
defineExpose({ init, fitTerminal, focus, clear, close, getStatus, sendResize, write2Term, writeln2Term });
|
||
</script>
|
||
<style lang="scss">
|
||
#terminal-body {
|
||
width: 100%;
|
||
|
||
.terminal {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
// .xterm .xterm-viewport {
|
||
// overflow-y: hidden;
|
||
// }
|
||
}
|
||
}
|
||
</style>
|