ui: migrate all Vue components from Options API to Composition API

Signed-off-by: Jianhui Zhao <zhaojh329@gmail.com>
This commit is contained in:
Jianhui Zhao
2025-07-20 17:06:33 +08:00
parent 9ff0f441eb
commit 1c5c761470
12 changed files with 1233 additions and 1208 deletions
+413 -397
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@
"element-plus": "^2.10.1", "element-plus": "^2.10.1",
"vue": "^3.5.16", "vue": "^3.5.16",
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-clipboard2": "^0.3.3", "vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.5", "vue-i18n": "^11.1.5",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
+64 -62
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="content" class="content" :style="{top: axis.y + 'px', left: axis.x + 'px'}" v-if="visibility"> <div ref="content" class="content" :style="{top: axis.y + 'px', left: axis.x + 'px'}" v-if="model">
<template v-for="(item, index) in menus" :key="item.name"> <template v-for="(item, index) in menus" :key="item.name">
<a @click="onMenuClick(item.name)" :style="{'text-decoration': item.underline ? 'underline' : 'none'}"> <a @click="onMenuClick(item.name)" :style="{'text-decoration': item.underline ? 'underline' : 'none'}">
{{item.caption || item.name}} {{item.caption || item.name}}
@@ -9,75 +9,77 @@
</div> </div>
</template> </template>
<script> <script setup>
export default { import { reactive, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue'
name: 'ContextMenu',
props: {
menus: Array
},
data() {
return {
visibility: false,
axis: {x: 0, y: 0}
}
},
watch: {
visibility(val) {
if (!val)
document.removeEventListener('mousedown', this.close)
}
},
methods: {
close(e) {
const el = this.$refs.content
if (e.clientX >= this.axis.x && e.clientX <= this.axis.x + el.clientWidth && defineProps({
e.clientY >= this.axis.y && e.clientY <= this.axis.y + el.clientHeight) { menus: Array
return })
}
this.visibility = false const model = defineModel()
},
show(e) {
document.addEventListener('mousedown', this.close)
this.axis = {x: e.clientX, y: e.clientY} const emit = defineEmits(['click'])
this.visibility = true
this.$nextTick(() => { const content = useTemplateRef('content')
const el = this.$refs.content const axis = reactive({ x: 0, y: 0 })
if (!el) return
const rect = el.getBoundingClientRect() const close = (e) => {
const viewportWidth = window.innerWidth const el = content.value
const viewportHeight = window.innerHeight
let x = e.clientX if (e.clientX >= axis.x && e.clientX <= axis.x + el.clientWidth &&
let y = e.clientY e.clientY >= axis.y && e.clientY <= axis.y + el.clientHeight) {
return
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width - 15
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height - 15
}
x = Math.max(15, x)
y = Math.max(15, y)
this.axis = {x, y}
})
},
onMenuClick(name) {
this.visibility = false
this.$emit('click', name)
}
},
beforeUnmount() {
document.removeEventListener('mousedown', this.close)
} }
model.value = null
} }
const show = (clientX, clientY) => {
document.addEventListener('mousedown', close)
axis.x = clientX
axis.y = clientY
nextTick(() => {
const el = content.value
if (!el) return
const rect = el.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = clientX
let y = clientY
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width - 15
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height - 15
}
x = Math.max(15, x)
y = Math.max(15, y)
axis.x = x
axis.y = y
})
}
const onMenuClick = (name) => {
model.value = null
emit('click', name)
}
watch(() => model.value, (val) => {
if (!val)
document.removeEventListener('mousedown', close)
else
show(val.x, val.y)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', close))
</script> </script>
<style scoped> <style scoped>
+141 -130
View File
@@ -53,145 +53,156 @@
</el-dialog> </el-dialog>
</template> </template>
<script> <script setup>
export default { import { ref, reactive, computed, nextTick, useTemplateRef } from 'vue'
name: 'RttyCmd', import { useI18n } from 'vue-i18n'
props: { import { ElMessage } from 'element-plus'
selection: Array import axios from 'axios'
},
data() {
return {
cmdModal: false,
inputParamVisible: false,
inputParamValue: '',
cmdStatus: {
total: 0,
modal: false,
execing: 0,
fail: 0,
respModal: false,
responses: []
},
cmdData: {
username: '',
cmd: '',
params: [],
currentParam: '',
wait: 30
},
cmdRuleValidate: {
username: [{required: true, message: this.$t('username is required')}],
cmd: [{required: true, message: this.$t('command is required')}],
wait: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
if (!Number.isInteger(value) || value < 0 || value > 30) { const { t } = useI18n()
callback(new Error(this.$t('must be an integer between 0 and 30')))
}
callback() const props = defineProps({
}}] selection: Array
} })
const cmdForm = useTemplateRef('cmdForm')
const inputParam = useTemplateRef('inputParam')
const cmdModal = ref(false)
const inputParamVisible = ref(false)
const inputParamValue = ref('')
const cmdStatus = reactive({
total: 0,
modal: false,
execing: 0,
fail: 0,
respModal: false,
responses: []
})
const cmdData = reactive({
username: '',
cmd: '',
params: [],
currentParam: '',
wait: 30
})
const cmdRuleValidate = {
username: [{required: true, message: t('username is required')}],
cmd: [{required: true, message: t('command is required')}],
wait: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
} }
},
computed: { if (!Number.isInteger(value) || value < 0 || value > 30) {
cmdStatusPercent() { callback(new Error(t('must be an integer between 0 and 30')))
if (this.cmdStatus.total === 0)
return 0
return (this.cmdStatus.total - this.cmdStatus.execing) / this.cmdStatus.total * 100
} }
},
methods: {
showCmdForm() {
if (this.selection.length < 1) {
this.$message.error(this.$t('Please select the devices you want to operate'))
return
}
this.cmdModal = true
},
delCmdParam(tag) {
this.cmdData.params.splice(this.cmdData.params.indexOf(tag), 1)
},
showInputParam() {
this.inputParamVisible = true
this.$nextTick(() => {
(this.$refs.inputParam).focus()
})
},
handleInputParamConfirm() {
const value = this.inputParamValue
if (value) {
this.cmdData.params.push(value)
}
this.inputParamVisible = false
this.inputParamValue = ''
},
doCmd() {
(this.$refs['cmdForm']).validate(valid => {
if (valid) {
const selection = this.selection.filter(item => item.proto > 4)
this.cmdModal = false callback()
this.cmdStatus.modal = true }}]
this.cmdStatus.total = selection.length }
this.cmdStatus.execing = selection.length
this.cmdStatus.fail = 0
this.cmdStatus.responses = []
selection.forEach(item => { const cmdStatusPercent = computed(() => {
const data = { if (cmdStatus.total === 0)
username: this.cmdData.username, return 0
cmd: this.cmdData.cmd, return (cmdStatus.total - cmdStatus.execing) / cmdStatus.total * 100
params: this.cmdData.params })
const showCmdForm = () => {
if (props.selection.length < 1) {
ElMessage.error(t('Please select the devices you want to operate'))
return
}
cmdModal.value = true
}
const delCmdParam = (tag) => cmdData.params.splice(cmdData.params.indexOf(tag), 1)
const showInputParam = () => {
inputParamVisible.value = true
nextTick(() => inputParam.value.focus())
}
const handleInputParamConfirm = () => {
const value = inputParamValue.value
if (value) {
cmdData.params.push(value)
}
inputParamVisible.value = false
inputParamValue.value = ''
}
const doCmd = () => {
cmdForm.value.validate(valid => {
if (valid) {
const selection = props.selection.filter(item => item.proto > 4)
cmdModal.value = false
cmdStatus.modal = true
cmdStatus.total = selection.length
cmdStatus.execing = selection.length
cmdStatus.fail = 0
cmdStatus.responses = []
selection.forEach(item => {
const data = {
username: cmdData.username,
cmd: cmdData.cmd,
params: cmdData.params
}
axios.post(`/cmd/${item.id}?group=${item.group}&wait=${cmdData.wait}`, data).then((response) => {
if (cmdData.wait === 0) {
cmdStatus.responses.push({
err: 0,
msg: '',
id: item.id,
code: 0,
stdout: '',
stderr: ''
})
} else {
const resp = response.data
if (resp.err && resp.err !== 0) {
cmdStatus.fail++
resp.stdout = ''
resp.stderr = ''
} else {
resp.stdout = window.atob(resp.stdout || '')
resp.stderr = window.atob(resp.stderr || '')
} }
this.axios.post(`/cmd/${item.id}?group=${item.group}&wait=${this.cmdData.wait}`, data).then((response) => { resp.id = item.id
if (this.cmdData.wait === 0) { cmdStatus.responses.push(resp)
this.cmdStatus.responses.push({ }
err: 0, cmdStatus.execing--
msg: '', })
id: item.id,
code: 0,
stdout: '',
stderr: ''
})
} else {
const resp = response.data
if (resp.err && resp.err !== 0) {
this.cmdStatus.fail++
resp.stdout = ''
resp.stderr = ''
} else {
resp.stdout = window.atob(resp.stdout || '')
resp.stderr = window.atob(resp.stderr || '')
}
resp.id = item.id
this.cmdStatus.responses.push(resp)
}
this.cmdStatus.execing--
})
})
}
}) })
},
resetCmdData() {
(this.$refs['cmdForm']).resetFields()
},
ignoreCmdResp() {
this.cmdStatus.execing = 0
this.cmdStatus.respModal = true
this.cmdStatus.modal = false
},
showCmdResp() {
this.cmdStatus.modal = false
if (this.cmdStatus.responses.length > 0)
this.cmdStatus.respModal = true
} }
} })
} }
const resetCmdData = () => cmdForm.value.resetFields()
const ignoreCmdResp = () => {
cmdStatus.execing = 0
cmdStatus.respModal = true
cmdStatus.modal = false
}
const showCmdResp = () => {
cmdStatus.modal = false
if (cmdStatus.responses.length > 0)
cmdStatus.respModal = true
}
defineExpose({
showCmdForm,
resetCmdData
})
</script> </script>
+8 -16
View File
@@ -33,7 +33,7 @@ const startPos = ref(0)
const startSizes = ref([]) const startSizes = ref([])
const containerRef = ref(null) const containerRef = ref(null)
function initializePanelSizes() { const initializePanelSizes = () => {
if (!props.config.panels) return if (!props.config.panels) return
if (panelSizes.value.length !== props.config.panels.length) { if (panelSizes.value.length !== props.config.panels.length) {
@@ -42,7 +42,7 @@ function initializePanelSizes() {
} }
} }
function getPanelStyle(index) { const getPanelStyle = (index) => {
if (!props.config.panels) return {} if (!props.config.panels) return {}
initializePanelSizes() initializePanelSizes()
@@ -64,7 +64,7 @@ function getPanelStyle(index) {
} }
} }
function startResize(index, event) { const startResize = (index, event) => {
isResizing.value = true isResizing.value = true
resizingIndex.value = index resizingIndex.value = index
startPos.value = props.config.direction === 'horizontal' ? event.clientX : event.clientY startPos.value = props.config.direction === 'horizontal' ? event.clientX : event.clientY
@@ -78,7 +78,7 @@ function startResize(index, event) {
document.body.style.userSelect = 'none' document.body.style.userSelect = 'none'
} }
function handleResizeMove(event) { const handleResizeMove = (event) => {
if (!isResizing.value) return if (!isResizing.value) return
const currentPos = props.config.direction === 'horizontal' ? event.clientX : event.clientY const currentPos = props.config.direction === 'horizontal' ? event.clientX : event.clientY
@@ -107,7 +107,7 @@ function handleResizeMove(event) {
panelSizes.value = newSizes panelSizes.value = newSizes
} }
function stopResize() { const stopResize = () => {
isResizing.value = false isResizing.value = false
resizingIndex.value = -1 resizingIndex.value = -1
containerRef.value = null containerRef.value = null
@@ -120,17 +120,9 @@ function stopResize() {
emit('resize') emit('resize')
} }
function handleSplitPanel(panelId, position) { const handleSplitPanel = (panelId, position) => emit('split', panelId, position)
emit('split', panelId, position) const handleClosePanel = (panelId) => emit('close', panelId)
} const handleResize = () => emit('resize')
function handleClosePanel(panelId) {
emit('close', panelId)
}
function handleResize() {
emit('resize')
}
</script> </script>
<style scoped> <style scoped>
+338 -326
View File
@@ -1,29 +1,32 @@
<template> <template>
<div class="terminal-container"> <div class="terminal-container">
<div ref="terminal" class="terminal" @contextmenu.prevent="showContextmenu"></div> <div ref="terminal" class="terminal" @contextmenu.prevent="showContextmenu"></div>
<el-dialog v-model="file.modal" :title="$t('Upload file to device')" @close="onUploadDialogClosed" :width="400"> <el-dialog v-model="fileCtx.modal" :title="$t('Upload file to device')" @close="onUploadDialogClosed" :width="400">
<el-upload :before-upload="beforeUpload" action="#"> <el-upload :before-upload="beforeUpload" action="#">
<el-button type="primary">{{ $t("Select file") }}</el-button> <el-button type="primary">{{ $t("Select file") }}</el-button>
</el-upload> </el-upload>
<p v-if="file.file !== null"> {{ file.file.name }}</p> <p v-if="fileCtx.file !== null"> {{ fileCtx.file.name }}</p>
<template #footer> <template #footer>
<el-button @click="file.modal = false">{{ $t('Cancel') }}</el-button> <el-button @click="fileCtx.modal = false">{{ $t('Cancel') }}</el-button>
<el-button type="primary" @click="doUploadFile">{{ $t('OK') }}</el-button> <el-button type="primary" @click="doUploadFile">{{ $t('OK') }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
<contextmenu ref="contextmenu" :menus="contextmenus" @click="onContextmenuClick"/> <ContextMenu v-model="contextmenuPos" :menus="contextmenus" @click="onContextmenuClick"/>
</div> </div>
</template> </template>
<script> <script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick, useTemplateRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
import useClipboard from 'vue-clipboard3'
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import OverlayAddon from '../xterm-addon/xterm-addon-overlay' import OverlayAddon from '../xterm-addon/xterm-addon-overlay'
import Contextmenu from '../components/ContextMenu.vue' import ContextMenu from '../components/ContextMenu.vue'
import { ElLoading } from 'element-plus'
const LoginErrorOffline = 4000 const LoginErrorOffline = 4000
const LoginErrorBusy = 4001 const LoginErrorBusy = 4001
@@ -35,333 +38,342 @@ const ReadFileBlkSize = 63 * 1024
const AckBlkSize = 4 * 1024 const AckBlkSize = 4 * 1024
export default { const props = defineProps({
name: 'RttyTerm', devid: String,
components: { panelId: String
'Contextmenu': Contextmenu })
},
props: {
devid: String,
panelId: String
},
data() {
return {
contextmenus: [
{name: 'copy', caption: this.$t('Copy - Ctrl+Insert')},
{name: 'paste', caption: this.$t('Paste - Shift+Insert')},
{name: 'clear', caption: this.$t('Clear Scrollback')},
{name: 'font+', caption: this.$t('font+')},
{name: 'font-', caption: this.$t('font-')},
{name: 'upload', caption: this.$t('Upload file') + ' - rtty -R'},
{name: 'download', caption: this.$t('Download file') + ' - rtty -S file'},
{name: 'split-left', caption: this.$t('split-left')},
{name: 'split-right', caption: this.$t('split-right')},
{name: 'split-up', caption: this.$t('split-up')},
{name: 'split-down', caption: this.$t('split-down')},
{name: 'close', caption: this.$t('Close')},
{name: 'about', caption: this.$t('About')}
],
file: {
modal: false,
accepted: false,
file: null,
offset: 0,
fr: new FileReader(),
name: '',
chunks: []
},
disposables: [],
socket: null,
term: null,
fitAddon: null,
unack: 0
}
},
methods: {
showContextmenu(e) {
this.$refs.contextmenu.show(e)
},
onContextmenuClick(name) {
if (name === 'copy') {
const text = this.term.getSelection()
if (text) {
this.$copyText(text).then(() => {
this.$message.success(this.$t('Copied to clipboard'))
})
}
} else if (name === 'paste') {
this.pasteFromClipboard()
} else if (name === 'clear') {
this.term.clear()
} else if (name === 'font+') {
this.updateFontSize(1)
} else if (name === 'font-') {
this.updateFontSize(-1)
} else if (name === 'upload') {
this.$message.success(this.$t('Please execute command "rtty -R" in current terminal!'))
} else if (name === 'download') {
this.$message.success(this.$t('Please execute command "rtty -S file" in current terminal!'))
} else if (name === 'split-left') {
this.$emit('split', this.panelId, 'left')
} else if (name === 'split-right') {
this.$emit('split', this.panelId, 'right')
} else if (name === 'split-up') {
this.$emit('split', this.panelId, 'up')
} else if (name === 'split-down') {
this.$emit('split', this.panelId, 'down')
} else if (name === 'close') {
this.$emit('close', this.panelId)
} else if (name === 'about') {
window.open('https://github.com/zhaojh329/rtty')
}
this.term.focus() const emit = defineEmits(['split', 'close'])
},
async pasteFromClipboard() {
try {
if (!navigator.clipboard || !navigator.clipboard.readText) {
this.$message.info(this.$t('Please use shortcut "Shift+Insert"'))
return
}
const text = await navigator.clipboard.readText() const router = useRouter()
if (text) { const { t } = useI18n()
this.sendTermData(text) const { toClipboard } = useClipboard()
this.$message.success(this.$t('Pasted from clipboard'))
}
} catch (error) {
if (error.name === 'NotAllowedError') {
this.$alert(this.$t('clipboard_instructions'), this.$t('Clipboard Permission Required'),
{
type: 'warning'
}
)
} else {
this.$message.info(this.$t('Please use shortcut "Shift+Insert"'))
}
}
},
updateFontSize(size) {
this.term.options.fontSize += size
this.fitAddon.fit()
},
onUploadDialogClosed() {
this.term.focus()
if (this.file.accepted)
return
this.file.file = null
const msg = {type: 'fileCanceled'}
this.socket.send(JSON.stringify(msg))
},
beforeUpload(file) {
this.file.file = file
return false
},
submitUploadFile() {
this.$refs.upload.submit()
},
sendFileInfo(file) {
const msg = {type: 'fileInfo', size: file.size, name: file.name}
this.socket.send(JSON.stringify(msg))
},
readFileBlob(fr, file, offset, size) {
const blob = file.slice(offset, offset + size)
fr.readAsArrayBuffer(blob)
},
doUploadFile() {
if (!this.file.file) {
this.onUploadDialogClosed()
return
}
this.term.focus() const terminal = useTemplateRef('terminal')
const contextmenuPos = ref(null)
if (this.file.size > 0xffffffff) { const contextmenus = [
this.$message.error(this.$t('The file you will upload is too large(> 4294967295 Byte)')) {name: 'copy', caption: t('Copy - Ctrl+Insert')},
return {name: 'paste', caption: t('Paste - Shift+Insert')},
} {name: 'clear', caption: t('Clear Scrollback')},
{name: 'font+', caption: t('font+')},
{name: 'font-', caption: t('font-')},
{name: 'upload', caption: t('Upload file') + ' - rtty -R'},
{name: 'download', caption: t('Download file') + ' - rtty -S file'},
{name: 'split-left', caption: t('split-left')},
{name: 'split-right', caption: t('split-right')},
{name: 'split-up', caption: t('split-up')},
{name: 'split-down', caption: t('split-down')},
{name: 'close', caption: t('Close')},
{name: 'about', caption: t('About')}
]
this.file.accepted = true const fileCtx = reactive({
this.file.modal = false modal: false,
accepted: false,
file: null,
offset: 0,
fr: new FileReader(),
name: '',
chunks: []
})
this.sendFileInfo(this.file.file) let disposables = []
let socket = null
let term = null
let fitAddon = null
let unack = 0
if (this.file.size === 0) { const copyText = async(text) => {
this.sendFileData(null) try {
return await toClipboard(text)
} return Promise.resolve()
} catch (err) {
this.file.offset = 0 return Promise.reject(err)
const fr = this.file.fr
fr.onload = e => {
this.file.offset += e.loaded
this.sendFileData(new Uint8Array(fr.result))
}
this.readFileBlob(fr, this.file.file, this.file.offset, ReadFileBlkSize)
},
sendTermData(data) {
this.socket.send(new Uint8Array([0, ...new TextEncoder().encode(data)]))
},
sendFileData(data) {
let b
if (data !== null)
b = new Uint8Array([1, MsgTypeFileData, ...data])
else
b = new Uint8Array([1, MsgTypeFileData])
this.socket.send(b)
},
fitTerm() {
this.$nextTick(() => this.fitAddon.fit())
},
closed() {
if (this.term)
this.term.write('\n\n\r\x1B[1;3;31mConnection is closed.\x1B[0m')
this.dispose()
this.$emit('close', this.panelId)
},
openTerm() {
const term = new Terminal({
cursorBlink: true,
fontSize: 16
})
this.term = term
const fitAddon = new FitAddon()
this.fitAddon = fitAddon
term.loadAddon(fitAddon)
const overlayAddon = new OverlayAddon()
term.loadAddon(overlayAddon)
term.open(this.$refs['terminal'])
term.focus()
this.disposables.push(term.onData(data => this.sendTermData(data)))
this.disposables.push(term.onBinary(data => this.sendTermData(data)))
this.disposables.push(term.onResize(size => {
const msg = {type: 'winsize', cols: size.cols, rows: size.rows}
this.socket.send(JSON.stringify(msg))
overlayAddon.show(term.cols + 'x' + term.rows)
}))
window.addEventListener('rtty-resize', this.fitTerm)
this.fitTerm()
},
dispose() {
this.disposables.forEach(d => d.dispose())
}
},
mounted() {
const loading = ElLoading.service({
lock: true,
text: this.$t('Requesting device to create terminal...'),
background: '#555',
customClass: 'rtty-loading'
})
const group = this.$route.query.group ?? ''
const protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'
const socket = new WebSocket(protocol + location.host + `/connect/${this.devid}?group=${group}`)
socket.binaryType = 'arraybuffer'
this.socket = socket
socket.addEventListener('close', (ev) => {
loading.close()
if (ev.code === LoginErrorOffline) {
this.$router.push('/error/offline')
} else if (ev.code === LoginErrorBusy) {
this.$router.push('/error/full')
} else if (ev.code === LoginErrorTimeout) {
this.$router.push('/error/timeout')
} else {
this.closed()
}
})
socket.addEventListener('error', () => {
loading.close()
let href = `/connect/${this.devid}`
if (group)
href += `?group=${group}`
window.location.href = href
})
socket.addEventListener('message', ev => {
const data = ev.data
if (typeof data === 'string') {
const msg = JSON.parse(data)
if (msg.type === 'login') {
loading.close()
this.openTerm()
} else if (msg.type === 'sendfile') {
this.file.name = msg.name
this.file.chunks = []
socket.send(JSON.stringify({type: 'fileAck'}))
} else if (msg.type === 'recvfile') {
this.file.modal = true
this.file.file = null
this.file.accepted = false
this.term.blur()
} else if (msg.type === 'fileAck') {
if (this.file.file && this.file.offset < this.file.file.size)
this.readFileBlob(this.file.fr, this.file.file, this.file.offset, ReadFileBlkSize)
}
} else {
const data = new Uint8Array(ev.data)
if (data[0] === 0) {
this.unack += data.length - 1
this.term.write(data.slice(1))
if (this.unack > AckBlkSize) {
const msg = {type: 'ack', ack: this.unack}
socket.send(JSON.stringify(msg))
this.unack = 0
}
} else {
if (data.length === 1) {
const blob = new Blob(this.file.chunks)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = this.file.name
document.body.appendChild(a)
a.click()
setTimeout(() => {
this.file.chunks = []
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 100)
} else {
this.file.chunks.push(data.slice(1))
socket.send(JSON.stringify({type: 'fileAck'}))
}
}
}
})
},
unmounted() {
window.removeEventListener('rtty-resize', this.fitTerm)
this.dispose()
if (this.term)
this.term.dispose()
if (this.socket)
this.socket.close()
} }
} }
const showContextmenu = (e) => contextmenuPos.value = { x: e.clientX, y: e.clientY }
const onContextmenuClick = (name) => {
if (name === 'copy') {
const text = term.getSelection()
if (text)
copyText(text).then(() => ElMessage.success(t('Copied to clipboard')))
} else if (name === 'paste') {
pasteFromClipboard()
} else if (name === 'clear') {
term.clear()
} else if (name === 'font+') {
updateFontSize(1)
} else if (name === 'font-') {
updateFontSize(-1)
} else if (name === 'upload') {
ElMessage.success(t('Please execute command "rtty -R" in current terminal!'))
} else if (name === 'download') {
ElMessage.success(t('Please execute command "rtty -S file" in current terminal!'))
} else if (name === 'split-left') {
emit('split', props.panelId, 'left')
} else if (name === 'split-right') {
emit('split', props.panelId, 'right')
} else if (name === 'split-up') {
emit('split', props.panelId, 'up')
} else if (name === 'split-down') {
emit('split', props.panelId, 'down')
} else if (name === 'close') {
emit('close', props.panelId)
} else if (name === 'about') {
window.open('https://github.com/zhaojh329/rtty')
}
term.focus()
}
const pasteFromClipboard = async() => {
try {
if (!navigator.clipboard || !navigator.clipboard.readText) {
ElMessage.info(t('Please use shortcut "Shift+Insert"'))
return
}
const text = await navigator.clipboard.readText()
if (text) {
sendTermData(text)
ElMessage.success(t('Pasted from clipboard'))
}
} catch (error) {
if (error.name === 'NotAllowedError') {
ElMessageBox.alert(t('clipboard_instructions'), t('Clipboard Permission Required'), {
type: 'warning'
})
} else {
ElMessage.info(t('Please use shortcut "Shift+Insert"'))
}
}
}
const updateFontSize = (size) => {
term.options.fontSize += size
fitAddon.fit()
}
const onUploadDialogClosed = () => {
term.focus()
if (fileCtx.accepted)
return
fileCtx.file = null
const msg = {type: 'fileCanceled'}
socket.send(JSON.stringify(msg))
}
const beforeUpload = (file) => {
fileCtx.file = file
return false
}
const sendFileInfo = (file) => {
const msg = {type: 'fileInfo', size: file.size, name: file.name}
socket.send(JSON.stringify(msg))
}
const readFileBlob = (fr, file, offset, size) => {
const blob = file.slice(offset, offset + size)
fr.readAsArrayBuffer(blob)
}
const doUploadFile = () => {
if (!fileCtx.file) {
onUploadDialogClosed()
return
}
term.focus()
if (fileCtx.file.size > 0xffffffff) {
ElMessage.error(t('The file you will upload is too large(> 4294967295 Byte)'))
return
}
fileCtx.accepted = true
fileCtx.modal = false
sendFileInfo(fileCtx.file)
if (fileCtx.file.size === 0) {
sendFileData(null)
return
}
fileCtx.offset = 0
const fr = fileCtx.fr
fr.onload = e => {
fileCtx.offset += e.loaded
sendFileData(new Uint8Array(fr.result))
}
readFileBlob(fr, fileCtx.file, fileCtx.offset, ReadFileBlkSize)
}
const sendTermData = (data) => socket.send(new Uint8Array([0, ...new TextEncoder().encode(data)]))
const sendFileData = (data) => {
let b
if (data !== null)
b = new Uint8Array([1, MsgTypeFileData, ...data])
else
b = new Uint8Array([1, MsgTypeFileData])
socket.send(b)
}
const fitTerm = () => nextTick(() => fitAddon.fit())
const closed = () => {
if (term)
term.write('\n\n\r\x1B[1;3;31mConnection is closed.\x1B[0m')
dispose()
emit('close', props.panelId)
}
const openTerm = () => {
term = new Terminal({
cursorBlink: true,
fontSize: 16
})
const fitAddonInstance = new FitAddon()
fitAddon = fitAddonInstance
term.loadAddon(fitAddon)
const overlayAddon = new OverlayAddon()
term.loadAddon(overlayAddon)
term.open(terminal.value)
term.focus()
disposables.push(term.onData(data => sendTermData(data)))
disposables.push(term.onBinary(data => sendTermData(data)))
disposables.push(term.onResize(size => {
const msg = {type: 'winsize', cols: size.cols, rows: size.rows}
socket.send(JSON.stringify(msg))
overlayAddon.show(term.cols + 'x' + term.rows)
}))
window.addEventListener('rtty-resize', fitTerm)
fitTerm()
}
const dispose = () => disposables.forEach(d => d.dispose())
onMounted(() => {
const loading = ElLoading.service({
lock: true,
text: t('Requesting device to create terminal...'),
background: '#555',
customClass: 'rtty-loading'
})
const route = useRoute()
const group = route.query.group ?? ''
const protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'
socket = new WebSocket(protocol + location.host + `/connect/${props.devid}?group=${group}`)
socket.binaryType = 'arraybuffer'
socket.addEventListener('close', (ev) => {
loading.close()
if (ev.code === LoginErrorOffline) {
router.push('/error/offline')
} else if (ev.code === LoginErrorBusy) {
router.push('/error/full')
} else if (ev.code === LoginErrorTimeout) {
router.push('/error/timeout')
} else {
closed()
}
})
socket.addEventListener('error', () => {
loading.close()
let href = `/connect/${props.devid}`
if (group)
href += `?group=${group}`
window.location.href = href
})
socket.addEventListener('message', ev => {
const data = ev.data
if (typeof data === 'string') {
const msg = JSON.parse(data)
if (msg.type === 'login') {
loading.close()
openTerm()
} else if (msg.type === 'sendfile') {
fileCtx.name = msg.name
fileCtx.chunks = []
socket.send(JSON.stringify({type: 'fileAck'}))
} else if (msg.type === 'recvfile') {
fileCtx.modal = true
fileCtx.file = null
fileCtx.accepted = false
term.blur()
} else if (msg.type === 'fileAck') {
if (fileCtx.file && fileCtx.offset < fileCtx.file.size)
readFileBlob(fileCtx.fr, fileCtx.file, fileCtx.offset, ReadFileBlkSize)
}
} else {
const data = new Uint8Array(ev.data)
if (data[0] === 0) {
unack += data.length - 1
term.write(data.slice(1))
if (unack > AckBlkSize) {
const msg = {type: 'ack', ack: unack}
socket.send(JSON.stringify(msg))
unack = 0
}
} else {
if (data.length === 1) {
const blob = new Blob(fileCtx.chunks)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileCtx.name
document.body.appendChild(a)
a.click()
setTimeout(() => {
fileCtx.chunks = []
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 100)
} else {
fileCtx.chunks.push(data.slice(1))
socket.send(JSON.stringify({type: 'fileAck'}))
}
}
}
})
})
onUnmounted(() => {
window.removeEventListener('rtty-resize', fitTerm)
dispose()
if (term)
term.dispose()
if (socket)
socket.close()
})
</script> </script>
<style scoped> <style scoped>
+98 -100
View File
@@ -1,5 +1,5 @@
<template> <template>
<el-dialog v-model="modal" :title="$t('Access your devices\'s Web')" width="300"> <el-dialog v-model="model" :title="$t('Access your devices\'s Web')" width="300">
<el-form ref="form" :label-width="80" label-position="left" :model="formData" :rules="ruleValidate"> <el-form ref="form" :label-width="80" label-position="left" :model="formData" :rules="ruleValidate">
<el-form-item :label="$t('Proto')" prop="proto"> <el-form-item :label="$t('Proto')" prop="proto">
<el-radio-group v-model="formData.proto"> <el-radio-group v-model="formData.proto">
@@ -19,119 +19,117 @@
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
<el-button @click="modal = false">{{ $t('Cancel') }}</el-button> <el-button @click="model = false">{{ $t('Cancel') }}</el-button>
<el-button type="primary" @click="open">{{ $t('OK') }}</el-button> <el-button type="primary" @click="open">{{ $t('OK') }}</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script> <script setup>
export default { import { reactive, useTemplateRef } from 'vue'
name: 'RttyWeb', import { useI18n } from 'vue-i18n'
data() { import { ElMessage } from 'element-plus'
return {
modal: false,
formData: {
proto: 'http',
ipaddr: '',
port: null,
path: ''
},
ruleValidate: {
ipaddr: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
if (!this.isValidIP(value)) { const props = defineProps({
callback(new Error(this.$t('Invalid IP address'))) dev: Object
} })
callback() const { t } = useI18n()
}}],
port: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
if (!Number.isInteger(value) || value < 1 || value > 65536) { const form = useTemplateRef('form')
callback(new Error(this.$t('Invalid port'))) const model = defineModel()
}
callback() const formData = reactive({
}}], proto: 'http',
path: [{validator: (rule, value, callback) => { ipaddr: '',
if (!value) { port: null,
callback() path: ''
return })
}
if (!value.startsWith('/')) { const isValidIP = (addr) => {
callback(new Error(this.$t('Must start with /'))) const ipv4Pattern = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)'
} return new RegExp(ipv4Pattern).test(addr)
}
callback() const ruleValidate = {
}}] ipaddr: [{validator: (rule, value, callback) => {
}, if (!value) {
group: '', callback()
devid: '', return
devProto: null
} }
},
methods: {
show(dev) {
this.group = dev.group
this.devid = dev.id
this.devProto = dev.proto
this.formData.proto = 'http'
this.formData.ipaddr = ''
this.formData.port = null
this.formData.path = ''
this.modal = true
},
isValidIP(addr) {
const ipv4Pattern = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)'
return new RegExp(ipv4Pattern).test(addr)
},
open() {
this.$refs.form.validate(valid => {
if (!valid)
return
if (this.devProto < 4 && this.formData.proto === 'https') { if (!isValidIP(value)) {
this.$message.error(this.$t('Your device\'s rtty does not support https proxy, please upgrade it.')) callback(new Error(t('Invalid IP address')))
return
}
this.modal = false
setTimeout(() => {
const proto = this.formData.proto
let ipaddr = this.formData.ipaddr
let port = this.formData.port
let path = this.formData.path
if (!ipaddr)
ipaddr = '127.0.0.1'
if (!port)
port = proto === 'https' ? 443 : 80
if (!path)
path = '/'
const addr = encodeURIComponent(`${ipaddr}:${port}${path}`)
if (this.group)
window.open(`/web2/${this.group}/${this.devid}/${proto}/${addr}`)
else
window.open(`/web/${this.devid}/${proto}/${addr}`)
}, 100)
})
} }
}
callback()
}}],
port: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
if (!Number.isInteger(value) || value < 1 || value > 65536) {
callback(new Error(t('Invalid port')))
}
callback()
}}],
path: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
if (!value.startsWith('/')) {
callback(new Error(t('Must start with /')))
}
callback()
}}]
}
const open = () => {
form.value.validate(valid => {
if (!valid)
return
const dev = props.dev
if (dev.proto < 4 && formData.proto === 'https') {
ElMessage.error(t('Your device\'s rtty does not support https proxy, please upgrade it.'))
return
}
model.value = false
setTimeout(() => {
const proto = formData.proto
let ipaddr = formData.ipaddr
let port = formData.port
let path = formData.path
if (!ipaddr)
ipaddr = '127.0.0.1'
if (!port)
port = proto === 'https' ? 443 : 80
if (!path)
path = '/'
const addr = encodeURIComponent(`${ipaddr}:${port}${path}`)
const group = dev.group
const devid = dev.id
if (group)
window.open(`/web2/${group}/${devid}/${proto}/${addr}`)
else
window.open(`/web/${devid}/${proto}/${addr}`)
}, 100)
})
} }
</script> </script>
-2
View File
@@ -4,7 +4,6 @@
*/ */
import { createApp } from 'vue' import { createApp } from 'vue'
import VueClipboard from 'vue-clipboard2'
import VueAxios from 'vue-axios' import VueAxios from 'vue-axios'
import axios from 'axios' import axios from 'axios'
import App from './App.vue' import App from './App.vue'
@@ -14,7 +13,6 @@ import ElementPlus from './element-plus'
const app = createApp(App) const app = createApp(App)
app.use(VueClipboard)
app.use(VueAxios, axios) app.use(VueAxios, axios)
app.use(router) app.use(router)
app.use(i18n) app.use(i18n)
+30 -32
View File
@@ -8,40 +8,38 @@
</div> </div>
</template> </template>
<script> <script setup>
import { Warning as WarningIcon } from '@vicons/ionicons5' import { Warning as WarningIcon } from '@vicons/ionicons5'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
export default { const { t } = useI18n()
name: 'Error',
props: { const props = defineProps({
err: String err: String
}, })
components: {
WarningIcon const title = computed(() => {
}, const err = props.err
computed: { if (err === 'offline')
title() { return t('Device Unavailable')
const err = this.err else if (err === 'full')
if (err === 'offline') return t('Terminal Session Limit Reached')
return this.$t('Device Unavailable') else if (err === 'timeout')
else if (err === 'full') return t('Device Response Timeout')
return this.$t('Terminal Session Limit Reached') return ''
else if (err === 'timeout') })
return this.$t('Device Response Timeout')
return '' const message = computed(() => {
}, const err = props.err
message() { if (err === 'offline')
const err = this.err return t('The device is currently offline. Please check the device status and try again.')
if (err === 'offline') else if (err === 'full')
return this.$t('The device is currently offline. Please check the device status and try again.') return t('The maximum number of concurrent terminal sessions has been reached. Please try again later.')
else if (err === 'full') else if (err === 'timeout')
return this.$t('The maximum number of concurrent terminal sessions has been reached. Please try again later.') return t('The device did not respond to the terminal session request within the expected time. Please check the device status and try again.')
else if (err === 'timeout') return ''
return this.$t('The device did not respond to the terminal session request within the expected time. Please check the device status and try again.') })
return ''
}
}
}
</script> </script>
<style scoped> <style scoped>
+109 -109
View File
@@ -47,130 +47,130 @@
</el-card> </el-card>
</el-main> </el-main>
<RttyCmd ref="rttyCmd" :selection="selection"/> <RttyCmd ref="rttyCmd" :selection="selection"/>
<RttyWeb ref="rttyWeb"/> <RttyWeb v-model="web.modal" :dev="web.dev"/>
</el-container> </el-container>
</template> </template>
<script> <script setup>
import { ref, reactive, computed, onMounted, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { InternetExplorer as IEIcon } from '@vicons/fa' import { InternetExplorer as IEIcon } from '@vicons/fa'
import { Terminal as TerminalIcon } from '@vicons/ionicons5' import { Terminal as TerminalIcon } from '@vicons/ionicons5'
import RttyCmd from '../components/RttyCmd.vue' import RttyCmd from '../components/RttyCmd.vue'
import RttyWeb from '../components/RttyWeb.vue' import RttyWeb from '../components/RttyWeb.vue'
import axios from 'axios'
export default { const router = useRouter()
name: 'Home',
components: {
IEIcon,
TerminalIcon,
RttyCmd,
RttyWeb
},
data() {
return {
group: '',
groups: [],
filterString: '',
loading: true,
devlists: [],
filteredDevices: [],
selection: [],
currentPage: 1,
pageSize: 10
}
},
computed: {
pagedevlists() {
return this.filteredDevices.slice((this.currentPage - 1) * this.pageSize, this.currentPage * this.pageSize)
}
},
methods: {
formatTime(t) {
let ts = t || 0
let tm = 0
let th = 0
let td = 0
if (ts > 59) { const rttyCmd = useTemplateRef('rttyCmd')
tm = Math.floor(ts / 60)
ts = ts % 60
}
if (tm > 59) { const group = ref('')
th = Math.floor(tm / 60) const groups = ref([])
tm = tm % 60 const filterString = ref('')
} const loading = ref(true)
const devlists = ref([])
const filteredDevices = ref([])
const selection = ref([])
const currentPage = ref(1)
const pageSize = ref(10)
const web = reactive({
modal: false,
dev: null
})
if (th > 23) { const pagedevlists = computed(() => {
td = Math.floor(th / 24) return filteredDevices.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value)
th = th % 24 })
}
let s = '' const formatTime = (t) => {
let ts = t || 0
let tm = 0
let th = 0
let td = 0
if (td > 0) if (ts > 59) {
s = `${td}d ` tm = Math.floor(ts / 60)
ts = ts % 60
return s + `${th}h ${tm}m ${ts}s`
},
handlePageChange(page, size) {
this.currentPage = page
this.pageSize = size
},
handleLogout() {
this.axios.get('/signout').then(() => {
this.$router.push('/login')
})
},
handleSearch() {
this.filteredDevices = this.devlists.filter((d) => {
const filterString = this.filterString.toLowerCase()
return d.id.toLowerCase().indexOf(filterString) > -1 || d.description.toLowerCase().indexOf(filterString) > -1
})
},
getGroups() {
this.axios.get('/groups').then(res => {
this.groups = res.data
if (this.groups.indexOf(this.group) === -1)
this.group = this.groups[0]
this.getDevices()
})
},
getDevices() {
this.axios.get(`/devs?group=${this.group}`).then(res => {
this.loading = false
this.devlists = res.data
this.selection = []
this.handleSearch()
}).catch(() => {
this.$router.push('/login')
})
},
handleRefresh() {
this.loading = true
setTimeout(() => {
this.getGroups()
}, 500)
},
handleSelection(selection) {
this.selection = selection
},
connectDevice(devid) {
let url = `/rtty/${devid}`
if (this.group)
url += `?group=${this.group}`
window.open(url)
},
connectDeviceWeb(dev) {
this.$refs.rttyWeb.show(dev)
},
showCmdForm() {
this.$refs.rttyCmd.showCmdForm()
}
},
mounted() {
this.getGroups()
} }
if (tm > 59) {
th = Math.floor(tm / 60)
tm = tm % 60
}
if (th > 23) {
td = Math.floor(th / 24)
th = th % 24
}
let s = ''
if (td > 0)
s = `${td}d `
return s + `${th}h ${tm}m ${ts}s`
} }
const handlePageChange = (page, size) => {
currentPage.value = page
pageSize.value = size
}
const handleLogout = () => {
axios.get('/signout').then(() => {
router.push('/login')
})
}
const handleSearch = () => {
filteredDevices.value = devlists.value.filter((d) => {
const filterStr = filterString.value.toLowerCase()
return d.id.toLowerCase().indexOf(filterStr) > -1 || d.description.toLowerCase().indexOf(filterStr) > -1
})
}
const getGroups = () => {
axios.get('/groups').then(res => {
groups.value = res.data
if (groups.value.indexOf(group.value) === -1)
group.value = groups.value[0]
getDevices()
})
}
const getDevices = () => {
axios.get(`/devs?group=${group.value}`).then(res => {
loading.value = false
devlists.value = res.data
selection.value = []
handleSearch()
}).catch(() => {
router.push('/login')
})
}
const handleRefresh = () => {
loading.value = true
setTimeout(() => getGroups(), 500)
}
const handleSelection = (sel) => selection.value = sel
const connectDevice = (devid) => {
let url = `/rtty/${devid}`
if (group.value)
url += `?group=${group.value}`
window.open(url)
}
const connectDeviceWeb = (dev) => {
web.dev = dev
web.modal = true
}
const showCmdForm = () => rttyCmd.value.showCmdForm()
onMounted(() => getGroups())
</script> </script>
<style scoped> <style scoped>
+23 -21
View File
@@ -14,29 +14,31 @@
</el-card> </el-card>
</template> </template>
<script> <script setup>
export default { import { ref, reactive } from 'vue'
data() { import { useRouter } from 'vue-router'
return { import { useI18n } from 'vue-i18n'
loading: false, import { ElMessage } from 'element-plus'
formValue: { import axios from 'axios'
password: ''
}
}
},
methods: {
handleSubmit() {
const params = {
password: this.formValue.password
}
this.axios.post('/signin', params).then(() => { const { t } = useI18n()
this.$router.push('/') const router = useRouter()
}).catch(() => {
this.$message.error(this.$t('Signin Fail! password wrong.')) const loading = ref(false)
}) const formValue = reactive({
} password: ''
})
const handleSubmit = () => {
const params = {
password: formValue.password
} }
axios.post('/signin', params).then(() => {
router.push('/')
}).catch(() => {
ElMessage.error(t('Signin Fail! password wrong.'))
})
} }
</script> </script>
+8 -12
View File
@@ -36,7 +36,7 @@ const terms = computed(() => {
return ids return ids
}) })
function moveTerminalsToPool() { const moveTerminalsToPool = () => {
const pool = document.getElementById('terminal-pool') const pool = document.getElementById('terminal-pool')
terms.value.forEach(termId => { terms.value.forEach(termId => {
@@ -45,7 +45,7 @@ function moveTerminalsToPool() {
}) })
} }
function moveTerminalsToPlaceholders() { const moveTerminalsToPlaceholders = () => {
nextTick(() => { nextTick(() => {
terms.value.forEach(termId => { terms.value.forEach(termId => {
const term = document.querySelector(`[data-terminal-id="${termId}"]`) const term = document.querySelector(`[data-terminal-id="${termId}"]`)
@@ -59,23 +59,19 @@ function moveTerminalsToPlaceholders() {
onMounted(() => moveTerminalsToPlaceholders()) onMounted(() => moveTerminalsToPlaceholders())
function handleResize() { const handleResize = () => dispatchEventRttyResize()
dispatchEventRttyResize()
}
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
function dispatchEventRttyResize() { const dispatchEventRttyResize = () => window.dispatchEvent(new CustomEvent('rtty-resize'))
window.dispatchEvent(new CustomEvent('rtty-resize'))
}
function handleClosePanel(panelId) { const handleClosePanel = (panelId) => {
moveTerminalsToPool() moveTerminalsToPool()
deletePanel(rootConfig.value, panelId, 0) deletePanel(rootConfig.value, panelId, 0)
moveTerminalsToPlaceholders() moveTerminalsToPlaceholders()
} }
function deletePanel(config, panelId, index, parent) { const deletePanel = (config, panelId, index, parent) => {
if (parent && config.id === panelId) { if (parent && config.id === panelId) {
parent.panels.splice(index, 1) parent.panels.splice(index, 1)
if (parent.panels.length === 1) { if (parent.panels.length === 1) {
@@ -90,13 +86,13 @@ function deletePanel(config, panelId, index, parent) {
return false return false
} }
function handleSplitPanel(panelId, direction) { const handleSplitPanel = (panelId, direction) => {
moveTerminalsToPool() moveTerminalsToPool()
splitPanel(rootConfig.value, panelId, 0, direction) splitPanel(rootConfig.value, panelId, 0, direction)
moveTerminalsToPlaceholders() moveTerminalsToPlaceholders()
} }
function splitPanel(config, panelId, index, position, parent) { const splitPanel = (config, panelId, index, position, parent) => {
if (config.id === panelId) { if (config.id === panelId) {
const direction = (position === 'left' || position === 'right') ? 'horizontal' : 'vertical' const direction = (position === 'left' || position === 'right') ? 'horizontal' : 'vertical'
const newId = 'panel-' + Math.random().toString(36).substring(2, 10) const newId = 'panel-' + Math.random().toString(36).substring(2, 10)