mirror of https://github.com/1Panel-dev/1Panel
fix: 主机树显示调整、表单创建格式调整
parent
28de822917
commit
c74883bde9
|
@ -3,7 +3,7 @@ package model
|
|||
type Host struct {
|
||||
BaseModel
|
||||
GroupBelong string `gorm:"type:varchar(64);not null" json:"groupBelong"`
|
||||
Name string `gorm:"type:varchar(64);unique;not null" json:"name"`
|
||||
Name string `gorm:"type:varchar(64);not null" json:"name"`
|
||||
Addr string `gorm:"type:varchar(16);not null" json:"addr"`
|
||||
Port int `gorm:"type:varchar(5);not null" json:"port"`
|
||||
User string `gorm:"type:varchar(64);not null" json:"user"`
|
||||
|
|
|
@ -48,6 +48,9 @@ func (u *HostService) SearchForTree(search dto.SearchForTree) ([]dto.HostTree, e
|
|||
data.Label = group.Name
|
||||
for _, host := range hosts {
|
||||
label := fmt.Sprintf("%s@%s:%d", host.User, host.Addr, host.Port)
|
||||
if len(host.Name) != 0 {
|
||||
label = fmt.Sprintf("%s-%s@%s:%d", host.Name, host.User, host.Addr, host.Port)
|
||||
}
|
||||
if host.GroupBelong == group.Name {
|
||||
data.Children = append(data.Children, dto.TreeChild{ID: host.ID, Label: label})
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ export default {
|
|||
message: 'Message',
|
||||
description: 'Description',
|
||||
interval: 'Interval',
|
||||
title: 'Title',
|
||||
},
|
||||
msg: {
|
||||
delete: 'This operation cannot be rolled back. Do you want to continue',
|
||||
|
@ -555,6 +556,7 @@ export default {
|
|||
},
|
||||
terminal: {
|
||||
conn: 'connection',
|
||||
connLocalErr: 'Unable to automatically authenticate, please fill in the local server login information!',
|
||||
testConn: 'Test connection',
|
||||
saveAndConn: 'Save and Connect',
|
||||
connTestOk: 'Connection information available',
|
||||
|
|
|
@ -50,7 +50,7 @@ export default {
|
|||
statusSuccess: '成功',
|
||||
statusFailed: '失败',
|
||||
records: '任务输出',
|
||||
group: '组',
|
||||
group: '分组',
|
||||
createdAt: '创建时间',
|
||||
date: '时间',
|
||||
updatedAt: '更新时间',
|
||||
|
@ -58,6 +58,7 @@ export default {
|
|||
message: '信息',
|
||||
description: '描述信息',
|
||||
interval: '耗时',
|
||||
title: '标题',
|
||||
},
|
||||
msg: {
|
||||
delete: '删除 操作不可回滚,是否继续',
|
||||
|
@ -568,6 +569,7 @@ export default {
|
|||
},
|
||||
terminal: {
|
||||
conn: '连接',
|
||||
connLocalErr: '无法自动认证,请填写本地服务器的登录信息!',
|
||||
testConn: '连接测试',
|
||||
saveAndConn: '保存并连接',
|
||||
connTestOk: '连接信息可用',
|
||||
|
|
|
@ -65,6 +65,10 @@ const rules = reactive({
|
|||
});
|
||||
let operate = ref<string>('create');
|
||||
|
||||
const acceptParams = () => {
|
||||
search();
|
||||
};
|
||||
|
||||
let commandInfo = reactive<Command.CommandOperate>({
|
||||
id: 0,
|
||||
name: '',
|
||||
|
@ -146,10 +150,7 @@ const search = async () => {
|
|||
paginationConfig.total = res.data.total;
|
||||
};
|
||||
|
||||
function onInit() {
|
||||
search();
|
||||
}
|
||||
defineExpose({
|
||||
onInit,
|
||||
acceptParams,
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -46,7 +46,12 @@
|
|||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node" @mouseover="hover = data.id" @mouseleave="hover = null">
|
||||
<span v-if="node.label !== currentGroup || !data.onEdit">{{ node.label }}</span>
|
||||
<div v-if="node.label !== currentGroup || !data.onEdit">
|
||||
<span v-if="node.label.length <= 35">{{ node.label }}</span>
|
||||
<el-tooltip v-else :content="node.label" placement="top-start">
|
||||
<span>{{ node.label.substring(0, 32) }}...</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-input v-else v-model="currentGroupValue"></el-input>
|
||||
<div
|
||||
style="margin-left: 10px"
|
||||
|
@ -64,32 +69,10 @@
|
|||
</el-col>
|
||||
<el-col :span="16">
|
||||
<el-card class="el-card">
|
||||
<el-form
|
||||
ref="hostInfoRef"
|
||||
label-width="100px"
|
||||
label-position="left"
|
||||
:model="hostInfo"
|
||||
:rules="rules"
|
||||
>
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input clearable v-model="hostInfo.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.group')" prop="groupBelong">
|
||||
<el-select filterable v-model="hostInfo.groupBelong" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in groupList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form ref="hostInfoRef" label-width="100px" :model="hostInfo" :rules="rules">
|
||||
<el-form-item label="IP" prop="addr">
|
||||
<el-input clearable v-model="hostInfo.addr" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.port')" prop="port">
|
||||
<el-input clearable v-model.number="hostInfo.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.user')" prop="user">
|
||||
<el-input clearable v-model="hostInfo.user" />
|
||||
</el-form-item>
|
||||
|
@ -109,6 +92,22 @@
|
|||
<el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey">
|
||||
<el-input clearable type="textarea" v-model="hostInfo.privateKey" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.port')" prop="port">
|
||||
<el-input clearable v-model.number="hostInfo.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.group')" prop="groupBelong">
|
||||
<el-select filterable v-model="hostInfo.groupBelong" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in groupList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.title')" prop="name">
|
||||
<el-input clearable v-model="hostInfo.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||
<el-input clearable type="textarea" v-model="hostInfo.description" />
|
||||
</el-form-item>
|
||||
|
@ -157,8 +156,7 @@ import type Node from 'element-plus/es/components/tree/src/model/node';
|
|||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
const hostInfoRef = ref<FormInstance>();
|
||||
const rules = reactive({
|
||||
name: [Rules.requiredInput, Rules.name],
|
||||
group: [Rules.requiredSelect],
|
||||
groupBelong: [Rules.requiredSelect],
|
||||
addr: [Rules.requiredInput, Rules.ip],
|
||||
port: [Rules.requiredInput, Rules.port],
|
||||
user: [Rules.requiredInput],
|
||||
|
@ -213,6 +211,11 @@ const loadHostTree = async () => {
|
|||
hostTree.value = res.data;
|
||||
};
|
||||
|
||||
const acceptParams = () => {
|
||||
loadHostTree();
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
const res = await getGroupList({ type: 'host' });
|
||||
groupList.value = res.data;
|
||||
|
@ -324,12 +327,8 @@ const onEdit = async (node: Node, data: Tree) => {
|
|||
}
|
||||
};
|
||||
|
||||
function onInit() {
|
||||
loadHostTree();
|
||||
loadGroups();
|
||||
}
|
||||
defineExpose({
|
||||
onInit,
|
||||
acceptParams,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -2,476 +2,61 @@
|
|||
<div>
|
||||
<el-card class="topCard">
|
||||
<el-radio-group @change="handleChange" v-model="activeNames">
|
||||
<el-radio-button class="topButton" size="default" label="terminal">
|
||||
<el-radio-button class="topButton" size="large" label="terminal">
|
||||
{{ $t('menu.terminal') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button class="topButton" size="default" label="host">
|
||||
<el-radio-button class="topButton" size="large" label="host">
|
||||
{{ $t('menu.host') }}
|
||||
</el-radio-button>
|
||||
<el-radio-button class="topButton" size="default" label="command">
|
||||
<el-radio-button class="topButton" size="large" label="command">
|
||||
{{ $t('terminal.quickCommand') }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-card>
|
||||
<div v-show="activeNames === 'terminal'">
|
||||
<el-tabs
|
||||
type="card"
|
||||
class="terminal-tabs"
|
||||
style="background-color: #efefef; margin-top: 20px"
|
||||
v-model="terminalValue"
|
||||
:before-leave="beforeLeave"
|
||||
@edit="handleTabsRemove"
|
||||
>
|
||||
<el-tab-pane
|
||||
:key="item.key"
|
||||
v-for="item in terminalTabs"
|
||||
:closable="true"
|
||||
:label="item.title"
|
||||
:name="item.key"
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tabs-label">
|
||||
<el-icon style="margin-top: 1px" color="#67C23A" v-if="item.status === 'online'">
|
||||
<circleCheck />
|
||||
</el-icon>
|
||||
<el-button
|
||||
v-if="item.status === 'closed'"
|
||||
icon="Refresh"
|
||||
style="color: white"
|
||||
size="default"
|
||||
link
|
||||
@click="onReconnect(item)"
|
||||
/>
|
||||
<span> {{ item.title }} </span>
|
||||
</span>
|
||||
</template>
|
||||
<Terminal
|
||||
style="height: calc(100vh - 178px); background-color: #000"
|
||||
:ref="'Ref' + item.key"
|
||||
:key="item.Refresh"
|
||||
:wsID="item.wsID"
|
||||
:terminalID="item.key"
|
||||
></Terminal>
|
||||
<div>
|
||||
<el-select
|
||||
v-model="quickCmd"
|
||||
clearable
|
||||
filterable
|
||||
@change="quickInput"
|
||||
style="width: 25%"
|
||||
:placeholder="$t('terminal.quickCommand')"
|
||||
>
|
||||
<el-option
|
||||
v-for="cmd in commandList"
|
||||
:key="cmd.id"
|
||||
:label="cmd.name + ' [ ' + cmd.command + ' ] '"
|
||||
:value="cmd.command"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
:placeholder="$t('terminal.batchInput')"
|
||||
v-model="batchVal"
|
||||
@keyup.enter="batchInput"
|
||||
style="width: 75%"
|
||||
>
|
||||
<template #append>
|
||||
<el-switch v-model="isBatch" class="ml-2" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :closable="false" name="newTabs">
|
||||
<template #label>
|
||||
<el-button
|
||||
v-popover="popoverRef"
|
||||
style="background-color: #ededed; border: 0"
|
||||
icon="Plus"
|
||||
></el-button>
|
||||
<el-popover ref="popoverRef" width="250px" trigger="hover" virtual-triggering persistent>
|
||||
<el-button-group style="width: 100%">
|
||||
<el-button @click="onNewSsh">New ssh</el-button>
|
||||
<el-button @click="onConnLocal">New tab</el-button>
|
||||
</el-button-group>
|
||||
<el-input clearable style="margin-top: 5px" v-model="hostfilterInfo">
|
||||
<template #append><el-button icon="search" /></template>
|
||||
</el-input>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:expand-on-click-node="false"
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:data="hostTree"
|
||||
:props="defaultProps"
|
||||
:filter-node-method="filterHost"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<span>
|
||||
<a @click="onConn(node, data)">{{ node.label }}</a>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<div v-if="terminalTabs.length === 0">
|
||||
<el-empty
|
||||
style="background-color: #000; height: calc(100vh - 150px)"
|
||||
:description="$t('terminal.emptyTerminal')"
|
||||
></el-empty>
|
||||
</div>
|
||||
</el-tabs>
|
||||
<el-button @click="toggleFullscreen" class="fullScreen" icon="FullScreen"></el-button>
|
||||
<TerminalTab ref="terminalTabRef" />
|
||||
</div>
|
||||
<div v-if="activeNames === 'host'">
|
||||
<HostTab ref="hostTabRef" />
|
||||
</div>
|
||||
<div v-if="activeNames === 'command'">
|
||||
<CommandTab ref="commandTabRef" />
|
||||
</div>
|
||||
<div v-if="activeNames === 'host'"><HostTab ref="hostTabRef" /></div>
|
||||
<div v-if="activeNames === 'command'"><CommandTab ref="commandTabRef" /></div>
|
||||
|
||||
<el-dialog v-model="connVisiable" :title="$t('terminal.addHost')" width="30%">
|
||||
<el-form ref="hostInfoRef" label-width="100px" label-position="left" :model="hostInfo" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input clearable v-model="hostInfo.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP" prop="addr">
|
||||
<el-input clearable v-model="hostInfo.addr" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.port')" prop="port">
|
||||
<el-input clearable v-model.number="hostInfo.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.user')" prop="user">
|
||||
<el-input clearable v-model="hostInfo.user" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.authMode')" prop="authMode">
|
||||
<el-radio-group v-model="hostInfo.authMode">
|
||||
<el-radio label="password">{{ $t('terminal.passwordMode') }}</el-radio>
|
||||
<el-radio label="key">{{ $t('terminal.keyMode') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.password')" v-if="hostInfo.authMode === 'password'" prop="password">
|
||||
<el-input clearable show-password type="password" v-model="hostInfo.password" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey">
|
||||
<el-input clearable type="textarea" v-model="hostInfo.privateKey" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="connVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button @click="submitAddHost(hostInfoRef, 'testConn')">
|
||||
{{ $t('terminal.testConn') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitAddHost(hostInfoRef, 'saveAndConn')">
|
||||
{{ $t('terminal.saveAndConn') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeMount, ref, watch, reactive, getCurrentInstance } from 'vue';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { testConn, getHostTree, addHost } from '@/api/modules/host';
|
||||
import { getCommandList } from '@/api/modules/command';
|
||||
import i18n from '@/lang';
|
||||
import { ElForm } from 'element-plus';
|
||||
import { Host } from '@/api/interface/host';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import Terminal from '@/views/host/terminal/terminal/index.vue';
|
||||
import HostTab from '@/views/host/terminal/host/index.vue';
|
||||
import CommandTab from '@/views/host/terminal/command/index.vue';
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node';
|
||||
import { ElTree } from 'element-plus';
|
||||
import screenfull from 'screenfull';
|
||||
|
||||
let timer: NodeJS.Timer | null = null;
|
||||
import TerminalTab from '@/views/host/terminal/terminal/index.vue';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const activeNames = ref<string>('terminal');
|
||||
const hostTabRef = ref();
|
||||
const commandTabRef = ref();
|
||||
const terminalTabRef = ref();
|
||||
|
||||
const terminalValue = ref();
|
||||
const terminalTabs = ref([]) as any;
|
||||
let tabIndex = 0;
|
||||
const commandList = ref();
|
||||
let quickCmd = ref();
|
||||
let batchVal = ref();
|
||||
let isBatch = ref<boolean>(false);
|
||||
const popoverRef = ref();
|
||||
|
||||
const connVisiable = ref<boolean>(false);
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
|
||||
const hostInfoRef = ref<FormInstance>();
|
||||
const hostTree = ref<Array<Host.HostTree>>();
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const defaultProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
};
|
||||
const hostfilterInfo = ref('');
|
||||
interface Tree {
|
||||
id: number;
|
||||
label: string;
|
||||
children?: Tree[];
|
||||
}
|
||||
const rules = reactive({
|
||||
name: [Rules.requiredInput, Rules.name],
|
||||
addr: [Rules.requiredInput, Rules.ip],
|
||||
port: [Rules.requiredInput, Rules.port],
|
||||
user: [Rules.requiredInput],
|
||||
authMode: [Rules.requiredSelect],
|
||||
password: [Rules.requiredInput],
|
||||
privateKey: [Rules.requiredInput],
|
||||
});
|
||||
|
||||
const localHostID = ref();
|
||||
|
||||
let hostInfo = reactive<Host.HostOperate>({
|
||||
id: 0,
|
||||
name: '',
|
||||
groupBelong: '',
|
||||
addr: '',
|
||||
port: 22,
|
||||
user: '',
|
||||
authMode: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const ctx = getCurrentInstance() as any;
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.toggle();
|
||||
}
|
||||
}
|
||||
const handleChange = (tab: any) => {
|
||||
if (tab === 'host') {
|
||||
hostTabRef.value!.onInit();
|
||||
hostTabRef.value!.acceptParams();
|
||||
}
|
||||
if (tab === 'command') {
|
||||
commandTabRef.value!.onInit();
|
||||
commandTabRef.value!.acceptParams();
|
||||
}
|
||||
if (tab === 'terminal') {
|
||||
loadCommand();
|
||||
loadHost();
|
||||
terminalTabRef.value!.acceptParams();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabsRemove = (targetName: string, action: 'remove' | 'add') => {
|
||||
if (action !== 'remove') {
|
||||
return;
|
||||
}
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${targetName}`] && ctx.refs[`Ref${targetName}`][0].onClose();
|
||||
}
|
||||
const tabs = terminalTabs.value;
|
||||
let activeName = terminalValue.value;
|
||||
if (activeName === targetName) {
|
||||
tabs.forEach((tab: any, index: any) => {
|
||||
if (tab.key === targetName) {
|
||||
const nextTab = tabs[index + 1] || tabs[index - 1];
|
||||
if (nextTab) {
|
||||
activeName = nextTab.key;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
terminalValue.value = activeName;
|
||||
terminalTabs.value = tabs.filter((tab: any) => tab.key !== targetName);
|
||||
};
|
||||
|
||||
const loadHost = async () => {
|
||||
const res = await getHostTree({});
|
||||
hostTree.value = res.data;
|
||||
for (const item of hostTree.value) {
|
||||
if (!item.children) {
|
||||
continue;
|
||||
}
|
||||
for (const host of item.children) {
|
||||
if (host.label.indexOf('127.0.0.1') !== -1) {
|
||||
localHostID.value = host.id;
|
||||
if (terminalTabs.value.length === 0) {
|
||||
onConnLocal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
hostInfo.name = 'localhost';
|
||||
hostInfo.addr = '127.0.0.1';
|
||||
connVisiable.value = true;
|
||||
};
|
||||
watch(hostfilterInfo, (val: any) => {
|
||||
treeRef.value!.filter(val);
|
||||
});
|
||||
const filterHost = (value: string, data: any) => {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
};
|
||||
const loadCommand = async () => {
|
||||
const res = await getCommandList();
|
||||
commandList.value = res.data;
|
||||
};
|
||||
|
||||
function quickInput(val: any) {
|
||||
if (val !== '') {
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${terminalValue.value}`] && ctx.refs[`Ref${terminalValue.value}`][0].onSendMsg(val + '\n');
|
||||
quickCmd.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function batchInput() {
|
||||
if (batchVal.value === '' || !ctx) {
|
||||
return;
|
||||
}
|
||||
if (isBatch.value) {
|
||||
for (const tab of terminalTabs.value) {
|
||||
ctx.refs[`Ref${tab.key}`] && ctx.refs[`Ref${tab.key}`][0].onSendMsg(batchVal.value + '\n');
|
||||
}
|
||||
batchVal.value = '';
|
||||
return;
|
||||
}
|
||||
ctx.refs[`Ref${terminalValue.value}`] && ctx.refs[`Ref${terminalValue.value}`][0].onSendMsg(batchVal.value + '\n');
|
||||
batchVal.value = '';
|
||||
}
|
||||
|
||||
function beforeLeave(activeName: string) {
|
||||
if (activeName === 'newTabs') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const onNewSsh = () => {
|
||||
connVisiable.value = true;
|
||||
if (hostInfoRef.value) {
|
||||
hostInfoRef.value.resetFields();
|
||||
}
|
||||
};
|
||||
|
||||
const onConn = (node: Node, data: Tree) => {
|
||||
if (node.level === 1) {
|
||||
return;
|
||||
}
|
||||
let addr = data.label.split('@')[1].split(':')[0];
|
||||
terminalTabs.value.push({
|
||||
key: `${addr}-${++tabIndex}`,
|
||||
title: addr,
|
||||
wsID: data.id,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `${addr}-${tabIndex}`;
|
||||
};
|
||||
|
||||
const onReconnect = async (item: any) => {
|
||||
if (ctx) {
|
||||
ctx.refs[`Ref${item.key}`] && ctx.refs[`Ref${item.key}`][0].onClose();
|
||||
}
|
||||
item.Refresh = !item.Refresh;
|
||||
ctx.refs[`Ref${item.key}`];
|
||||
syncTerminal();
|
||||
};
|
||||
|
||||
const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
|
||||
if (!formEl) return;
|
||||
formEl.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
hostInfo.groupBelong = 'default';
|
||||
switch (ops) {
|
||||
case 'testConn':
|
||||
await testConn(hostInfo);
|
||||
ElMessage.success(i18n.global.t('terminal.connTestOk'));
|
||||
break;
|
||||
case 'saveAndConn':
|
||||
const res = await addHost(hostInfo);
|
||||
terminalTabs.value.push({
|
||||
key: `${res.data.addr}-${++tabIndex}`,
|
||||
title: res.data.addr,
|
||||
wsID: res.data.id,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `${res.data.addr}-${tabIndex}`;
|
||||
connVisiable.value = false;
|
||||
loadHost();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onConnLocal = () => {
|
||||
terminalTabs.value.push({
|
||||
key: `127.0.0.1-${++tabIndex}`,
|
||||
title: i18n.global.t('terminal.localhost'),
|
||||
wsID: localHostID.value,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = `127.0.0.1-${tabIndex}`;
|
||||
};
|
||||
|
||||
function syncTerminal() {
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`Ref${terminal.key}`][0]) {
|
||||
terminal.status = ctx.refs[`Ref${terminal.key}`][0].isWsOpen() ? 'online' : 'closed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadHost();
|
||||
loadCommand();
|
||||
timer = setInterval(() => {
|
||||
syncTerminal();
|
||||
}, 1000 * 5);
|
||||
handleChange('terminal');
|
||||
});
|
||||
onBeforeMount(() => {
|
||||
clearInterval(Number(timer));
|
||||
timer = null;
|
||||
onUnmounted(() => {
|
||||
terminalTabRef.value?.cleanTimer();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.terminal-tabs {
|
||||
:deep .el-tabs__header {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
::deep .el-tabs__nav {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
transition: transform var(--el-transition-duration);
|
||||
float: left;
|
||||
z-index: calc(var(--el-index-normal) + 1);
|
||||
}
|
||||
:deep .el-tabs__item {
|
||||
color: #575758;
|
||||
padding: 0 0px;
|
||||
}
|
||||
:deep .el-tabs__item.is-active {
|
||||
color: #ebeef5;
|
||||
background-color: #575758;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-tabs > .el-tabs__content {
|
||||
padding: 32px;
|
||||
color: #6b778c;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fullScreen {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 86px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.el-tabs--top.el-tabs--card > .el-tabs__header .el-tabs__item:last-child {
|
||||
padding-right: 0px;
|
||||
}
|
||||
<style>
|
||||
.topCard {
|
||||
--el-card-border-color: var(--el-border-color-light);
|
||||
--el-card-border-radius: 4px;
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div>
|
||||
<el-dialog v-model="dialogVisiable" :title="$t('terminal.addHost')" width="30%">
|
||||
<el-alert
|
||||
style="margin-bottom: 20px"
|
||||
center
|
||||
:title="$t('terminal.connLocalErr')"
|
||||
:closable="false"
|
||||
type="warning"
|
||||
/>
|
||||
<el-form ref="hostRef" label-width="100px" :model="hostInfo" :rules="rules">
|
||||
<el-form-item label="IP" prop="addr">
|
||||
<el-input v-if="!isLocal" clearable v-model="hostInfo.addr" />
|
||||
<span v-if="isLocal">{{ hostInfo.addr }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.user')" prop="user">
|
||||
<el-input clearable v-model="hostInfo.user" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.authMode')" prop="authMode">
|
||||
<el-radio-group v-model="hostInfo.authMode">
|
||||
<el-radio label="password">{{ $t('terminal.passwordMode') }}</el-radio>
|
||||
<el-radio label="key">{{ $t('terminal.keyMode') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.password')" v-if="hostInfo.authMode === 'password'" prop="password">
|
||||
<el-input clearable show-password type="password" v-model="hostInfo.password" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.key')" v-if="hostInfo.authMode === 'key'" prop="privateKey">
|
||||
<el-input clearable type="textarea" v-model="hostInfo.privateKey" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('terminal.port')" prop="port">
|
||||
<el-input clearable v-model.number="hostInfo.port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.title')" prop="name">
|
||||
<el-input clearable v-model="hostInfo.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||
<el-input clearable v-model="hostInfo.description" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button @click="submitAddHost(hostRef, 'testConn')">
|
||||
{{ $t('terminal.testConn') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="submitAddHost(hostRef, 'saveAndConn')">
|
||||
{{ $t('terminal.saveAndConn') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElForm, ElMessage } from 'element-plus';
|
||||
import { Host } from '@/api/interface/host';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { addHost, testConn } from '@/api/modules/host';
|
||||
import i18n from '@/lang';
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const dialogVisiable = ref();
|
||||
type FormInstance = InstanceType<typeof ElForm>;
|
||||
const hostRef = ref<FormInstance>();
|
||||
|
||||
let hostInfo = reactive<Host.HostOperate>({
|
||||
id: 0,
|
||||
name: '',
|
||||
groupBelong: '',
|
||||
addr: '',
|
||||
port: 22,
|
||||
user: '',
|
||||
authMode: 'password',
|
||||
password: '',
|
||||
privateKey: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
addr: [Rules.requiredInput, Rules.ip],
|
||||
port: [Rules.requiredInput, Rules.port],
|
||||
user: [Rules.requiredInput],
|
||||
authMode: [Rules.requiredSelect],
|
||||
password: [Rules.requiredInput],
|
||||
privateKey: [Rules.requiredInput],
|
||||
});
|
||||
|
||||
const isLocal = ref(false);
|
||||
interface DialogProps {
|
||||
isLocal: boolean;
|
||||
}
|
||||
const acceptParams = (props: DialogProps) => {
|
||||
if (props.isLocal) {
|
||||
isLocal.value = props.isLocal;
|
||||
hostInfo.addr = '127.0.0.1';
|
||||
}
|
||||
dialogVisiable.value = true;
|
||||
};
|
||||
|
||||
const emit = defineEmits(['on-conn-terminal', 'load-host-tree']);
|
||||
|
||||
const submitAddHost = (formEl: FormInstance | undefined, ops: string) => {
|
||||
if (!formEl) return;
|
||||
formEl.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
hostInfo.groupBelong = 'default';
|
||||
switch (ops) {
|
||||
case 'testConn':
|
||||
await testConn(hostInfo);
|
||||
ElMessage.success(i18n.global.t('terminal.connTestOk'));
|
||||
break;
|
||||
case 'saveAndConn':
|
||||
const res = await addHost(hostInfo);
|
||||
dialogVisiable.value = false;
|
||||
let title = res.data.user + '@' + res.data.addr + ':' + res.data.port;
|
||||
if (res.data.name.length !== 0) {
|
||||
title = res.data.name + '-' + title;
|
||||
}
|
||||
emit('on-conn-terminal', title, res.data.id, res.data.addr);
|
||||
emit('load-host-tree');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
});
|
||||
</script>
|
|
@ -1,172 +1,366 @@
|
|||
<template>
|
||||
<div :id="'terminal' + props.terminalID"></div>
|
||||
<div>
|
||||
<el-tabs
|
||||
type="card"
|
||||
class="terminal-tabs"
|
||||
style="background-color: #efefef; margin-top: 20px"
|
||||
v-model="terminalValue"
|
||||
:before-leave="beforeLeave"
|
||||
@edit="handleTabsRemove"
|
||||
>
|
||||
<el-tab-pane
|
||||
:key="item.index"
|
||||
v-for="item in terminalTabs"
|
||||
:closable="true"
|
||||
:label="item.title"
|
||||
:name="item.index"
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tabs-label">
|
||||
<el-icon style="margin-top: 1px" color="#67C23A" v-if="item.status === 'online'">
|
||||
<circleCheck />
|
||||
</el-icon>
|
||||
<el-button
|
||||
v-if="item.status === 'closed'"
|
||||
icon="Refresh"
|
||||
style="color: white"
|
||||
size="default"
|
||||
link
|
||||
@click="onReconnect(item)"
|
||||
/>
|
||||
<span v-if="item.title.length <= 20"> {{ item.title }} </span>
|
||||
<el-tooltip v-else :content="item.title" placement="top-start">
|
||||
<span> {{ item.title.substring(0, 17) }}... </span>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<Terminal
|
||||
style="height: calc(100vh - 178px); background-color: #000"
|
||||
:ref="'t-' + item.index"
|
||||
:key="item.Refresh"
|
||||
></Terminal>
|
||||
<div>
|
||||
<el-select
|
||||
v-model="quickCmd"
|
||||
clearable
|
||||
filterable
|
||||
@change="quickInput"
|
||||
style="width: 25%"
|
||||
:placeholder="$t('terminal.quickCommand')"
|
||||
>
|
||||
<el-option
|
||||
v-for="cmd in commandList"
|
||||
:key="cmd.id"
|
||||
:label="cmd.name + ' [ ' + cmd.command + ' ] '"
|
||||
:value="cmd.command"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
:placeholder="$t('terminal.batchInput')"
|
||||
v-model="batchVal"
|
||||
@keyup.enter="batchInput"
|
||||
style="width: 75%"
|
||||
>
|
||||
<template #append>
|
||||
<el-switch v-model="isBatch" class="ml-2" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :closable="false" name="newTabs">
|
||||
<template #label>
|
||||
<el-button
|
||||
v-popover="popoverRef"
|
||||
style="background-color: #ededed; border: 0"
|
||||
icon="Plus"
|
||||
></el-button>
|
||||
<el-popover ref="popoverRef" width="250px" trigger="hover" virtual-triggering persistent>
|
||||
<el-button-group style="width: 100%">
|
||||
<el-button @click="onNewSsh">New ssh</el-button>
|
||||
<el-button @click="onNewLocal">New tab</el-button>
|
||||
</el-button-group>
|
||||
<el-input clearable style="margin-top: 5px" v-model="hostfilterInfo">
|
||||
<template #append><el-button icon="search" /></template>
|
||||
</el-input>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:expand-on-click-node="false"
|
||||
node-key="id"
|
||||
:default-expand-all="true"
|
||||
:data="hostTree"
|
||||
:props="defaultProps"
|
||||
:filter-node-method="filterHost"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<span v-if="node.label.length <= 25">
|
||||
<a @click="onClickConn(node, data)">{{ node.label }}</a>
|
||||
</span>
|
||||
<el-tooltip v-else :content="node.label" placement="top-start">
|
||||
<span>
|
||||
<a @click="onClickConn(node, data)">{{ node.label.substring(0, 22) }}...</a>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-popover>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<div v-if="terminalTabs.length === 0">
|
||||
<el-empty
|
||||
style="background-color: #000; height: calc(100vh - 150px)"
|
||||
:description="$t('terminal.emptyTerminal')"
|
||||
></el-empty>
|
||||
</div>
|
||||
</el-tabs>
|
||||
<el-button @click="toggleFullscreen" class="fullScreen" icon="FullScreen"></el-button>
|
||||
|
||||
<HostDialog ref="dialogRef" @on-conn-terminal="onConnTerminal" @load-host-tree="loadHostTree" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { Terminal } from 'xterm';
|
||||
import { AttachAddon } from 'xterm-addon-attach';
|
||||
import { Base64 } from 'js-base64';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { ref, getCurrentInstance, watch, nextTick } from 'vue';
|
||||
import Terminal from '@/views/host/terminal/terminal/terminal.vue';
|
||||
import HostDialog from '@/views/host/terminal/terminal/host-create.vue';
|
||||
import type Node from 'element-plus/es/components/tree/src/model/node';
|
||||
import { ElTree } from 'element-plus';
|
||||
import screenfull from 'screenfull';
|
||||
import i18n from '@/lang';
|
||||
import { Host } from '@/api/interface/host';
|
||||
import { getHostTree } from '@/api/modules/host';
|
||||
import { getCommandList } from '@/api/modules/command';
|
||||
|
||||
interface WsProps {
|
||||
terminalID: string;
|
||||
wsID: number;
|
||||
const dialogRef = ref();
|
||||
const ctx = getCurrentInstance() as any;
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.toggle();
|
||||
}
|
||||
}
|
||||
const props = withDefaults(defineProps<WsProps>(), {
|
||||
terminalID: '',
|
||||
wsID: 0,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
const loading = ref(true);
|
||||
let terminalSocket = ref(null) as unknown as WebSocket;
|
||||
let term = ref(null) as unknown as Terminal;
|
||||
|
||||
const runRealTerminal = () => {
|
||||
loading.value = false;
|
||||
const localHostID = ref();
|
||||
|
||||
let timer: NodeJS.Timer | null = null;
|
||||
const terminalValue = ref();
|
||||
const terminalTabs = ref([]) as any;
|
||||
let tabIndex = 0;
|
||||
|
||||
const commandList = ref();
|
||||
let quickCmd = ref();
|
||||
let batchVal = ref();
|
||||
let isBatch = ref<boolean>(false);
|
||||
|
||||
const popoverRef = ref();
|
||||
|
||||
const hostfilterInfo = ref('');
|
||||
const hostTree = ref<Array<Host.HostTree>>();
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const defaultProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
};
|
||||
interface Tree {
|
||||
id: number;
|
||||
label: string;
|
||||
children?: Tree[];
|
||||
}
|
||||
|
||||
const acceptParams = () => {
|
||||
loadHostTree();
|
||||
loadCommand();
|
||||
timer = setInterval(() => {
|
||||
syncTerminal();
|
||||
}, 1000 * 5);
|
||||
// for (const item of hostTree.value) {
|
||||
// if (!item.children) {
|
||||
// continue;
|
||||
// }
|
||||
// for (const host of item.children) {
|
||||
// if (host.label.indexOf('127.0.0.1') !== -1) {
|
||||
// localHostID.value = host.id;
|
||||
// if (terminalTabs.value.length === 0) {
|
||||
// onConnTerminal(i18n.global.t('terminal.localhost'), localHostID.value, '127.0.0.1');
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
};
|
||||
const cleanTimer = () => {
|
||||
clearInterval(Number(timer));
|
||||
timer = null;
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`t-${terminal.key}`][0]) {
|
||||
terminal.status = ctx.refs[`t-${terminal.key}`][0].onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onWSReceive = (message: any) => {
|
||||
if (!isJson(message.data)) {
|
||||
const handleTabsRemove = (targetName: string, action: 'remove' | 'add') => {
|
||||
if (action !== 'remove') {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(message.data);
|
||||
term.element && term.focus();
|
||||
term.write(data.Data);
|
||||
if (ctx) {
|
||||
ctx.refs[`t-${targetName}`] && ctx.refs[`t-${targetName}`][0].onClose();
|
||||
}
|
||||
const tabs = terminalTabs.value;
|
||||
let activeName = terminalValue.value;
|
||||
if (activeName === targetName) {
|
||||
tabs.forEach((tab: any, index: any) => {
|
||||
if (tab.index === targetName) {
|
||||
const nextTab = tabs[index + 1] || tabs[index - 1];
|
||||
if (nextTab) {
|
||||
activeName = nextTab.index;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
terminalValue.value = activeName;
|
||||
terminalTabs.value = tabs.filter((tab: any) => tab.index !== targetName);
|
||||
};
|
||||
|
||||
function isJson(str: string) {
|
||||
try {
|
||||
if (typeof JSON.parse(str) === 'object') {
|
||||
return true;
|
||||
const loadHostTree = async () => {
|
||||
const res = await getHostTree({});
|
||||
hostTree.value = res.data;
|
||||
};
|
||||
watch(hostfilterInfo, (val: any) => {
|
||||
treeRef.value!.filter(val);
|
||||
});
|
||||
const filterHost = (value: string, data: any) => {
|
||||
if (!value) return true;
|
||||
return data.label.includes(value);
|
||||
};
|
||||
const loadCommand = async () => {
|
||||
const res = await getCommandList();
|
||||
commandList.value = res.data;
|
||||
};
|
||||
|
||||
function quickInput(val: any) {
|
||||
if (val !== '') {
|
||||
if (ctx) {
|
||||
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].onSendMsg(val + '\n');
|
||||
quickCmd.value = '';
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function batchInput() {
|
||||
if (batchVal.value === '' || !ctx) {
|
||||
return;
|
||||
}
|
||||
if (isBatch.value) {
|
||||
for (const tab of terminalTabs.value) {
|
||||
ctx.refs[`t-${tab.key}`] && ctx.refs[`t-${tab.key}`][0].onSendMsg(batchVal.value + '\n');
|
||||
}
|
||||
batchVal.value = '';
|
||||
return;
|
||||
}
|
||||
ctx.refs[`t-${terminalValue.value}`] && ctx.refs[`t-${terminalValue.value}`][0].onSendMsg(batchVal.value + '\n');
|
||||
batchVal.value = '';
|
||||
}
|
||||
|
||||
function beforeLeave(activeName: string) {
|
||||
if (activeName === 'newTabs') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const errorRealTerminal = (ex: any) => {
|
||||
let message = ex.message;
|
||||
if (!message) message = 'disconnected';
|
||||
term.write(`\x1b[31m${message}\x1b[m\r\n`);
|
||||
console.log('err');
|
||||
const onNewSsh = () => {
|
||||
dialogRef.value!.acceptParams({ isLocal: false });
|
||||
};
|
||||
const onNewLocal = () => {
|
||||
onConnTerminal(i18n.global.t('terminal.localhost'), localHostID.value);
|
||||
};
|
||||
|
||||
const closeRealTerminal = (ev: CloseEvent) => {
|
||||
term.write(ev.reason);
|
||||
};
|
||||
|
||||
const initTerm = () => {
|
||||
let ifm = document.getElementById('terminal' + props.terminalID) as HTMLInputElement | null;
|
||||
let href = window.location.href;
|
||||
let ipLocal = href.split('//')[1].split(':')[0];
|
||||
term = new Terminal({
|
||||
lineHeight: 1.2,
|
||||
fontSize: 12,
|
||||
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: '#000000',
|
||||
},
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
scrollback: 100,
|
||||
tabStopWidth: 4,
|
||||
});
|
||||
if (ifm) {
|
||||
term.open(ifm);
|
||||
terminalSocket = new WebSocket(
|
||||
`ws://${ipLocal}:9999/api/v1/terminals?id=${props.wsID}&cols=${term.cols}&rows=${term.rows}`,
|
||||
);
|
||||
terminalSocket.onopen = runRealTerminal;
|
||||
terminalSocket.onmessage = onWSReceive;
|
||||
terminalSocket.onclose = closeRealTerminal;
|
||||
terminalSocket.onerror = errorRealTerminal;
|
||||
term.onData((data: any) => {
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
term.loadAddon(new AttachAddon(terminalSocket));
|
||||
term.loadAddon(fitAddon);
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 30);
|
||||
const onClickConn = (node: Node, data: Tree) => {
|
||||
if (node.level === 1) {
|
||||
return;
|
||||
}
|
||||
onConnTerminal(node.label, data.id);
|
||||
};
|
||||
|
||||
const fitTerm = () => {
|
||||
fitAddon.fit();
|
||||
const onReconnect = async (item: any) => {
|
||||
if (ctx) {
|
||||
ctx.refs[`t-${item.key}`] && ctx.refs[`t-${item.key}`][0].onClose();
|
||||
}
|
||||
item.Refresh = !item.Refresh;
|
||||
ctx.refs[`t-${item.key}`];
|
||||
syncTerminal();
|
||||
};
|
||||
|
||||
const isWsOpen = () => {
|
||||
const readyState = terminalSocket && terminalSocket.readyState;
|
||||
return readyState === 1;
|
||||
const onConnTerminal = (title: string, wsID: number) => {
|
||||
terminalTabs.value.push({
|
||||
index: tabIndex,
|
||||
title: title,
|
||||
wsID: wsID,
|
||||
status: 'online',
|
||||
});
|
||||
terminalValue.value = tabIndex;
|
||||
nextTick(() => {
|
||||
ctx.refs[`t-${terminalValue.value}`] &&
|
||||
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({
|
||||
wsID: wsID,
|
||||
terminalID: terminalValue.value,
|
||||
});
|
||||
});
|
||||
tabIndex++;
|
||||
};
|
||||
|
||||
function onClose() {
|
||||
window.removeEventListener('resize', changeTerminalSize);
|
||||
terminalSocket && terminalSocket.close();
|
||||
term && term.dispose();
|
||||
}
|
||||
|
||||
function onSendMsg(command: string) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(command),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function changeTerminalSize() {
|
||||
fitTerm();
|
||||
const { cols, rows } = term;
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
);
|
||||
function syncTerminal() {
|
||||
for (const terminal of terminalTabs.value) {
|
||||
if (ctx && ctx.refs[`t-${terminal.index}`][0]) {
|
||||
terminal.status = ctx.refs[`t-${terminal.index}`][0].isWsOpen() ? 'online' : 'closed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onClose,
|
||||
isWsOpen,
|
||||
onSendMsg,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initTerm();
|
||||
window.addEventListener('resize', changeTerminalSize);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onClose();
|
||||
acceptParams,
|
||||
cleanTimer,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.terminal-tabs {
|
||||
:deep .el-tabs__header {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
::deep .el-tabs__nav {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
transition: transform var(--el-transition-duration);
|
||||
float: left;
|
||||
z-index: calc(var(--el-index-normal) + 1);
|
||||
}
|
||||
:deep .el-tabs__item {
|
||||
color: #575758;
|
||||
padding: 0 0px;
|
||||
}
|
||||
:deep .el-tabs__item.is-active {
|
||||
color: #ebeef5;
|
||||
background-color: #575758;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-tabs > .el-tabs__content {
|
||||
padding: 32px;
|
||||
color: #6b778c;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fullScreen {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 86px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.el-tabs--top.el-tabs--card > .el-tabs__header .el-tabs__item:last-child {
|
||||
padding-right: 0px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
<template>
|
||||
<div :id="'terminal-' + terminalID"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, nextTick } from 'vue';
|
||||
import { Terminal } from 'xterm';
|
||||
import { AttachAddon } from 'xterm-addon-attach';
|
||||
import { Base64 } from 'js-base64';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
const terminalID = ref();
|
||||
const wsID = ref();
|
||||
interface WsProps {
|
||||
terminalID: string;
|
||||
wsID: number;
|
||||
}
|
||||
const acceptParams = (props: WsProps) => {
|
||||
terminalID.value = props.terminalID;
|
||||
wsID.value = props.wsID;
|
||||
nextTick(() => {
|
||||
initTerm();
|
||||
window.addEventListener('resize', changeTerminalSize);
|
||||
});
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const loading = ref(true);
|
||||
let terminalSocket = ref(null) as unknown as WebSocket;
|
||||
let term = ref(null) as unknown as Terminal;
|
||||
|
||||
const runRealTerminal = () => {
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const onWSReceive = (message: any) => {
|
||||
if (!isJson(message.data)) {
|
||||
return;
|
||||
}
|
||||
const data = JSON.parse(message.data);
|
||||
term.element && term.focus();
|
||||
term.write(data.Data);
|
||||
};
|
||||
|
||||
function isJson(str: string) {
|
||||
try {
|
||||
if (typeof JSON.parse(str) === 'object') {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const errorRealTerminal = (ex: any) => {
|
||||
let message = ex.message;
|
||||
if (!message) message = 'disconnected';
|
||||
term.write(`\x1b[31m${message}\x1b[m\r\n`);
|
||||
console.log('err');
|
||||
};
|
||||
|
||||
const closeRealTerminal = (ev: CloseEvent) => {
|
||||
term.write(ev.reason);
|
||||
};
|
||||
|
||||
const initTerm = () => {
|
||||
let ifm = document.getElementById('terminal-' + terminalID.value) as HTMLInputElement | null;
|
||||
let href = window.location.href;
|
||||
let ipLocal = href.split('//')[1].split(':')[0];
|
||||
term = new Terminal({
|
||||
lineHeight: 1.2,
|
||||
fontSize: 12,
|
||||
fontFamily: "Monaco, Menlo, Consolas, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: '#000000',
|
||||
},
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
scrollback: 100,
|
||||
tabStopWidth: 4,
|
||||
});
|
||||
if (ifm) {
|
||||
term.open(ifm);
|
||||
terminalSocket = new WebSocket(
|
||||
`ws://${ipLocal}:9999/api/v1/terminals?id=${wsID.value}&cols=${term.cols}&rows=${term.rows}`,
|
||||
);
|
||||
terminalSocket.onopen = runRealTerminal;
|
||||
terminalSocket.onmessage = onWSReceive;
|
||||
terminalSocket.onclose = closeRealTerminal;
|
||||
terminalSocket.onerror = errorRealTerminal;
|
||||
term.onData((data: any) => {
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
term.loadAddon(new AttachAddon(terminalSocket));
|
||||
term.loadAddon(fitAddon);
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
};
|
||||
|
||||
const fitTerm = () => {
|
||||
fitAddon.fit();
|
||||
};
|
||||
|
||||
const isWsOpen = () => {
|
||||
const readyState = terminalSocket && terminalSocket.readyState;
|
||||
return readyState === 1;
|
||||
};
|
||||
|
||||
function onClose() {
|
||||
window.removeEventListener('resize', changeTerminalSize);
|
||||
terminalSocket && terminalSocket.close();
|
||||
term && term.dispose();
|
||||
}
|
||||
|
||||
function onSendMsg(command: string) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'cmd',
|
||||
cmd: Base64.encode(command),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function changeTerminalSize() {
|
||||
fitTerm();
|
||||
const { cols, rows } = term;
|
||||
if (isWsOpen()) {
|
||||
terminalSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
onClose,
|
||||
isWsOpen,
|
||||
onSendMsg,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
onClose();
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
#terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
|
@ -127,7 +127,7 @@ import i18n from '@/lang';
|
|||
import { Rules } from '@/global/form-rules';
|
||||
import { dateFromat } from '@/utils/util';
|
||||
|
||||
const emit = defineEmits(['on-save', 'search']);
|
||||
const emit = defineEmits(['search']);
|
||||
|
||||
const loading = ref(false);
|
||||
const form = reactive({
|
||||
|
|
Loading…
Reference in New Issue