feat(frontend): 新增全局服务器快捷检索并优化工作台
新增 Ctrl+Shift+F 全局服务器检索面板,支持对 SSH、 RDP、VNC 连接进行本地模糊搜索、键盘导航与直接连接, 并统一复用现有 sessionStore 连接链路 同时将 Workbench 导航从左侧竖排 icon rail 调整为标题上方 横向纯图标栏,并补充终端标签页“关闭全部”菜单项
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { ConnectionInfo } from '../stores/connections.store';
|
||||
import { searchConnections } from '../utils/connectionSearch';
|
||||
|
||||
const props = defineProps<{
|
||||
connections: ConnectionInfo[];
|
||||
isLoading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'select', connection: ConnectionInfo): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const query = ref('');
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const results = computed(() => searchConnections(props.connections, query.value, 8));
|
||||
|
||||
watch(results, async (nextResults) => {
|
||||
if (nextResults.length === 0) {
|
||||
selectedIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndex.value < 0 || selectedIndex.value >= nextResults.length) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
inputRef.value?.focus();
|
||||
inputRef.value?.select();
|
||||
});
|
||||
|
||||
const close = () => emit('close');
|
||||
|
||||
const selectResult = (index: number) => {
|
||||
const target = results.value[index];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', target.connection);
|
||||
};
|
||||
|
||||
const moveSelection = (direction: 1 | -1) => {
|
||||
if (results.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndex.value === -1) {
|
||||
selectedIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedIndex.value = (selectedIndex.value + direction + results.value.length) % results.value.length;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
moveSelection(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
moveSelection(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedIndex.value >= 0) {
|
||||
selectResult(selectedIndex.value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
close();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionLabel = (connection: ConnectionInfo): string => connection.name || connection.host;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 z-[10001] flex items-start justify-center px-4 pt-[12vh]"
|
||||
:style="{ backgroundColor: 'var(--overlay-bg-color)' }"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="w-full max-w-2xl overflow-hidden rounded-2xl border border-border bg-background shadow-2xl">
|
||||
<div class="border-b border-border/70 px-5 py-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-text-secondary">
|
||||
{{ t('globalConnectionSearch.shortcut') }}
|
||||
</p>
|
||||
<h2 class="mt-1 text-xl font-semibold text-foreground">
|
||||
{{ t('globalConnectionSearch.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-sm text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
|
||||
@click="close"
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="mt-4 w-full rounded-xl border border-border/70 bg-input px-4 py-3 text-base text-foreground shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-text-secondary focus:border-primary focus:ring-2 focus:ring-primary/40"
|
||||
:placeholder="t('globalConnectionSearch.placeholder')"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-3 py-3">
|
||||
<div v-if="isLoading && connections.length === 0" class="px-3 py-8 text-center text-sm text-text-secondary">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>{{ t('globalConnectionSearch.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length === 0" class="px-3 py-8 text-center text-sm text-text-secondary">
|
||||
<i class="fas fa-search mb-3 block text-xl"></i>
|
||||
<p v-if="query">{{ t('globalConnectionSearch.noResults', { query }) }}</p>
|
||||
<p v-else>{{ t('globalConnectionSearch.emptyHint') }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="(item, index) in results"
|
||||
:key="item.connection.id"
|
||||
type="button"
|
||||
class="mb-2 flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-all duration-150 last:mb-0"
|
||||
:class="index === selectedIndex
|
||||
? 'border-primary/60 bg-primary/10 shadow-[0_0_0_1px_rgba(34,197,94,0.12)]'
|
||||
: 'border-border/60 bg-header/40 hover:border-primary/30 hover:bg-primary/5'"
|
||||
@mouseenter="selectedIndex = index"
|
||||
@click="selectResult(index)"
|
||||
>
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary/12 text-primary">
|
||||
<i :class="['fas', item.connection.type === 'RDP' ? 'fa-desktop' : (item.connection.type === 'VNC' ? 'fa-plug' : 'fa-server')]"></i>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-foreground">
|
||||
{{ getConnectionLabel(item.connection) }}
|
||||
</span>
|
||||
<span class="rounded-full bg-border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-text-secondary">
|
||||
{{ item.connection.type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-secondary">
|
||||
<span>{{ item.connection.host }}:{{ item.connection.port }}</span>
|
||||
<span>{{ item.connection.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-border/70 px-5 py-3 text-xs text-text-secondary">
|
||||
<span>{{ t('globalConnectionSearch.footerHint') }}</span>
|
||||
<span>{{ t('globalConnectionSearch.footerActions') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -243,6 +243,9 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
|
||||
case 'close':
|
||||
emitWorkspaceEvent('session:close', { sessionId: targetId });
|
||||
break;
|
||||
case 'close-all':
|
||||
emitWorkspaceEvent('session:closeAll');
|
||||
break;
|
||||
case 'close-others':
|
||||
emitWorkspaceEvent('session:closeOthers', { targetSessionId: targetId });
|
||||
break;
|
||||
@@ -324,6 +327,7 @@ const contextMenuItems = computed(() => {
|
||||
items.push({ label: 'tabs.contextMenu.close', action: 'close' });
|
||||
|
||||
if (totalTabs > 1) {
|
||||
items.push({ label: 'tabs.contextMenu.closeAll', action: 'close-all' });
|
||||
items.push({ label: 'tabs.contextMenu.closeOthers', action: 'close-others' });
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 overflow-hidden bg-background">
|
||||
<aside class="workbench-rail flex w-14 flex-shrink-0 flex-col items-center gap-2 border-r border-border px-2 py-3">
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div class="workbench-rail flex flex-shrink-0 items-center gap-2 overflow-x-auto border-b border-border px-3 py-2">
|
||||
<button
|
||||
v-for="tab in workbenchTabs"
|
||||
:key="tab.id"
|
||||
@@ -115,64 +115,62 @@ watch(
|
||||
>
|
||||
<i :class="tab.icon"></i>
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
|
||||
<div class="border-b border-border bg-header px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ t('workspace.workbench.title', 'Workbench') }}
|
||||
</h3>
|
||||
<p class="mt-1 truncate text-xs text-text-secondary">
|
||||
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
|
||||
</p>
|
||||
<div class="border-b border-border bg-header px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ t('workspace.workbench.title', 'Workbench') }}
|
||||
</h3>
|
||||
<p class="mt-1 truncate text-xs text-text-secondary">
|
||||
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
|
||||
{{ t('workspace.workbench.label', '工作台') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<div v-show="activeWorkbenchTab === 'quickCommands'" class="absolute inset-0 min-h-0 workbench-quick-commands">
|
||||
<QuickCommandsView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
|
||||
<FileManager
|
||||
v-if="hasFileManagerContext"
|
||||
:session-id="fileManagerSessionId"
|
||||
:instance-id="fileManagerInstanceId"
|
||||
:db-connection-id="fileManagerConnectionId"
|
||||
:ws-deps="fileManagerWsDeps"
|
||||
class="h-full"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-text-secondary"
|
||||
>
|
||||
<i class="fas fa-plug text-3xl"></i>
|
||||
<div class="text-sm font-medium">
|
||||
{{ t('layout.noActiveSession.title', '没有活动的会话') }}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
|
||||
</div>
|
||||
<span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
|
||||
{{ t('workspace.workbench.label', '工作台') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<div v-show="activeWorkbenchTab === 'quickCommands'" class="absolute inset-0 min-h-0 workbench-quick-commands">
|
||||
<QuickCommandsView />
|
||||
</div>
|
||||
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
|
||||
<CommandHistoryView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
|
||||
<FileManager
|
||||
v-if="hasFileManagerContext"
|
||||
:session-id="fileManagerSessionId"
|
||||
:instance-id="fileManagerInstanceId"
|
||||
:db-connection-id="fileManagerConnectionId"
|
||||
:ws-deps="fileManagerWsDeps"
|
||||
class="h-full"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-text-secondary"
|
||||
>
|
||||
<i class="fas fa-plug text-3xl"></i>
|
||||
<div class="text-sm font-medium">
|
||||
{{ t('layout.noActiveSession.title', '没有活动的会话') }}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
|
||||
<CommandHistoryView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
|
||||
<FileEditorContainer
|
||||
:tabs="tabs"
|
||||
:active-tab-id="activeTabId"
|
||||
:session-id="sessionId"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
|
||||
<FileEditorContainer
|
||||
:tabs="tabs"
|
||||
:active-tab-id="activeTabId"
|
||||
:session-id="sessionId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,8 +179,8 @@ watch(
|
||||
<style scoped>
|
||||
.workbench-rail {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30, 41, 59, 0.94) 0%, rgba(17, 24, 39, 0.98) 100%);
|
||||
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.04);
|
||||
linear-gradient(90deg, rgba(30, 41, 59, 0.94) 0%, rgba(17, 24, 39, 0.98) 100%);
|
||||
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.workbench-quick-commands {
|
||||
|
||||
Reference in New Issue
Block a user