feat(frontend): 新增全局服务器快捷检索并优化工作台

新增 Ctrl+Shift+F 全局服务器检索面板,支持对 SSH、
RDP、VNC 连接进行本地模糊搜索、键盘导航与直接连接,
并统一复用现有 sessionStore 连接链路

同时将 Workbench 导航从左侧竖排 icon rail 调整为标题上方
横向纯图标栏,并补充终端标签页“关闭全部”菜单项
This commit is contained in:
yinjianm
2026-03-30 02:18:23 +08:00
parent d3e8d598b8
commit 6160be6e08
16 changed files with 811 additions and 57 deletions
+57
View File
@@ -9,12 +9,14 @@ import { useAppearanceStore } from './stores/appearance.store';
import { useLayoutStore } from './stores/layout.store';
import { useFocusSwitcherStore } from './stores/focusSwitcher.store';
import { useSessionStore } from './stores/session.store';
import { useConnectionsStore } from './stores/connections.store';
import { useFavoritePathsStore } from './stores/favoritePaths.store';
import { storeToRefs } from 'pinia';
import UINotificationDisplay from './components/UINotificationDisplay.vue';
import FileEditorOverlay from './components/FileEditorOverlay.vue';
import StyleCustomizer from './components/StyleCustomizer.vue';
import FocusSwitcherConfigurator from './components/FocusSwitcherConfigurator.vue';
import GlobalConnectionQuickSearch from './components/GlobalConnectionQuickSearch.vue';
import RemoteDesktopModal from './components/RemoteDesktopModal.vue';
import VncModal from './components/VncModal.vue';
import ConfirmDialog from './components/common/ConfirmDialog.vue';
@@ -27,10 +29,12 @@ const appearanceStore = useAppearanceStore();
const layoutStore = useLayoutStore();
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const sessionStore = useSessionStore(); // +++ 实例化 Session Store +++
const connectionsStore = useConnectionsStore();
const dialogStore = useDialogStore(); // +++ 实例化 DialogStore +++
const { state: dialogState } = storeToRefs(dialogStore);
const favoritePathsStore = useFavoritePathsStore(); // +++ 实例化 favoritePathsStore +++
const { isAuthenticated } = storeToRefs(authStore);
const { connections, isLoading: connectionsLoading } = storeToRefs(connectionsStore);
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
@@ -46,6 +50,7 @@ const underlineRef = ref<HTMLElement | null>(null);
const lastFocusedIdBySwitcher = ref<string | null>(null);
const isAltPressed = ref(false); // 跟踪 Alt 键是否按下
const altShortcutKey = ref<string | null>(null);
const isGlobalConnectionSearchVisible = ref(false);
// --- 移除 shortcutTriggeredInKeyDown 标志 ---
const updateUnderline = async () => {
@@ -70,6 +75,7 @@ onMounted(() => {
setTimeout(updateUnderline, 100);
// +++ 全局 Alt 键监听器 +++
window.addEventListener('keydown', handleGlobalShortcutKeyDown);
window.addEventListener('keydown', handleAltKeyDown); // +++ 监听 keydown 设置状态 +++
window.addEventListener('keyup', handleGlobalKeyUp); // +++ 监听 keyup 执行切换 +++
@@ -96,6 +102,7 @@ watch(isAuthenticated, (loggedIn) => {
// +++ 卸载钩子以移除监听器 +++
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalShortcutKeyDown);
window.removeEventListener('keydown', handleAltKeyDown); // +++ 移除 keydown 监听 +++
window.removeEventListener('keyup', handleGlobalKeyUp); // +++ 移除 keyup 监听 +++
});
@@ -108,6 +115,12 @@ watch(route, () => {
updateUnderline();
}, { immediate: true }); // *** 确保 immediate: true 存在 ***
watch(isAuthenticated, (loggedIn) => {
if (!loggedIn) {
isGlobalConnectionSearchVisible.value = false;
}
});
const handleLogout = () => {
authStore.logout();
@@ -123,8 +136,43 @@ const closeStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(false);
};
const openGlobalConnectionSearch = () => {
if (!isAuthenticated.value) {
return;
}
isGlobalConnectionSearchVisible.value = true;
void connectionsStore.fetchConnections();
};
const closeGlobalConnectionSearch = () => {
isGlobalConnectionSearchVisible.value = false;
};
const handleGlobalConnectionSelect = (connection: (typeof connections.value)[number]) => {
closeGlobalConnectionSearch();
sessionStore.handleConnectRequest(connection);
};
const handleGlobalShortcutKeyDown = (event: KeyboardEvent) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (!(event.ctrlKey && event.shiftKey && key === 'f')) {
return;
}
if (!isAuthenticated.value) {
return;
}
event.preventDefault();
if (!isGlobalConnectionSearchVisible.value) {
openGlobalConnectionSearch();
}
};
// +++ 处理 Alt 键按下的事件处理函数,并记录快捷键 +++
const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +++
if (isGlobalConnectionSearchVisible.value) return;
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
// 只在 Alt 键首次按下时设置状态
if (event.key === 'Alt' && !event.repeat) {
@@ -178,6 +226,7 @@ const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +
// +++ 全局键盘事件处理函数,监听 keyup,优先处理快捷键 +++
const handleGlobalKeyUp = async (event: KeyboardEvent) => {
if (isGlobalConnectionSearchVisible.value) return;
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
if (event.key === 'Alt') {
const altWasPressed = isAltPressed.value;
@@ -363,6 +412,14 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
@update:visible="(val: boolean) => dialogStore.state.visible = val"
/>
<GlobalConnectionQuickSearch
v-if="isGlobalConnectionSearchVisible"
:connections="connections"
:is-loading="connectionsLoading"
@close="closeGlobalConnectionSearch"
@select="handleGlobalConnectionSelect"
/>
</div>
</template>
@@ -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 {
+11
View File
@@ -1632,9 +1632,20 @@
"serverEntryTitle": "{name} · {count} terminals",
"terminalCount": "{count} terminals"
},
"globalConnectionSearch": {
"shortcut": "Ctrl+Shift+F",
"title": "Global Server Search",
"placeholder": "Search by name, host, username, or type...",
"loading": "Loading connections...",
"emptyHint": "Type any keyword to fuzzy-search servers, or pick a recent connection directly.",
"noResults": "No servers matched “{query}”.",
"footerHint": "Quick connect for SSH / RDP / VNC",
"footerActions": "↑↓ Navigate · Enter Connect · Esc Close"
},
"tabs": {
"contextMenu": {
"close": "Close Tab",
"closeAll": "Close All Tabs",
"closeOthers": "Close Other Tabs",
"closeRight": "Close Tabs to the Right",
"closeLeft": "Close Tabs to the Left",
+10
View File
@@ -1590,6 +1590,16 @@
"openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}"
},
"globalConnectionSearch": {
"shortcut": "Ctrl+Shift+F",
"title": "グローバルサーバー検索",
"placeholder": "名前、ホスト、ユーザー名、種類で検索...",
"loading": "接続一覧を読み込み中...",
"emptyHint": "キーワードを入力してサーバーをあいまい検索するか、最近の接続を直接選択してください。",
"noResults": "「{query}」に一致するサーバーはありません。",
"footerHint": "SSH / RDP / VNC をすばやく接続",
"footerActions": "↑↓ 移動 · Enter 接続 · Esc 閉じる"
},
"tabs": {
"contextMenu": {
"close": "タブを閉じる",
+11
View File
@@ -1636,9 +1636,20 @@
"serverEntryTitle": "{name} · {count} 个终端",
"terminalCount": "{count} 个终端"
},
"globalConnectionSearch": {
"shortcut": "Ctrl+Shift+F",
"title": "全局服务器检索",
"placeholder": "输入名称、主机、用户名或类型...",
"loading": "正在加载连接列表...",
"emptyHint": "输入任意关键词开始模糊检索服务器,或直接选择最近连接。",
"noResults": "没有找到与 “{query}” 匹配的服务器。",
"footerHint": "支持 SSH / RDP / VNC 全类型快速连接",
"footerActions": "↑↓ 切换 · Enter 连接 · Esc 关闭"
},
"tabs": {
"contextMenu": {
"close": "关闭标签页",
"closeAll": "关闭全部标签页",
"closeOthers": "关闭其他标签页",
"closeRight": "关闭右侧标签页",
"closeLeft": "关闭左侧标签页",
@@ -0,0 +1,123 @@
import type { ConnectionInfo } from '../stores/connections.store';
export interface ConnectionSearchResult {
connection: ConnectionInfo;
score: number;
}
const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase();
const getDisplayName = (connection: ConnectionInfo): string => connection.name?.trim() || connection.host;
const getEmptyQuerySortValue = (connection: ConnectionInfo): number => connection.last_connected_at ?? 0;
const getFieldScore = (text: string, query: string): number => {
if (!text || !query) {
return 0;
}
if (text === query) {
return 320;
}
if (text.startsWith(query)) {
return 260 - Math.min(text.length - query.length, 40);
}
const includeIndex = text.indexOf(query);
if (includeIndex >= 0) {
return 220 - Math.min(includeIndex * 6, 90);
}
let queryIndex = 0;
let firstMatchIndex = -1;
let previousMatchIndex = -1;
let gapPenalty = 0;
for (let index = 0; index < text.length && queryIndex < query.length; index += 1) {
if (text[index] !== query[queryIndex]) {
continue;
}
if (firstMatchIndex === -1) {
firstMatchIndex = index;
}
if (previousMatchIndex >= 0) {
gapPenalty += index - previousMatchIndex - 1;
}
previousMatchIndex = index;
queryIndex += 1;
}
if (queryIndex !== query.length || firstMatchIndex === -1) {
return 0;
}
return Math.max(70, 180 - firstMatchIndex * 4 - gapPenalty * 3);
};
const scoreConnection = (connection: ConnectionInfo, query: string): number => {
const fields: Array<[string, number]> = [
[normalize(connection.name), 40],
[normalize(connection.host), 28],
[normalize(connection.username), 16],
[normalize(connection.type), 10],
];
let bestScore = 0;
for (const [field, weight] of fields) {
const fieldScore = getFieldScore(field, query);
if (fieldScore <= 0) {
continue;
}
bestScore = Math.max(bestScore, fieldScore + weight);
}
return bestScore;
};
export const searchConnections = (
connections: ConnectionInfo[],
rawQuery: string,
limit = 8,
): ConnectionSearchResult[] => {
const query = normalize(rawQuery);
if (!query) {
return [...connections]
.sort((left, right) => {
const recentDiff = getEmptyQuerySortValue(right) - getEmptyQuerySortValue(left);
if (recentDiff !== 0) {
return recentDiff;
}
return getDisplayName(left).localeCompare(getDisplayName(right));
})
.slice(0, limit)
.map((connection) => ({ connection, score: 0 }));
}
return connections
.map((connection) => ({
connection,
score: scoreConnection(connection, query),
}))
.filter((item) => item.score > 0)
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
const recentDiff = getEmptyQuerySortValue(right.connection) - getEmptyQuerySortValue(left.connection);
if (recentDiff !== 0) {
return recentDiff;
}
return getDisplayName(left.connection).localeCompare(getDisplayName(right.connection));
})
.slice(0, limit);
};