写的不好? 那就推翻重来.

This commit is contained in:
lijiahangmax
2024-02-01 00:18:13 +08:00
parent 66ab7a7637
commit 3abd273f88
38 changed files with 3701 additions and 48 deletions

View File

@@ -191,14 +191,14 @@ body[host-space-theme='dark'] .arco-modal-container {
}
// 侧栏图标
.terminal-sidebar-icon-wrapper {
.host-space-sidebar-icon-wrapper {
width: var(--sidebar-icon-wrapper-size);
height: var(--sidebar-icon-wrapper-size);
display: flex;
align-items: center;
justify-content: center;
.terminal-sidebar-icon {
.host-space-sidebar-icon {
width: var(--sidebar-icon-size);
height: var(--sidebar-icon-size);
font-size: var(--sidebar-icon-font-size);
@@ -226,44 +226,44 @@ body[host-space-theme='dark'] .arco-modal-container {
}
// 终端设置容器
.terminal-setting-container {
.host-space-setting-container {
padding: 32px 16px 16px 16px;
width: 100%;
display: flex;
flex-direction: column;
.terminal-setting-wrapper {
.host-space-setting-wrapper {
min-width: 932px;
max-width: 90%;
margin: 0 auto;
position: relative;
}
.terminal-setting-title {
.host-space-setting-title {
margin: 0 0 24px 0;
user-select: none;
font-size: 1.65em;
color: var(--color-content-text-3);
}
.terminal-setting-block {
.host-space-setting-block {
color: var(--color-content-text-2);
margin-bottom: 24px;
}
.terminal-setting-subtitle-wrapper {
.host-space-setting-subtitle-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.terminal-setting-subtitle {
.host-space-setting-subtitle {
margin: 0 0 16px 0;
user-select: none;
color: var(--color-content-text-3);
}
.terminal-setting-body {
.host-space-setting-body {
display: flex;
&.block-body {
@@ -277,13 +277,13 @@ body[host-space-theme='dark'] .arco-modal-container {
}
// tooltip 内容
.terminal-tooltip-content {
.host-space-tooltip-content {
color: var(--color-sidebar-tooltip-text);
background: var(--color-sidebar-tooltip-bg);
}
// 终端右键菜单
.terminal-context-menu {
// 右键菜单
.host-space-context-menu {
.arco-dropdown-option {
padding: 0 6px;
line-height: 32px;

View File

@@ -6,6 +6,7 @@ import useTabBarStore from './modules/tab-bar';
import useCacheStore from './modules/cache';
import useTipsStore from './modules/tips';
import useDictStore from './modules/dict';
import useHostSpaceStore from './modules/host-space';
import useTerminalStore from './modules/terminal';
const pinia = createPinia();
@@ -19,6 +20,7 @@ export {
useTipsStore,
useDictStore,
useTerminalStore,
useHostSpaceStore,
};
export default pinia;

View File

@@ -0,0 +1,179 @@
import type {
TerminalActionBarSetting,
TerminalDisplaySetting,
TerminalInteractSetting,
TerminalPluginsSetting,
TerminalPreference,
TerminalSessionSetting,
TerminalShortcutSetting,
TerminalState
} from './types';
import type { AuthorizedHostQueryResponse } from '@/api/asset/asset-authorized-data';
import type { HostQueryResponse } from '@/api/asset/host';
import type { TerminalTheme, TerminalThemeSchema } from '@/api/asset/host-terminal';
import { getCurrentAuthorizedHost } from '@/api/asset/asset-authorized-data';
import { getTerminalThemes } from '@/api/asset/host-terminal';
import { defineStore } from 'pinia';
import { getPreference, updatePreference } from '@/api/user/preference';
import { nextSessionId } from '@/utils';
import { Message } from '@arco-design/web-vue';
import { TerminalTabType } from '@/views/host/terminal/types/terminal.const';
import TerminalTabManager from '@/views/host/terminal/handler/terminal-tab-manager';
import TerminalSessionManager from '@/views/host/terminal/handler/terminal-session-manager';
// 终端偏好项
export const TerminalPreferenceItem = {
// 新建连接类型
NEW_CONNECTION_TYPE: 'newConnectionType',
// 终端主题
THEME: 'theme',
// 显示设置
DISPLAY_SETTING: 'displaySetting',
// 操作栏设置
ACTION_BAR_SETTING: 'actionBarSetting',
// 右键菜单设置
RIGHT_MENU_SETTING: 'rightMenuSetting',
// 交互设置
INTERACT_SETTING: 'interactSetting',
// 插件设置
PLUGINS_SETTING: 'pluginsSetting',
// 会话设置
SESSION_SETTING: 'sessionSetting',
// 快捷键设置
SHORTCUT_SETTING: 'shortcutSetting',
};
export default defineStore('hostSpace', {
state: (): TerminalState => ({
preference: {
newConnectionType: 'group',
theme: {
schema: {} as TerminalThemeSchema
} as TerminalTheme,
displaySetting: {} as TerminalDisplaySetting,
actionBarSetting: {} as TerminalActionBarSetting,
rightMenuSetting: [],
interactSetting: {} as TerminalInteractSetting,
pluginsSetting: {} as TerminalPluginsSetting,
sessionSetting: {} as TerminalSessionSetting,
shortcutSetting: {
enabled: false,
keys: []
} as TerminalShortcutSetting,
},
hosts: {} as AuthorizedHostQueryResponse,
tabManager: new TerminalTabManager(),
sessionManager: new TerminalSessionManager()
}),
actions: {
// 加载终端偏好
async fetchPreference() {
try {
// 加载偏好
const { data } = await getPreference<TerminalPreference>('TERMINAL');
// theme 不存在则默认加载第一个
if (!data.theme?.name) {
const { data: themes } = await getTerminalThemes();
data.theme = themes[0];
// 更新默认主题偏好
await this.updateTerminalPreference(TerminalPreferenceItem.THEME, data.theme);
}
// 移除禁用的快捷键
if (data.shortcutSetting?.enabled) {
data.shortcutSetting.keys = data.shortcutSetting.keys.filter(s => s.enabled);
} else {
data.shortcutSetting = {
enabled: false,
keys: []
};
}
// 选择赋值 (不能修改引用)
const keys = Object.keys(this.preference);
keys.forEach(key => {
const item = data[key as keyof TerminalPreference];
if (item) {
this.preference[key as keyof TerminalPreference] = item as any;
}
});
} catch (e) {
Message.error('配置加载失败');
}
},
// 更新终端偏好
async updateTerminalPreference(item: string, value: any, setLocal = false) {
if (setLocal) {
this.preference[item as keyof TerminalPreference] = value;
}
try {
// 修改配置
await updatePreference({
type: 'TERMINAL',
item,
value
});
} catch (e) {
Message.error('同步失败');
}
},
// 加载主机列表
async loadHosts() {
if (this.hosts.hostList?.length) {
return;
}
const { data } = await getCurrentAuthorizedHost();
Object.keys(data).forEach(k => {
this.hosts[k as keyof AuthorizedHostQueryResponse] = data[k as keyof AuthorizedHostQueryResponse] as any;
});
},
// 打开终端
openTerminal(record: HostQueryResponse) {
// 添加到最近连接
this.hosts.latestHosts = [...new Set([record.id, ...this.hosts.latestHosts])];
// 获取 seq
const tabSeqArr = this.tabManager.items
.map(s => s.seq)
.filter(Boolean)
.map(Number);
const nextSeq = tabSeqArr.length
? Math.max(...tabSeqArr) + 1
: 1;
// 打开 tab
this.tabManager.openTab({
type: TerminalTabType.TERMINAL,
key: nextSessionId(10),
seq: nextSeq,
title: `(${nextSeq}) ${record.alias || record.name}`,
hostId: record.id,
address: record.address
});
},
// 复制并且打开终端
openCopyTerminal(hostId: number) {
const host = this.hosts.hostList
.find(s => s.id === hostId);
if (host) {
this.openTerminal(host);
}
},
// 获取当前终端会话
getCurrentTerminalSession(tips: boolean = true) {
const tab = this.tabManager.getCurrentTab();
if (!tab || tab.type !== TerminalTabType.TERMINAL) {
if (tips) {
Message.warning('请切换到终端标签页');
}
return;
}
// 获取处理器并截图
return this.sessionManager.getSession(tab.key);
},
},
});

View File

@@ -0,0 +1,97 @@
import type { ITerminalSessionManager, ITerminalTabManager } from '@/views/host/terminal/types/terminal.type';
import type { AuthorizedHostQueryResponse } from '@/api/asset/asset-authorized-data';
import type { TerminalTheme } from '@/api/asset/host-terminal';
export interface TerminalState {
preference: TerminalPreference;
hosts: AuthorizedHostQueryResponse;
tabManager: ITerminalTabManager;
sessionManager: ITerminalSessionManager;
}
// 终端配置
export interface TerminalPreference {
newConnectionType: string;
theme: TerminalTheme;
displaySetting: TerminalDisplaySetting;
actionBarSetting: TerminalActionBarSetting;
rightMenuSetting: Array<string>,
interactSetting: TerminalInteractSetting;
pluginsSetting: TerminalPluginsSetting;
sessionSetting: TerminalSessionSetting;
shortcutSetting: TerminalShortcutSetting;
}
// 显示设置
export interface TerminalDisplaySetting {
fontFamily?: string;
fontSize?: number;
lineHeight?: number;
fontWeight?: string | number;
fontWeightBold?: string | number;
cursorStyle?: string;
cursorBlink?: boolean;
}
// 操作栏设置
export interface TerminalActionBarSetting {
commandInput?: boolean;
connectStatus?: boolean;
[key: string]: unknown;
}
// 交互设置
export interface TerminalInteractSetting {
fastScrollModifier: boolean;
altClickMovesCursor: boolean;
rightClickSelectsWord: boolean;
selectionChangeCopy: boolean;
copyAutoTrim: boolean;
pasteAutoTrim: boolean;
rightClickPaste: boolean;
enableRightClickMenu: boolean;
enableBell: boolean;
wordSeparator: string;
}
// 插件设置
export interface TerminalPluginsSetting {
enableWeblinkPlugin: boolean;
enableWebglPlugin: boolean;
enableImagePlugin: boolean;
}
// 会话设置
export interface TerminalSessionSetting {
terminalEmulationType: string;
scrollBackLine: number;
}
// 终端快捷键设置
export interface TerminalShortcutSetting {
enabled: boolean;
keys: Array<TerminalShortcutKey>;
}
// 终端快捷键
export interface ShortcutKey {
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
code: string;
}
// 终端快捷键
export interface TerminalShortcutKey extends ShortcutKey {
item: string;
enabled: boolean;
}
// 终端快捷键编辑
export interface TerminalShortcutKeyEditable extends TerminalShortcutKey {
editable: boolean;
content: string;
type: number;
shortcutKey?: string;
}

View File

@@ -1,5 +1,5 @@
<template>
<a-dropdown class="terminal-context-menu"
<a-dropdown class="host-space-context-menu"
:popup-max-height="false"
trigger="contextMenu"
position="bl"
@@ -52,35 +52,35 @@
<template #content>
<!-- 复制 -->
<a-doption @click="copyCommand">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-copy />
</div>
<div>复制</div>
</a-doption>
<!-- 粘贴 -->
<a-doption @click="paste">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-paste />
</div>
<div>粘贴</div>
</a-doption>
<!-- 执行 -->
<a-doption @click="exec">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-thunderbolt />
</div>
<div>执行</div>
</a-doption>
<!-- 修改 -->
<a-doption @click="openUpdateSnippet(item)">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-edit />
</div>
<div>修改</div>
</a-doption>
<!-- 删除 -->
<a-doption @click="removeSnippet(item.id)">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-delete />
</div>
<div>删除</div>
@@ -88,7 +88,7 @@
<!-- 展开 -->
<a-doption v-if="!item.expand"
@click="() => item.expand = true">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-expand />
</div>
<div>展开</div>
@@ -96,7 +96,7 @@
<!-- 收起 -->
<a-doption v-else
@click="() => item.expand = false">
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<icon-shrink />
</div>
<div>收起</div>

View File

@@ -0,0 +1,163 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper">
<!-- 组合容器 -->
<div class="combined-container">
<!-- 新建连接 -->
<div class="combined-handler" v-for="(handler, index) in combinedHandlers"
:key="index"
@click="clickHandlerItem(handler)">
<!-- 图标 -->
<div class="combined-handler-icon">
<component :is="handler.icon" />
</div>
<!-- 内容 -->
<div class="combined-handler-text">
{{ handler.title }}
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'emptyRecommend'
};
</script>
<script lang="ts" setup>
import type { TabItem, CombinedHandlerItem } from '../../types/type';
import type { HostQueryResponse } from '@/api/asset/host';
import { onMounted, ref } from 'vue';
import { useHostSpaceStore } from '@/store';
import { InnerTabs, TabType } from '../../types/const';
import { get } from 'lodash';
const totalCount = 7;
const { tabManager, hosts, openTerminal } = useHostSpaceStore();
const combinedHandlers = ref<Array<CombinedHandlerItem>>([{
title: InnerTabs.NEW_CONNECTION.title,
settingTab: InnerTabs.NEW_CONNECTION,
type: TabType.SETTING,
icon: InnerTabs.NEW_CONNECTION.icon
}]);
// 点击组合操作元素
const clickHandlerItem = (item: CombinedHandlerItem) => {
if (item.type === TabType.SETTING) {
// 打开内置 tab
tabManager.openTab(item.settingTab as TabItem);
} else {
// 打开终端
openTerminal(item.host as HostQueryResponse);
}
};
// 组合主机列表
onMounted(() => {
// 推荐的主机 tab
const combinedHosts = [
...new Set([
...hosts.latestHosts,
...hosts.hostList.filter(s => s.favorite).map(s => s.id),
...hosts.hostList.map(s => s.id)
])
].slice(0, totalCount - 1)
.map(s => hosts.hostList.find(t => t.id === s) as HostQueryResponse)
.filter(Boolean)
.map(s => {
return {
title: `${s.alias || s.name} (${s.address})`,
type: TabType.TERMINAL,
host: s,
icon: 'icon-desktop'
};
});
// 插入主机列表
combinedHandlers.value.push(...combinedHosts);
// 不足显示的行数用设置补充
if (totalCount - 1 - combinedHosts.length > 0) {
const fillTabs = Object.keys(InnerTabs)
.filter(s => s !== 'NEW_CONNECTION')
.map(s => get(InnerTabs, s) as TabItem)
.slice(0, totalCount - 1 - combinedHosts.length)
.map(s => {
return {
title: s.title,
settingTab: s,
type: TabType.SETTING,
icon: s.icon as string
};
});
combinedHandlers.value.push(...fillTabs);
}
});
</script>
<style lang="less" scoped>
@transform-x: 8px;
@container-width: 406px;
@container-height: 448px;
@handler-height: 44px;
.combined-container {
padding: 12px;
margin: 64px auto;
width: @container-width;
height: @container-height;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: content-box;
overflow: hidden;
&:hover {
overflow: auto;
}
}
.combined-handler {
width: calc(@container-width - @transform-x);
height: @handler-height;
border-radius: 4px;
margin-bottom: 6px;
color: var(--color-content-text-1);
background-color: var(--color-fill-2);
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
width: @container-width;
}
&-icon {
width: @handler-height;
height: @handler-height;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
&-text {
height: 100%;
width: calc(100% - @handler-height - 12px);
display: flex;
align-items: center;
font-size: 12px;
&-wrapper {
display: block;
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
}
}
}
</style>

View File

@@ -5,11 +5,11 @@
:position="position as any"
:mini="true"
:show-arrow="false"
content-class="terminal-tooltip-content"
content-class="host-space-tooltip-content"
:auto-fix-position="false"
:content="action.content">
<div class="terminal-sidebar-icon-wrapper" v-if="action.visible !== false">
<div class="terminal-sidebar-icon"
<div class="host-space-sidebar-icon-wrapper" v-if="action.visible !== false">
<div class="host-space-sidebar-icon"
:class="[
iconClass,
action.disabled === true ? 'disabled-item' : '',
@@ -30,7 +30,7 @@
</script>
<script lang="ts" setup>
import type { SidebarAction } from '../../terminal/types/terminal.type';
import type { SidebarAction } from '../../types/type';
import type { PropType } from 'vue';
defineProps({

View File

@@ -40,14 +40,14 @@
</script>
<script lang="ts" setup>
import type { SidebarAction } from '../../terminal/types/terminal.type';
import type { SidebarAction } from '../../types/type';
import { useFullscreen } from '@vueuse/core';
import { computed } from 'vue';
import { useTerminalStore } from '@/store';
import { useHostSpaceStore } from '@/store';
import IconActions from './icon-actions.vue';
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
const { tabManager } = useTerminalStore();
const { tabManager } = useHostSpaceStore();
//
const actions = computed<Array<SidebarAction>>(() => [

View File

@@ -0,0 +1,166 @@
<template>
<div class="layout-main">
<!-- 内容 tabs -->
<a-tabs v-if="tabManager.active"
v-model:active-key="tabManager.active">
<a-tab-pane v-for="tab in tabManager.items"
:key="tab.key"
:title="tab.title">
<!-- 设置 -->
<template v-if="tab.type === TabType.SETTING">
<!-- 新建连接 -->
<new-connection-view v-if="tab.key === InnerTabs.NEW_CONNECTION.key" />
<!-- 显示设置 -->
<terminal-display-setting v-else-if="tab.key === InnerTabs.DISPLAY_SETTING.key" />
<!-- 主题设置 -->
<terminal-theme-setting v-else-if="tab.key === InnerTabs.THEME_SETTING.key" />
<!-- 终端设置 -->
<terminal-general-setting v-else-if="tab.key === InnerTabs.TERMINAL_SETTING.key" />
<!-- 快捷键设置 -->
<terminal-shortcut-setting v-else-if="tab.key === InnerTabs.SHORTCUT_SETTING.key" />
</template>
<!-- 终端 -->
<template v-else-if="tab.type === TabType.TERMINAL">
<!-- <terminal-view :tab="tab" />-->
</template>
</a-tab-pane>
</a-tabs>
<!-- 承载页推荐 -->
<empty-recommend v-else />
</div>
</template>
<script lang="ts">
export default {
name: 'layoutMain'
};
</script>
<script lang="ts" setup>
import { TabType, InnerTabs, TabShortcutKeys } from '../../types/const';
import { useHostSpaceStore } from '@/store';
import { onMounted, onUnmounted, watch } from 'vue';
import { addEventListen, removeEventListen } from '@/utils/event';
import EmptyRecommend from './empty-recommend.vue';
import NewConnectionView from '../new-connection/new-connection-view.vue';
import TerminalDisplaySetting from '../setting/terminal-display-setting.vue';
import TerminalThemeSetting from '../setting/terminal-theme-setting.vue';
import TerminalGeneralSetting from '../setting/terminal-general-setting.vue';
import TerminalShortcutSetting from '../setting/terminal-shortcut-setting.vue';
// import TerminalView from '../xterm/terminal-view.vue';
const { preference, tabManager, sessionManager } = useHostSpaceStore();
// 监听 tab 修改
watch(() => tabManager.active, (active, before) => {
if (before) {
// 失焦已经切换的终端
const beforeTab = tabManager.items.find(s => s.key === before);
if (beforeTab && beforeTab?.type === TabType.TERMINAL) {
sessionManager.getSession(before)?.blur();
}
}
if (active) {
// 获取 activeTab
const activeTab = tabManager.items.find(s => s.key === active);
if (!activeTab) {
return;
}
// 修改标题
document.title = activeTab.title;
// 终端自动聚焦
if (activeTab?.type === TabType.TERMINAL) {
sessionManager.getSession(active)?.focus();
}
} else {
// 修改标题
document.title = '主机终端';
}
});
// 处理快捷键逻辑
const handlerKeyboard = (event: Event) => {
// 当前页面非 terminal 的时候再触发快捷键 (terminal 有内置逻辑)
if (tabManager.active
&& tabManager.items.find(s => s.key === tabManager.active)?.type === TabType.TERMINAL) {
return;
}
const e = event as KeyboardEvent;
// 检测触发的快捷键
const key = preference.shortcutSetting.keys.find(key => {
return key.code === e.code
&& key.altKey === e.altKey
&& key.shiftKey === e.shiftKey
&& key.ctrlKey === e.ctrlKey;
});
if (!key) {
return;
}
// 触发逻辑
switch (key.item) {
case TabShortcutKeys.CLOSE_TAB:
// 关闭 tab
if (tabManager.active) {
tabManager.deleteTab(tabManager.active);
}
break;
case TabShortcutKeys.CHANGE_TO_PREV_TAB:
// 切换至前一个 tab
tabManager.changeToPrevTab();
break;
case TabShortcutKeys.CHANGE_TO_NEXT_TAB:
// 切换至后一个 tab
tabManager.changeToNextTab();
break;
case TabShortcutKeys.OPEN_NEW_CONNECT_TAB:
// 切换到新建连接 tab
tabManager.openTab(InnerTabs.NEW_CONNECTION);
break;
default:
break;
}
};
// 监听键盘事件
onMounted(() => {
if (preference.shortcutSetting.enabled) {
addEventListen(window, 'keydown', handlerKeyboard);
}
});
// 移除键盘事件
onUnmounted(() => {
if (preference.shortcutSetting.enabled) {
removeEventListen(window, 'keydown', handlerKeyboard);
}
});
</script>
<style lang="less" scoped>
.layout-main {
width: 100%;
height: 100%;
position: relative;
:deep(.arco-tabs) {
width: 100%;
height: 100%;
.arco-tabs-nav {
display: none;
}
.arco-tabs-content {
padding: 0;
width: 100%;
height: 100%;
overflow: auto;
&::-webkit-scrollbar {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div class="layout-left-sidebar">
<!-- 顶部操作按钮 -->
<icon-actions class="top-actions"
:actions="topActions"
position="left" />
<!-- 底部操作按钮 -->
<icon-actions class="bottom-actions"
:actions="bottomActions"
position="right" />
</div>
</template>
<script lang="ts">
export default {
name: 'leftSidebar'
};
</script>
<script lang="ts" setup>
import type { SidebarAction } from '../../types/type';
import { InnerTabs } from '../../types/const';
import { useHostSpaceStore } from '@/store';
import IconActions from './icon-actions.vue';
const { tabManager } = useHostSpaceStore();
// 顶部操作
const topActions: Array<SidebarAction> = [
{
icon: InnerTabs.NEW_CONNECTION.icon,
content: InnerTabs.NEW_CONNECTION.title,
click: () => tabManager.openTab(InnerTabs.NEW_CONNECTION)
},
];
// 底部操作
const bottomActions: Array<SidebarAction> = [
{
icon: InnerTabs.SHORTCUT_SETTING.icon,
content: InnerTabs.SHORTCUT_SETTING.title,
click: () => tabManager.openTab(InnerTabs.SHORTCUT_SETTING)
},
{
icon: InnerTabs.DISPLAY_SETTING.icon,
content: InnerTabs.DISPLAY_SETTING.title,
click: () => tabManager.openTab(InnerTabs.DISPLAY_SETTING)
},
{
icon: InnerTabs.THEME_SETTING.icon,
content: InnerTabs.THEME_SETTING.title,
click: () => tabManager.openTab(InnerTabs.THEME_SETTING)
},
{
icon: InnerTabs.TERMINAL_SETTING.icon,
content: InnerTabs.TERMINAL_SETTING.title,
click: () => tabManager.openTab(InnerTabs.TERMINAL_SETTING)
},
];
</script>
<style lang="less" scoped>
.layout-left-sidebar {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper loading-skeleton">
<!-- 加载骨架 -->
<a-skeleton class="full"
:animation="true">
<a-skeleton-line :rows="6"
:line-height="42"
:line-spacing="24" />
</a-skeleton>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'loadingSkeleton'
};
</script>
<script lang="ts" setup>
</script>
<style lang="less" scoped>
.loading-skeleton {
margin: 32px auto;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="layout-right-sidebar">
<!-- 顶部操作按钮 -->
<icon-actions class="top-actions"
:actions="topActions"
position="left" />
<!-- 底部操作按钮 -->
<icon-actions class="bottom-actions"
:actions="bottomActions"
position="left" />
<!-- 命令片段列表抽屉 -->
<command-snippet-list-drawer ref="snippetRef" />
</div>
</template>
<script lang="ts">
export default {
name: 'rightSidebar'
};
</script>
<script lang="ts" setup>
import type { SidebarAction } from '../../types/type';
import { useHostSpaceStore } from '@/store';
import { ref } from 'vue';
import IconActions from './icon-actions.vue';
import CommandSnippetListDrawer from '../../../command-snippet/components/command-snippet-list-drawer.vue';
const emits = defineEmits(['openSftp', 'openTransfer']);
const { getCurrentTerminalSession } = useHostSpaceStore();
const snippetRef = ref();
// 顶部操作
const topActions = [
{
icon: 'icon-code',
content: '打开命令片段',
click: () => snippetRef.value.open()
},
{
icon: 'icon-folder',
content: '打开 SFTP',
click: () => emits('openSftp')
},
{
icon: 'icon-swap',
content: '文件传输列表',
iconStyle: {
transform: 'rotate(90deg)'
},
click: () => emits('openTransfer')
},
];
// 底部操作
const bottomActions: Array<SidebarAction> = [
{
icon: 'icon-camera',
content: '截图',
click: () => screenshot()
},
];
// 终端截屏
const screenshot = () => {
const handler = getCurrentTerminalSession()?.handler;
if (handler && handler.enabledStatus('screenshot')) {
handler.screenshot();
}
};
</script>
<style lang="less" scoped>
.layout-right-sidebar {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="group-view-container">
<!-- 主机分组 -->
<div class="host-group-container">
<a-scrollbar>
<a-tree v-model:selected-keys="selectedGroup"
class="host-tree block-tree"
:data="groupTree"
:blockNode="true">
<!-- 组内数量 -->
<template #extra="node">
<span class="node-host-count span-blue">{{ treeNodes[node.key]?.length || 0 }}</span>
</template>
</a-tree>
</a-scrollbar>
</div>
<!-- 主机列表 -->
<host-list-view class="host-list"
:hostList="hostList"
empty-value="当前分组内无授权主机!" />
</div>
</template>
<script lang="ts">
export default {
name: 'hostGroupView'
};
</script>
<script lang="ts" setup>
import { computed } from 'vue';
import { HostQueryResponse } from '@/api/asset/host';
import { HostGroupQueryResponse } from '@/api/asset/host-group';
import HostListView from './host-list-view.vue';
const props = defineProps<{
modelValue: number,
groupTree: Array<HostGroupQueryResponse>;
hostList: Array<HostQueryResponse>;
treeNodes: Record<string, Array<number>>;
}>();
const emits = defineEmits(['update:modelValue']);
const selectedGroup = computed({
get() {
return [props.modelValue];
},
set(e) {
emits('update:modelValue', e[0]);
}
});
</script>
<style lang="less" scoped>
@tree-width: 298px;
@tree-gap: 32px;
.group-view-container {
display: flex;
justify-content: space-between;
max-height: 100%;
width: 100%;
}
.host-group-container {
:deep(.arco-scrollbar) {
width: @tree-width;
height: 100%;
margin-right: @tree-gap;
border-radius: 4px;
&-container {
width: 100%;
max-height: 100%;
overflow: auto;
}
}
.host-tree {
min-width: 100%;
width: max-content;
user-select: none;
overflow: hidden;
.node-host-count {
margin-right: 10px;
font-size: 13px;
user-select: none;
display: flex;
justify-content: flex-end;
align-items: center;
font-weight: bold;
}
}
}
.host-list {
width: calc(100% - @tree-width - @tree-gap);
border-radius: 4px;
max-height: 100%;
overflow: auto;
position: relative;
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div class="hosts-list-container">
<a-list size="large"
max-height="100%"
:hoverable="true"
:data="hostList">
<!-- 空数据 -->
<template #empty>
<a-empty>
<template #image>
<icon-desktop />
</template>
{{ emptyValue }}
</a-empty>
</template>
<!-- 数据 -->
<template #item="{ item }">
<a-list-item class="host-item-wrapper">
<div class="host-item">
<!-- 左侧图标-名称 -->
<div class="flex-center host-item-left">
<!-- 图标 -->
<span class="host-item-left-icon" @click="openTerminal(item)">
<icon-desktop />
</span>
<!-- 名称 -->
<span class="host-item-left-name">
<!-- 名称文本 -->
<template v-if="!item.editable">
<!-- 文本 -->
<a-tooltip position="top"
:mini="true"
content-class="host-space-tooltip-content"
arrow-class="host-space-tooltip-content"
:content="item.alias || `${item.name} (${item.code})`">
<span class="host-item-text host-item-left-name-text">
<template v-if="item.alias">
{{ item.alias }}
</template>
<template v-else>
{{ `${item.name} (${item.code})` }}
</template>
</span>
</a-tooltip>
<!-- 修改别名 -->
<a-tooltip position="top"
:mini="true"
:auto-fix-position="false"
content-class="host-space-tooltip-content"
arrow-class="host-space-tooltip-content"
content="修改别名">
<icon-edit class="host-item-left-name-edit"
@click="clickEditAlias(item)" />
</a-tooltip>
</template>
<!-- 名称输入框 -->
<template v-else>
<a-input v-model="item.alias"
ref="aliasNameInput"
class="host-item-left-name-input"
:max-length="32"
:disabled="item.loading"
size="mini"
:placeholder="item.name"
@blur="saveAlias(item)"
@pressEnter="saveAlias(item)"
@change="saveAlias(item)">
<template #suffix>
<!-- 加载中 -->
<icon-loading v-if="item.loading" />
<!-- 保存 -->
<icon-check v-else
class="pointer"
title="保存"
@click="saveAlias(item)" />
</template>
</a-input>
</template>
</span>
</div>
<!-- 中间ip -->
<div class="flex-center host-item-center">
<!-- ip -->
<a-tooltip position="top"
:mini="true"
:auto-fix-position="false"
content-class="host-space-tooltip-content"
arrow-class="host-space-tooltip-content"
:content="item.address">
<span class="host-item-text host-item-center-address">
{{ item.address }}
</span>
</a-tooltip>
</div>
<!-- 右侧tag-操作 -->
<div class="flex-center host-item-right">
<!-- tags -->
<div class="host-item-right-tags">
<template v-if="item.tags?.length">
<a-tag v-for="(tag, i) in item.tags"
class="host-item-text"
:key="tag.id"
:style="{
maxWidth: `calc(${100 / item.tags.length}% - ${i !== item.tags.length - 1 ? '8px' : '0px'})`,
marginRight: `${i !== item.tags.length - 1 ? '8px' : '0'}`,
}"
:color="dataColor(tag.name, tagColor)">
{{ tag.name }}
</a-tag>
</template>
</div>
<!-- 操作 -->
<div class="host-item-right-actions">
<!-- 连接主机 -->
<a-tooltip position="top"
:mini="true"
:auto-fix-position="false"
content-class="host-space-tooltip-content"
arrow-class="host-space-tooltip-content"
content="连接主机">
<div class="host-space-sidebar-icon-wrapper">
<div class="host-space-sidebar-icon" @click="openTerminal(item)">
<icon-thunderbolt />
</div>
</div>
</a-tooltip>
<!-- 连接设置 -->
<a-tooltip position="top"
:mini="true"
:auto-fix-position="false"
content-class="host-space-tooltip-content"
arrow-class="host-space-tooltip-content"
content="连接设置">
<div class="host-space-sidebar-icon-wrapper">
<div class="host-space-sidebar-icon" @click="openSetting(item)">
<icon-settings />
</div>
</div>
</a-tooltip>
<!-- 收藏 -->
<a-tooltip position="top"
:mini="true"
:auto-fix-position="false"
content-class="host-space-tooltip-content"
arrow-class="host-space-tooltip-content"
content="收藏">
<div class="host-space-sidebar-icon-wrapper">
<div class="host-space-sidebar-icon" @click="setFavorite(item)">
<icon-star-fill class="favorite" v-if="item.favorite" />
<icon-star v-else />
</div>
</div>
</a-tooltip>
</div>
</div>
</div>
</a-list-item>
</template>
</a-list>
</div>
</template>
<script lang="ts">
export default {
name: 'hostListView'
};
</script>
<script lang="ts" setup>
import type { HostQueryResponse } from '@/api/asset/host';
import { ref, nextTick, inject } from 'vue';
import useFavorite from '@/hooks/favorite';
import { dataColor } from '@/utils';
import { tagColor } from '@/views/asset/host-list/types/const';
import { updateHostAlias } from '@/api/asset/host-extra';
import { openSshModalKey } from '../../types/const';
import { useHostSpaceStore } from '@/store';
const props = defineProps<{
hostList: Array<HostQueryResponse>,
emptyValue: string
}>();
const { openTerminal } = useHostSpaceStore();
const { toggle: toggleFavorite, loading: favoriteLoading } = useFavorite('HOST');
const aliasNameInput = ref();
// 点击修改别名
const clickEditAlias = (item: HostQueryResponse) => {
item.editable = true;
if (!item.alias) {
item.alias = '';
}
nextTick(() => {
aliasNameInput.value?.focus();
});
};
// 保存别名
const saveAlias = async (item: HostQueryResponse) => {
item.loading = true;
item.modCount = (item.modCount || 0) + 1;
if (item.modCount != 1) {
return;
}
try {
// 修改别名
await updateHostAlias({
id: item.id,
name: item.alias || ''
});
item.editable = false;
} catch (e) {
} finally {
item.loading = false;
item.modCount = 0;
}
};
// 打开配置
const openSetting = inject(openSshModalKey) as (record: HostQueryResponse) => void;
// 设置收藏
const setFavorite = async (item: HostQueryResponse) => {
if (favoriteLoading.value) {
return;
}
await toggleFavorite(item, item.id);
};
</script>
<style lang="less" scoped>
@host-item-height: 56px;
:deep(.arco-list-bordered) {
border: 1px solid var(--color-fill-3);
.arco-empty {
padding: 16px 0;
flex-direction: column;
.arco-empty-image {
margin-bottom: 0;
}
}
.arco-list-item:not(:last-child) {
border-bottom: 1px solid var(--color-fill-3);
}
.arco-list-item:hover {
background-color: var(--color-fill-2);
}
}
.host-item-wrapper {
padding: 0 !important;
height: @host-item-height;
font-size: 12px;
color: var(--color-content-text-2);
.host-item {
width: 100%;
height: @host-item-height;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
&-text {
display: inline-block;
white-space: pre;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
.host-item-left-name-edit {
display: inline;
}
.host-item-right-tags {
display: none;
}
.host-item-right-actions {
display: flex;
}
}
}
.host-item-left {
width: 35%;
height: 100%;
padding-left: 18px;
position: absolute;
&-icon {
width: 32px;
height: 32px;
border-radius: 32px;
margin-right: 10px;
font-size: 16px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-3);
background: var(--color-fill-3);
}
&-name {
// 100% - icon-width - icon-margin-right
width: calc(100% - 42px);
display: flex;
align-items: center;
&-text {
// 100% - edit-margin-left - edit-font-size
max-width: calc(100% - 18px);
}
&-edit {
display: none;
margin-left: 4px;
cursor: pointer;
color: rgb(var(--blue-6));
font-size: 14px;
}
&-input {
width: 80%;
}
}
}
.host-item-center {
width: 25%;
height: 100%;
left: 35%;
padding: 0 8px;
position: absolute;
&-address {
max-width: 100%;
}
}
.host-item-right {
width: 40%;
height: 100%;
left: 60%;
padding-right: 18px;
flex-direction: column;
justify-content: center;
position: absolute;
&-tags {
width: 100%;
}
&-actions {
display: none;
width: 100%;
justify-content: flex-end;
}
}
}
.favorite {
color: rgb(var(--yellow-6));
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div>
<!-- 分组视图列表 -->
<host-group-view v-if="NewConnectionType.GROUP === newConnectionType"
v-model="selectedGroup"
:group-tree="hosts.groupTree"
:tree-nodes="treeNodes"
:host-list="hostList"
:filter-value="filterValue" />
<!-- 列表视图 -->
<host-list-view v-if="NewConnectionType.LIST === newConnectionType"
:hostList="hostList"
empty-value="无授权主机!" />
<!-- 我的收藏 -->
<host-list-view v-if="NewConnectionType.FAVORITE === newConnectionType"
class="list-view-container"
:hostList="hostList"
empty-value="无收藏记录, 快去点击主机右侧的进行收藏吧!" />
<!-- 最近连接 -->
<host-list-view v-if="NewConnectionType.LATEST === newConnectionType"
class="list-view-container"
:hostList="hostList"
empty-value="暂无连接记录, 快去体验吧!" />
<!-- 修改主机设置模态框 -->
<ssh-extra-modal ref="sshModal" />
</div>
</template>
<script lang="ts">
export default {
name: 'hostsView'
};
</script>
<script lang="ts" setup>
import { onMounted, provide, ref, watch } from 'vue';
import { NewConnectionType, openSshModalKey } from '../../types/const';
import { AuthorizedHostQueryResponse } from '@/api/asset/asset-authorized-data';
import { HostQueryResponse } from '@/api/asset/host';
import HostGroupView from './host-group-view.vue';
import HostListView from './host-list-view.vue';
import SshExtraModal from '../setting/ssh-extra-modal.vue';
const props = defineProps<{
hosts: AuthorizedHostQueryResponse,
filterValue: string,
newConnectionType: string
}>();
const hostList = ref<Array<HostQueryResponse>>([]);
const treeNodes = ref<Record<string, Array<number>>>({});
const selectedGroup = ref(
props.hosts?.groupTree?.length
? props.hosts.groupTree[0].key
: 0
);
const sshModal = ref();
// 暴露打开 ssh 配置模态框
provide(openSshModalKey, (record: any) => {
sshModal.value?.open(record);
});
// 主机数据处理
const shuffleHosts = () => {
let list = [...props.hosts?.hostList];
// 过滤
const filterVal = props.filterValue.toLowerCase();
if (filterVal) {
list = filterVal.startsWith('@')
// tag 过滤
? list.filter(item => item.tags.some(tag => (tag.name as string).toLowerCase().startsWith(filterVal.substring(1, filterVal.length))))
// 名称/编码/地址 过滤
: list.filter(item => {
return (item.name as string)?.toLowerCase().indexOf(filterVal) > -1
|| (item.code as string)?.toLowerCase().indexOf(filterVal) > -1
|| (item.alias as string)?.toLowerCase().indexOf(filterVal) > -1
|| (item.address as string)?.toLowerCase().indexOf(filterVal) > -1;
});
}
// 判断类型
if (NewConnectionType.GROUP === props.newConnectionType) {
// 过滤-分组
const groupNodes = { ...props.hosts.treeNodes };
Object.keys(groupNodes).forEach(k => {
groupNodes[k] = (groupNodes[k] || []).filter(item => list.some(host => host.id === item));
});
treeNodes.value = groupNodes;
// 当前组内数据
list = list.filter(item => groupNodes[selectedGroup.value]?.some(id => id === item.id));
} else if (NewConnectionType.FAVORITE === props.newConnectionType) {
// 过滤-个人收藏
list = list.filter(item => item.favorite);
} else if (NewConnectionType.LATEST === props.newConnectionType) {
// 过滤-最近连接
list = props.hosts.latestHosts
.map(s => list.find(item => item.id === s) as HostQueryResponse)
.filter(Boolean);
}
// 非最近连接排序
if (NewConnectionType.LATEST !== props.newConnectionType) {
hostList.value = list.sort((o1, o2) => {
if (o1.favorite || o2.favorite) {
if (o1.favorite && o2.favorite) {
return o2.id < o1.id ? 1 : -1;
}
return o2.favorite ? 1 : -1;
} else {
return o2.id < o1.id ? 1 : -1;
}
});
} else {
// 最近连接不排序
hostList.value = list;
}
};
// 监听搜索值变化
watch(() => props.filterValue, shuffleHosts);
// 监听类型变化
watch(() => props.newConnectionType, shuffleHosts);
// 监听分组变化
watch(selectedGroup, shuffleHosts);
// 初始化 加载主机
onMounted(shuffleHosts);
</script>
<style lang="less" scoped>
.list-view-container {
max-height: 100%;
width: 100%;
overflow: auto;
position: relative;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper">
<!-- 主标题 -->
<h2 class="host-space-setting-title">新建连接</h2>
<!-- 操作栏 -->
<div class="host-space-setting-block header-actions">
<!-- 视图类型 -->
<a-radio-group v-model="newConnectionType"
type="button"
class="usn"
:options="toRadioOptions(newConnectionTypeKey)"
@change="s => updateTerminalPreference(TerminalPreferenceItem.NEW_CONNECTION_TYPE, s as string, true)" />
<!-- 过滤 -->
<a-auto-complete v-model="filterValue"
class="host-filter"
placeholder="别名/名称/编码/IP @标签"
:allow-clear="true"
:data="filterOptions"
:filter-option="searchFilter">
<template #option="{ data: { raw: { label, isTag} } }">
<!-- tag -->
<a-tag v-if="isTag" :color="dataColor(label, tagColor)">
{{ label }}
</a-tag>
<!-- 文本 -->
<template v-else>
{{ label }}
</template>
</template>
</a-auto-complete>
</div>
<!-- 授权主机 -->
<div class="host-space-setting-block" style="margin: 0;">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
授权主机
</h3>
</div>
<!-- 内容区域 -->
<div class="host-space-setting-body body-container">
<!-- 无数据 -->
<a-empty v-if="!hosts.hostList?.length">
<template #image>
<icon-desktop />
</template>
Oops! 无授权主机 请联系管理员授权后重试!
</a-empty>
<!-- 主机列表 -->
<hosts-view v-else
class="host-view-container"
:hosts="hosts"
:filter-value="filterValue"
:new-connection-type="newConnectionType" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'newConnectionView'
};
</script>
<script lang="ts" setup>
import type { SelectOptionData } from '@arco-design/web-vue';
import { onBeforeMount, ref } from 'vue';
import { NewConnectionType, newConnectionTypeKey } from '../../types/const';
import { useDictStore, useHostSpaceStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import { dataColor } from '@/utils';
import { tagColor } from '@/views/asset/host-list/types/const';
import HostsView from './hosts-view.vue';
const { toRadioOptions } = useDictStore();
const { preference, updateTerminalPreference, hosts } = useHostSpaceStore();
const newConnectionType = ref(preference.newConnectionType || NewConnectionType.GROUP);
const filterValue = ref('');
const filterOptions = ref<Array<SelectOptionData>>([]);
// 过滤输入
const searchFilter = (searchValue: string, option: SelectOptionData) => {
if (searchValue.startsWith('@')) {
// tag 过滤
return option.isTag && (option.label as string).toLowerCase().startsWith(searchValue.substring(1, searchValue.length).toLowerCase());
} else {
// 文本过滤
return !option.isTag && (option.label as string).toLowerCase().indexOf(searchValue.toLowerCase()) > -1;
}
};
// 初始化过滤器项
const initFilterOptions = () => {
// 添加 tags
const tagNames = hosts.hostList?.map(s => s.tags)
.filter(s => s?.length)
.flat(1)
.sort((o1, o2) => o1.id - o2.id)
.map(s => s.name);
[...new Set(tagNames)].map(value => {
return { label: value, value: `@${value}`, isTag: true };
}).forEach(s => filterOptions.value.push(s));
// 添加主机信息
const hostMeta = hosts.hostList?.map(s => {
return [s.name, s.code, s.address, s.alias];
}).filter(Boolean).flat(1);
[...new Set(hostMeta)].map(value => {
return { label: value, value };
}).forEach(s => filterOptions.value.push(s));
};
// 初始化过滤器项
onBeforeMount(initFilterOptions);
</script>
<style lang="less" scoped>
.header-actions {
display: flex;
align-items: center;
justify-content: space-between;
:deep(.host-filter) {
width: 36%;
}
}
.body-container {
justify-content: space-between;
.host-view-container {
width: 100%;
height: calc(100vh - 240px);
position: relative;
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<a-col :span="12">
<div class="block-form-item-wrapper">
<div class="block-form-item-header">
<!-- label -->
<div class="block-form-item-label">
{{ label }}
</div>
<!-- item -->
<div class="block-form-item-value">
<slot />
</div>
</div>
<!-- 描述 -->
<div class="block-form-item-desc">
{{ desc }}
</div>
</div>
</a-col>
</template>
<script lang="ts">
export default {
name: 'blockSettingItem'
};
</script>
<script lang="ts" setup>
defineProps<{
label: string,
desc: string,
}>();
</script>
<style lang="less" scoped>
.block-form-item-wrapper {
height: 100%;
min-height: 64px;
border-radius: 4px;
background: var(--color-fill-2);
padding: 16px;
display: flex;
flex-direction: column;
.block-form-item-header {
display: flex;
justify-content: space-between;
margin-bottom: 14px;
}
.block-form-item-label {
color: var(--color-content-text-3);
font-size: 14px;
}
.block-form-item-desc {
color: var(--color-text-2);
font-size: 12px;
}
:deep(.arco-input-wrapper) {
background-color: var(--color-fill-3)
}
:deep(.arco-select) {
background-color: var(--color-fill-3)
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<a-modal v-model:visible="visible"
body-class="modal-form"
title-align="start"
:title="title"
:top="80"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
ok-text="保存"
:ok-button-props="{ disabled: loading }"
:cancel-button-props="{ disabled: loading }"
:on-before-ok="handlerOk"
@close="handleClose">
<a-spin class="full" :loading="loading">
<a-form :model="formModel"
ref="formRef"
label-align="right"
:style="{ width: '460px' }"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }"
:rules="{}">
<!-- 验证方式 -->
<a-form-item field="authType" label="验证方式">
<a-radio-group type="button"
v-model="formModel.authType"
:options="toRadioOptions(extraSshAuthTypeKey)" />
</a-form-item>
<!-- 用户名 -->
<a-form-item v-if="formModel.authType === ExtraSshAuthType.CUSTOM_KEY"
field="username"
label="用户名">
<a-input v-model="formModel.username" placeholder="请输入用户名" />
</a-form-item>
<!-- 主机秘钥 -->
<a-form-item v-if="formModel.authType === ExtraSshAuthType.CUSTOM_KEY"
field="keyId"
label="主机秘钥"
:rules="{ required: true, message: '请选择主机秘钥' }">
<host-key-selector v-model="formModel.keyId"
:authorized="true" />
</a-form-item>
<!-- 主机身份 -->
<a-form-item v-if="formModel.authType === ExtraSshAuthType.CUSTOM_IDENTITY"
field="identityId"
label="主机身份"
:rules="{ required: true, message: '请选择主机身份' }">
<host-identity-selector v-model="formModel.identityId"
:authorized="true" />
</a-form-item>
</a-form>
</a-spin>
</a-modal>
</template>
<script lang="ts">
export default {
name: 'sshExtraModal'
};
</script>
<script lang="ts" setup>
import type { HostQueryResponse } from '@/api/asset/host';
import type { SshExtraModel } from '../../types/type';
import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible';
import { Message } from '@arco-design/web-vue';
import { getHostExtraItem, updateHostExtra } from '@/api/asset/host-extra';
import { ExtraSshAuthType, extraSshAuthTypeKey } from '../../types/const';
import { useDictStore } from '@/store';
import HostIdentitySelector from '@/components/asset/host-identity/host-identity-selector.vue';
import HostKeySelector from '@/components/asset/host-key/host-key-selector.vue';
const { toRadioOptions } = useDictStore();
const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const title = ref<string>();
const hostId = ref<number>();
const formRef = ref();
const formModel = ref<SshExtraModel>({});
// 打开配置
const open = (record: HostQueryResponse) => {
hostId.value = record.id;
title.value = record.alias || `${record.name} (${record.code})`;
renderForm();
setVisible(true);
};
defineExpose({ open });
// 渲染表单
const renderForm = async () => {
try {
setLoading(true);
const { data } = await getHostExtraItem<SshExtraModel>({ hostId: hostId.value, item: 'ssh' });
formModel.value = data;
} catch (e) {
} finally {
setLoading(false);
}
};
// 确定
const handlerOk = async () => {
setLoading(true);
try {
// 验证参数
const error = await formRef.value.validate();
if (error) {
return false;
}
// 修改
await updateHostExtra({
hostId: hostId.value,
item: 'ssh',
extra: JSON.stringify(formModel.value)
});
Message.success('保存成功');
// 清空
handlerClear();
} catch (e) {
return false;
} finally {
setLoading(false);
}
};
// 关闭
const handleClose = () => {
handlerClear();
};
// 清空
const handlerClear = () => {
setLoading(false);
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
操作栏设置
</h3>
</div>
<!-- 提示 -->
<a-alert class="mb16">修改后会立刻保存, 立即生效 (无需刷新页面)</a-alert>
<!-- 内容区域 -->
<div class="host-space-setting-body block-body setting-body">
<a-form class="host-space-setting-form"
:model="formModel"
layout="vertical">
<a-space>
<!-- 顶部操作按钮 -->
<a-form-item field="actions" label="顶部操作按钮">
<icon-actions class="form-item-actions"
:actions="actions"
position="bottom" />
</a-form-item>
<!-- 命令输入框 -->
<a-form-item field="commandInput" label="命令输入框">
<a-switch v-model="formModel.commandInput"
class="form-item-command-input"
:default-checked="true"
type="round" />
</a-form-item>
<!-- 终端连接状态 -->
<a-form-item field="showStatus" label="终端连接状态">
<a-switch v-model="formModel.connectStatus"
:default-checked="true"
type="round" />
</a-form-item>
</a-space>
</a-form>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalActionBarBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalActionBarSetting } from '@/store/modules/host-space/types';
import type { SidebarAction } from '../../types/type';
import { computed, ref, watch } from 'vue';
import { useHostSpaceStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import { ActionBarItems } from '../../types/const';
import IconActions from '../layout/icon-actions.vue';
const { preference, updateTerminalPreference } = useHostSpaceStore();
const formModel = ref<TerminalActionBarSetting>({ ...preference.actionBarSetting });
// 监听同步
watch(formModel, (v) => {
if (!v) {
return;
}
// 同步
updateTerminalPreference(TerminalPreferenceItem.ACTION_BAR_SETTING, formModel.value, true);
}, { deep: true });
// 右侧操作
const actions = computed<Array<SidebarAction>>(() => {
return ActionBarItems.map(s => {
return {
icon: s.icon,
content: (formModel.value[s.item] === false ? '显示 ' : '隐藏 ') + s.content,
checked: formModel.value[s.item] !== false,
click: () => {
formModel.value[s.item] = formModel.value[s.item] === false;
}
};
});
});
</script>
<style lang="less" scoped>
.form-item-actions {
display: flex;
background-color: var(--color-fill-2);
padding: 4px;
border-radius: 4px;
:deep(.host-space-sidebar-icon-wrapper) {
width: 36px;
height: 36px;
}
:deep(.host-space-sidebar-icon) {
width: 28px;
height: 28px;
font-size: 20px;
}
}
.form-item-actions, .form-item-command-input {
margin-right: 48px;
}
:deep(.arco-form) {
.arco-form-item-label {
user-select: none;
}
.arco-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
显示偏好
</h3>
</div>
<!-- 提示 -->
<a-alert class="mb16">修改后会立刻保存, 重新打开终端后生效 (无需刷新页面)</a-alert>
<!-- 内容区域 -->
<div class="host-space-setting-body block-body setting-body">
<a-form class="host-space-setting-form"
:model="formModel"
layout="vertical">
<a-space>
<!-- 字体样式 -->
<a-form-item field="fontFamily" label="字体样式">
<a-select v-model="formModel.fontFamily"
class="form-item-font-family"
placeholder="请选择字体样式"
:options="toOptions(fontFamilyKey)"
:allow-create="true"
:filter-option="labelFilter">
<template #option="{ data }">
<span :style="{ fontFamily: data.value }">{{ data.label }}</span>
</template>
</a-select>
</a-form-item>
<!-- 字体大小 -->
<a-form-item field="fontSize" label="字体大小">
<a-select v-model="formModel.fontSize"
class="form-item-font-size"
placeholder="请选择字体大小"
:options="toOptions(fontSizeKey)" />
</a-form-item>
<!-- 行高 -->
<a-form-item field="lineHeight" label="行高">
<a-input-number v-model="formModel.lineHeight"
class="form-item-line-height"
placeholder="请输入行高"
:precision="2"
:min="1"
:max="2"
hide-button />
</a-form-item>
</a-space>
<a-space>
<!-- 普通文本字重 -->
<a-form-item field="fontWeight" label="普通文本字重">
<a-select v-model="formModel.fontWeight"
class="form-item-font-weight"
placeholder="请选择字重"
:options="toOptions(fontWeightKey)" />
</a-form-item>
<!-- 加粗文本字重 -->
<a-form-item field="fontWeightBold" label="加粗文本字重">
<a-select v-model="formModel.fontWeightBold"
class="form-item-font-bold-weight"
placeholder="请选择字重"
:options="toOptions(fontWeightKey)" />
</a-form-item>
</a-space>
<a-space>
<!-- 光标样式 -->
<a-form-item field="cursorStyle" label="光标样式">
<a-radio-group type="button"
v-model="formModel.cursorStyle"
class="form-item-cursor-style usn"
:options="toRadioOptions(cursorStyleKey)" />
</a-form-item>
<!-- 光标闪烁 -->
<a-form-item field="cursorBlink" label="光标是否闪烁">
<a-switch v-model="formModel.cursorBlink"
type="round"
class="form-item-cursor-blink" />
</a-form-item>
</a-space>
</a-form>
<!-- 预览区域 -->
<div class="terminal-example">
<span class="vertical-form-label">预览效果</span>
<div class="terminal-example-wrapper"
:style="{ background: preference.theme.schema.background }">
<terminal-example :schema="preference.theme.schema"
ref="previewTerminal" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalDisplayBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalDisplaySetting } from '@/store/modules/host-space/types';
import { ref, watch } from 'vue';
import { useDictStore, useHostSpaceStore } from '@/store';
import { fontFamilyKey, fontSizeKey, fontWeightKey, fontFamilySuffix, cursorStyleKey } from '../../types/const';
import { labelFilter } from '@/types/form';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import TerminalExample from '../setting/terminal-example.vue';
const { toOptions, toRadioOptions } = useDictStore();
const { preference, updateTerminalPreference } = useHostSpaceStore();
const previewTerminal = ref();
const formModel = ref<TerminalDisplaySetting>({ ...preference.displaySetting });
// 监听内容变化
watch(formModel, (v) => {
if (!v) {
return;
}
const options = previewTerminal.value?.term?.options;
if (!options) {
return;
}
// 修改预览终端配置
Object.keys(v).forEach(key => {
if (key === 'fontFamily') {
options[key] = (formModel.value as any)[key] + fontFamilySuffix;
} else {
options[key] = (formModel.value as any)[key];
}
});
// 同步
updateTerminalPreference(TerminalPreferenceItem.DISPLAY_SETTING, formModel.value, true);
// 聚焦
previewTerminal.value.term.focus();
}, { deep: true });
</script>
<style lang="less" scoped>
@terminal-width: 458px;
.setting-body {
height: 248px;
justify-content: space-between;
}
:deep(.arco-form) {
.arco-form-item-label {
user-select: none;
}
.arco-form-item {
margin-bottom: 14px;
}
.form-item-font-family {
width: 158px;
}
.form-item-font-size {
width: 148px;
}
.form-item-line-height {
width: 114px;
}
.form-item-font-weight, .form-item-font-bold-weight {
width: 178px;
}
.form-item-font-weight {
margin-right: 70px;
}
.form-item-cursor-style {
margin-right: 90px;
.arco-radio-button-content {
padding: 0 20px;
}
}
}
.terminal-example {
height: 100%;
&-wrapper {
border-radius: 4px;
width: calc(@terminal-width - 16px);
height: calc(100% - 16px - 12px);
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper">
<!-- 主标题 -->
<h2 class="host-space-setting-title">显示设置</h2>
<!-- 显示偏好 -->
<terminal-display-block />
<!-- 顶部工具栏 -->
<terminal-action-bar-block />
<!-- 右键菜单 -->
<terminal-right-menu-block />
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalDisplaySetting'
};
</script>
<script lang="ts" setup>
import TerminalDisplayBlock from './terminal-display-block.vue';
import TerminalActionBarBlock from './terminal-action-bar-block.vue';
import TerminalRightMenuBlock from './terminal-right-menu-block.vue';
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="terminal-example" ref="terminal"></div>
</template>
<script lang="ts">
export default {
name: 'terminalExample'
};
</script>
<script lang="ts" setup>
import type { TerminalThemeSchema } from '@/api/asset/host-terminal';
import { Terminal } from 'xterm';
import { onMounted, onUnmounted, ref } from 'vue';
const props = defineProps<{
schema: TerminalThemeSchema | Record<string, any>
}>();
const terminal = ref();
const term = ref();
onMounted(() => {
term.value = new Terminal({
theme: { ...props.schema, cursor: props.schema.background },
cols: 42,
rows: 6,
fontSize: 15,
cursorInactiveStyle: 'none',
});
term.value.open(terminal.value);
term.value.write(
'[root@OrionServer usr]#\r\n' +
'dr-xr-xr-x. 2 root root bin\r\n' +
'dr-xr-xr-x. 2 root root sbin\r\n' +
'drwxr-xr-x. 4 root root src\r\n' +
'lrwxrwxrwx. 1 root root tmp -> ../var/tmp '
);
});
defineExpose({ term });
onUnmounted(() => {
term.value?.dispose();
});
</script>
<style lang="less" scoped>
.terminal-example {
padding: 16px;
width: 100%;
height: 100%;
}
:deep(.xterm-viewport) {
overflow: hidden !important;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper">
<!-- 主标题 -->
<h2 class="host-space-setting-title">终端设置</h2>
<!-- 交互设置 -->
<terminal-interact-block />
<!-- 插件设置 -->
<terminal-plugins-block />
<!-- 会话设置 -->
<terminal-session-block />
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalGeneralSetting'
};
</script>
<script lang="ts" setup>
import TerminalInteractBlock from './terminal-interact-block.vue';
import TerminalPluginsBlock from './terminal-plugins-block.vue';
import TerminalSessionBlock from './terminal-session-block.vue';
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
交互设置
</h3>
</div>
<!-- 提示 -->
<a-alert class="mb16">修改后会立刻保存, 刷新页面后生效</a-alert>
<!-- 内容区域 -->
<div class="host-space-setting-body setting-body">
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 快速滚动 -->
<block-setting-item label="快速滚动" desc="alt + 鼠标滚轮快速滚动">
<a-switch type="round"
v-model="formModel.fastScrollModifier" />
</block-setting-item>
<!-- 点击移动光标 -->
<block-setting-item label="点击移动光标" desc="alt + 鼠标左键可以切换光标位置">
<a-switch type="round"
v-model="formModel.altClickMovesCursor" />
</block-setting-item>
</a-row>
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 右键选中词条 -->
<block-setting-item label="右键选中词条" desc="右键文本后会根据单词分隔符自动选中词条">
<a-switch type="round"
v-model="formModel.rightClickSelectsWord" />
</block-setting-item>
<!-- 选中自动复制 -->
<block-setting-item label="选中自动复制" desc="自动将选中的文本复制到剪切板">
<a-switch type="round"
v-model="formModel.selectionChangeCopy" />
</block-setting-item>
</a-row>
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 复制去除空格 -->
<block-setting-item label="复制去除空格" desc="复制文本后自动删除尾部空格">
<a-switch type="round"
v-model="formModel.copyAutoTrim" />
</block-setting-item>
<!-- 粘贴去除空格 -->
<block-setting-item label="粘贴去除空格" desc="粘贴文本前自动删除尾部空格 如: 命令输入框, 命令编辑器, 右键粘贴, 粘贴按钮, 右键菜单粘贴, 自定义粘贴快捷键">
<a-switch type="round"
v-model="formModel.pasteAutoTrim" />
</block-setting-item>
</a-row>
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 右键粘贴 -->
<block-setting-item label="右键粘贴" desc="右键自动粘贴, 启用后需要关闭右键菜单 (若开启了右键选中词条, 有选中的文本时, 右键粘贴无效)">
<a-switch type="round"
v-model="formModel.rightClickPaste" />
</block-setting-item>
<!-- 启用右键菜单 -->
<block-setting-item label="启用右键菜单" desc="右键终端将打开自定义菜单, 启用后需要关闭右键粘贴">
<a-switch type="round"
v-model="formModel.enableRightClickMenu" />
</block-setting-item>
</a-row>
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 启用响铃 -->
<block-setting-item label="启用响铃" desc="系统接收到 \a 时发出响铃 (一般不用开启)">
<a-switch type="round"
v-model="formModel.enableBell" />
</block-setting-item>
<!-- 单词分隔符 -->
<block-setting-item label="单词分隔符" desc="在终端中双击文本将使用该分隔符进行分割 (一般不用修改)">
<a-input size="small"
v-model="formModel.wordSeparator"
placeholder="单词分隔符"
allow-clear />
</block-setting-item>
</a-row>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalInteractBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalInteractSetting } from '@/store/modules/host-space/types';
import { ref, watch } from 'vue';
import { useHostSpaceStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import BlockSettingItem from './block-setting-item.vue';
const { preference, updateTerminalPreference } = useHostSpaceStore();
const formModel = ref<TerminalInteractSetting>({ ...preference.interactSetting });
// 监听内容变化
watch(formModel, (v) => {
if (!v) {
return;
}
// 同步
updateTerminalPreference(TerminalPreferenceItem.INTERACT_SETTING, formModel.value);
}, { deep: true });
</script>
<style lang="less" scoped>
.setting-body {
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
插件设置
</h3>
</div>
<!-- 内容区域 -->
<div class="host-space-setting-body setting-body">
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 超链接插件 -->
<block-setting-item label="超链接插件" desc="自动检测 http(https) url 并可以点击">
<a-switch type="round"
v-model="formModel.enableWeblinkPlugin" />
</block-setting-item>
<!-- WebGL 渲染插件 -->
<block-setting-item label="WebGL 渲染插件" desc="使用 WebGL 加速渲染终端 (建议开启, 若无法开启终端请关闭)">
<a-switch type="round"
v-model="formModel.enableWebglPlugin" />
</block-setting-item>
</a-row>
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 图片渲染插件 -->
<block-setting-item label="图片渲染插件" desc="支持使用 sixel 打开图片 (一般不需要开启)">
<a-switch type="round"
v-model="formModel.enableImagePlugin" />
</block-setting-item>
</a-row>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalPluginsBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalPluginsSetting } from '@/store/modules/host-space/types';
import { ref, watch } from 'vue';
import { useHostSpaceStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import BlockSettingItem from './block-setting-item.vue';
const { preference, updateTerminalPreference } = useHostSpaceStore();
const formModel = ref<TerminalPluginsSetting>({ ...preference.pluginsSetting });
// 监听内容变化
watch(formModel, (v) => {
if (!v) {
return;
}
// 同步
updateTerminalPreference(TerminalPreferenceItem.PLUGINS_SETTING, formModel.value);
}, { deep: true });
</script>
<style lang="less" scoped>
.setting-body {
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
右键菜单设置
</h3>
</div>
<!-- 提示 -->
<a-alert class="mb16">修改后会立刻保存, 重新打开终端后生效 (无需刷新页面)</a-alert>
<!-- 内容区域 -->
<div class="host-space-setting-body block-body setting-body">
<!-- 功能项 -->
<div class="actions-container">
<div class="vertical-form-label">功能</div>
<!-- 功能项列表 -->
<div class="actions-wrapper">
<a-row :gutter="[8, 8]">
<a-col :span="12"
class="action-item-wrapper"
v-for="(action, index) in ActionBarItems"
:key="index">
<div class="action-item" @click="clickAction(action.item)">
<!-- 图标 -->
<div class="action-icon">
<component :is="action.icon" />
</div>
<!-- 描述 -->
<div class="action-desc">
{{ action.content }}
</div>
</div>
</a-col>
</a-row>
</div>
</div>
<!-- 菜单预览容器 -->
<div class="preview-container">
<div class="vertical-form-label">菜单预览</div>
<div ref="popupContainer" />
</div>
<!-- 预览下拉菜单 -->
<a-dropdown v-if="popupContainer"
:popup-visible="true"
:popup-container="popupContainer"
:popup-max-height="false">
<template #content v-if="rightActions.length">
<a-doption v-for="(action, index) in rightActions"
:key="index">
<div class="preview-action">
<!-- 图标 -->
<div class="preview-icon">
<component :is="action.icon" />
</div>
<!-- 文本 -->
<div>{{ action.content }}</div>
</div>
<!-- 关闭按钮 -->
<div class="close-icon" @click="clickAction(action.item)">
<icon-close />
</div>
</a-doption>
</template>
<!-- 空数据 -->
<template #content v-else>
<a-doption>
点击左侧功能添加
</a-doption>
</template>
</a-dropdown>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'terminalRightMenuBlock'
};
</script>
<script lang="ts" setup>
import type { ContextMenuItem } from '../../types/type';
import { computed, ref, watch } from 'vue';
import { useHostSpaceStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import { ActionBarItems } from '../../types/const';
const { preference, updateTerminalPreference } = useHostSpaceStore();
const popupContainer = ref();
const rightActionItems = ref<Array<string>>([...preference.rightMenuSetting]);
// // 监听同步
watch(rightActionItems, (v) => {
// 同步
updateTerminalPreference(TerminalPreferenceItem.RIGHT_MENU_SETTING, v, true);
}, { deep: true });
// 实际操作项
const rightActions = computed<Array<ContextMenuItem>>(() => {
return rightActionItems.value
.map(s => ActionBarItems.find(i => i.item === s) as ContextMenuItem)
.filter(Boolean);
});
// 添加操作项
const clickAction = (item: string) => {
if (rightActionItems.value.includes(item)) {
// 移除
rightActionItems.value.splice(rightActionItems.value.indexOf(item), 1);
} else {
// 添加
rightActionItems.value.push(item);
}
};
</script>
<style lang="less" scoped>
@container-width: 418px;
@wrapper-margin-r: 32px;
@transform-x: 8px;
@item-width: (@container-width - @wrapper-margin-r) / 2;
.setting-body {
display: flex;
}
.actions-container {
width: @container-width;
height: auto;
.actions-wrapper {
padding-right: 8px;
margin-right: @wrapper-margin-r;
}
.action-item-wrapper {
transition: all 0.2s;
border-radius: 4px;
width: calc((@item-width) - @transform-x);
&:hover {
width: calc(@item-width);
padding: 4px 0 !important;
.action-item {
background: var(--color-fill-3);
}
.action-icon {
background: var(--color-fill-4);
}
}
}
.action-item {
display: flex;
padding: 6px;
align-items: center;
cursor: pointer;
border-radius: 4px;
width: inherit;
background: var(--color-fill-2);
}
.action-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
border-radius: 4px;
margin-right: 8px;
background-color: var(--color-fill-3);
}
.action-desc {
display: flex;
align-items: center;
font-size: 14px;
user-select: none;
}
}
.preview-container {
width: 242px;
height: auto;
:deep(.arco-dropdown-option-content) {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
&:hover {
.close-icon {
display: flex;
}
}
}
.preview-action {
display: flex;
align-items: center;
justify-content: space-between;
}
.preview-icon {
font-size: 18px;
margin-right: 6px;
}
.close-icon {
display: none;
padding: 3px;
border-radius: 12px;
align-items: center;
justify-content: center;
transition: .2s;
font-size: 15px;
&:hover {
background-color: var(--color-fill-3);
}
}
}
:deep(.arco-trigger-popup) {
position: relative;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
会话设置
</h3>
</div>
<!-- 内容区域 -->
<div class="host-space-setting-body setting-body">
<a-row class="mb16" align="stretch" :gutter="16">
<!-- 终端类型 -->
<block-setting-item label="终端类型" desc="若显示异常请尝试切换此选项 兼容性 vt100 > xterm > 16color > 256color">
<a-select style="width: 160px;"
v-model="formModel.terminalEmulationType"
size="small"
:options="toOptions(terminalEmulationTypeKey)" />
</block-setting-item>
<!-- 缓冲区行数 -->
<block-setting-item label="缓冲区行数" desc="保存在缓冲区的行数, 多出的行数会被忽略, 此值越大占用内存的内存会更多">
<a-input-number v-model="formModel.scrollBackLine"
size="small"
:min="1"
:max="10000"
placeholder="缓冲区行数 默认 1000 行"
allow-clear
hide-button />
</block-setting-item>
</a-row>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalSessionBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalSessionSetting } from '@/store/modules/host-space/types';
import { ref, watch } from 'vue';
import { useDictStore, useHostSpaceStore } from '@/store';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import { terminalEmulationTypeKey } from '../../types/const';
import BlockSettingItem from './block-setting-item.vue';
const { toOptions } = useDictStore();
const { preference, updateTerminalPreference } = useHostSpaceStore();
const formModel = ref<TerminalSessionSetting>({ ...preference.sessionSetting });
// 监听内容变化
watch(formModel, (v) => {
if (!v) {
return;
}
// 同步
updateTerminalPreference(TerminalPreferenceItem.SESSION_SETTING, formModel.value);
}, { deep: true });
</script>
<style lang="less" scoped>
.setting-body {
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
快捷键操作
</h3>
</div>
<!-- 内容区域 -->
<div class="host-space-setting-body setting-body">
<!-- 提示 -->
<a-alert class="mb16">点击保存按钮后需要刷新页面生效 (恢复默认配置后也需要点击保存按钮) (设置时需要避免与浏览器内置快捷键冲突)</a-alert>
<a-space class="action-container" size="mini">
<!-- 是否启用 -->
<a-switch v-model="value"
checked-text="启用"
unchecked-text="禁用"
type="round" />
<a-button size="small"
type="text"
@click="emits('save')">
保存
</a-button>
<a-button size="small"
type="text"
@click="emits('reset')">
恢复默认配置
</a-button>
</a-space>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'terminalShortcutActionBlock'
};
</script>
<script lang="ts" setup>
import { computed } from 'vue';
const props = defineProps<{
enabled: boolean
}>();
const emits = defineEmits(['update:enabled', 'save', 'reset']);
const value = computed<boolean>({
get() {
return props.enabled;
},
set(e) {
emits('update:enabled', e);
}
});
</script>
<style lang="less" scoped>
.setting-body {
flex-direction: column;
}
.action-container {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
{{ title }}
</h3>
</div>
<!-- 内容区域 -->
<div class="host-space-setting-body terminal-shortcut-container">
<template v-for="item in items">
<div class="shortcut-row" v-if="item.type === type">
<!-- 名称 -->
<span class="shortcut-name">{{ item.content }}</span>
<!-- 快捷键 -->
<div class="shortcut-key-container">
<!-- 启用-修改中 -->
<a-input v-if="item.editable && item.enabled"
v-model="item.shortcutKey"
:ref="setEditRef as unknown as VNodeRef"
class="trigger-input"
size="small"
placeholder="请按下快捷键"
readonly
@blur="clearEditableStatus" />
<!-- 启用-未修改 -->
<span v-else-if="item.enabled">{{ item.shortcutKey }}</span>
<!-- 禁用 -->
<span v-else />
</div>
<!-- 操作 -->
<a-space class="shortcut-actions-container">
<!-- 屏蔽 -->
<div class="click-icon-wrapper"
v-if="item.enabled"
title="屏蔽"
@click="updateEnabledStatus(item, false)">
<icon-message-banned />
</div>
<!-- 恢复 -->
<div class="click-icon-wrapper"
v-if="!item.enabled"
title="恢复"
@click="updateEnabledStatus(item, true)">
<icon-message />
</div>
<!-- 设置 -->
<div class="click-icon-wrapper"
v-if="!item.editable && item.enabled"
title="设置"
@click="setEditableStatus(item)">
<icon-settings />
</div>
</a-space>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalShortcutKeysBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalShortcutKeyEditable } from '@/store/modules/host-space/types';
import type { VNodeRef } from 'vue';
import { nextTick } from 'vue';
defineProps<{
title: string;
type: number;
items: Array<TerminalShortcutKeyEditable>
}>();
const emits = defineEmits(['setEditable', 'clearEditable', 'updateEnabled']);
// 设置 ref
const setEditRef = (el: HTMLElement) => {
// 自动聚焦
nextTick(() => {
el && el.focus();
});
};
// 修改启用状态
const updateEnabledStatus = (item: TerminalShortcutKeyEditable, enabled: boolean) => {
emits('updateEnabled', item, enabled);
};
// 设置可编辑状态
const setEditableStatus = (item: TerminalShortcutKeyEditable) => {
emits('setEditable', item);
};
// 清除可编辑状态
const clearEditableStatus = () => {
emits('clearEditable');
};
</script>
<style lang="less" scoped>
.terminal-shortcut-container {
flex-direction: column;
.shortcut-row {
display: flex;
align-items: center;
margin-bottom: 8px;
background: var(--color-neutral-2);
height: 42px;
padding: 8px 16px;
border-radius: 4px;
user-select: none;
transition: .3s;
&:hover {
background: var(--color-neutral-3);
.shortcut-actions-container {
display: flex;
}
}
.shortcut-name {
font-size: 14px;
width: 238px;
}
.shortcut-key-container {
width: 268px;
.trigger-input {
width: 188px;
}
}
}
.shortcut-actions-container {
display: none;
.click-icon-wrapper {
font-size: 18px;
padding: 4px;
&:hover {
background: var(--color-neutral-4);
}
}
}
}
</style>

View File

@@ -0,0 +1,248 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper">
<!-- 主标题 -->
<h2 class="host-space-setting-title">快捷键设置</h2>
<!-- 加载中 -->
<a-skeleton v-if="!render"
class="skeleton-wrapper"
:animation="true">
<a-skeleton-line :rows="8"
:line-height="42"
:line-spacing="12" />
</a-skeleton>
<!-- 设置 -->
<a-spin v-else
class="full"
:loading="loading">
<!-- 快捷键操作 -->
<terminal-shortcut-action-block v-model:enabled="enabled"
@reset="loadDefaultPreference"
@save="savePreference" />
<!-- 系统快捷键 -->
<terminal-shortcut-keys-block title="系统快捷键"
:type="ShortcutType.SYSTEM"
:items="shortcutKeys"
@set-editable="setEditableStatus"
@clear-editable="clearEditableStatus"
@update-enabled="updateEnabledStatus" />
<!-- 终端快捷键 -->
<terminal-shortcut-keys-block title="终端快捷键"
:type="ShortcutType.TERMINAL"
:items="shortcutKeys"
@set-editable="setEditableStatus"
@clear-editable="clearEditableStatus"
@update-enabled="updateEnabledStatus" />
</a-spin>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'terminalShortcutSetting'
};
</script>
<script lang="ts" setup>
import type { TerminalShortcutKeyEditable, TerminalShortcutSetting } from '@/store/modules/host-space/types';
import { ref, onMounted, onUnmounted } from 'vue';
import { getDefaultPreference, getPreference } from '@/api/user/preference';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import { TerminalShortcutItems, ShortcutType } from '../../types/const';
import { useHostSpaceStore } from '@/store';
import useLoading from '@/hooks/loading';
import { useDebounceFn } from '@vueuse/core';
import { addEventListen, removeEventListen } from '@/utils/event';
import { Message } from '@arco-design/web-vue';
import TerminalShortcutKeysBlock from './terminal-shortcut-keys-block.vue';
import TerminalShortcutActionBlock from './terminal-shortcut-action-block.vue';
const { updateTerminalPreference } = useHostSpaceStore();
const { loading, setLoading } = useLoading();
const render = ref(false);
const enabled = ref(false);
const editable = ref(false);
const currentItem = ref<TerminalShortcutKeyEditable>();
const shortcutKeys = ref<Array<TerminalShortcutKeyEditable>>([]);
// 修改快捷键状态
const updateEnabledStatus = (item: TerminalShortcutKeyEditable, enabled: boolean) => {
clearEditableStatus();
item.editable = false;
item.enabled = enabled;
};
// 设置可编辑
const setEditableStatus = (item: TerminalShortcutKeyEditable) => {
item.editable = true;
editable.value = true;
currentItem.value = item;
};
// 清除可编辑状态
const clearEditableStatus = () => {
editable.value = false;
if (currentItem.value) {
currentItem.value.editable = false;
currentItem.value = undefined;
}
};
// 计算显示的快捷键
const computeShortcutKey = (item: TerminalShortcutKeyEditable): string => {
const keys = [];
if (item.ctrlKey) {
keys.push('Ctrl');
}
if (item.altKey) {
keys.push('Alt');
}
if (item.shiftKey) {
keys.push('Shift');
}
let code = item.code;
if (code) {
if (code.startsWith('Key')) {
code = code.substring(3);
} else if (code.startsWith('Digit')) {
code = code.substring(5);
} else {
const keyMap: Record<string, any> = {
'Backquote': '`',
'Minus': '-',
'Equal': '=',
'BracketLeft': '[',
'BracketRight': ']',
'Backslash': '\\',
'Semicolon': ';',
'Quote': '\'',
'Comma': ',',
'Period': '.',
'Slash': '/',
'ArrowUp': '↑',
'ArrowDown': '↓',
'ArrowLeft': '←',
'ArrowRight': '→',
};
if (Object.keys(keyMap).includes(code)) {
code = keyMap[code] as string;
}
}
keys.push(code);
}
return keys.join(' + ');
};
// 处理快捷键逻辑 防抖函数
const handlerKeyboardFn = useDebounceFn((e: KeyboardEvent, item: TerminalShortcutKeyEditable) => {
item.ctrlKey = e.ctrlKey;
item.shiftKey = e.shiftKey;
item.altKey = e.altKey;
if (e.key !== 'Control' && e.key !== 'Shift' && e.key !== 'Alt') {
item.code = e.code;
} else {
item.code = '';
}
item.shortcutKey = computeShortcutKey(item);
});
// 处理快捷键逻辑
const handlerKeyboard = (event: Event) => {
if (editable.value && !!currentItem.value) {
const e = event as KeyboardEvent;
event.preventDefault();
event.stopPropagation();
// 修改快捷键
handlerKeyboardFn(e, currentItem.value);
}
};
// 保存
const savePreference = async () => {
setLoading(true);
try {
await updateTerminalPreference(TerminalPreferenceItem.SHORTCUT_SETTING, {
enabled: enabled.value,
keys: shortcutKeys.value
} as TerminalShortcutSetting);
Message.success('保存成功');
} catch (e) {
} finally {
setLoading(false);
}
};
// 恢复默认设置
const loadDefaultPreference = async () => {
const { data } = await getDefaultPreference<Record<string, any>>('TERMINAL', [TerminalPreferenceItem.SHORTCUT_SETTING]);
const setting = data[TerminalPreferenceItem.SHORTCUT_SETTING] as TerminalShortcutSetting;
renderShortcutKeys(setting);
};
// 加载用户设置
const loadUserPreference = async () => {
// 加载偏好
const { data } = await getPreference<Record<string, any>>('TERMINAL', [TerminalPreferenceItem.SHORTCUT_SETTING]);
const setting = data[TerminalPreferenceItem.SHORTCUT_SETTING] as TerminalShortcutSetting;
renderShortcutKeys(setting);
};
// 渲染快捷键
const renderShortcutKeys = (setting: TerminalShortcutSetting) => {
// 设置快捷键
const keys: Array<TerminalShortcutKeyEditable> = [];
for (const shortcutItem of TerminalShortcutItems) {
const shortcutKey = setting.keys?.find(s => s.item === shortcutItem.item);
if (shortcutKey) {
// 存在
keys.push({
...shortcutItem,
...shortcutKey,
editable: false,
});
} else {
// 不存在
keys.push({
...shortcutItem,
ctrlKey: false,
shiftKey: false,
altKey: false,
code: '',
enabled: false,
editable: false,
});
}
}
// 计算快捷键
keys.forEach(key => key.shortcutKey = computeShortcutKey(key));
shortcutKeys.value = keys;
enabled.value = setting.enabled;
};
// 加载用户快捷键
onMounted(async () => {
try {
await loadUserPreference();
} catch (e) {
} finally {
render.value = true;
}
});
// 监听键盘事件
onMounted(() => {
addEventListen(window, 'keydown', handlerKeyboard, true);
});
// 移除键盘事件
onUnmounted(() => {
removeEventListen(window, 'keydown', handlerKeyboard, true);
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="host-space-setting-block">
<!-- 顶部 -->
<div class="host-space-setting-subtitle-wrapper">
<h3 class="host-space-setting-subtitle">
主题设置
</h3>
</div>
<!-- 加载中 -->
<a-skeleton v-if="loading"
class="skeleton-wrapper"
:animation="true">
<a-skeleton-line :rows="8" />
</a-skeleton>
<!-- 内容区域 -->
<div v-else class="host-space-setting-body terminal-theme-container">
<!-- 提示 -->
<a-alert class="mb16">选择后会立刻保存, 刷新页面后生效</a-alert>
<!-- 终端主题 -->
<div class="theme-row"
v-for="rowIndex in themes.length / 2"
:key="rowIndex">
<a-card v-for="(theme, colIndex) in [themes[(rowIndex - 1) * 2], themes[(rowIndex - 1) * 2 + 1]]"
:key="theme.name"
class="terminal-theme-card simple-card"
:class="{
'terminal-theme-card-check': theme.name === currentThemeName
}"
:title="theme.name"
:style="{
background: theme.schema.background,
marginRight: colIndex === 0 ? '16px' : 0
}"
:header-style="{
color: theme.dark ? 'rgba(255, 255, 255, .8)' : 'rgba(0, 0, 0, .8)',
userSelect: 'none'
}"
@click="selectTheme(theme)">
<!-- 样例 -->
<terminal-example :schema="theme.schema" />
<!-- 选中按钮 -->
<icon-check class="theme-check-icon"
v-show="theme.name === currentThemeName" />
</a-card>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalThemeBlock'
};
</script>
<script lang="ts" setup>
import type { TerminalTheme } from '@/api/asset/host-terminal';
import { TerminalPreferenceItem } from '@/store/modules/host-space';
import { useHostSpaceStore } from '@/store';
import { onMounted, ref } from 'vue';
import { getTerminalThemes } from '@/api/asset/host-terminal';
import { getPreference } from '@/api/user/preference';
import useLoading from '@/hooks/loading';
import TerminalExample from './terminal-example.vue';
const { updateTerminalPreference } = useHostSpaceStore();
const { loading, setLoading } = useLoading();
const currentThemeName = ref();
const themes = ref<Array<TerminalTheme>>([]);
// 选择主题
const selectTheme = async (theme: TerminalTheme) => {
currentThemeName.value = theme.name;
await updateTerminalPreference(TerminalPreferenceItem.THEME, theme);
};
// 加载用户主题
onMounted(async () => {
try {
const { data } = await getPreference<Record<string, any>>('TERMINAL', [TerminalPreferenceItem.THEME]);
currentThemeName.value = data[TerminalPreferenceItem.THEME]?.name;
} catch (e) {
}
});
// 加载主题列表
onMounted(async () => {
setLoading(true);
try {
// 加载全部主题
const { data } = await getTerminalThemes();
themes.value = data;
} catch (e) {
} finally {
setLoading(false);
}
});
</script>
<style lang="less" scoped>
@terminal-width: 458px;
@terminal-height: 138px;
.terminal-theme-container {
flex-direction: column;
.theme-row {
display: flex;
margin-bottom: 16px;
}
}
.terminal-theme-card {
width: @terminal-width;
height: calc(@terminal-height + 44px);
border: 2px solid var(--color-border);
cursor: pointer;
:deep(.arco-card-header) {
padding: 4px 16px;
height: 40px;
border-bottom: .5px solid var(--color-border-2);
&-title {
color: unset;
font-size: 16px;
font-weight: 600;
}
}
:deep(.arco-card-body) {
height: @terminal-height;
padding: 0;
display: flex;
position: relative;
.theme-check-icon {
display: flex;
position: absolute;
color: #FFF;
right: 0;
bottom: 0;
z-index: 10;
}
}
&-check, &:hover {
border: 2px solid rgb(var(--blue-6));
}
&-check::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
width: 0;
height: 0;
border-bottom: 28px solid rgb(var(--blue-6));
border-left: 28px solid transparent;
}
}
</style>

View File

@@ -0,0 +1,25 @@
<template>
<div class="host-space-setting-container">
<div class="host-space-setting-wrapper">
<!-- 主标题 -->
<h2 class="host-space-setting-title">主题设置</h2>
<!-- 主题设置 -->
<terminal-theme-block />
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'TerminalThemeSetting'
};
</script>
<script lang="ts" setup>
import TerminalThemeBlock from './terminal-theme-block.vue';
</script>
<style lang="less" scoped>
</style>

View File

@@ -8,18 +8,18 @@
<main class="host-space-layout-main">
<!-- 左侧操作栏 -->
<div class="host-space-layout-left">
<terminal-left-sidebar />
<left-sidebar />
</div>
<!-- 内容区域 -->
<div class="host-space-layout-content">
<!-- 主机加载中骨架 -->
<loading-skeleton v-if="contentLoading" />
<!-- 终端内容区域 -->
<terminal-content v-else />
<!-- 内容区域 -->
<layout-main v-else />
</div>
<!-- 右侧操作栏 -->
<div class="host-space-layout-right">
<terminal-right-sidebar />
<right-sidebar />
</div>
</main>
</div>
@@ -33,18 +33,18 @@
<script lang="ts" setup>
import { ref, onBeforeMount, onUnmounted, onMounted } from 'vue';
import { dictKeys, InnerTabs } from '../terminal/types/terminal.const';
import { useCacheStore, useDictStore, useTerminalStore } from '@/store';
import { dictKeys, InnerTabs } from './types/const';
import { useCacheStore, useDictStore, useHostSpaceStore } from '@/store';
import useLoading from '@/hooks/loading';
import TerminalLeftSidebar from '../terminal/components/layout/terminal-left-sidebar.vue';
import TerminalRightSidebar from '../terminal/components/layout/terminal-right-sidebar.vue';
import TerminalContent from '../terminal/components/layout/terminal-content.vue';
import LoadingSkeleton from '../terminal/components/layout/loading-skeleton.vue';
import LayoutHeader from './components/layout/layout-header.vue';
import LeftSidebar from './components/layout/left-sidebar.vue';
import RightSidebar from './components/layout/right-sidebar.vue';
import LoadingSkeleton from './components/layout/loading-skeleton.vue';
import LayoutMain from './components/layout/layout-main.vue';
import '@/assets/style/host-space-layout.less';
import 'xterm/css/xterm.css';
import LayoutHeader from './components/layout-header.vue';
const terminalStore = useTerminalStore();
const hostSpaceStore = useHostSpaceStore();
const dictStore = useDictStore();
const cacheStore = useCacheStore();
const { loading: contentLoading, setLoading: setContentLoading } = useLoading(true);
@@ -60,9 +60,9 @@
// 加载用户终端偏好
onBeforeMount(async () => {
await terminalStore.fetchPreference();
await hostSpaceStore.fetchPreference();
// 设置系统主题配色
const dark = terminalStore.preference.theme.dark;
const dark = hostSpaceStore.preference.theme.dark;
document.body.setAttribute('host-space-theme', dark ? 'dark' : 'light');
render.value = true;
});
@@ -75,7 +75,7 @@
// 加载主机信息
onMounted(async () => {
try {
await terminalStore.loadHosts();
await hostSpaceStore.loadHosts();
} catch (e) {
} finally {
setContentLoading(false);

View File

@@ -0,0 +1,247 @@
import { ShortcutKeyItem } from './type';
// tab 类型
export const TabType = {
SETTING: 'setting',
TERMINAL: 'terminal',
};
// 内置 tab
export const InnerTabs = {
NEW_CONNECTION: {
key: 'newConnection',
title: '新建连接',
icon: 'icon-plus',
type: TabType.SETTING
},
SHORTCUT_SETTING: {
key: 'shortcutSetting',
title: '快捷键设置',
icon: 'icon-command',
type: TabType.SETTING
},
DISPLAY_SETTING: {
key: 'displaySetting',
title: '显示设置',
icon: 'icon-stamp',
type: TabType.SETTING
},
THEME_SETTING: {
key: 'themeSetting',
title: '主题设置',
icon: 'icon-palette',
type: TabType.SETTING
},
TERMINAL_SETTING: {
key: 'terminalSetting',
title: '终端设置',
icon: 'icon-settings',
type: TabType.SETTING
},
};
// 新建连接类型
export const NewConnectionType = {
GROUP: 'group',
LIST: 'list',
FAVORITE: 'favorite',
LATEST: 'latest'
};
// 主机额外配置 ssh 认证方式
export const ExtraSshAuthType = {
// 使用默认认证方式
DEFAULT: 'DEFAULT',
// 自定义秘钥
CUSTOM_KEY: 'CUSTOM_KEY',
// 自定义身份
CUSTOM_IDENTITY: 'CUSTOM_IDENTITY',
};
// 终端状态
export const TerminalStatus = {
// 连接中
CONNECTING: 0,
// 已连接
CONNECTED: 1,
// 已断开
CLOSED: 2
};
// 终端操作栏-操作项
export const ActionBarItems = [
{
item: 'toTop',
icon: 'icon-up',
content: '去顶部',
}, {
item: 'toBottom',
icon: 'icon-down',
content: '去底部',
}, {
item: 'selectAll',
icon: 'icon-expand',
content: '全选',
}, {
item: 'search',
icon: 'icon-find-replace',
content: '搜索',
}, {
item: 'copy',
icon: 'icon-copy',
content: '复制',
}, {
item: 'paste',
icon: 'icon-paste',
content: '粘贴',
}, {
item: 'interrupt',
icon: 'icon-formula',
content: 'ctrl + c',
}, {
item: 'enter',
icon: 'icon-play-arrow-fill',
content: '回车',
}, {
item: 'fontSizePlus',
icon: 'icon-zoom-in',
content: '增大字号',
}, {
item: 'fontSizeSubtract',
icon: 'icon-zoom-out',
content: '减小字号',
}, {
item: 'commandEditor',
icon: 'icon-code-square',
content: '命令编辑器',
}, {
item: 'clear',
icon: 'icon-delete',
content: '清空',
}, {
item: 'disconnect',
icon: 'icon-poweroff',
content: '断开连接',
}, {
item: 'closeTab',
icon: 'icon-close',
content: '关闭终端',
}
];
// 快捷键操作类型
export const ShortcutType = {
SYSTEM: 1,
TERMINAL: 2
};
// 终端操作快捷键 key
export const TabShortcutKeys = {
// 切换为前一个 tab
CHANGE_TO_PREV_TAB: 'changeToPrevTab',
// 切换为后一个 tab
CHANGE_TO_NEXT_TAB: 'changeToNextTab',
// 关闭 tab
CLOSE_TAB: 'closeTab',
// 打开新建连接 tab
OPEN_NEW_CONNECT_TAB: 'openNewConnectTab',
};
// 终端操作快捷键
export const TerminalShortcutItems: Array<ShortcutKeyItem> = [
{
item: TabShortcutKeys.CHANGE_TO_PREV_TAB,
content: '切换为前一个 tab',
type: ShortcutType.SYSTEM
}, {
item: TabShortcutKeys.CHANGE_TO_NEXT_TAB,
content: '切换为后一个 tab',
type: ShortcutType.SYSTEM
}, {
item: TabShortcutKeys.CLOSE_TAB,
content: '关闭当前 tab',
type: ShortcutType.SYSTEM
}, {
item: TabShortcutKeys.OPEN_NEW_CONNECT_TAB,
content: '打开新建连接 tab',
type: ShortcutType.SYSTEM
}, {
item: 'openCopyTerminalTab',
content: '复制当前终端 tab',
type: ShortcutType.TERMINAL
}, {
item: 'copy',
content: '复制',
type: ShortcutType.TERMINAL
}, {
item: 'paste',
content: '粘贴',
type: ShortcutType.TERMINAL
}, {
item: 'toTop',
content: '去顶部',
type: ShortcutType.TERMINAL
}, {
item: 'toBottom',
content: '去底部',
type: ShortcutType.TERMINAL
}, {
item: 'selectAll',
content: '全选',
type: ShortcutType.TERMINAL
}, {
item: 'search',
content: '搜索',
type: ShortcutType.TERMINAL
}, {
item: 'fontSizePlus',
content: '增大字号',
type: ShortcutType.TERMINAL
}, {
item: 'fontSizeSubtract',
content: '减小字号',
type: ShortcutType.TERMINAL
}, {
item: 'commandEditor',
content: '命令编辑器',
type: ShortcutType.TERMINAL
},
];
// 打开 sshModal key
export const openSshModalKey = Symbol();
// 字体后缀 兜底
export const fontFamilySuffix = ',courier-new, courier, monospace';
// 终端字体样式
export const fontFamilyKey = 'terminalFontFamily';
// 终端字体大小
export const fontSizeKey = 'terminalFontSize';
// 终端字体字重
export const fontWeightKey = 'terminalFontWeight';
// 终端光标样式
export const cursorStyleKey = 'terminalCursorStyle';
// 主机新建连接类型
export const newConnectionTypeKey = 'hostNewConnectionType';
// 终端新建连接类型
export const extraSshAuthTypeKey = 'hostExtraSshAuthType';
// 终端状态
export const connectStatusKey = 'terminalConnectStatus';
// 终端类型
export const terminalEmulationTypeKey = 'terminalEmulationType';
// 加载的字典值
export const dictKeys = [
fontFamilyKey, fontSizeKey,
fontWeightKey, cursorStyleKey,
newConnectionTypeKey, extraSshAuthTypeKey,
connectStatusKey, terminalEmulationTypeKey
];

View File

@@ -0,0 +1,55 @@
import type { CSSProperties } from 'vue';
import { HostQueryResponse } from '@/api/asset/host';
import { TerminalTabItem } from '@/views/host/terminal/types/terminal.type';
// tab 元素
export interface TabItem {
key: string;
title: string;
type: string;
icon?: string;
[key: string]: unknown;
}
// sidebar 操作类型
export interface SidebarAction {
icon: string;
content: string;
visible?: boolean;
disabled?: boolean;
checked?: boolean;
iconStyle?: CSSProperties;
click: () => void;
}
// 组合操作元素
export interface CombinedHandlerItem {
icon: string,
type: string,
title: string;
settingTab?: TerminalTabItem;
host?: HostQueryResponse;
}
// 右键菜单元素
export interface ContextMenuItem {
item: string;
icon: string;
content: string;
}
// 快捷键元素
export interface ShortcutKeyItem {
item: string;
content: string;
type: number;
}
// ssh 额外配置
export interface SshExtraModel {
authType?: string;
username?: string;
keyId?: number;
identityId?: number;
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- 终端右键菜单 -->
<a-dropdown class="terminal-context-menu"
<a-dropdown class="host-space-context-menu"
:popup-max-height="false"
trigger="contextMenu"
position="bl"
@@ -14,7 +14,7 @@
:disabled="!session.handler.enabledStatus(action.item)"
@click="emits('click', action.item)">
<!-- 图标 -->
<div class="terminal-context-menu-icon">
<div class="host-space-context-menu-icon">
<component :is="action.icon" />
</div>
<!-- 文本 -->

View File

@@ -68,7 +68,7 @@
</script>
<script lang="ts" setup>
import type { ITerminalSession, TerminalTabItem, SidebarAction, ITerminalSessionHandler } from '../../types/terminal.type';
import type { ITerminalSession, TerminalTabItem, SidebarAction } from '../../types/terminal.type';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useDictStore, useTerminalStore } from '@/store';
import useCopy from '@/hooks/copy';

View File

@@ -1,5 +1,5 @@
// tab 类型
import { ShortcutKeyItem } from '@/views/host/space/types/terminal.type';
import { ShortcutKeyItem } from './terminal.type';
export const TerminalTabType = {
SETTING: 'setting',
@@ -226,8 +226,8 @@ export const fontWeightKey = 'terminalFontWeight';
// 终端光标样式
export const cursorStyleKey = 'terminalCursorStyle';
// 终端新建连接类型
export const newConnectionTypeKey = 'terminalNewConnectionType';
// 主机新建连接类型
export const newConnectionTypeKey = 'hostNewConnectionType';
// 终端新建连接类型
export const extraSshAuthTypeKey = 'hostExtraSshAuthType';