✨ 支持终端主题与背景图设置
@ -39,7 +39,8 @@
|
|||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"vue": "^3.4.31",
|
"vue": "^3.4.31",
|
||||||
"vue-codemirror": "^6.1.1",
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-router": "^4.4.0"
|
"vue-router": "^4.4.0",
|
||||||
|
"xterm-theme": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.5",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
BIN
web/public/terminal/01.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
web/public/terminal/02.png
Normal file
After Width: | Height: | Size: 164 KiB |
BIN
web/public/terminal/03.png
Normal file
After Width: | Height: | Size: 501 KiB |
BIN
web/public/terminal/04.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
web/public/terminal/05.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
web/public/terminal/06.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
web/public/terminal/07.jpg
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
web/public/terminal/08.jpg
Normal file
After Width: | Height: | Size: 58 KiB |
BIN
web/public/terminal/09.png
Normal file
After Width: | Height: | Size: 177 KiB |
148
web/src/views/terminal/components/terminal-setting.vue
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="600px"
|
||||||
|
top="120px"
|
||||||
|
title="终端设置"
|
||||||
|
:append-to-body="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
label-suffix=":"
|
||||||
|
label-width="60px"
|
||||||
|
:show-message="false"
|
||||||
|
>
|
||||||
|
<el-form-item label="主题" prop="theme">
|
||||||
|
<el-select
|
||||||
|
v-model="theme"
|
||||||
|
placeholder=""
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(value, key) in themeList"
|
||||||
|
:key="key"
|
||||||
|
:label="key"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="背景" prop="backgroundImage">
|
||||||
|
<ul class="background_list">
|
||||||
|
<li :class="background ? '' : 'active'" @click="changeBackground('')">
|
||||||
|
<el-image class="image">
|
||||||
|
<template #error>
|
||||||
|
<div class="theme_background_text">
|
||||||
|
主题背景
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-image>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="url in backgroundImages"
|
||||||
|
:key="url"
|
||||||
|
:class="background === url ? 'active' : ''"
|
||||||
|
@click="changeBackground(url)"
|
||||||
|
>
|
||||||
|
<el-image class="image" :src="url" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog_footer">
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import themeList from 'xterm-theme'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
themeName: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:show', 'update:themeName', 'update:background',])
|
||||||
|
|
||||||
|
const backgroundImages = ref([
|
||||||
|
'/terminal/03.png',
|
||||||
|
'/terminal/04.png',
|
||||||
|
'/terminal/01.png',
|
||||||
|
'/terminal/02.png',
|
||||||
|
'/terminal/05.png',
|
||||||
|
'/terminal/06.png',
|
||||||
|
'/terminal/07.jpg',
|
||||||
|
'/terminal/08.jpg',
|
||||||
|
'/terminal/09.png',
|
||||||
|
])
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (newVal) => emit('update:show', newVal)
|
||||||
|
})
|
||||||
|
const theme = computed({
|
||||||
|
get: () => props.themeName,
|
||||||
|
set: (newVal) => emit('update:themeName', newVal)
|
||||||
|
})
|
||||||
|
const backgroundUrl = computed({
|
||||||
|
get: () => props.background,
|
||||||
|
set: (newVal) => emit('update:background', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeBackground = (url) => {
|
||||||
|
backgroundUrl.value = url || ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.background_list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
li {
|
||||||
|
width: 130px;
|
||||||
|
height: 75px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 0 5px 5px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 5px #1890ff;
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
box-shadow: 0 0 5px #1890ff;
|
||||||
|
border: 1px solid #1890ff;
|
||||||
|
}
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.theme_background_text {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dialog_footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="terminalRefs" class="terminal_tab_container" />
|
<div ref="terminalRef" class="terminal_tab_container" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, onBeforeUnmount, getCurrentInstance } from 'vue'
|
import { ref, onMounted, computed, onBeforeUnmount, getCurrentInstance, watch, nextTick } from 'vue'
|
||||||
import { Terminal } from '@xterm/xterm'
|
import { Terminal } from '@xterm/xterm'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
@ -19,6 +19,14 @@ const props = defineProps({
|
|||||||
host: {
|
host: {
|
||||||
required: true,
|
required: true,
|
||||||
type: String
|
type: String
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -31,9 +39,34 @@ const timer = ref(null)
|
|||||||
const fitAddon = ref(null)
|
const fitAddon = ref(null)
|
||||||
const searchBar = ref(null)
|
const searchBar = ref(null)
|
||||||
const isManual = ref(false)
|
const isManual = ref(false)
|
||||||
const terminalRefs = ref(null)
|
const terminal = ref(null)
|
||||||
|
const terminalRef = ref(null)
|
||||||
|
|
||||||
const token = computed(() => $store.token)
|
const token = computed(() => $store.token)
|
||||||
|
const theme = computed(() => props.theme)
|
||||||
|
const background = computed(() => props.background)
|
||||||
|
|
||||||
|
watch(theme, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!background.value) terminal.value.options.theme = theme.value
|
||||||
|
else terminal.value.options.theme = { ...theme.value, background: '#00000080' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(background, (newVal) => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (newVal) {
|
||||||
|
// terminal.value.options.theme.background = '#00000080'
|
||||||
|
terminal.value.options.theme = { ...theme.value, background: '#00000080' }
|
||||||
|
terminalRef.value.style.backgroundImage = `url(${ background.value })`
|
||||||
|
terminalRef.value.style.backgroundImage = `url(${ background.value })`
|
||||||
|
// terminalRef.value.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15)), url(${ background.value })`
|
||||||
|
} else {
|
||||||
|
terminal.value.options.theme.background = theme.value.background
|
||||||
|
terminalRef.value.style.backgroundImage = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
const getCommand = async () => {
|
const getCommand = async () => {
|
||||||
let { data } = await $api.getCommand(props.host)
|
let { data } = await $api.getCommand(props.host)
|
||||||
@ -117,7 +150,7 @@ const reConnect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createLocalTerminal = () => {
|
const createLocalTerminal = () => {
|
||||||
let terminal = new Terminal({
|
let terminalInstance = new Terminal({
|
||||||
rendererType: 'dom',
|
rendererType: 'dom',
|
||||||
bellStyle: 'sound',
|
bellStyle: 'sound',
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
@ -126,20 +159,22 @@ const createLocalTerminal = () => {
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
minimumContrastRatio: 7,
|
minimumContrastRatio: 7,
|
||||||
allowTransparency: true,
|
allowTransparency: true,
|
||||||
theme: {
|
theme: theme.value
|
||||||
foreground: '#ECECEC',
|
// {
|
||||||
background: '#000000', // 'transparent',
|
// foreground: '#ECECEC',
|
||||||
// cursor: 'help',
|
// background: '#000000', // 'transparent',
|
||||||
selection: '#ff9900',
|
// // cursor: 'help',
|
||||||
lineHeight: 20
|
// selection: '#ff9900',
|
||||||
}
|
// lineHeight: 20
|
||||||
|
// }
|
||||||
})
|
})
|
||||||
term.value = terminal
|
term.value = terminalInstance
|
||||||
terminal.open(terminalRefs.value)
|
terminalInstance.open(terminalRef.value)
|
||||||
terminal.writeln('\x1b[1;32mWelcome to EasyNode terminal\x1b[0m.')
|
terminalInstance.writeln('\x1b[1;32mWelcome to EasyNode terminal\x1b[0m.')
|
||||||
terminal.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
|
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
|
||||||
terminal.focus()
|
terminalInstance.focus()
|
||||||
onSelectionChange()
|
onSelectionChange()
|
||||||
|
terminal.value = terminalInstance
|
||||||
}
|
}
|
||||||
|
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
@ -315,9 +350,8 @@ defineExpose({
|
|||||||
.terminal_tab_container {
|
.terminal_tab_container {
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
|
|
||||||
// background-image: url('@/assets/bg.jpg');
|
background-size: 100% 100%;
|
||||||
// background-size: cover;
|
background-repeat: no-repeat;
|
||||||
// background-repeat: no-repeat;
|
|
||||||
|
|
||||||
:deep(.xterm) {
|
:deep(.xterm) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -12,17 +12,6 @@
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<!-- <el-dropdown trigger="click">
|
|
||||||
<span class="link_text">会话同步<el-icon><arrow-down /></el-icon></span>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item @click="handleSyncSession">
|
|
||||||
<el-icon v-show="isSyncAllSession"><Select class="action_icon" /></el-icon>
|
|
||||||
<span>同步键盘输入到所有会话</span>
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown> -->
|
|
||||||
<el-dropdown
|
<el-dropdown
|
||||||
trigger="click"
|
trigger="click"
|
||||||
max-height="50vh"
|
max-height="50vh"
|
||||||
@ -38,6 +27,16 @@
|
|||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
|
<!-- <el-dropdown trigger="click" max-height="50vh">
|
||||||
|
<span class="link_text">主题<el-icon><arrow-down /></el-icon></span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-for="(value, key) in themeList" :key="key" @click="handleChangeTheme(key)">
|
||||||
|
<span :style="{color: key === themeName ? 'var(--el-menu-active-color)' : ''}">{{ key }}</span>
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown> -->
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
<span class="link_text">设置<el-icon><arrow-down /></el-icon></span>
|
<span class="link_text">设置<el-icon><arrow-down /></el-icon></span>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@ -45,8 +44,8 @@
|
|||||||
<el-dropdown-item @click="handleFullScreen">
|
<el-dropdown-item @click="handleFullScreen">
|
||||||
<span>开启全屏</span>
|
<span>开启全屏</span>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item disabled @click="handleFullScreen">
|
<el-dropdown-item @click="showSetting = true">
|
||||||
<span>终端设置(开发中)</span>
|
<span>终端设置</span>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
@ -124,6 +123,8 @@
|
|||||||
<TerminalTab
|
<TerminalTab
|
||||||
ref="terminalRefs"
|
ref="terminalRefs"
|
||||||
:host="item.host"
|
:host="item.host"
|
||||||
|
:theme="themeObj"
|
||||||
|
:background="terminalBackground"
|
||||||
@input-command="terminalInput"
|
@input-command="terminalInput"
|
||||||
@cd-command="cdCommand"
|
@cd-command="cdCommand"
|
||||||
/>
|
/>
|
||||||
@ -144,6 +145,12 @@
|
|||||||
@update-list="handleUpdateList"
|
@update-list="handleUpdateList"
|
||||||
@closed="updateHostData = null"
|
@closed="updateHostData = null"
|
||||||
/>
|
/>
|
||||||
|
<TerminalSetting
|
||||||
|
v-model:show="showSetting"
|
||||||
|
v-model:themeName="themeName"
|
||||||
|
v-model:background="terminalBackground"
|
||||||
|
@closed="showSetting = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -155,7 +162,9 @@ import InfoSide from './info-side.vue'
|
|||||||
import Sftp from './sftp.vue'
|
import Sftp from './sftp.vue'
|
||||||
import InputCommand from '@/components/input-command/index.vue'
|
import InputCommand from '@/components/input-command/index.vue'
|
||||||
import HostForm from '../../server/components/host-form.vue'
|
import HostForm from '../../server/components/host-form.vue'
|
||||||
|
import TerminalSetting from './terminal-setting.vue'
|
||||||
// import { randomStr } from '@utils/index.js'
|
// import { randomStr } from '@utils/index.js'
|
||||||
|
import themeList from 'xterm-theme'
|
||||||
|
|
||||||
const { proxy: { $nextTick, $store, $message } } = getCurrentInstance()
|
const { proxy: { $nextTick, $store, $message } } = getCurrentInstance()
|
||||||
|
|
||||||
@ -172,19 +181,33 @@ const showInputCommand = ref(false)
|
|||||||
const infoSideRef = ref(null)
|
const infoSideRef = ref(null)
|
||||||
const terminalRefs = ref([])
|
const terminalRefs = ref([])
|
||||||
const sftpRefs = ref([])
|
const sftpRefs = ref([])
|
||||||
let activeTabIndex = ref(0)
|
const activeTabIndex = ref(0)
|
||||||
let visible = ref(true)
|
const visible = ref(true)
|
||||||
let showSftp = ref(localStorage.getItem('showSftp') === 'true')
|
const showSftp = ref(localStorage.getItem('showSftp') === 'true')
|
||||||
let mainHeight = ref('')
|
const mainHeight = ref('')
|
||||||
let isSyncAllSession = ref(false)
|
const isSyncAllSession = ref(false)
|
||||||
let hostFormVisible = ref(false)
|
const hostFormVisible = ref(false)
|
||||||
let updateHostData = ref(null)
|
const updateHostData = ref(null)
|
||||||
|
const showSetting = ref(false)
|
||||||
|
const themeName = ref(localStorage.getItem('themeName') || 'Afterglow')
|
||||||
|
let localTerminalBackground = localStorage.getItem('terminalBackground')
|
||||||
|
const terminalBackground = ref(localTerminalBackground === undefined ? '/01.png' : localTerminalBackground)
|
||||||
|
|
||||||
const terminalTabs = computed(() => props.terminalTabs)
|
const terminalTabs = computed(() => props.terminalTabs)
|
||||||
const terminalTabsLen = computed(() => props.terminalTabs.length)
|
const terminalTabsLen = computed(() => props.terminalTabs.length)
|
||||||
let hostList = computed(() => $store.hostList)
|
const hostList = computed(() => $store.hostList)
|
||||||
const curHost = computed(() => hostList.value.find(item => item.host === terminalTabs.value[activeTabIndex.value]?.host))
|
const curHost = computed(() => hostList.value.find(item => item.host === terminalTabs.value[activeTabIndex.value]?.host))
|
||||||
let scriptList = computed(() => $store.scriptList)
|
const scriptList = computed(() => $store.scriptList)
|
||||||
|
const themeObj = computed(() => themeList[themeName.value])
|
||||||
|
|
||||||
|
watch(themeName, (newVal) => {
|
||||||
|
console.log('update theme:', newVal)
|
||||||
|
localStorage.setItem('themeName', newVal)
|
||||||
|
})
|
||||||
|
watch(terminalBackground, (newVal) => {
|
||||||
|
console.log('update terminalBackground:', newVal)
|
||||||
|
localStorage.setItem('terminalBackground', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
handleResizeTerminalSftp()
|
handleResizeTerminalSftp()
|
||||||
|
@ -4731,6 +4731,11 @@ xmlhttprequest-ssl@~2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
|
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
|
||||||
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
|
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
|
||||||
|
|
||||||
|
xterm-theme@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/xterm-theme/-/xterm-theme-1.1.0.tgz#2e499c4dd6d2cf592c90389022095e1ce509bbbe"
|
||||||
|
integrity sha512-n2GocBEbqcz4vEl4OYkU93hEVia8GWdnqchiz/0nQ/olRUyhulGf4wfha23x/D2m0imWaIavRZtt8c6kWZXdsA==
|
||||||
|
|
||||||
y18n@^5.0.5:
|
y18n@^5.0.5:
|
||||||
version "5.0.8"
|
version "5.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||||
|