diff --git a/packages/frontend/src/components/CommandInputBar.vue b/packages/frontend/src/components/CommandInputBar.vue new file mode 100644 index 0000000..9db4fb5 --- /dev/null +++ b/packages/frontend/src/components/CommandInputBar.vue @@ -0,0 +1,76 @@ + + + + + + + + + + + + + diff --git a/packages/frontend/src/components/TerminalTabBar.vue b/packages/frontend/src/components/TerminalTabBar.vue index a60db1f..374ed89 100644 --- a/packages/frontend/src/components/TerminalTabBar.vue +++ b/packages/frontend/src/components/TerminalTabBar.vue @@ -1,5 +1,7 @@ @@ -48,8 +66,23 @@ const closeSession = (event: MouseEvent, sessionId: string) => { - - + + + + + + + + + × + 选择要连接的服务器 + + + @@ -60,7 +93,8 @@ const closeSession = (event: MouseEvent, sessionId: string) => { border-bottom: 1px solid #bdbdbd; overflow-x: auto; /* 如果标签过多则允许水平滚动 */ white-space: nowrap; - padding: 0 0.5rem; /* 左右留出一点空间 */ + /* padding: 0 0.5rem; */ /* 移除左右内边距,让标签列表和按钮自己控制 */ + padding-right: 0.5rem; /* 只保留右侧内边距给按钮 */ height: 2.5rem; /* 固定标签栏高度 */ box-sizing: border-box; /* 确保 padding 不会增加总高度 */ } @@ -83,8 +117,8 @@ const closeSession = (event: MouseEvent, sessionId: string) => { .tab-list { list-style: none; - padding: 0; - margin: 0; + padding: 0; /* 确保列表无内边距 */ + margin: 0; /* 确保列表无外边距 */ display: flex; } @@ -156,18 +190,91 @@ const closeSession = (event: MouseEvent, sessionId: string) => { color: #333333; } -/* 可选:添加新标签按钮样式 */ -/* +/* 添加新标签按钮样式 */ .add-tab-button { background: none; border: none; - padding: 0.5rem 0.8rem; + border-left: 1px solid #bdbdbd; /* 左侧分隔线 */ + padding: 0 0.8rem; + /* margin-left: 0.5rem; */ /* 移除左外边距 */ cursor: pointer; - font-size: 1.2em; + font-size: 1.1em; color: #616161; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + flex-shrink: 0; /* 防止按钮被压缩 */ } .add-tab-button:hover { background-color: #d0d0d0; } -*/ +.add-tab-button i { + line-height: 1; /* 确保图标垂直居中 */ +} + +/* 弹出窗口样式 */ +.connection-list-popup { + position: fixed; /* 固定定位,覆盖整个屏幕 */ + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* 半透明背景 */ + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; /* 确保在最上层 */ +} + +.popup-content { + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + width: 80%; + max-width: 500px; /* 限制最大宽度 */ + max-height: 80vh; /* 限制最大高度 */ + display: flex; + flex-direction: column; + position: relative; /* 为了关闭按钮定位 */ +} + +.popup-close-button { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #aaa; + line-height: 1; +} +.popup-close-button:hover { + color: #333; +} + +.popup-content h3 { + margin-top: 0; + margin-bottom: 15px; + text-align: center; + color: #333; +} + +.popup-connection-list { + flex-grow: 1; /* 让列表占据剩余空间 */ + overflow-y: auto; /* 列表内容滚动 */ + /* 可能需要覆盖 WorkspaceConnectionList 的一些默认样式 */ + border: 1px solid #eee; + border-radius: 4px; +} +/* 覆盖 WorkspaceConnectionList 内部样式(如果需要) */ +/* :deep(.popup-connection-list .search-add-bar) { */ + /* display: none; */ /* 不再隐藏搜索栏 */ +/* } */ +:deep(.popup-connection-list .connection-list-area) { + padding: 0; /* 保持移除内边距 */ +} + diff --git a/packages/frontend/src/components/WorkspaceConnectionList.vue b/packages/frontend/src/components/WorkspaceConnectionList.vue index 72a3699..8f94496 100644 --- a/packages/frontend/src/components/WorkspaceConnectionList.vue +++ b/packages/frontend/src/components/WorkspaceConnectionList.vue @@ -108,19 +108,26 @@ const toggleGroup = (groupName: string) => { expandedGroups.value[groupName] = !expandedGroups.value[groupName]; }; -// 处理单击连接 +// 处理单击连接 (左键) const handleConnect = (connectionId: number) => { + console.log(`[WkspConnList] handleConnect (左键) called for ID: ${connectionId}`); emit('connect-request', connectionId); + console.log(`[WkspConnList] Emitted 'connect-request' for ID: ${connectionId}`); closeContextMenu(); // 点击连接后关闭菜单 }; // 显示右键菜单 const showContextMenu = (event: MouseEvent, connection: ConnectionInfo) => { + console.log(`[WkspConnList] showContextMenu (右键) called for ID: ${connection.id}. Event:`, event); + event.preventDefault(); // 再次确保阻止默认行为 + event.stopPropagation(); // 阻止事件冒泡 + console.log('[WkspConnList] Right-click default prevented and propagation stopped.'); contextTargetConnection.value = connection; contextMenuPosition.value = { x: event.clientX, y: event.clientY }; contextMenuVisible.value = true; // 添加全局点击监听器以关闭菜单 document.addEventListener('click', closeContextMenu, { once: true }); + return false; // 彻底停止事件处理 }; // 关闭右键菜单 @@ -159,7 +166,9 @@ onMounted(() => { // 处理中键点击(在新标签页打开) const handleOpenInNewTab = (connectionId: number) => { + console.log(`[WkspConnList] handleOpenInNewTab (中键/辅助键) called for ID: ${connectionId}`); emit('open-new-session', connectionId); + console.log(`[WkspConnList] Emitted 'open-new-session' for ID: ${connectionId}`); closeContextMenu(); // 如果右键菜单是打开的,也关闭它 }; diff --git a/packages/frontend/src/composables/useSshTerminal.ts b/packages/frontend/src/composables/useSshTerminal.ts index e90fe7b..39ed018 100644 --- a/packages/frontend/src/composables/useSshTerminal.ts +++ b/packages/frontend/src/composables/useSshTerminal.ts @@ -254,12 +254,22 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`); }; + /** + * 直接发送数据到 SSH 会话 (例如,从命令输入栏) + * @param data 要发送的字符串数据 + */ + const sendData = (data: string) => { + // console.debug(`[会话 ${sessionId}][SSH终端模块] 直接发送数据:`, data); + sendMessage({ type: 'ssh:input', sessionId, payload: { data } }); + }; + // 返回工厂实例 return { // 公共接口 handleTerminalReady, - handleTerminalData, + handleTerminalData, // 这个处理来自 xterm.js 的输入 handleTerminalResize, + sendData, // 新增:允许外部直接发送数据 cleanup }; } diff --git a/packages/frontend/src/composables/useWebSocketConnection.ts b/packages/frontend/src/composables/useWebSocketConnection.ts index bdfbdd9..43b87de 100644 --- a/packages/frontend/src/composables/useWebSocketConnection.ts +++ b/packages/frontend/src/composables/useWebSocketConnection.ts @@ -106,15 +106,47 @@ export function createWebSocketConnectionManager(sessionId: string, dbConnection reconnectTimeoutId = null; } - // 防止重复连接同一实例 - if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) { - console.warn(`[WebSocket ${instanceSessionId}] 连接已打开或正在连接中。`); + // --- 修改后的检查逻辑 --- + // 只有当 ws 实例存在,且其状态为 OPEN 或 CONNECTING, + // 并且我们自己维护的状态也是 connected 或 connecting 时,才阻止连接。 + if (ws.value && + (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING) && + (connectionStatus.value === 'connected' || connectionStatus.value === 'connecting') + ) { + console.warn(`[WebSocket ${instanceSessionId}] 连接已打开或正在连接中 (readyState: ${ws.value.readyState}, status: ${connectionStatus.value})。 阻止重复连接。`); return; } + // 处理状态不一致或旧连接未完全关闭的情况 + if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) { + // readyState 是 OPEN/CONNECTING 但 connectionStatus 是 disconnected/error + console.warn(`[WebSocket ${instanceSessionId}] 检测到状态不一致 (readyState: ${ws.value.readyState}, status: ${connectionStatus.value})。尝试关闭旧连接并继续...`); + // 临时标记为主动断开,防止 onclose 触发 scheduleReconnect + const oldWs = ws.value; // 保存旧 ws 引用 + const previousIntentionalDisconnect = intentionalDisconnect; + intentionalDisconnect = true; + // 在关闭前移除监听器,防止旧的 onclose 干扰 + if (oldWs) { + console.log(`[WebSocket ${instanceSessionId}] 移除旧连接的事件监听器...`); + oldWs.onopen = null; + oldWs.onmessage = null; + oldWs.onerror = null; + oldWs.onclose = null; // 阻止旧的 onclose 干扰 + console.log(`[WebSocket ${instanceSessionId}] 关闭旧连接 (强制)...`); + oldWs.close(1000, '状态不一致,强制重连'); + } + ws.value = null; // 清理 shallowRef 中的引用 + intentionalDisconnect = previousIntentionalDisconnect; // 恢复标记 + console.log(`[WebSocket ${instanceSessionId}] 旧连接处理完毕。`); + } else if (ws.value && ws.value.readyState === WebSocket.CLOSING) { + console.log(`[WebSocket ${instanceSessionId}] 检测到旧连接正在关闭 (readyState: ${ws.value.readyState})。清理引用并继续创建新连接...`); + ws.value = null; // 清理引用,让后续逻辑创建新的 + } + // 如果 ws.value 存在且 readyState 是 CLOSED,它应该已经在 onclose 中被设为 null + console.log(`[WebSocket ${instanceSessionId}] 尝试连接到: ${url} (DB Conn ID: ${instanceDbConnectionId})`); statusMessage.value = getStatusText('connectingWs', { url }); - connectionStatus.value = 'connecting'; + connectionStatus.value = 'connecting'; // 确保状态设置为 connecting isSftpReady.value = false; // 重置 SFTP 状态 try { diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index a335f02..5354c7f 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -519,5 +519,8 @@ "untagged": "未标记", "searchPlaceholder": "搜索名称或主机...", "noResults": "未找到匹配 \"{searchTerm}\" 的连接。" - } + }, + "commandInputBar": { + "placeholder": "在此输入命令后按 Enter 发送到终端..." +} } diff --git a/packages/frontend/src/stores/session.store.ts b/packages/frontend/src/stores/session.store.ts index a1c4906..f509eb4 100644 --- a/packages/frontend/src/stores/session.store.ts +++ b/packages/frontend/src/stores/session.store.ts @@ -198,38 +198,46 @@ export const useSessionStore = defineStore('session', () => { }; /** - * 处理连接列表的左键点击(连接或激活) + * 处理连接列表的左键点击(如果点击的是当前活动标签且断开则重连,否则总是新建标签) */ const handleConnectRequest = (connectionId: number | string) => { const connIdStr = String(connectionId); - console.log(`[SessionStore] 处理连接请求: ${connIdStr}`); + console.log(`[SessionStore] handleConnectRequest called for ID: ${connIdStr}`); + let existingSession: SessionState | null = null; let existingSessionId: string | null = null; + // 查找是否存在对应 connectionId 的会话 for (const [sessionId, session] of sessions.value.entries()) { if (session.connectionId === connIdStr) { + existingSession = session; existingSessionId = sessionId; break; } } - if (existingSessionId) { - if (activeSessionId.value !== existingSessionId) { - console.log(`[SessionStore] 激活已存在的会话: ${existingSessionId}`); - activateSession(existingSessionId); + // 检查点击的连接是否是当前活动的标签页 + if (existingSession && existingSessionId && existingSessionId === activeSessionId.value) { + // 是当前活动标签页 + const currentStatus = existingSession.wsManager.connectionStatus.value; + console.log(`[SessionStore] 点击的是当前活动会话 ${existingSessionId},状态: ${currentStatus}`); + if (currentStatus === 'disconnected' || currentStatus === 'error') { + // 如果已断开或出错,则尝试重连 + console.log(`[SessionStore] 活动会话 ${existingSessionId} 已断开或出错,尝试重连...`); + const wsUrl = `ws://${window.location.hostname}:3001`; // TODO: 从配置获取 URL + existingSession.wsManager.connect(wsUrl); + // 不需要再调用 activateSession,因为它已经是活动的 } else { - console.log(`[SessionStore] 点击的连接 ${connIdStr} 已在活动会话 ${existingSessionId} 中,无需操作。`); + // 如果状态正常,则无需操作 + console.log(`[SessionStore] 活动会话 ${existingSessionId} 状态正常,无需操作。`); } } else { - // 当前行为:替换当前活动会话(如果存在) - if (activeSession.value) { - console.log(`[SessionStore] 替换当前会话 ${activeSessionId.value} 为新连接 ${connIdStr}`); - closeSession(activeSessionId.value!); // 确保 activeSessionId 存在 - openNewSession(connIdStr); + // 点击的不是当前活动标签(可能是非活动标签,或根本不存在),总是新建标签页 + if (existingSessionId) { + console.log(`[SessionStore] 点击的连接 ${connIdStr} 存在于非活动会话 ${existingSessionId} 中,将打开新会话。`); } else { - console.log(`[SessionStore] 当前无活动会话,打开新会话: ${connIdStr}`); - openNewSession(connIdStr); + console.log(`[SessionStore] 未找到 ID 为 ${connIdStr} 的现有会话,将打开新会话。`); } - // 备选行为:总是打开新标签页?需要调整 openNewSession 逻辑 + openNewSession(connIdStr); // 直接调用 openNewSession } }; @@ -237,7 +245,7 @@ export const useSessionStore = defineStore('session', () => { * 处理连接列表的中键点击(总是打开新会话) */ const handleOpenNewSession = (connectionId: number | string) => { - console.log(`[SessionStore] 处理打开新会话请求: ${connectionId}`); + console.log(`[SessionStore] handleOpenNewSession called for ID: ${connectionId}`); openNewSession(connectionId); }; diff --git a/packages/frontend/src/views/WorkspaceView.vue b/packages/frontend/src/views/WorkspaceView.vue index 1155631..e2ce5e8 100644 --- a/packages/frontend/src/views/WorkspaceView.vue +++ b/packages/frontend/src/views/WorkspaceView.vue @@ -8,12 +8,13 @@ import StatusMonitorComponent from '../components/StatusMonitor.vue'; import WorkspaceConnectionListComponent from '../components/WorkspaceConnectionList.vue'; import AddConnectionFormComponent from '../components/AddConnectionForm.vue'; import TerminalTabBar from '../components/TerminalTabBar.vue'; -import { useSessionStore, type SessionTabInfoWithStatus } from '../stores/session.store'; +import CommandInputBar from '../components/CommandInputBar.vue'; // 导入新组件 +import { useSessionStore, type SessionTabInfoWithStatus, type SshTerminalInstance } from '../stores/session.store'; // 导入 SshTerminalInstance import type { ConnectionInfo } from '../stores/connections.store'; // 导入 splitpanes 组件 import { Splitpanes, Pane } from 'splitpanes'; // 导入管理器实例类型,用于 FileManagerComponent 的 prop 类型断言 -import type { SftpManagerInstance } from '../stores/session.store'; +// import type { SftpManagerInstance } from '../stores/session.store'; // SftpManagerInstance 已在上面导入 // --- Setup --- const { t } = useI18n(); @@ -61,6 +62,20 @@ onBeforeUnmount(() => { console.log('[工作区视图] 连接已更新'); handleFormClose(); }; + + // 处理命令发送 + const handleSendCommand = (command: string) => { + // 类型断言确保 terminalManager 存在 sendData 方法 + const terminalManager = activeSession.value?.terminalManager as (SshTerminalInstance | undefined); + if (terminalManager && typeof terminalManager.sendData === 'function') { + console.log(`[WorkspaceView] Sending command to active session ${activeSessionId.value}: ${command.trim()}`); + // 注意:CommandInputBar 已经添加了 '\n' + terminalManager.sendData(command); + } else { + console.warn('[WorkspaceView] Cannot send command, no active session or terminal manager with sendData method.'); + // 可以考虑给用户一个提示 + } + }; @@ -79,19 +94,19 @@ onBeforeUnmount(() => { { console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`); sessionStore.handleConnectRequest(id); }" + @open-new-session="(id) => { console.log(`[WorkspaceView] Received 'open-new-session' event for ID: ${id}`); sessionStore.handleOpenNewSession(id); }" + @request-add-connection="() => { console.log('[WorkspaceView] Received \'request-add-connection\' event'); handleRequestAddConnection(); }" + @request-edit-connection="(conn) => { console.log(`[WorkspaceView] Received 'request-edit-connection' event for connection:`, conn); handleRequestEditConnection(conn); }" /> - - - - - - + + + + + + { {{ t('workspace.selectConnectionPrompt') }} {{ t('workspace.selectConnectionHint') }} - - - {{ t('workspace.selectConnectionPrompt') }} - {{ t('workspace.selectConnectionHint') }} - - - + + + + + + + + { {{ t('fileManager.noActiveSession') }} - - + + @@ -190,10 +209,12 @@ onBeforeUnmount(() => { .workspace-view { display: flex; flex-direction: column; - height: calc(100vh - 60px - 30px - 2rem); /* 调整以适应您的 header/footer/padding */ + height: calc(100vh - 60px - 30px - 2rem); /* 恢复原始高度计算 */ overflow: hidden; } +/* 移除 fixed-command-bar 样式 */ + .main-content-area { display: flex; flex: 1; @@ -203,19 +224,40 @@ onBeforeUnmount(() => { /* 为 Pane 添加一些基本样式 */ .sidebar-pane, /* 用于左右侧边栏 */ +.middle-pane, /* 中间包含终端、命令栏、文件管理器的 Pane */ .terminal-pane, +.command-bar-pane, /* 命令栏 Pane */ .file-manager-pane { - display: flex; + display: flex; /* 确保 Pane 内容可以正确布局 */ flex-direction: column; overflow: hidden; /* Pane 内部内容溢出时隐藏 */ background-color: #f8f9fa; /* 默认背景色 */ } +.middle-pane { + padding: 0; /* 移除 middle-pane 的内边距 */ +} + +/* 命令栏 Pane 特定样式 - 添加 max-height */ +.command-bar-pane { + background-color: #e9ecef; /* 背景色 */ + justify-content: center; /* 垂直居中输入框 */ + max-height: 200px; /* 使用 CSS 限制最大高度,例如 200px */ + overflow: auto; /* 如果内容超出,允许滚动 */ +} +/* 调整内部 CommandInputBar 样式 */ +.command-bar-pane > .command-input-bar { + border: none; /* 移除 CommandInputBar 的边框 */ + background-color: transparent; /* 移除 CommandInputBar 的背景 */ + min-height: auto; /* 移除最小高度 */ + padding: 2px 10px; /* 调整内边距 */ +} + .terminal-pane { background-color: #1e1e1e; /* 终端背景 */ position: relative; /* 保持相对定位用于占位符 */ } .file-manager-pane { - border-top: 1px solid #ccc; /* 终端和文件管理器之间的分隔线 */ + /* 分隔线由 splitpanes 提供 */ } /* 终端会话包装器 */ @@ -234,14 +276,6 @@ onBeforeUnmount(() => { overflow: hidden; } -/* 文件管理器包装器 (内部组件应填充) */ -.file-manager-wrapper { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - /* 状态监视器包装器 (内部组件应填充) */ .status-monitor-wrapper { flex: 1; @@ -267,14 +301,6 @@ onBeforeUnmount(() => { } -/* 终端占位符 */ -.terminal-placeholder { - display: flex; - flex-direction: column; - overflow: hidden; -} - - /* 终端占位符 */ .terminal-placeholder { position: absolute; @@ -353,4 +379,9 @@ onBeforeUnmount(() => { bottom: 2px; width: 100%; } + +/* 尝试提高中间区域水平分割线的 z-index */ +.middle-pane .splitpanes--horizontal > .splitpanes__splitter { + z-index: 10; /* 确保分割线在内容之上 */ +}
{{ t('workspace.selectConnectionHint') }}