feat(workspace): 增强连接管理与终端状态展示

- 为连接管理页补充多级标签树、列头排序和行级更多菜单
- 支持同一 SSH 连接打开多个终端并显示终端序号
- 补充状态监控的内存与磁盘详情字段

---
 feat(workspace): enhance connection management and terminal status visibility

- add multi-level tag tree, sortable columns, and row-level more menu
- support multiple terminals per SSH connection with terminal indices
- extend status monitor with memory and disk detail fields
This commit is contained in:
yinjianm
2026-03-25 22:25:37 +08:00
parent 6553739c08
commit d730d06c5e
16 changed files with 798 additions and 72 deletions
@@ -64,6 +64,39 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
const activeSessionState = computed(() => {
if (!props.activeSessionId) {
return null;
}
return sessionStore.sessions.get(props.activeSessionId) ?? null;
});
const activeConnectionInfo = computed(() => {
const activeSession = activeSessionState.value;
if (!activeSession) {
return null;
}
return connectionsStore.connections.find((connection) => connection.id === Number(activeSession.connectionId)) ?? null;
});
const canAddTerminalToActiveConnection = computed(() => activeConnectionInfo.value?.type === 'SSH');
const openNewTerminalForActiveConnection = () => {
const activeConnection = activeConnectionInfo.value;
if (!activeConnection || activeConnection.type !== 'SSH') {
showConnectionListPopup.value = true;
return;
}
sessionStore.handleOpenNewSession(activeConnection.id);
};
const openConnectionPicker = () => {
showConnectionListPopup.value = true;
};
// + Watch prop changes to update local state
watch(() => props.sessions, (newSessions) => {
// Create a shallow copy to avoid modifying the prop directly
@@ -174,6 +207,22 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
// 注意:关闭左侧通常不包括当前标签本身
emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId });
break;
case 'new-terminal': {
const targetSessionState = sessionStore.sessions.get(targetId);
if (!targetSessionState) {
console.warn(`[TabBar] 'new-terminal' action failed: session ${targetId} not found.`);
break;
}
const targetConnectionInfo = connectionsStore.connections.find(c => c.id === Number(targetSessionState.connectionId));
if (!targetConnectionInfo || targetConnectionInfo.type !== 'SSH') {
console.warn(`[TabBar] 'new-terminal' action ignored for non-SSH connection. targetId=${targetId}`);
break;
}
sessionStore.handleOpenNewSession(targetConnectionInfo.id);
break;
}
case 'mark-for-suspend': // +++ 修改 action 名称 +++
if (typeof targetId === 'string') {
console.log(`[TabBar] Context menu action 'mark-for-suspend' requested for session ID: ${targetId}`);
@@ -213,6 +262,7 @@ const contextMenuItems = computed(() => {
// 添加标记/取消标记挂起会话菜单项(如果适用)
if (connectionInfo && connectionInfo.type === 'SSH') {
items.push({ label: 'terminalTabBar.newTerminalTooltip', action: 'new-terminal' });
const isActiveSession = targetSessionState.wsManager.isConnected.value;
if (isActiveSession) { // 只对活动的SSH会话显示相关操作
if (targetSessionState.isMarkedForSuspend) {
@@ -446,6 +496,12 @@ onBeforeUnmount(() => {
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
<span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span>
<span
class="ml-2 inline-flex flex-shrink-0 items-center rounded-full border border-border px-1.5 py-0.5 text-[11px] leading-none text-text-secondary"
:title="t('terminalTabBar.terminalBadge', { index: session.terminalIndex })"
>
{{ session.terminalIndex }}
</span>
<button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150"
:class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}"
@click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')">
@@ -456,11 +512,18 @@ onBeforeUnmount(() => {
</li>
</template>
</draggable>
<!-- Add Tab Button -->
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="togglePopup" :title="$t('tabs.newTabTooltip')">
<!-- Add Terminal Button -->
<button
class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="openNewTerminalForActiveConnection"
:title="canAddTerminalToActiveConnection ? t('terminalTabBar.newTerminalTooltip') : t('tabs.newTabTooltip')">
<i class="fas fa-plus text-sm"></i>
</button>
<!-- Open Connection Picker Button -->
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
@click="openConnectionPicker" :title="t('terminalTabBar.openConnectionPickerTooltip')">
<i class="fas fa-server text-sm"></i>
</button>
</div>
<!-- Action Buttons -->
<div class="flex items-center ml-auto h-full flex-shrink-0">
+4 -1
View File
@@ -1457,7 +1457,10 @@
},
"terminalTabBar": {
"selectServerTitle": "Select server to connect",
"showTransferProgressTooltip": "Show/Hide Transfer Progress"
"showTransferProgressTooltip": "Show/Hide Transfer Progress",
"newTerminalTooltip": "Open another terminal for the current server",
"openConnectionPickerTooltip": "Choose another server",
"terminalBadge": "Terminal {index}"
},
"tabs": {
"contextMenu": {
+4 -1
View File
@@ -1419,7 +1419,10 @@
},
"terminalTabBar": {
"selectServerTitle": "接続するサーバーを選択",
"showTransferProgressTooltip": "転送進捗の表示/非表示"
"showTransferProgressTooltip": "転送進捗の表示/非表示",
"newTerminalTooltip": "現在のサーバーに新しいターミナルを追加",
"openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}"
},
"workspace": {
"terminal": {
+4 -1
View File
@@ -1461,7 +1461,10 @@
},
"terminalTabBar": {
"selectServerTitle": "选择要连接的服务器",
"showTransferProgressTooltip": "显示/隐藏传输进度"
"showTransferProgressTooltip": "显示/隐藏传输进度",
"newTerminalTooltip": "为当前服务器新增终端",
"openConnectionPickerTooltip": "选择其他服务器",
"terminalBadge": "终端 {index}"
},
"tabs": {
"contextMenu": {
@@ -21,6 +21,18 @@ const findConnectionInfo = (connectionId: number | string, connectionsStore: Ret
return connectionsStore.connections.find(c => c.id === Number(connectionId));
};
const getNextTerminalIndex = (connectionId: string): number => {
let maxTerminalIndex = 0;
sessions.value.forEach((session) => {
if (session.connectionId === connectionId) {
maxTerminalIndex = Math.max(maxTerminalIndex, session.terminalIndex || 0);
}
});
return maxTerminalIndex + 1;
};
// --- Actions ---
export const openNewSession = (
connectionOrId: ConnectionInfo | number | string,
@@ -51,6 +63,7 @@ export const openNewSession = (
const newSessionId = existingSessionId || generateSessionId();
const dbConnId = String(connInfo.id); // connInfo is now guaranteed to be defined here
const terminalIndex = getNextTerminalIndex(dbConnId);
// 1. 创建管理器实例
const isResume = !!existingSessionId; // 如果提供了 existingSessionId,则为恢复流程
@@ -60,6 +73,7 @@ export const openNewSession = (
sessionId: newSessionId,
connectionId: dbConnId,
connectionName: connInfo.name || connInfo.host,
terminalIndex,
editorTabs: ref([]),
activeEditorTabId: ref(null),
commandInputContent: ref(''),
@@ -115,7 +129,7 @@ export const openNewSession = (
newSessionsMap.set(newSessionId, newSession);
sessions.value = newSessionsMap;
activeSessionId.value = newSessionId;
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`);
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId} (terminal #${terminalIndex})`);
// +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++
const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包
@@ -320,4 +334,4 @@ export const cleanupAllSessions = () => {
sessions.value = newSessionsMap;
}
activeSessionId.value = null;
};
};
@@ -7,7 +7,9 @@ import type { SessionState, SessionTabInfoWithStatus } from './types';
export const sessionTabs = computed(() => {
return Array.from(sessions.value.values()).map(session => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
}));
});
@@ -39,7 +41,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
})
.map(session => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
isMarkedForSuspend: session.isMarkedForSuspend,
}));
@@ -49,7 +53,9 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
.sort((a, b) => a.createdAt - b.createdAt)
.map(session => ({
sessionId: session.sessionId,
connectionId: session.connectionId,
connectionName: session.connectionName,
terminalIndex: session.terminalIndex,
status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态
isMarkedForSuspend: session.isMarkedForSuspend,
}));
@@ -59,4 +65,4 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] =>
export const activeSession = computed((): SessionState | null => {
if (!activeSessionId.value) return null;
return sessions.value.get(activeSessionId.value) || null;
});
});
@@ -29,6 +29,7 @@ export interface SessionState {
sessionId: string;
connectionId: string; // 数据库中的连接 ID
connectionName: string; // 用于显示
terminalIndex: number; // 同一连接下的终端序号,从 1 开始
wsManager: WsManagerInstance;
sftpManagers: Map<string, SftpManagerInstance>; // 使用 Map 管理多个实例
terminalManager: SshTerminalInstance;
@@ -49,7 +50,9 @@ export interface SessionState {
// 为标签栏定义包含状态的类型
export interface SessionTabInfoWithStatus {
sessionId: string;
connectionId: string;
connectionName: string;
terminalIndex: number;
status: WsConnectionStatus; // 添加状态字段
isMarkedForSuspend?: boolean; // +++ 用于UI指示会话是否已标记待挂起 +++
}
}
+397 -51
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import AddConnectionForm from '../components/AddConnectionForm.vue';
import BatchEditConnectionForm from '../components/BatchEditConnectionForm.vue';
import { useConnectionsStore } from '../stores/connections.store';
@@ -17,7 +17,8 @@ import { zhCN, enUS, ja } from 'date-fns/locale';
import type { Locale } from 'date-fns';
type ConnectionTypeFilter = 'ALL' | 'SSH' | 'RDP' | 'VNC';
type ScopeId = 'all' | 'untagged' | `tag:${number}`;
type ScopeId = 'all' | 'untagged' | `tag:${number}` | `group:${string}`;
type ConnectionSortField = SortField | 'host';
interface ScopeNode {
id: ScopeId;
@@ -25,6 +26,13 @@ interface ScopeNode {
count: number;
}
interface TagTreeNode extends ScopeNode {
fullLabel: string;
level: number;
expandable: boolean;
children: TagTreeNode[];
}
interface ConnectionTestState {
status: 'idle' | 'testing' | 'success' | 'error';
resultText: string;
@@ -48,12 +56,17 @@ const LS_FILTER_TAG_KEY = 'connections_view_filter_tag';
const LS_FILTER_SCOPE_KEY = 'connections_view_filter_scope';
const LS_TYPE_FILTER_KEY = 'connections_view_type_filter';
const localSortBy = ref<SortField>(localStorage.getItem(LS_SORT_BY_KEY) as SortField || 'last_connected_at');
const localSortBy = ref<ConnectionSortField>((localStorage.getItem(LS_SORT_BY_KEY) as ConnectionSortField) || 'last_connected_at');
const localSortOrder = ref<SortOrder>(localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder || 'desc');
const getInitialSelectedScope = (): ScopeId => {
const storedScope = localStorage.getItem(LS_FILTER_SCOPE_KEY);
if (storedScope === 'all' || storedScope === 'untagged' || storedScope?.startsWith('tag:')) {
if (
storedScope === 'all' ||
storedScope === 'untagged' ||
storedScope?.startsWith('tag:') ||
storedScope?.startsWith('group:')
) {
return storedScope as ScopeId;
}
@@ -80,21 +93,45 @@ const isBatchEditMode = ref(false);
const selectedConnectionIdsForBatch = ref<Set<number>>(new Set());
const showBatchEditForm = ref(false);
const isDeletingSelectedConnections = ref(false);
const expandedTreeNodes = ref<Record<string, boolean>>({});
const connectionTestStates = ref<Map<number, ConnectionTestState>>(new Map());
const isTestingAll = ref(false);
const isConnectingAll = ref(false);
const moreMenuOpenForId = ref<number | null>(null);
const sortOptions: { value: SortField; labelKey: string }[] = [
const sortOptions: { value: ConnectionSortField; labelKey: string }[] = [
{ value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' },
{ value: 'name', labelKey: 'dashboard.sortOptions.name' },
{ value: 'host', labelKey: 'connections.table.host' },
{ value: 'type', labelKey: 'dashboard.sortOptions.type' },
{ value: 'updated_at', labelKey: 'dashboard.sortOptions.updated' },
{ value: 'created_at', labelKey: 'dashboard.sortOptions.created' },
];
const TREE_EXPANDED_STORAGE_KEY = 'connections_view_tree_expanded';
const tagPathSeparatorRegex = /\s*(?:\/|>|\\)\s*/;
const normalizedSearchQuery = computed(() => searchQuery.value.toLowerCase().trim());
const loadInitialExpandedTreeState = (): Record<string, boolean> => {
try {
const rawValue = localStorage.getItem(TREE_EXPANDED_STORAGE_KEY);
if (!rawValue) {
return {};
}
const parsed = JSON.parse(rawValue);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (error) {
console.error('读取连接管理树展开状态失败:', error);
localStorage.removeItem(TREE_EXPANDED_STORAGE_KEY);
return {};
}
};
expandedTreeNodes.value = loadInitialExpandedTreeState();
const tagLookup = computed(() => {
const map = new Map<number, TagInfo>();
(tags.value as TagInfo[]).forEach((tag) => {
@@ -113,6 +150,21 @@ const getConnectionTagNames = (conn: ConnectionInfo): string[] => {
.filter((tagName): tagName is string => Boolean(tagName));
};
const getTagPathSegments = (tagName: string): string[] => {
return tagName
.split(tagPathSeparatorRegex)
.map((segment) => segment.trim())
.filter(Boolean);
};
const encodeGroupScopeId = (pathKey: string): ScopeId => {
return `group:${encodeURIComponent(pathKey)}`;
};
const decodeGroupScopeId = (scopeId: ScopeId): string => {
return decodeURIComponent(scopeId.replace('group:', ''));
};
const matchesSearchQuery = (conn: ConnectionInfo, query: string): boolean => {
if (!query) {
return true;
@@ -141,7 +193,19 @@ const matchesScope = (conn: ConnectionInfo, scope: ScopeId): boolean => {
}
const tagId = parseInt(scope.replace('tag:', ''), 10);
return conn.tag_ids?.includes(tagId) ?? false;
if (!Number.isNaN(tagId) && conn.tag_ids?.includes(tagId)) {
return true;
}
if (scope.startsWith('group:')) {
const pathPrefix = decodeGroupScopeId(scope);
return getConnectionTagNames(conn).some((tagName) => {
const pathKey = getTagPathSegments(tagName).join('/');
return pathKey === pathPrefix || pathKey.startsWith(`${pathPrefix}/`);
});
}
return false;
};
const matchedConnections = computed(() => {
@@ -151,16 +215,110 @@ const matchedConnections = computed(() => {
});
});
const tagTreeNodes = computed<ScopeNode[]>(() => {
return (tags.value as TagInfo[])
.map((tag) => ({
id: `tag:${tag.id}` as ScopeId,
label: tag.name,
count: matchedConnections.value.filter((conn) => conn.tag_ids?.includes(tag.id)).length,
}))
.sort((left, right) => left.label.localeCompare(right.label));
const tagTreeNodes = computed<TagTreeNode[]>(() => {
type DraftTreeNode = {
id: ScopeId;
label: string;
fullLabel: string;
children: Map<string, DraftTreeNode>;
tagId: number | null;
};
const rootChildren = new Map<string, DraftTreeNode>();
(tags.value as TagInfo[]).forEach((tag) => {
const segments = getTagPathSegments(tag.name);
if (!segments.length) {
return;
}
let currentChildren = rootChildren;
const currentPathSegments: string[] = [];
segments.forEach((segment, index) => {
currentPathSegments.push(segment);
const pathKey = currentPathSegments.join('/');
const isLeaf = index === segments.length - 1;
const nodeKey = (isLeaf ? `tag:${tag.id}` : encodeGroupScopeId(pathKey)) as ScopeId;
if (!currentChildren.has(nodeKey)) {
currentChildren.set(nodeKey, {
id: nodeKey,
label: segment,
fullLabel: currentPathSegments.join(' / '),
children: new Map<string, DraftTreeNode>(),
tagId: isLeaf ? tag.id : null,
});
}
const currentNode = currentChildren.get(nodeKey)!;
if (isLeaf) {
currentNode.fullLabel = tag.name;
currentNode.tagId = tag.id;
}
currentChildren = currentNode.children;
});
});
const buildNodes = (source: Map<string, DraftTreeNode>, level: number): TagTreeNode[] => {
return Array.from(source.values())
.sort((left, right) => left.label.localeCompare(right.label))
.map((node) => {
const children = buildNodes(node.children, level + 1);
const count =
node.tagId !== null
? matchedConnections.value.filter((conn) => conn.tag_ids?.includes(node.tagId!)).length
: matchedConnections.value.filter((conn) => matchesScope(conn, node.id)).length;
return {
id: node.id,
label: node.label,
fullLabel: node.fullLabel,
count,
level,
expandable: children.length > 0,
children,
};
});
};
return buildNodes(rootChildren, 0);
});
const visibleTagTreeNodes = computed<TagTreeNode[]>(() => {
const rows: TagTreeNode[] = [];
const appendVisibleNodes = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
rows.push(node);
if (node.expandable && (expandedTreeNodes.value[node.id] ?? true)) {
appendVisibleNodes(node.children);
}
});
};
appendVisibleNodes(tagTreeNodes.value);
return rows;
});
const expandableTreeNodeIds = computed<ScopeId[]>(() => {
const ids: ScopeId[] = [];
const collectNodeIds = (nodes: TagTreeNode[]) => {
nodes.forEach((node) => {
if (node.expandable) {
ids.push(node.id);
collectNodeIds(node.children);
}
});
};
collectNodeIds(tagTreeNodes.value);
return ids;
});
const hasExpandableTreeNodes = computed(() => expandableTreeNodeIds.value.length > 0);
const primaryScopeNodes = computed<ScopeNode[]>(() => {
return [
{
@@ -192,6 +350,10 @@ const filteredAndSortedConnections = computed(() => {
leftValue = left.name || left.host;
rightValue = right.name || right.host;
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
case 'host':
leftValue = left.host || '';
rightValue = right.host || '';
return String(leftValue).localeCompare(String(rightValue)) * sortOrderFactor;
case 'type':
leftValue = left.type || '';
rightValue = right.type || '';
@@ -225,6 +387,10 @@ const selectedScopeTitle = computed(() => {
return t('connections.untaggedGroup', '未标记');
}
if (selectedScope.value.startsWith('group:')) {
return decodeGroupScopeId(selectedScope.value).replaceAll('/', ' / ');
}
const selectedTagId = parseInt(selectedScope.value.replace('tag:', ''), 10);
return tagLookup.value.get(selectedTagId)?.name || t('connections.table.tags', '标签');
});
@@ -404,6 +570,14 @@ watch(activeTypeFilter, (newValue) => {
localStorage.setItem(LS_TYPE_FILTER_KEY, newValue);
});
watch(
expandedTreeNodes,
(newValue) => {
localStorage.setItem(TREE_EXPANDED_STORAGE_KEY, JSON.stringify(newValue));
},
{ deep: true },
);
watch([selectedScope, activeTypeFilter, searchQuery], () => {
if (isBatchEditMode.value) {
const visibleIds = new Set(filteredAndSortedConnections.value.map((conn) => conn.id));
@@ -419,6 +593,38 @@ const selectScope = (scopeId: ScopeId) => {
selectedScope.value = scopeId;
};
const toggleTreeNode = (nodeId: ScopeId) => {
expandedTreeNodes.value[nodeId] = !(expandedTreeNodes.value[nodeId] ?? true);
};
const handleTreeNodeSelect = (node: TagTreeNode) => {
selectScope(node.id);
if (node.expandable) {
toggleTreeNode(node.id);
}
};
const expandAllTreeNodes = () => {
if (!hasExpandableTreeNodes.value) {
return;
}
tagsSectionExpanded.value = true;
expandedTreeNodes.value = Object.fromEntries(expandableTreeNodeIds.value.map((nodeId) => [nodeId, true]));
};
const collapseAllTreeNodes = () => {
if (!hasExpandableTreeNodes.value) {
return;
}
expandedTreeNodes.value = Object.fromEntries(expandableTreeNodeIds.value.map((nodeId) => [nodeId, false]));
};
const resetScopeSelection = () => {
selectScope('all');
};
const connectTo = (connection: ConnectionInfo) => {
sessionStore.handleConnectRequest(connection);
};
@@ -437,6 +643,16 @@ const openEditConnectionForm = (connection: ConnectionInfo) => {
showAddEditConnectionForm.value = true;
};
const handleSortByColumn = (field: ConnectionSortField) => {
if (localSortBy.value === field) {
toggleSortOrder();
return;
}
localSortBy.value = field;
localSortOrder.value = field === 'last_connected_at' ? 'desc' : 'asc';
};
const handleFormClose = () => {
showAddEditConnectionForm.value = false;
connectionToEdit.value = null;
@@ -561,6 +777,34 @@ const handleBatchDeleteConnections = async () => {
}
};
const handleCloneConnection = async (connection: ConnectionInfo) => {
const allConnections = connectionsStore.connections;
const baseName = connection.name || connection.host;
let counter = 1;
let newName = `${baseName} (${counter})`;
while (allConnections.some((item) => item.name === newName)) {
counter += 1;
newName = `${baseName} (${counter})`;
}
await connectionsStore.cloneConnection(connection.id, newName);
await connectionsStore.fetchConnections();
};
const handleDeleteSingleConnection = async (connection: ConnectionInfo) => {
const confirmed = await showConfirmDialog({
message: t('connections.prompts.confirmDelete', { name: connection.name || connection.host }),
});
if (!confirmed) {
return;
}
await connectionsStore.deleteConnection(connection.id);
await connectionsStore.fetchConnections();
};
const handleTestSingleConnection = async (connection: ConnectionInfo) => {
if (!connection.id || connection.type !== 'SSH') {
return;
@@ -636,6 +880,26 @@ const handleConnectAllFilteredConnections = async () => {
isConnectingAll.value = false;
}
};
const toggleMoreMenu = (connectionId: number) => {
moreMenuOpenForId.value = moreMenuOpenForId.value === connectionId ? null : connectionId;
};
const closeMoreMenu = () => {
moreMenuOpenForId.value = null;
};
const handleGlobalClick = () => {
closeMoreMenu();
};
onMounted(() => {
document.addEventListener('click', handleGlobalClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleGlobalClick);
});
</script>
<template>
@@ -705,28 +969,72 @@ const handleConnectAllFilteredConnections = async () => {
<section>
<div class="px-2 mb-2 flex items-center justify-between gap-3 text-xs font-semibold uppercase tracking-[0.18em] text-text-secondary/80">
<span>{{ t('connections.table.tags', '标签') }}</span>
<span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ tagTreeNodes.length }}</span>
<span class="text-[11px] tracking-normal normal-case text-text-secondary">{{ visibleTagTreeNodes.length }}</span>
</div>
<div v-show="tagsSectionExpanded" class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<button
v-for="node in tagTreeNodes"
<div v-show="tagsSectionExpanded" class="space-y-2">
<div class="flex flex-wrap items-center gap-2 px-2">
<button
@click="expandAllTreeNodes"
:disabled="!hasExpandableTreeNodes"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-square-plus"></i>
<span>{{ t('common.expandAll', '展开全部') }}</span>
</button>
<button
@click="collapseAllTreeNodes"
:disabled="!hasExpandableTreeNodes"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-square-minus"></i>
<span>{{ t('common.collapseAll', '收起全部') }}</span>
</button>
<button
@click="resetScopeSelection"
:disabled="selectedScope === 'all'"
class="h-8 px-2.5 rounded-lg border border-border/60 bg-background text-text-secondary hover:bg-border hover:text-foreground transition-colors inline-flex items-center gap-2 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-rotate-left"></i>
<span>{{ t('common.reset', '重置范围') }}</span>
</button>
</div>
<div class="px-2 flex items-center justify-between gap-3 text-[11px] text-text-secondary">
<span>{{ t('connections.scopeHintCompact', '树节点按标签路径自动分层') }}</span>
<span>{{ selectedScopeTitle }}</span>
</div>
<div class="space-y-1 max-h-[520px] overflow-y-auto pr-1">
<div
v-for="node in visibleTagTreeNodes"
:key="node.id"
@click="selectScope(node.id)"
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-150',
getScopeNodeClass(node.id),
node.count === 0 ? 'opacity-55' : ''
]"
:style="{ paddingLeft: `${0.75 + node.level * 1.05}rem` }"
>
<span class="flex items-center gap-2 min-w-0">
<i class="fas fa-folder-tree w-4 text-center"></i>
<span class="truncate">{{ node.label }}</span>
</span>
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10">
<button
class="flex items-center gap-2 min-w-0 flex-1"
@click="handleTreeNodeSelect(node)"
>
<i
v-if="node.expandable"
:class="[
'fas w-4 text-center transition-transform duration-150',
(expandedTreeNodes[node.id] ?? true) ? 'fa-chevron-down' : 'fa-chevron-right'
]"
></i>
<i v-else class="fas fa-circle text-[8px] w-4 text-center opacity-60"></i>
<span class="truncate" :title="node.fullLabel">{{ node.label }}</span>
</button>
<span class="px-2 py-0.5 rounded-full text-xs border border-current/15 bg-black/10 flex-shrink-0">
{{ node.count }}
</span>
</button>
</div>
</div>
</div>
</section>
</div>
@@ -891,11 +1199,20 @@ const handleConnectAllFilteredConnections = async () => {
</div>
<div v-else class="min-w-0">
<div class="hidden xl:grid grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1.3fr)_160px_220px] gap-4 px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-text-secondary border-b border-border/50 bg-background/40 sticky top-0 z-10">
<div>{{ t('connections.table.name', '名称') }}</div>
<div>{{ t('connections.table.host', '地址') }}</div>
<div class="hidden xl:grid grid-cols-[minmax(0,2.25fr)_minmax(0,1.45fr)_minmax(0,1.25fr)_160px_170px] gap-4 px-4 py-3 text-xs font-semibold uppercase tracking-[0.16em] text-text-secondary border-b border-border/50 bg-background/40 sticky top-0 z-10">
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('name')">
<span>{{ t('connections.table.name', '名称') }}</span>
<i v-if="localSortBy === 'name'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
</button>
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('host')">
<span>{{ t('connections.table.host', '地址') }}</span>
<i v-if="localSortBy === 'host'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
</button>
<div>{{ t('connections.table.tags', '标签 / 备注') }}</div>
<div>{{ t('dashboard.lastConnected', '上次连接') }}</div>
<button class="flex items-center gap-2 text-left hover:text-foreground transition-colors" @click="handleSortByColumn('last_connected_at')">
<span>{{ t('dashboard.lastConnected', '上次连接') }}</span>
<i v-if="localSortBy === 'last_connected_at'" :class="['fas', isAscending ? 'fa-arrow-up-short-wide' : 'fa-arrow-down-wide-short']"></i>
</button>
<div>{{ t('connections.table.actions', '操作') }}</div>
</div>
@@ -910,7 +1227,7 @@ const handleConnectAllFilteredConnections = async () => {
isBatchEditMode && isConnectionSelectedForBatch(conn.id) ? 'bg-primary/10' : ''
]"
>
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1.3fr)_160px_220px] gap-4 items-start">
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,2.25fr)_minmax(0,1.45fr)_minmax(0,1.25fr)_160px_170px] gap-4 items-start">
<div class="min-w-0 flex items-start gap-3">
<input
v-if="isBatchEditMode"
@@ -997,27 +1314,7 @@ const handleConnectAllFilteredConnections = async () => {
</div>
</div>
<div class="flex flex-wrap justify-start xl:justify-end gap-2">
<button
v-if="conn.type === 'SSH'"
@click.stop="handleTestSingleConnection(conn)"
:disabled="isBatchEditMode || getSingleTestButtonInfo(conn.id, conn.type).disabled"
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
>
<i :class="getSingleTestButtonInfo(conn.id, conn.type).iconClass"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<button
@click.stop="openEditConnectionForm(conn)"
:disabled="isBatchEditMode"
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2 text-sm"
>
<i class="fas fa-pen"></i>
<span>{{ t('connections.actions.edit', '编辑') }}</span>
</button>
<div class="flex flex-wrap justify-start xl:justify-end gap-2 relative">
<button
@click.stop="connectTo(conn)"
:disabled="isBatchEditMode"
@@ -1026,6 +1323,55 @@ const handleConnectAllFilteredConnections = async () => {
<i class="fas fa-arrow-right-to-bracket"></i>
<span>{{ t('connections.actions.connect', '连接') }}</span>
</button>
<div class="relative">
<button
@click.stop="toggleMoreMenu(conn.id)"
:disabled="isBatchEditMode"
class="px-3 py-2 rounded-lg border border-border bg-background text-foreground hover:bg-border transition-colors inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="fas fa-ellipsis"></i>
<span>{{ t('common.more', '更多') }}</span>
</button>
<div
v-if="moreMenuOpenForId === conn.id"
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-border bg-background shadow-xl z-20 overflow-hidden"
@click.stop
>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
@click.stop="openEditConnectionForm(conn); closeMoreMenu()"
>
<i class="fas fa-pen w-4 text-center"></i>
<span>{{ t('connections.actions.edit', '编辑') }}</span>
</button>
<button
v-if="conn.type === 'SSH'"
:disabled="getSingleTestButtonInfo(conn.id, conn.type).disabled"
:title="getSingleTestButtonInfo(conn.id, conn.type).title"
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click.stop="handleTestSingleConnection(conn); closeMoreMenu()"
>
<i :class="[getSingleTestButtonInfo(conn.id, conn.type).iconClass, 'w-4 text-center']"></i>
<span>{{ getSingleTestButtonInfo(conn.id, conn.type).text }}</span>
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-foreground hover:bg-header transition-colors flex items-center gap-2"
@click.stop="handleCloneConnection(conn); closeMoreMenu()"
>
<i class="fas fa-clone w-4 text-center"></i>
<span>{{ t('connections.actions.clone', '克隆') }}</span>
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-error hover:bg-error/10 transition-colors flex items-center gap-2"
@click.stop="handleDeleteSingleConnection(conn); closeMoreMenu()"
>
<i class="fas fa-trash-alt w-4 text-center"></i>
<span>{{ t('connections.actions.delete', '删除') }}</span>
</button>
</div>
</div>
</div>
</div>
</li>