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",
"vue": "^3.5.16",
"vue-axios": "^3.5.2",
"vue-clipboard2": "^0.3.3",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.5",
"vue-router": "^4.5.1"
},
+64 -62
View File
@@ -1,5 +1,5 @@
<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">
<a @click="onMenuClick(item.name)" :style="{'text-decoration': item.underline ? 'underline' : 'none'}">
{{item.caption || item.name}}
@@ -9,75 +9,77 @@
</div>
</template>
<script>
export default {
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
<script setup>
import { reactive, watch, nextTick, onBeforeUnmount, useTemplateRef } from 'vue'
if (e.clientX >= this.axis.x && e.clientX <= this.axis.x + el.clientWidth &&
e.clientY >= this.axis.y && e.clientY <= this.axis.y + el.clientHeight) {
return
}
defineProps({
menus: Array
})
this.visibility = false
},
show(e) {
document.addEventListener('mousedown', this.close)
const model = defineModel()
this.axis = {x: e.clientX, y: e.clientY}
this.visibility = true
const emit = defineEmits(['click'])
this.$nextTick(() => {
const el = this.$refs.content
if (!el) return
const content = useTemplateRef('content')
const axis = reactive({ x: 0, y: 0 })
const rect = el.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const close = (e) => {
const el = content.value
let x = e.clientX
let y = e.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)
this.axis = {x, y}
})
},
onMenuClick(name) {
this.visibility = false
this.$emit('click', name)
}
},
beforeUnmount() {
document.removeEventListener('mousedown', this.close)
if (e.clientX >= axis.x && e.clientX <= axis.x + el.clientWidth &&
e.clientY >= axis.y && e.clientY <= axis.y + el.clientHeight) {
return
}
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>
<style scoped>
+141 -130
View File
@@ -53,145 +53,156 @@
</el-dialog>
</template>
<script>
export default {
name: 'RttyCmd',
props: {
selection: Array
},
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
}
<script setup>
import { ref, reactive, computed, nextTick, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import axios from 'axios'
if (!Number.isInteger(value) || value < 0 || value > 30) {
callback(new Error(this.$t('must be an integer between 0 and 30')))
}
const { t } = useI18n()
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: {
cmdStatusPercent() {
if (this.cmdStatus.total === 0)
return 0
return (this.cmdStatus.total - this.cmdStatus.execing) / this.cmdStatus.total * 100
if (!Number.isInteger(value) || value < 0 || value > 30) {
callback(new Error(t('must be an integer between 0 and 30')))
}
},
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
this.cmdStatus.modal = true
this.cmdStatus.total = selection.length
this.cmdStatus.execing = selection.length
this.cmdStatus.fail = 0
this.cmdStatus.responses = []
callback()
}}]
}
selection.forEach(item => {
const data = {
username: this.cmdData.username,
cmd: this.cmdData.cmd,
params: this.cmdData.params
const cmdStatusPercent = computed(() => {
if (cmdStatus.total === 0)
return 0
return (cmdStatus.total - cmdStatus.execing) / cmdStatus.total * 100
})
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) => {
if (this.cmdData.wait === 0) {
this.cmdStatus.responses.push({
err: 0,
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--
})
})
}
resp.id = item.id
cmdStatus.responses.push(resp)
}
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>
+8 -16
View File
@@ -33,7 +33,7 @@ const startPos = ref(0)
const startSizes = ref([])
const containerRef = ref(null)
function initializePanelSizes() {
const initializePanelSizes = () => {
if (!props.config.panels) return
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 {}
initializePanelSizes()
@@ -64,7 +64,7 @@ function getPanelStyle(index) {
}
}
function startResize(index, event) {
const startResize = (index, event) => {
isResizing.value = true
resizingIndex.value = index
startPos.value = props.config.direction === 'horizontal' ? event.clientX : event.clientY
@@ -78,7 +78,7 @@ function startResize(index, event) {
document.body.style.userSelect = 'none'
}
function handleResizeMove(event) {
const handleResizeMove = (event) => {
if (!isResizing.value) return
const currentPos = props.config.direction === 'horizontal' ? event.clientX : event.clientY
@@ -107,7 +107,7 @@ function handleResizeMove(event) {
panelSizes.value = newSizes
}
function stopResize() {
const stopResize = () => {
isResizing.value = false
resizingIndex.value = -1
containerRef.value = null
@@ -120,17 +120,9 @@ function stopResize() {
emit('resize')
}
function handleSplitPanel(panelId, position) {
emit('split', panelId, position)
}
function handleClosePanel(panelId) {
emit('close', panelId)
}
function handleResize() {
emit('resize')
}
const handleSplitPanel = (panelId, position) => emit('split', panelId, position)
const handleClosePanel = (panelId) => emit('close', panelId)
const handleResize = () => emit('resize')
</script>
<style scoped>
+338 -326
View File
@@ -1,29 +1,32 @@
<template>
<div class="terminal-container">
<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-button type="primary">{{ $t("Select file") }}</el-button>
</el-upload>
<p v-if="file.file !== null"> {{ file.file.name }}</p>
<p v-if="fileCtx.file !== null"> {{ fileCtx.file.name }}</p>
<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>
</template>
</el-dialog>
<contextmenu ref="contextmenu" :menus="contextmenus" @click="onContextmenuClick"/>
<ContextMenu v-model="contextmenuPos" :menus="contextmenus" @click="onContextmenuClick"/>
</div>
</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 { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import OverlayAddon from '../xterm-addon/xterm-addon-overlay'
import Contextmenu from '../components/ContextMenu.vue'
import { ElLoading } from 'element-plus'
import ContextMenu from '../components/ContextMenu.vue'
const LoginErrorOffline = 4000
const LoginErrorBusy = 4001
@@ -35,333 +38,342 @@ const ReadFileBlkSize = 63 * 1024
const AckBlkSize = 4 * 1024
export default {
name: 'RttyTerm',
components: {
'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')
}
const props = defineProps({
devid: String,
panelId: String
})
this.term.focus()
},
async pasteFromClipboard() {
try {
if (!navigator.clipboard || !navigator.clipboard.readText) {
this.$message.info(this.$t('Please use shortcut "Shift+Insert"'))
return
}
const emit = defineEmits(['split', 'close'])
const text = await navigator.clipboard.readText()
if (text) {
this.sendTermData(text)
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
}
const router = useRouter()
const { t } = useI18n()
const { toClipboard } = useClipboard()
this.term.focus()
const terminal = useTemplateRef('terminal')
const contextmenuPos = ref(null)
if (this.file.size > 0xffffffff) {
this.$message.error(this.$t('The file you will upload is too large(> 4294967295 Byte)'))
return
}
const contextmenus = [
{name: 'copy', caption: t('Copy - Ctrl+Insert')},
{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
this.file.modal = false
const fileCtx = reactive({
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) {
this.sendFileData(null)
return
}
this.file.offset = 0
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 copyText = async(text) => {
try {
await toClipboard(text)
return Promise.resolve()
} catch (err) {
return Promise.reject(err)
}
}
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>
<style scoped>
+98 -100
View File
@@ -1,5 +1,5 @@
<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-item :label="$t('Proto')" prop="proto">
<el-radio-group v-model="formData.proto">
@@ -19,119 +19,117 @@
</el-form>
<template #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>
</div>
</template>
</el-dialog>
</template>
<script>
export default {
name: 'RttyWeb',
data() {
return {
modal: false,
formData: {
proto: 'http',
ipaddr: '',
port: null,
path: ''
},
ruleValidate: {
ipaddr: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
<script setup>
import { reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
if (!this.isValidIP(value)) {
callback(new Error(this.$t('Invalid IP address')))
}
const props = defineProps({
dev: Object
})
callback()
}}],
port: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
const { t } = useI18n()
if (!Number.isInteger(value) || value < 1 || value > 65536) {
callback(new Error(this.$t('Invalid port')))
}
const form = useTemplateRef('form')
const model = defineModel()
callback()
}}],
path: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
const formData = reactive({
proto: 'http',
ipaddr: '',
port: null,
path: ''
})
if (!value.startsWith('/')) {
callback(new Error(this.$t('Must start with /')))
}
const 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)
}
callback()
}}]
},
group: '',
devid: '',
devProto: null
const ruleValidate = {
ipaddr: [{validator: (rule, value, callback) => {
if (!value) {
callback()
return
}
},
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') {
this.$message.error(this.$t('Your device\'s rtty does not support https proxy, please upgrade it.'))
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)
})
if (!isValidIP(value)) {
callback(new Error(t('Invalid IP address')))
}
}
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>
-2
View File
@@ -4,7 +4,6 @@
*/
import { createApp } from 'vue'
import VueClipboard from 'vue-clipboard2'
import VueAxios from 'vue-axios'
import axios from 'axios'
import App from './App.vue'
@@ -14,7 +13,6 @@ import ElementPlus from './element-plus'
const app = createApp(App)
app.use(VueClipboard)
app.use(VueAxios, axios)
app.use(router)
app.use(i18n)
+30 -32
View File
@@ -8,40 +8,38 @@
</div>
</template>
<script>
<script setup>
import { Warning as WarningIcon } from '@vicons/ionicons5'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
export default {
name: 'Error',
props: {
err: String
},
components: {
WarningIcon
},
computed: {
title() {
const err = this.err
if (err === 'offline')
return this.$t('Device Unavailable')
else if (err === 'full')
return this.$t('Terminal Session Limit Reached')
else if (err === 'timeout')
return this.$t('Device Response Timeout')
return ''
},
message() {
const err = this.err
if (err === 'offline')
return this.$t('The device is currently offline. Please check the device status and try again.')
else if (err === 'full')
return this.$t('The maximum number of concurrent terminal sessions has been reached. Please try again later.')
else if (err === 'timeout')
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 ''
}
}
}
const { t } = useI18n()
const props = defineProps({
err: String
})
const title = computed(() => {
const err = props.err
if (err === 'offline')
return t('Device Unavailable')
else if (err === 'full')
return t('Terminal Session Limit Reached')
else if (err === 'timeout')
return t('Device Response Timeout')
return ''
})
const message = computed(() => {
const err = props.err
if (err === 'offline')
return t('The device is currently offline. Please check the device status and try again.')
else if (err === 'full')
return t('The maximum number of concurrent terminal sessions has been reached. Please try again later.')
else if (err === 'timeout')
return 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>
<style scoped>
+109 -109
View File
@@ -47,130 +47,130 @@
</el-card>
</el-main>
<RttyCmd ref="rttyCmd" :selection="selection"/>
<RttyWeb ref="rttyWeb"/>
<RttyWeb v-model="web.modal" :dev="web.dev"/>
</el-container>
</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 { Terminal as TerminalIcon } from '@vicons/ionicons5'
import RttyCmd from '../components/RttyCmd.vue'
import RttyWeb from '../components/RttyWeb.vue'
import axios from 'axios'
export default {
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
const router = useRouter()
if (ts > 59) {
tm = Math.floor(ts / 60)
ts = ts % 60
}
const rttyCmd = useTemplateRef('rttyCmd')
if (tm > 59) {
th = Math.floor(tm / 60)
tm = tm % 60
}
const group = ref('')
const groups = ref([])
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) {
td = Math.floor(th / 24)
th = th % 24
}
const pagedevlists = computed(() => {
return filteredDevices.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value)
})
let s = ''
const formatTime = (t) => {
let ts = t || 0
let tm = 0
let th = 0
let td = 0
if (td > 0)
s = `${td}d `
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 (ts > 59) {
tm = Math.floor(ts / 60)
ts = ts % 60
}
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>
<style scoped>
+23 -21
View File
@@ -14,29 +14,31 @@
</el-card>
</template>
<script>
export default {
data() {
return {
loading: false,
formValue: {
password: ''
}
}
},
methods: {
handleSubmit() {
const params = {
password: this.formValue.password
}
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import axios from 'axios'
this.axios.post('/signin', params).then(() => {
this.$router.push('/')
}).catch(() => {
this.$message.error(this.$t('Signin Fail! password wrong.'))
})
}
const { t } = useI18n()
const router = useRouter()
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>
+8 -12
View File
@@ -36,7 +36,7 @@ const terms = computed(() => {
return ids
})
function moveTerminalsToPool() {
const moveTerminalsToPool = () => {
const pool = document.getElementById('terminal-pool')
terms.value.forEach(termId => {
@@ -45,7 +45,7 @@ function moveTerminalsToPool() {
})
}
function moveTerminalsToPlaceholders() {
const moveTerminalsToPlaceholders = () => {
nextTick(() => {
terms.value.forEach(termId => {
const term = document.querySelector(`[data-terminal-id="${termId}"]`)
@@ -59,23 +59,19 @@ function moveTerminalsToPlaceholders() {
onMounted(() => moveTerminalsToPlaceholders())
function handleResize() {
dispatchEventRttyResize()
}
const handleResize = () => dispatchEventRttyResize()
window.addEventListener('resize', handleResize)
function dispatchEventRttyResize() {
window.dispatchEvent(new CustomEvent('rtty-resize'))
}
const dispatchEventRttyResize = () => window.dispatchEvent(new CustomEvent('rtty-resize'))
function handleClosePanel(panelId) {
const handleClosePanel = (panelId) => {
moveTerminalsToPool()
deletePanel(rootConfig.value, panelId, 0)
moveTerminalsToPlaceholders()
}
function deletePanel(config, panelId, index, parent) {
const deletePanel = (config, panelId, index, parent) => {
if (parent && config.id === panelId) {
parent.panels.splice(index, 1)
if (parent.panels.length === 1) {
@@ -90,13 +86,13 @@ function deletePanel(config, panelId, index, parent) {
return false
}
function handleSplitPanel(panelId, direction) {
const handleSplitPanel = (panelId, direction) => {
moveTerminalsToPool()
splitPanel(rootConfig.value, panelId, 0, direction)
moveTerminalsToPlaceholders()
}
function splitPanel(config, panelId, index, position, parent) {
const splitPanel = (config, panelId, index, position, parent) => {
if (config.id === panelId) {
const direction = (position === 'left' || position === 'right') ? 'horizontal' : 'vertical'
const newId = 'panel-' + Math.random().toString(36).substring(2, 10)