update
This commit is contained in:
@@ -163,6 +163,7 @@ const paneLabels = computed(() => ({ // Assuming labels might depend on i18n
|
||||
commandHistory: t('layout.pane.commandHistory', '命令历史'),
|
||||
quickCommands: t('layout.pane.quickCommands', '快捷指令'),
|
||||
dockerManager: t('layout.pane.dockerManager', 'Docker 管理器'),
|
||||
suspendedSshSessions: t('layout.pane.suspendedSshSessions'),
|
||||
}));
|
||||
|
||||
// --- Methods ---
|
||||
|
||||
@@ -79,6 +79,7 @@ const componentMap: Record<PaneName, Component> = {
|
||||
commandHistory: defineAsyncComponent(() => import('../views/CommandHistoryView.vue')),
|
||||
quickCommands: defineAsyncComponent(() => import('../views/QuickCommandsView.vue')),
|
||||
dockerManager: defineAsyncComponent(() => import('./DockerManager.vue')), // <--- 添加 dockerManager 映射
|
||||
suspendedSshSessions: defineAsyncComponent(() => import('../views/SuspendedSshSessionsView.vue')),
|
||||
};
|
||||
|
||||
// --- Computed ---
|
||||
@@ -111,6 +112,7 @@ const paneLabels = computed(() => ({
|
||||
commandHistory: t('layout.pane.commandHistory', '命令历史'),
|
||||
quickCommands: t('layout.pane.quickCommands', '快捷指令'),
|
||||
dockerManager: t('layout.pane.dockerManager', 'Docker 管理器'),
|
||||
suspendedSshSessions: t('layout.panes.suspendedSshSessions', '挂起会话管理'),
|
||||
}));
|
||||
|
||||
|
||||
@@ -195,6 +197,10 @@ const componentProps = computed(() => {
|
||||
// onDockerCommand: (payload: { containerId: string; command: 'up' | 'down' | 'restart' | 'stop' }) => emit('dockerCommand', payload),
|
||||
// 暂时不添加事件转发,等组件实现后再确定
|
||||
};
|
||||
case 'suspendedSshSessions':
|
||||
return {
|
||||
class: 'flex flex-col flex-grow h-full overflow-auto', // 与 quickCommands 类似
|
||||
};
|
||||
default:
|
||||
return { class: 'pane-content' };
|
||||
}
|
||||
@@ -345,6 +351,7 @@ const getIconClasses = (paneName: PaneName): string[] => {
|
||||
case 'dockerManager': return ['fab', 'fa-docker']; // Use 'fab' for Docker
|
||||
case 'editor': return ['fas', 'fa-file-alt'];
|
||||
case 'statusMonitor': return ['fas', 'fa-tachometer-alt'];
|
||||
case 'suspendedSshSessions': return ['fas', 'fa-pause-circle']; // 图标:暂停圈
|
||||
// Add other specific icons here if needed
|
||||
default: return ['fas', 'fa-question-circle']; // Default icon
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.
|
||||
import { useLayoutStore, type PaneName } from '../stores/layout.store';
|
||||
import { useWorkspaceEventEmitter } from '../composables/workspaceEvents'; // +++ 新增导入 +++
|
||||
|
||||
import type { SessionTabInfoWithStatus } from '../stores/session.store';
|
||||
import type { SessionTabInfoWithStatus } from '../stores/session/types'; // 路径修正
|
||||
|
||||
|
||||
const { t } = useI18n(); // 初始化 i18n
|
||||
@@ -171,6 +171,15 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
|
||||
// 注意:关闭左侧通常不包括当前标签本身
|
||||
emitWorkspaceEvent('session:closeToLeft', { targetSessionId: targetId });
|
||||
break;
|
||||
case 'suspend-session': // 新增处理 suspend-session 动作
|
||||
// 确保 targetId 是字符串类型的 sessionId
|
||||
if (typeof targetId === 'string') {
|
||||
console.log(`[TabBar] Context menu action 'suspend-session' requested for session ID: ${targetId}`);
|
||||
sessionStore.requestStartSshSuspend(targetId);
|
||||
} else {
|
||||
console.warn(`[TabBar] 'suspend-session' action called with invalid targetId:`, targetId);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.warn(`[TabBar] Unknown context menu action: ${action}`);
|
||||
}
|
||||
@@ -180,25 +189,51 @@ const handleContextMenuAction = (payload: { action: string; targetId: string | n
|
||||
// 计算右键菜单项
|
||||
const contextMenuItems = computed(() => {
|
||||
const items = [];
|
||||
const targetId = contextTargetSessionId.value;
|
||||
if (!targetId) return [];
|
||||
const targetSessionIdValue = contextTargetSessionId.value; // 使用局部变量以避免多次访问 .value
|
||||
if (!targetSessionIdValue) return [];
|
||||
|
||||
const currentIndex = props.sessions.findIndex(s => s.sessionId === targetId);
|
||||
const targetSessionState = sessionStore.sessions.get(targetSessionIdValue);
|
||||
if (!targetSessionState) return []; // 如果找不到会话状态,则不显示菜单
|
||||
|
||||
const connectionIdNum = parseInt(targetSessionState.connectionId, 10);
|
||||
const connectionInfo = connectionsStore.connections.find(c => c.id === connectionIdNum);
|
||||
|
||||
const currentIndex = props.sessions.findIndex(s => s.sessionId === targetSessionIdValue);
|
||||
const totalTabs = props.sessions.length;
|
||||
|
||||
items.push({ label: 'tabs.contextMenu.close', action: 'close' }); // 使用 i18n key
|
||||
// 添加挂起会话菜单项(如果适用)
|
||||
if (connectionInfo && connectionInfo.type === 'SSH') {
|
||||
// 仅对活动的SSH会话显示 (可以进一步判断会话是否真的已连接等)
|
||||
const isActiveSession = targetSessionState.wsManager.isConnected.value; // 检查 WebSocket 是否连接
|
||||
if (isActiveSession) {
|
||||
items.push({ label: 'tabs.contextMenu.suspendSession', action: 'suspend-session' });
|
||||
// 为分隔符提供空的 label 和 action 以满足 MenuItem 类型
|
||||
items.push({ label: '', action: '', isSeparator: true });
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ label: 'tabs.contextMenu.close', action: 'close' });
|
||||
|
||||
if (totalTabs > 1) {
|
||||
items.push({ label: 'tabs.contextMenu.closeOthers', action: 'close-others' });
|
||||
}
|
||||
|
||||
if (currentIndex < totalTabs - 1) {
|
||||
if (currentIndex < totalTabs - 1 && totalTabs > 1) { // 仅当有右侧标签时显示
|
||||
items.push({ label: 'tabs.contextMenu.closeRight', action: 'close-right' });
|
||||
}
|
||||
|
||||
if (currentIndex > 0) {
|
||||
if (currentIndex > 0 && totalTabs > 1) { // 仅当有左侧标签时显示
|
||||
items.push({ label: 'tabs.contextMenu.closeLeft', action: 'close-left' });
|
||||
}
|
||||
|
||||
// 移除末尾可能存在的分隔符(如果它是最后一项)
|
||||
// 确保在 pop 之前检查 items[items.length - 1] 是否真的存在并且是分隔符
|
||||
if (items.length > 0) {
|
||||
const lastItem = items[items.length - 1];
|
||||
if (lastItem && lastItem.isSeparator) {
|
||||
items.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
@@ -314,7 +349,7 @@ animation="150"
|
||||
<span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</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="关闭标签页">
|
||||
@click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -324,7 +359,7 @@ animation="150"
|
||||
</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="新建连接标签页">
|
||||
@click="togglePopup" :title="$t('tabs.newTabTooltip')">
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref, readonly, type Ref, ComputedRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'; // +++ Add import for useI18n +++
|
||||
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
|
||||
import type { Terminal } from 'xterm';
|
||||
import type { SearchAddon, ISearchOptions } from '@xterm/addon-search'; // *** 移除 ISearchResult 导入 ***
|
||||
@@ -18,7 +19,7 @@ export interface SshTerminalDependencies {
|
||||
* @param t i18n 翻译函数,从父组件传入
|
||||
* @returns SSH 终端管理器实例
|
||||
*/
|
||||
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: Function) {
|
||||
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: ReturnType<typeof useI18n>['t']) { // +++ Update type of t +++
|
||||
// 使用依赖注入的 WebSocket 函数
|
||||
const { sendMessage, onMessage, isConnected } = wsDeps;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref, shallowRef, computed, readonly } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'; // +++ Add import for useI18n +++
|
||||
// 从 websocket.types.ts 导入并重新导出 ConnectionStatus
|
||||
import type { ConnectionStatus as WsConnectionStatusType, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types';
|
||||
|
||||
@@ -14,7 +15,7 @@ export type WsConnectionStatus = WsConnectionStatusType;
|
||||
* @param {Function} t - i18n 翻译函数,从父组件传入
|
||||
* @returns 一个包含状态和方法的 WebSocket 连接管理器对象。
|
||||
*/
|
||||
export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: Function) {
|
||||
export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: ReturnType<typeof useI18n>['t']) { // +++ Update type of t +++
|
||||
// --- Instance State ---
|
||||
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
|
||||
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
|
||||
|
||||
@@ -26,7 +26,12 @@ if (availableLocales.length === 0) {
|
||||
// 类型推断 (基于第一个加载的语言文件,假设所有文件结构一致)
|
||||
// 如果没有加载到文件,则使用空对象作为 fallback,避免运行时错误
|
||||
// 使用更通用的类型 Record<string, any> 来避免动态索引的类型推断问题
|
||||
type MessageSchema = Record<string, any>;
|
||||
// 尝试一个更具体的类型来帮助 TypeScript 推断,以解决深层实例化问题
|
||||
// 这允许嵌套的翻译键,例如 'parent.child.grandchild'
|
||||
interface RecursiveStringRecord {
|
||||
[key: string]: string | RecursiveStringRecord;
|
||||
}
|
||||
type MessageSchema = RecursiveStringRecord;
|
||||
|
||||
// 定义默认语言 (优先使用 'en-US',如果不存在则使用第一个找到的语言)
|
||||
export const defaultLng = availableLocales.includes('en-US') ? 'en-US' : availableLocales[0] || 'en-US'; // 更新为 en-US
|
||||
|
||||
@@ -975,7 +975,8 @@
|
||||
"statusMonitor": "Status Monitor",
|
||||
"commandHistory": "Command History",
|
||||
"quickCommands": "Quick Commands",
|
||||
"dockerManager": "Docker Manager"
|
||||
"dockerManager": "Docker Manager",
|
||||
"suspendedSshSessions": "Suspended Sessions Management"
|
||||
},
|
||||
"noActiveSession": {
|
||||
"title": "No Active Session",
|
||||
@@ -985,7 +986,8 @@
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"hide": "Hide"
|
||||
"hide": "Hide",
|
||||
"show": "Show Top Navigation"
|
||||
},
|
||||
"commandHistory": {
|
||||
"searchPlaceholder": "Search history...",
|
||||
@@ -1134,8 +1136,11 @@
|
||||
"close": "Close Tab",
|
||||
"closeOthers": "Close Other Tabs",
|
||||
"closeRight": "Close Tabs to the Right",
|
||||
"closeLeft": "Close Tabs to the Left"
|
||||
}
|
||||
"closeLeft": "Close Tabs to the Left",
|
||||
"suspendSession": "Suspend Session"
|
||||
},
|
||||
"closeTabTooltip": "Close Tab",
|
||||
"newTabTooltip": "New Connection Tab"
|
||||
},
|
||||
"sshKeys": {
|
||||
"selector": {
|
||||
@@ -1166,5 +1171,30 @@
|
||||
"keyUpdateNote": "Leave private key blank to keep the existing key. Passphrase always needs re-entry if required.",
|
||||
"passphraseUpdateNote": "Leave blank to keep or remove the passphrase. Enter a new passphrase to update."
|
||||
}
|
||||
},
|
||||
"suspendedSshSessions": {
|
||||
"searchPlaceholder": "Search sessions (name, connection...)",
|
||||
"loading": "Loading suspended sessions...",
|
||||
"noResults": "No suspended sessions found matching your criteria.",
|
||||
"tooltip": {
|
||||
"editName": "Click to edit name"
|
||||
},
|
||||
"label": {
|
||||
"originalConnection": "Original Connection",
|
||||
"suspendedAt": "Suspended At"
|
||||
},
|
||||
"disconnectedAt": "Disconnected at {time}",
|
||||
"status": {
|
||||
"hanging": "Active",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"action": {
|
||||
"resume": "Resume",
|
||||
"remove": "Remove"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"unknown": "Unknown time",
|
||||
"invalidDate": "Invalid date"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,7 +977,8 @@
|
||||
"statusMonitor": "状态监视器",
|
||||
"commandHistory": "命令历史",
|
||||
"quickCommands": "快捷指令",
|
||||
"dockerManager": "Docker 管理器"
|
||||
"dockerManager": "Docker 管理器",
|
||||
"suspendedSshSessions": "挂起会话管理"
|
||||
},
|
||||
"noActiveSession": {
|
||||
"title": "无活动会话",
|
||||
@@ -987,7 +988,8 @@
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"hide": "隐藏"
|
||||
"hide": "隐藏",
|
||||
"show": "显示顶部导航"
|
||||
},
|
||||
"commandHistory": {
|
||||
"searchPlaceholder": "搜索历史记录...",
|
||||
@@ -1136,8 +1138,11 @@
|
||||
"close": "关闭标签页",
|
||||
"closeOthers": "关闭其他标签页",
|
||||
"closeRight": "关闭右侧标签页",
|
||||
"closeLeft": "关闭左侧标签页"
|
||||
}
|
||||
"closeLeft": "关闭左侧标签页",
|
||||
"suspendSession": "挂起会话"
|
||||
},
|
||||
"closeTabTooltip": "关闭标签页",
|
||||
"newTabTooltip": "新建连接标签页"
|
||||
},
|
||||
"sshKeys": {
|
||||
"selector": {
|
||||
@@ -1168,5 +1173,30 @@
|
||||
"keyUpdateNote": "将私钥留空以保留现有密钥。密码短语始终需要重新输入(如果需要)。",
|
||||
"passphraseUpdateNote": "留空表示不修改或移除密码短语。输入新密码短语以更新。"
|
||||
}
|
||||
},
|
||||
"suspendedSshSessions": {
|
||||
"searchPlaceholder": "搜索会话 (名称, 连接名...)",
|
||||
"loading": "正在加载挂起的会话...",
|
||||
"noResults": "没有找到符合条件的挂起会话。",
|
||||
"tooltip": {
|
||||
"editName": "点击编辑名称"
|
||||
},
|
||||
"label": {
|
||||
"originalConnection": "原始连接",
|
||||
"suspendedAt": "挂起于"
|
||||
},
|
||||
"disconnectedAt": "已于 {time} 断开",
|
||||
"status": {
|
||||
"hanging": "活跃",
|
||||
"disconnected": "已断开"
|
||||
},
|
||||
"action": {
|
||||
"resume": "恢复",
|
||||
"remove": "移除"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"unknown": "未知时间",
|
||||
"invalidDate": "无效日期"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, watch, type Ref, type ComputedRef } from 'vue';
|
||||
import apiClient from '../utils/apiClient';
|
||||
|
||||
// 定义所有可用面板的名称
|
||||
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager';
|
||||
export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager' | 'suspendedSshSessions';
|
||||
|
||||
// 定义布局节点接口
|
||||
export interface LayoutNode {
|
||||
@@ -166,7 +166,7 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
const allPossiblePanes: Ref<PaneName[]> = ref([
|
||||
'connections', 'terminal', 'commandBar', 'fileManager',
|
||||
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
|
||||
'dockerManager' // <--- 在这里添加 'dockerManager'
|
||||
'dockerManager', 'suspendedSshSessions' // <-- 添加新的挂起 SSH 会话视图
|
||||
]);
|
||||
// 新增:控制布局(Header/Footer)可见性的状态
|
||||
const isLayoutVisible: Ref<boolean> = ref(true); // 控制整体布局(Header/Footer)可见性
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
rdpConnectionInfo,
|
||||
isVncModalOpen,
|
||||
vncConnectionInfo,
|
||||
// SSH Suspend Mode State
|
||||
suspendedSshSessions,
|
||||
isLoadingSuspendedSessions,
|
||||
} from './session/state';
|
||||
|
||||
// 从新模块导入 Getters
|
||||
@@ -28,6 +31,7 @@ import * as editorActions from './session/actions/editorActions';
|
||||
import * as sftpManagerActions from './session/actions/sftpManagerActions';
|
||||
import * as modalActions from './session/actions/modalActions';
|
||||
import * as commandInputActions from './session/actions/commandInputActions';
|
||||
import * as sshSuspendActions from './session/actions/sshSuspendActions'; // 新增:导入 SSH 挂起 Actions
|
||||
|
||||
// 导入需要的类型 (例如 FileInfo 可能会在参数中使用)
|
||||
import type { FileInfo } from './fileEditor.store';
|
||||
@@ -51,7 +55,7 @@ export const useSessionStore = defineStore('session', () => {
|
||||
|
||||
// Session Actions
|
||||
const openNewSession = (connectionId: number | string) =>
|
||||
sessionActions.openNewSession(connectionId, { connectionsStore, t });
|
||||
sessionActions.openNewSession(connectionId, { connectionsStore, t }); // 移除了 router 和不正确的 registerSshSuspendHandlers
|
||||
const activateSession = (sessionId: string) => sessionActions.activateSession(sessionId);
|
||||
const closeSession = (sessionId: string) => sessionActions.closeSession(sessionId);
|
||||
const handleConnectRequest = (connection: ConnectionInfo) =>
|
||||
@@ -63,7 +67,7 @@ export const useSessionStore = defineStore('session', () => {
|
||||
t,
|
||||
});
|
||||
const handleOpenNewSession = (connectionId: number | string) =>
|
||||
sessionActions.handleOpenNewSession(connectionId, { connectionsStore, t });
|
||||
sessionActions.handleOpenNewSession(connectionId, { connectionsStore, t }); // 移除了 router 和不正确的 registerSshSuspendHandlers
|
||||
const cleanupAllSessions = () => sessionActions.cleanupAllSessions();
|
||||
|
||||
// SFTP Manager Actions
|
||||
@@ -105,6 +109,9 @@ export const useSessionStore = defineStore('session', () => {
|
||||
rdpConnectionInfo,
|
||||
isVncModalOpen,
|
||||
vncConnectionInfo,
|
||||
// SSH Suspend Mode State
|
||||
suspendedSshSessions,
|
||||
isLoadingSuspendedSessions,
|
||||
|
||||
// Getters (直接从 getters 模块导出)
|
||||
sessionTabs,
|
||||
@@ -134,5 +141,8 @@ export const useSessionStore = defineStore('session', () => {
|
||||
openVncModal,
|
||||
closeVncModal,
|
||||
updateSessionCommandInput,
|
||||
|
||||
// SSH Suspend Actions (直接从模块导出,Pinia 会处理)
|
||||
...sshSuspendActions,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useConnectionsStore, type ConnectionInfo } from '../../connections.store'; // 路径: packages/frontend/src/stores/connections.store.ts
|
||||
import { sessions, activeSessionId } from '../state';
|
||||
import { generateSessionId } from '../utils';
|
||||
import type { SessionState, SshTerminalInstance, StatusMonitorInstance, DockerManagerInstance, SftpManagerInstance } from '../types';
|
||||
import type { SessionState, SshTerminalInstance, StatusMonitorInstance, DockerManagerInstance, SftpManagerInstance, WsManagerInstance } from '../types';
|
||||
|
||||
// Composables for manager creation - 路径相对于此文件
|
||||
import { createWebSocketConnectionManager } from '../../../composables/useWebSocketConnection';
|
||||
import { createSshTerminalManager, type SshTerminalDependencies } from '../../../composables/useSshTerminal';
|
||||
import { createStatusMonitorManager, type StatusMonitorDependencies } from '../../../composables/useStatusMonitor';
|
||||
import { createDockerManager, type DockerManagerDependencies } from '../../../composables/useDockerManager';
|
||||
import { registerSshSuspendHandlers } from './sshSuspendActions'; // 新增:导入 SSH 挂起处理器注册函数
|
||||
// getOrCreateSftpManager 将在 sftpManagerActions.ts 中定义,并在主 store 中协调
|
||||
|
||||
// --- 辅助函数 (特定于此模块的 actions) ---
|
||||
@@ -26,10 +27,11 @@ export const openNewSession = (
|
||||
dependencies: {
|
||||
connectionsStore: ReturnType<typeof useConnectionsStore>;
|
||||
t: ReturnType<typeof useI18n>['t'];
|
||||
}
|
||||
},
|
||||
existingSessionId?: string // 新增:可选的预定义会话 ID
|
||||
) => {
|
||||
const { connectionsStore, t } = dependencies;
|
||||
console.log(`[SessionActions] 请求打开新会话: ${connectionId}`);
|
||||
console.log(`[SessionActions] 请求打开新会话: ${connectionId}${existingSessionId ? `, 使用预定义 ID: ${existingSessionId}` : ''}`);
|
||||
const connInfo = findConnectionInfo(connectionId, connectionsStore);
|
||||
if (!connInfo) {
|
||||
console.error(`[SessionActions] 无法打开新会话:找不到 ID 为 ${connectionId} 的连接信息。`);
|
||||
@@ -37,7 +39,7 @@ export const openNewSession = (
|
||||
return;
|
||||
}
|
||||
|
||||
const newSessionId = generateSessionId();
|
||||
const newSessionId = existingSessionId || generateSessionId();
|
||||
const dbConnId = String(connInfo.id);
|
||||
|
||||
// 1. 创建管理器实例
|
||||
@@ -73,6 +75,7 @@ export const openNewSession = (
|
||||
editorTabs: ref([]),
|
||||
activeEditorTabId: ref(null),
|
||||
commandInputContent: ref(''),
|
||||
disposables: [], // 初始化 disposables 数组
|
||||
};
|
||||
|
||||
// 3. 添加到 Map 并激活
|
||||
@@ -82,6 +85,57 @@ export const openNewSession = (
|
||||
activeSessionId.value = newSessionId;
|
||||
console.log(`[SessionActions] 已创建新会话实例: ${newSessionId} for connection ${dbConnId}`);
|
||||
|
||||
// +++ 在连接前设置 ssh:connected 处理器以更新 sessionId +++
|
||||
const originalFrontendSessionIdForHandler = newSessionId; // 捕获初始ID给闭包
|
||||
|
||||
const unregisterConnectedHandler = wsManager.onMessage('ssh:connected', (connectedPayload: any) => {
|
||||
const backendSID = connectedPayload.sessionId as string;
|
||||
const backendCID = String(connectedPayload.connectionId);
|
||||
|
||||
console.log(`[SessionActions/ssh:connected] 收到消息。前端初始SID: ${originalFrontendSessionIdForHandler}, 后端SID: ${backendSID}, 后端CID: ${backendCID}`);
|
||||
|
||||
const sessionToUpdate = sessions.value.get(originalFrontendSessionIdForHandler);
|
||||
|
||||
if (sessionToUpdate) {
|
||||
if (sessionToUpdate.connectionId !== backendCID) {
|
||||
console.warn(`[SessionActions/ssh:connected] 后端CID ${backendCID} 与会话 ${originalFrontendSessionIdForHandler} 的期望CID ${sessionToUpdate.connectionId} 不匹配。中止SID更新。`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSID && backendSID !== originalFrontendSessionIdForHandler) {
|
||||
console.log(`[SessionActions/ssh:connected] 会话ID需要更新:从 ${originalFrontendSessionIdForHandler} 到 ${backendSID}。`);
|
||||
const currentSessions = new Map(sessions.value);
|
||||
currentSessions.delete(originalFrontendSessionIdForHandler);
|
||||
|
||||
sessionToUpdate.sessionId = backendSID; // 更新会话对象内部的sessionId
|
||||
|
||||
currentSessions.set(backendSID, sessionToUpdate);
|
||||
sessions.value = currentSessions;
|
||||
|
||||
if (activeSessionId.value === originalFrontendSessionIdForHandler) {
|
||||
activeSessionId.value = backendSID;
|
||||
console.log(`[SessionActions/ssh:connected] 活动会话ID已更新为 ${backendSID}。`);
|
||||
}
|
||||
console.log(`[SessionActions/ssh:connected] 会话存储已更新,新键为 ${backendSID}。`);
|
||||
} else if (backendSID === originalFrontendSessionIdForHandler) {
|
||||
console.log(`[SessionActions/ssh:connected] 后端SID ${backendSID} 与前端SID匹配。无需重新键控。`);
|
||||
} else {
|
||||
console.error(`[SessionActions/ssh:connected] 从后端收到的 ssh:connected 消息中缺少有效的sessionId。Payload:`, connectedPayload);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[SessionActions/ssh:connected] 当处理后端SID ${backendSID} 时,在存储中未找到对应的前端初始SID ${originalFrontendSessionIdForHandler} 的会话。`);
|
||||
}
|
||||
// 此处理器主要用于初始的 sessionId 同步,通常在第一次收到 ssh:connected 后就可以注销,
|
||||
// 以避免后续可能的意外重连消息再次触发此逻辑。
|
||||
// 但如果 backendID 保证在 ssh:connected 时才首次确定,则保留可能也无害。
|
||||
// 为简单起见,暂不在此处自动注销。注销将在 closeSession 中处理。
|
||||
});
|
||||
|
||||
if (newSession.disposables) {
|
||||
newSession.disposables.push(unregisterConnectedHandler);
|
||||
}
|
||||
|
||||
|
||||
// 4. 启动 WebSocket 连接
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsHostAndPort = window.location.host;
|
||||
@@ -89,6 +143,16 @@ export const openNewSession = (
|
||||
console.log(`[SessionActions] Generated WebSocket URL: ${wsUrl}`);
|
||||
wsManager.connect(wsUrl);
|
||||
console.log(`[SessionActions] 已为会话 ${newSessionId} 启动 WebSocket 连接。`);
|
||||
|
||||
// 注册 SSH 挂起相关的 WebSocket 消息处理器
|
||||
// 确保只对 SSH 类型的连接注册 (虽然 wsManager 本身不包含类型信息,但 openNewSession 通常只为 SSH 调用)
|
||||
// 如果 connInfo 存在且类型为 SSH,则注册
|
||||
if (connInfo && connInfo.type === 'SSH') {
|
||||
registerSshSuspendHandlers(wsManager);
|
||||
console.log(`[SessionActions] 已为 SSH 会话 ${newSessionId} 注册 SSH 挂起处理器。`);
|
||||
} else if (connInfo) {
|
||||
console.log(`[SessionActions] 会话 ${newSessionId} 类型为 ${connInfo.type},不注册 SSH 挂起处理器。`);
|
||||
}
|
||||
};
|
||||
|
||||
export const activateSession = (sessionId: string) => {
|
||||
@@ -121,6 +185,18 @@ export const closeSession = (sessionId: string) => {
|
||||
});
|
||||
sessionToClose.sftpManagers.clear();
|
||||
sessionToClose.terminalManager.cleanup();
|
||||
// 调用存储在会话中的所有清理函数
|
||||
if (sessionToClose.disposables && Array.isArray(sessionToClose.disposables)) {
|
||||
sessionToClose.disposables.forEach(dispose => {
|
||||
try {
|
||||
dispose();
|
||||
} catch (e) {
|
||||
console.error(`[SessionActions] 清理disposable时出错:`, e);
|
||||
}
|
||||
});
|
||||
sessionToClose.disposables = []; // 清空数组
|
||||
console.log(`[SessionActions] 已为会话 ${sessionId} 调用所有disposables。`);
|
||||
}
|
||||
console.log(`[SessionActions] 已为会话 ${sessionId} 调用 terminalManager.cleanup()`);
|
||||
sessionToClose.statusMonitorManager.cleanup();
|
||||
console.log(`[SessionActions] 已为会话 ${sessionId} 调用 statusMonitorManager.cleanup()`);
|
||||
@@ -197,7 +273,7 @@ export const handleOpenNewSession = (
|
||||
}
|
||||
) => {
|
||||
console.log(`[SessionActions] handleOpenNewSession called for ID: ${connectionId}`);
|
||||
openNewSession(connectionId, dependencies);
|
||||
openNewSession(connectionId, dependencies); // existingSessionId 将为 undefined,因此会生成新的
|
||||
};
|
||||
|
||||
export const cleanupAllSessions = () => {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
// packages/frontend/src/stores/session/actions/sshSuspendActions.ts
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { sessions, suspendedSshSessions, isLoadingSuspendedSessions, activeSessionId } from '../state';
|
||||
import type {
|
||||
MessagePayload, // 新增导入
|
||||
SshSuspendStartReqMessage,
|
||||
// SshSuspendListReqMessage, // 不再需要,因为 fetch 将通过 HTTP
|
||||
SshSuspendResumeReqMessage,
|
||||
SshSuspendTerminateReqMessage,
|
||||
SshSuspendRemoveEntryReqMessage,
|
||||
SshSuspendEditNameReqMessage,
|
||||
// S2C Payloads
|
||||
SshSuspendStartedRespPayload,
|
||||
SshSuspendListResponsePayload, // 仍然需要处理来自 WS 的列表更新推送(如果后端支持)
|
||||
SshSuspendResumedNotifPayload,
|
||||
SshOutputCachedChunkPayload,
|
||||
SshSuspendTerminatedRespPayload,
|
||||
SshSuspendEntryRemovedRespPayload,
|
||||
SshSuspendNameEditedRespPayload,
|
||||
SshSuspendAutoTerminatedNotifPayload,
|
||||
} from '../../../types/websocket.types'; // 路径: packages/frontend/src/types/websocket.types.ts
|
||||
import type { WsManagerInstance, SessionState } from '../types'; // 路径: packages/frontend/src/stores/session/types.ts
|
||||
import { closeSession as closeSessionAction, activateSession as activateSessionAction, openNewSession } from './sessionActions'; // 使用 openNewSession
|
||||
import { useConnectionsStore } from '../../connections.store'; // 用于获取连接信息
|
||||
import { useUiNotificationsStore } from '../../uiNotifications.store'; // 用于显示通知
|
||||
import type { SuspendedSshSession } from '../../../types/ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts
|
||||
import i18n from '../../../i18n'; // 直接导入 i18n 实例
|
||||
import type { ComposerTranslation } from 'vue-i18n'; // 导入 ComposerTranslation 类型
|
||||
import apiClient from '../../../utils/apiClient'; // +++ 新增:导入 apiClient +++
|
||||
|
||||
const t: ComposerTranslation = i18n.global.t; // 从全局 i18n 实例获取 t 函数并显式注解类型
|
||||
|
||||
// 辅助函数:获取一个可用的 WebSocket 管理器
|
||||
// 优先使用当前激活的会话,或者任意一个已连接的 SSH 会话
|
||||
// 注意:此函数主要用于那些仍然需要 WebSocket 的操作 (如 resume, terminate)
|
||||
const getActiveWsManager = (): WsManagerInstance | null => {
|
||||
console.log(`[getActiveWsManager] 尝试获取可用 WebSocket。当前 sessions 数量: ${sessions.value.size}`);
|
||||
sessions.value.forEach((session, sessionId) => {
|
||||
console.log(`[getActiveWsManager] - 会话 ID: ${sessionId}, WS Manager 存在: ${!!session.wsManager}, WS 已连接: ${session.wsManager?.isConnected?.value}`);
|
||||
});
|
||||
|
||||
const firstSessionKey = sessions.value.size > 0 ? sessions.value.keys().next().value : null;
|
||||
console.log(`[getActiveWsManager] 尝试使用第一个会话 Key (如果存在): ${firstSessionKey}`);
|
||||
|
||||
if (firstSessionKey) {
|
||||
const session = sessions.value.get(firstSessionKey);
|
||||
console.log(`[getActiveWsManager] 第一个会话 (ID: ${firstSessionKey}): WS Manager 存在: ${!!session?.wsManager}, WS 已连接: ${session?.wsManager?.isConnected?.value}`);
|
||||
if (session && session.wsManager && session.wsManager.isConnected.value) {
|
||||
console.log(`[getActiveWsManager] 使用第一个会话 (ID: ${firstSessionKey}) 的 WebSocket。`);
|
||||
return session.wsManager;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[getActiveWsManager] 第一个会话的 WebSocket 不可用或不存在,开始遍历所有会话...');
|
||||
for (const [sessionId, session] of sessions.value) {
|
||||
console.log(`[getActiveWsManager] 遍历中 - 检查会话 ID: ${sessionId}, WS Manager 存在: ${!!session.wsManager}, WS 已连接: ${session.wsManager?.isConnected?.value}`);
|
||||
if (session.wsManager && session.wsManager.isConnected.value) {
|
||||
console.log(`[getActiveWsManager] 遍历成功,使用会话 (ID: ${sessionId}) 的 WebSocket。`);
|
||||
return session.wsManager;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('[getActiveWsManager] 遍历结束,仍未找到可用的 WebSocket 连接来发送 SSH 挂起相关请求。');
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 请求启动 SSH 会话挂起
|
||||
* @param sessionId 要挂起的活动会话 ID
|
||||
*/
|
||||
export const requestStartSshSuspend = (sessionId: string): void => {
|
||||
const session = sessions.value.get(sessionId);
|
||||
if (session && session.wsManager) {
|
||||
if (!session.wsManager.isConnected.value) {
|
||||
console.warn(`[${t('term.sshSuspend')}] WebSocket 未连接,无法启动挂起模式 (会话 ID: ${sessionId})。`);
|
||||
// 可选:通知用户
|
||||
return;
|
||||
}
|
||||
const message: SshSuspendStartReqMessage = {
|
||||
type: 'SSH_SUSPEND_START',
|
||||
payload: { sessionId },
|
||||
};
|
||||
session.wsManager.sendMessage(message);
|
||||
console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_START_REQ (会话 ID: ${sessionId})`);
|
||||
} else {
|
||||
console.warn(`[${t('term.sshSuspend')}] 未找到会话或 WebSocket 管理器 (会话 ID: ${sessionId}),无法启动挂起。`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取挂起的 SSH 会话列表 (通过 HTTP API)
|
||||
*/
|
||||
export const fetchSuspendedSshSessions = async (): Promise<void> => {
|
||||
isLoadingSuspendedSessions.value = true;
|
||||
try {
|
||||
// 假设后端 API 端点为 /api/ssh/suspended-sessions
|
||||
// 并且它返回 SuspendedSshSession[] 类型的数据
|
||||
const response = await apiClient.get<SuspendedSshSession[]>('ssh-suspend/suspended-sessions');
|
||||
suspendedSshSessions.value = response.data;
|
||||
console.log(`[${t('term.sshSuspend')}] 已通过 HTTP 获取挂起列表,数量: ${response.data.length}`);
|
||||
} catch (error) {
|
||||
console.error(`[${t('term.sshSuspend')}] 通过 HTTP 获取挂起列表失败:`, error);
|
||||
// 可选:通知用户错误
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.fetchListError', { error: String(error) }),
|
||||
});
|
||||
// 即使失败,也可能需要清空旧数据或保留旧数据,具体取决于产品需求
|
||||
// suspendedSshSessions.value = []; // 例如,失败时清空
|
||||
} finally {
|
||||
isLoadingSuspendedSessions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求恢复指定的挂起 SSH 会话
|
||||
* @param suspendSessionId 要恢复的挂起会话的 ID
|
||||
*/
|
||||
export const resumeSshSession = (suspendSessionId: string): void => {
|
||||
const wsManager = getActiveWsManager();
|
||||
if (wsManager) {
|
||||
const newFrontendSessionId = uuidv4(); // 为恢复的会话生成新的前端 ID
|
||||
const message: SshSuspendResumeReqMessage = {
|
||||
type: 'SSH_SUSPEND_RESUME_REQUEST',
|
||||
payload: { suspendSessionId, newFrontendSessionId },
|
||||
};
|
||||
wsManager.sendMessage(message);
|
||||
console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_RESUME_REQ (挂起 ID: ${suspendSessionId}, 新前端 ID: ${newFrontendSessionId})`);
|
||||
} else {
|
||||
console.warn(`[${t('term.sshSuspend')}] 恢复会话失败 (挂起 ID: ${suspendSessionId}):无可用 WebSocket 连接。`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求终止并移除一个活跃的挂起 SSH 会话
|
||||
* @param suspendSessionId 要终止并移除的挂起会话 ID
|
||||
*/
|
||||
export const terminateAndRemoveSshSession = async (suspendSessionId: string): Promise<void> => {
|
||||
console.log(`[${t('term.sshSuspend')}] 请求通过 HTTP API 终止并移除挂起会话 (ID: ${suspendSessionId})`);
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
try {
|
||||
// 假设后端 API 返回成功时状态码为 200/204,失败时返回错误信息
|
||||
await apiClient.delete(`ssh-suspend/terminate/${suspendSessionId}`);
|
||||
console.log(`[${t('term.sshSuspend')}] HTTP API 终止并移除会话 ${suspendSessionId} 成功。`);
|
||||
|
||||
// 复用或直接实现 handleSshSuspendTerminatedResp 的逻辑
|
||||
const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === suspendSessionId);
|
||||
if (index !== -1) {
|
||||
const removedSession = suspendedSshSessions.value.splice(index, 1)[0];
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'info',
|
||||
message: t('sshSuspend.notifications.terminatedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }),
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[${t('term.sshSuspend')}] 通过 HTTP API 终止并移除会话 ${suspendSessionId} 失败:`, error);
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.terminateError', { error: error.response?.data?.message || error.message || t('term.unknownError') }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求移除一个已断开的挂起 SSH 会话条目
|
||||
* @param suspendSessionId 要移除的挂起会话条目 ID
|
||||
*/
|
||||
export const removeSshSessionEntry = async (suspendSessionId: string): Promise<void> => {
|
||||
console.log(`[${t('term.sshSuspend')}] 请求通过 HTTP API 移除已断开的挂起条目 (ID: ${suspendSessionId})`);
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
try {
|
||||
await apiClient.delete(`ssh-suspend/entry/${suspendSessionId}`);
|
||||
console.log(`[${t('term.sshSuspend')}] HTTP API 移除已断开条目 ${suspendSessionId} 成功。`);
|
||||
|
||||
// 复用或直接实现 handleSshSuspendEntryRemovedResp 的逻辑
|
||||
const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === suspendSessionId);
|
||||
if (index !== -1) {
|
||||
const removedSession = suspendedSshSessions.value.splice(index, 1)[0];
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'info',
|
||||
message: t('sshSuspend.notifications.entryRemovedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }),
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[${t('term.sshSuspend')}] 通过 HTTP API 移除已断开条目 ${suspendSessionId} 失败:`, error);
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.entryRemovedError', { error: error.response?.data?.message || error.message || t('term.unknownError') }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求编辑挂起 SSH 会话的自定义名称
|
||||
* @param suspendSessionId 要编辑的挂起会话 ID
|
||||
* @param customName 新的自定义名称
|
||||
*/
|
||||
export const editSshSessionName = (suspendSessionId: string, customName: string): void => {
|
||||
const wsManager = getActiveWsManager();
|
||||
if (wsManager) {
|
||||
const message: SshSuspendEditNameReqMessage = {
|
||||
type: 'SSH_SUSPEND_EDIT_NAME',
|
||||
payload: { suspendSessionId, customName },
|
||||
};
|
||||
wsManager.sendMessage(message);
|
||||
console.log(`[${t('term.sshSuspend')}] 已发送 SSH_SUSPEND_EDIT_NAME_REQ (挂起 ID: ${suspendSessionId}, 名称: "${customName}")`);
|
||||
} else {
|
||||
console.warn(`[${t('term.sshSuspend')}] 编辑挂起名称失败 (挂起 ID: ${suspendSessionId}):无可用 WebSocket 连接。`);
|
||||
}
|
||||
};
|
||||
|
||||
// --- S2C Message Handlers ---
|
||||
|
||||
const handleSshSuspendStartedResp = (payload: SshSuspendStartedRespPayload): void => {
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_STARTED_RESP:`, payload);
|
||||
if (payload.success) {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'success',
|
||||
message: t('sshSuspend.notifications.suspendStartedSuccess', { id: payload.suspendSessionId.slice(0, 8) }),
|
||||
});
|
||||
// 成功后关闭原会话标签页
|
||||
closeSessionAction(payload.frontendSessionId);
|
||||
// 刷新挂起列表 (可选,或者等待列表更新通知)
|
||||
fetchSuspendedSshSessions();
|
||||
} else {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.suspendStartedError', { error: payload.error || t('term.unknownError') }),
|
||||
});
|
||||
console.error(`[${t('term.sshSuspend')}] 挂起失败 (前端会话 ID: ${payload.frontendSessionId}): ${payload.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshSuspendListResponse = (payload: SshSuspendListResponsePayload): void => {
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_LIST_RESPONSE,数量: ${payload.suspendSessions.length}`);
|
||||
suspendedSshSessions.value = payload.suspendSessions;
|
||||
isLoadingSuspendedSessions.value = false;
|
||||
};
|
||||
|
||||
const handleSshSuspendResumedNotif = async (payload: SshSuspendResumedNotifPayload): Promise<void> => {
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_RESUMED_NOTIF:`, payload);
|
||||
|
||||
if (payload.success) {
|
||||
const suspendedSession = suspendedSshSessions.value.find(s => s.suspendSessionId === payload.suspendSessionId);
|
||||
if (!suspendedSession) {
|
||||
console.error(`[${t('term.sshSuspend')}] 找不到要恢复的挂起会话信息 (ID: ${payload.suspendSessionId})`);
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.resumeErrorInfoNotFound', { id: payload.suspendSessionId.slice(0, 8) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 从 connectionsStore 获取原始连接信息
|
||||
// 注意:这里假设 suspendedSession.originalConnectionInfo 存储了足够的信息,或者至少有 originalConnectionId
|
||||
const connectionToFindId = parseInt(suspendedSession.connectionId, 10);
|
||||
const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionToFindId);
|
||||
if (!connectionInfo) {
|
||||
console.error(`[${t('term.sshSuspend')}] 恢复会话失败:找不到原始连接配置 (ID: ${suspendedSession.connectionId})`);
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.resumeErrorConnectionConfigNotFound', { id: suspendedSession.connectionId }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用 openNewSession 创建会话
|
||||
openNewSession(
|
||||
connectionInfo.id, // connectionId
|
||||
{ connectionsStore, t }, // dependencies
|
||||
payload.newFrontendSessionId // existingSessionId
|
||||
);
|
||||
|
||||
// 获取新创建的会话
|
||||
const newSession = sessions.value.get(payload.newFrontendSessionId) as SessionState | undefined;
|
||||
|
||||
if (newSession && newSession.wsManager) {
|
||||
// 标记会话为正在恢复
|
||||
newSession.isResuming = true;
|
||||
// (可选) 如果需要存储原始挂起ID,可以在 SessionState 中添加 originalSuspendId 字段并在此设置
|
||||
// newSession.originalSuspendId = payload.suspendSessionId;
|
||||
|
||||
console.log(`[${t('term.sshSuspend')}] 为恢复的会话 (新前端 ID: ${payload.newFrontendSessionId}) 创建/复用了新的会话实例。`);
|
||||
// 激活新标签页
|
||||
activateSessionAction(payload.newFrontendSessionId);
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'success',
|
||||
message: t('sshSuspend.notifications.resumeSuccess', { name: suspendedSession.customSuspendName || suspendedSession.connectionName }),
|
||||
});
|
||||
// 后端会开始发送 SSH_OUTPUT_CACHED_CHUNK
|
||||
} else {
|
||||
throw new Error('通过 openNewSession 创建或获取新会话实例失败,或 WebSocket 管理器未初始化。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${t('term.sshSuspend')}] 处理会话恢复通知时出错:`, error);
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.resumeErrorGeneric', { error: String(error) }),
|
||||
});
|
||||
}
|
||||
// 成功恢复后,从挂起列表中移除 (或者等 SSH_SUSPEND_ENTRY_REMOVED_RESP)
|
||||
// fetchSuspendedSshSessions(); // 刷新列表
|
||||
} else {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.resumeErrorBackend', { error: payload.error || t('term.unknownError') }),
|
||||
});
|
||||
console.error(`[${t('term.sshSuspend')}] 恢复会话失败 (挂起 ID: ${payload.suspendSessionId}): ${payload.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshOutputCachedChunk = (payload: SshOutputCachedChunkPayload): void => {
|
||||
const session = sessions.value.get(payload.frontendSessionId) as SessionState | undefined;
|
||||
if (session && session.terminalManager && session.terminalManager.terminalInstance.value) { // 检查 terminalInstance.value
|
||||
// console.debug(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 接到 SSH_OUTPUT_CACHED_CHUNK, isLast: ${payload.isLastChunk}`);
|
||||
session.terminalManager.terminalInstance.value.write(payload.data); // 调用 terminalInstance.value.write
|
||||
if (payload.isLastChunk) {
|
||||
console.log(`[${t('term.sshSuspend')}] (会话: ${payload.frontendSessionId}) 已接收所有缓存输出。`);
|
||||
// 可选:在这里触发一个事件或状态,表明缓存输出已加载完毕
|
||||
// 例如,如果之前终端是只读/加载状态,现在可以解除
|
||||
if (session.isResuming === true) {
|
||||
session.isResuming = false;
|
||||
// 可能需要重新聚焦终端或进行其他 UI 更新
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`[${t('term.sshSuspend')}] 收到缓存数据块,但找不到对应会话、终端管理器或终端实例 (ID: ${payload.frontendSessionId})`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshSuspendTerminatedResp = (payload: SshSuspendTerminatedRespPayload): void => {
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_TERMINATED_RESP:`, payload);
|
||||
if (payload.success) {
|
||||
const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === payload.suspendSessionId);
|
||||
if (index !== -1) {
|
||||
const removedSession = suspendedSshSessions.value.splice(index, 1)[0];
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'info',
|
||||
message: t('sshSuspend.notifications.terminatedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.terminateError', { error: payload.error || t('term.unknownError') }),
|
||||
});
|
||||
console.error(`[${t('term.sshSuspend')}] 终止挂起会话失败 (ID: ${payload.suspendSessionId}): ${payload.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshSuspendEntryRemovedResp = (payload: SshSuspendEntryRemovedRespPayload): void => {
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_ENTRY_REMOVED_RESP:`, payload);
|
||||
if (payload.success) {
|
||||
const index = suspendedSshSessions.value.findIndex(s => s.suspendSessionId === payload.suspendSessionId);
|
||||
if (index !== -1) {
|
||||
const removedSession = suspendedSshSessions.value.splice(index, 1)[0];
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'info',
|
||||
message: t('sshSuspend.notifications.entryRemovedSuccess', { name: removedSession.customSuspendName || removedSession.connectionName }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.entryRemovedError', { error: payload.error || t('term.unknownError') }),
|
||||
});
|
||||
console.error(`[${t('term.sshSuspend')}] 移除挂起条目失败 (ID: ${payload.suspendSessionId}): ${payload.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshSuspendNameEditedResp = (payload: SshSuspendNameEditedRespPayload): void => {
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_NAME_EDITED_RESP:`, payload);
|
||||
if (payload.success && payload.customName !== undefined) {
|
||||
const session = suspendedSshSessions.value.find(s => s.suspendSessionId === payload.suspendSessionId);
|
||||
if (session) {
|
||||
session.customSuspendName = payload.customName;
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'success',
|
||||
message: t('sshSuspend.notifications.nameEditedSuccess', { name: payload.customName }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'error',
|
||||
message: t('sshSuspend.notifications.nameEditedError', { error: payload.error || t('term.unknownError') }),
|
||||
});
|
||||
console.error(`[${t('term.sshSuspend')}] 编辑挂起名称失败 (ID: ${payload.suspendSessionId}): ${payload.error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSshSuspendAutoTerminatedNotif = (payload: SshSuspendAutoTerminatedNotifPayload): void => {
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
console.log(`[${t('term.sshSuspend')}] 接到 SSH_SUSPEND_AUTO_TERMINATED_NOTIF:`, payload);
|
||||
const session = suspendedSshSessions.value.find(s => s.suspendSessionId === payload.suspendSessionId);
|
||||
if (session) {
|
||||
session.backendSshStatus = 'disconnected_by_backend'; // 使用正确的字段名
|
||||
session.disconnectionTimestamp = new Date().toISOString(); // 更新为 ISO 字符串
|
||||
// 可以在 SuspendedSshSession 类型中添加 disconnectionReason 字段
|
||||
// session.disconnectionReason = payload.reason;
|
||||
uiNotificationsStore.addNotification({
|
||||
type: 'warning',
|
||||
message: t('sshSuspend.notifications.autoTerminated', { name: session.customSuspendName || session.connectionName, reason: payload.reason }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册 SSH 挂起相关的 WebSocket 消息处理器。
|
||||
* 此函数应在 WebSocket 连接建立后,针对每个会话的 wsManager 实例调用。
|
||||
* @param wsManager 与特定 SSH 会话关联的 WebSocket 管理器实例
|
||||
*/
|
||||
export const registerSshSuspendHandlers = (wsManager: WsManagerInstance): void => {
|
||||
console.log(`[${t('term.sshSuspend')}] 尝试为 WebSocket 管理器注册 SSH 挂起处理器...`);
|
||||
|
||||
if (!wsManager) {
|
||||
console.error(`[${t('term.sshSuspend')}] 注册处理器失败:wsManager 未定义。`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 注意:wsManager.onMessage 返回一个注销函数,如果需要,可以收集它们并在会话关闭时调用。
|
||||
// 但通常这些处理器会随 wsManager 实例的生命周期一起存在。
|
||||
wsManager.onMessage('SSH_SUSPEND_STARTED_RESP', (p: MessagePayload) => handleSshSuspendStartedResp(p as SshSuspendStartedRespPayload));
|
||||
wsManager.onMessage('SSH_SUSPEND_LIST_RESPONSE', (p: MessagePayload) => handleSshSuspendListResponse(p as SshSuspendListResponsePayload));
|
||||
wsManager.onMessage('SSH_SUSPEND_RESUMED_NOTIF', (p: MessagePayload) => handleSshSuspendResumedNotif(p as SshSuspendResumedNotifPayload));
|
||||
wsManager.onMessage('SSH_OUTPUT_CACHED_CHUNK', (p: MessagePayload) => handleSshOutputCachedChunk(p as SshOutputCachedChunkPayload));
|
||||
wsManager.onMessage('SSH_SUSPEND_TERMINATED_RESP', (p: MessagePayload) => handleSshSuspendTerminatedResp(p as SshSuspendTerminatedRespPayload));
|
||||
wsManager.onMessage('SSH_SUSPEND_ENTRY_REMOVED_RESP', (p: MessagePayload) => handleSshSuspendEntryRemovedResp(p as SshSuspendEntryRemovedRespPayload));
|
||||
wsManager.onMessage('SSH_SUSPEND_NAME_EDITED_RESP', (p: MessagePayload) => handleSshSuspendNameEditedResp(p as SshSuspendNameEditedRespPayload));
|
||||
wsManager.onMessage('SSH_SUSPEND_AUTO_TERMINATED_NOTIF', (p: MessagePayload) => handleSshSuspendAutoTerminatedNotif(p as SshSuspendAutoTerminatedNotifPayload));
|
||||
|
||||
console.log(`[${t('term.sshSuspend')}] SSH 挂起模式的 WebSocket 消息处理器已注册。`);
|
||||
|
||||
// 连接建立后,主动获取一次挂起列表
|
||||
// 考虑:是否应该在这里做,或者在应用启动时做一次?
|
||||
// 如果 wsManager 是针对某个具体会话的,那么每个会话连接时都获取列表可能不是最优。
|
||||
// 更好的地方可能是在 App.vue 或主会话 store 初始化时,通过一个“全局”的 wsManager (如果存在) 或其中一个 wsManager 获取。
|
||||
// 但如果挂起列表只通过当前连接的 ws 通道获取,那这里是合适的。
|
||||
// 假设 getActiveWsManager 能取到这个 wsManager 实例,那 actions.ts 里的 fetchSuspendedSshSessions() 会用它
|
||||
// 这里直接调用 fetchSuspendedSshSessions() 也可以
|
||||
fetchSuspendedSshSessions();
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { ref, shallowRef } from 'vue';
|
||||
import type { SessionState } from './types';
|
||||
// 修正导入路径
|
||||
import type { ConnectionInfo } from '../connections.store'; // 路径: packages/frontend/src/stores/connections.store.ts
|
||||
import type { SuspendedSshSession } from '../../types/ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts
|
||||
|
||||
// 使用 shallowRef 避免深度响应性问题,保留管理器实例内部的响应性
|
||||
export const sessions = shallowRef<Map<string, SessionState>>(new Map());
|
||||
@@ -15,4 +16,8 @@ export const rdpConnectionInfo = ref<ConnectionInfo | null>(null);
|
||||
|
||||
// --- VNC Modal State ---
|
||||
export const isVncModalOpen = ref(false);
|
||||
export const vncConnectionInfo = ref<ConnectionInfo | null>(null);
|
||||
export const vncConnectionInfo = ref<ConnectionInfo | null>(null);
|
||||
|
||||
// --- SSH Suspend Mode State ---
|
||||
export const suspendedSshSessions = ref<SuspendedSshSession[]>([]);
|
||||
export const isLoadingSuspendedSessions = ref<boolean>(false);
|
||||
@@ -41,6 +41,8 @@ export interface SessionState {
|
||||
activeEditorTabId: Ref<string | null>; // 当前活动的编辑器标签页 ID
|
||||
// --- 新增:命令输入框内容 ---
|
||||
commandInputContent: Ref<string>; // 当前会话的命令输入框内容
|
||||
isResuming?: boolean; // 新增:标记会话是否正在从挂起状态恢复
|
||||
disposables?: (() => void)[]; // 新增:用于存储清理函数,例如取消注册消息处理器
|
||||
}
|
||||
|
||||
// 为标签栏定义包含状态的类型
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 表示从后端获取的单个挂起 SSH 会话的详细信息。
|
||||
*/
|
||||
export interface SuspendedSshSession {
|
||||
/** 挂起会话的唯一ID。 */
|
||||
suspendSessionId: string;
|
||||
/** 原始连接的名称,通常是主机名或用户定义的连接别名。 */
|
||||
connectionName: string;
|
||||
/** 原始连接的ID。 */
|
||||
connectionId: string;
|
||||
/** 会话挂起的开始时间,ISO 格式的日期字符串。 */
|
||||
suspendStartTime: string;
|
||||
/** 用户为该挂起会话自定义的名称。 */
|
||||
customSuspendName?: string;
|
||||
/**
|
||||
* 后端 SSH 连接的当前状态。
|
||||
* - 'hanging': SSH 连接仍在后端保持活跃。
|
||||
* - 'disconnected_by_backend': SSH 连接已从后端意外断开。
|
||||
*/
|
||||
backendSshStatus: 'hanging' | 'disconnected_by_backend';
|
||||
/**
|
||||
* 如果连接已从后端断开 (backendSshStatus === 'disconnected_by_backend'),
|
||||
* 则此字段表示断开连接的时间戳,ISO 格式的日期字符串。
|
||||
*/
|
||||
disconnectionTimestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSH_SUSPEND_LIST_RESPONSE 消息的载荷结构。
|
||||
*/
|
||||
export interface SshSuspendListResponsePayload {
|
||||
suspendSessions: SuspendedSshSession[];
|
||||
}
|
||||
@@ -15,3 +15,173 @@ export interface WebSocketMessage {
|
||||
|
||||
// 消息处理器函数类型
|
||||
export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void; // 恢复 message 参数为必需
|
||||
|
||||
// --- SSH Suspend Mode WebSocket Message Types ---
|
||||
|
||||
// 导入挂起会话类型,用于相关消息的 payload
|
||||
import type { SuspendedSshSession } from './ssh-suspend.types'; // 路径: packages/frontend/src/types/ssh-suspend.types.ts
|
||||
|
||||
// --- Client to Server (C2S) Message Payloads ---
|
||||
export interface SshSuspendStartReqPayload {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendResumeReqPayload {
|
||||
suspendSessionId: string;
|
||||
newFrontendSessionId: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendTerminateReqPayload {
|
||||
suspendSessionId: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendRemoveEntryReqPayload {
|
||||
suspendSessionId: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendEditNameReqPayload {
|
||||
suspendSessionId: string;
|
||||
customName: string;
|
||||
}
|
||||
|
||||
// --- Server to Client (S2C) Message Payloads ---
|
||||
export interface SshSuspendStartedRespPayload {
|
||||
frontendSessionId: string;
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendListResponsePayload {
|
||||
suspendSessions: SuspendedSshSession[];
|
||||
}
|
||||
|
||||
export interface SshSuspendResumedNotifPayload {
|
||||
suspendSessionId: string;
|
||||
newFrontendSessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SshOutputCachedChunkPayload {
|
||||
frontendSessionId: string;
|
||||
data: string;
|
||||
isLastChunk: boolean;
|
||||
}
|
||||
|
||||
export interface SshSuspendTerminatedRespPayload {
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendEntryRemovedRespPayload {
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendNameEditedRespPayload {
|
||||
suspendSessionId: string;
|
||||
success: boolean;
|
||||
customName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SshSuspendAutoTerminatedNotifPayload {
|
||||
suspendSessionId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// --- Specific C2S Message Interfaces ---
|
||||
export interface SshSuspendStartReqMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_START';
|
||||
payload: SshSuspendStartReqPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendListReqMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_LIST_REQUEST';
|
||||
payload?: {}; // 明确 payload 可以为空对象
|
||||
}
|
||||
|
||||
export interface SshSuspendResumeReqMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_RESUME_REQUEST';
|
||||
payload: SshSuspendResumeReqPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendTerminateReqMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_TERMINATE_REQUEST';
|
||||
payload: SshSuspendTerminateReqPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendRemoveEntryReqMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_REMOVE_ENTRY';
|
||||
payload: SshSuspendRemoveEntryReqPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendEditNameReqMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_EDIT_NAME';
|
||||
payload: SshSuspendEditNameReqPayload;
|
||||
}
|
||||
|
||||
// --- Specific S2C Message Interfaces ---
|
||||
export interface SshSuspendStartedRespMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_STARTED';
|
||||
payload: SshSuspendStartedRespPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendListResponseMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_LIST_RESPONSE';
|
||||
payload: SshSuspendListResponsePayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendResumedNotifMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_RESUMED';
|
||||
payload: SshSuspendResumedNotifPayload;
|
||||
}
|
||||
|
||||
export interface SshOutputCachedChunkMessage extends WebSocketMessage {
|
||||
type: 'SSH_OUTPUT_CACHED_CHUNK';
|
||||
payload: SshOutputCachedChunkPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendTerminatedRespMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_TERMINATED';
|
||||
payload: SshSuspendTerminatedRespPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendEntryRemovedRespMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_ENTRY_REMOVED';
|
||||
payload: SshSuspendEntryRemovedRespPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendNameEditedRespMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_NAME_EDITED';
|
||||
payload: SshSuspendNameEditedRespPayload;
|
||||
}
|
||||
|
||||
export interface SshSuspendAutoTerminatedNotifMessage extends WebSocketMessage {
|
||||
type: 'SSH_SUSPEND_AUTO_TERMINATED';
|
||||
payload: SshSuspendAutoTerminatedNotifPayload;
|
||||
}
|
||||
|
||||
// Union type for all SSH Suspend related messages (optional, but can be useful)
|
||||
export type SshSuspendC2SMessage =
|
||||
| SshSuspendStartReqMessage
|
||||
| SshSuspendListReqMessage
|
||||
| SshSuspendResumeReqMessage
|
||||
| SshSuspendTerminateReqMessage
|
||||
| SshSuspendRemoveEntryReqMessage
|
||||
| SshSuspendEditNameReqMessage;
|
||||
|
||||
export type SshSuspendS2CMessage =
|
||||
| SshSuspendStartedRespMessage
|
||||
| SshSuspendListResponseMessage
|
||||
| SshSuspendResumedNotifMessage
|
||||
| SshOutputCachedChunkMessage
|
||||
| SshSuspendTerminatedRespMessage
|
||||
| SshSuspendEntryRemovedRespMessage
|
||||
| SshSuspendNameEditedRespMessage
|
||||
| SshSuspendAutoTerminatedNotifMessage;
|
||||
|
||||
export type AllSshSuspendMessages = SshSuspendC2SMessage | SshSuspendS2CMessage;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="suspended-ssh-sessions-view p-2 flex flex-col h-full">
|
||||
<div class="view-header mb-2">
|
||||
<div class="relative w-full">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-text-secondary"></i>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchTerm"
|
||||
:placeholder="$t('suspendedSshSessions.searchPlaceholder')"
|
||||
class="w-full pl-10 pr-4 py-2 border border-border rounded-md bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
|
||||
@input="filterSessions"
|
||||
/>
|
||||
</div>
|
||||
<!-- 可选:显示挂起会话总数 -->
|
||||
<!-- <div class="text-sm text-gray-500 mt-1">
|
||||
当前挂起会话总数: {{ filteredSessions.length }} / {{ allSuspendedSshSessions.length }}
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div class="session-list-container flex-grow overflow-y-auto">
|
||||
<div v-if="isLoading" class="text-center p-4">
|
||||
<i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
|
||||
<p>{{ $t('suspendedSshSessions.loading') }}</p>
|
||||
</div>
|
||||
<div v-else-if="filteredSessions.length === 0 && !isLoading" class="text-center p-4">
|
||||
<p>{{ $t('suspendedSshSessions.noResults') }}</p>
|
||||
</div>
|
||||
<ul v-else class="list-none p-0 m-0">
|
||||
<li
|
||||
v-for="session in filteredSessions"
|
||||
:key="session.suspendSessionId"
|
||||
class="session-item p-3 mb-2 border rounded-md bg-surface-ground"
|
||||
:class="{ 'opacity-60': session.backendSshStatus === 'disconnected_by_backend' }"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="session-info flex-grow mr-2">
|
||||
<div class="font-bold text-lg">
|
||||
<span
|
||||
v-if="!session.isEditingName"
|
||||
class="cursor-pointer hover:text-primary"
|
||||
:title="$t('suspendedSshSessions.tooltip.editName')"
|
||||
@click="startEditingName(session)"
|
||||
>
|
||||
{{ session.customSuspendName || session.connectionName }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
v-model="session.editingNameValue"
|
||||
type="text"
|
||||
class="text-lg font-bold w-full px-1 py-0.5 border border-primary rounded-md bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autofocus
|
||||
@blur="finishEditingName(session)"
|
||||
@keydown.enter.prevent="finishEditingName(session)"
|
||||
@keydown.esc.prevent="cancelEditingName(session)"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-sm text-muted-color">
|
||||
{{ $t('suspendedSshSessions.label.originalConnection') }}: {{ session.connectionName }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-color mt-1">
|
||||
{{ $t('suspendedSshSessions.label.suspendedAt') }}: {{ formatDateTime(session.suspendStartTime) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="session.backendSshStatus === 'disconnected_by_backend' && session.disconnectionTimestamp"
|
||||
class="text-xs text-orange-500 mt-1"
|
||||
>
|
||||
{{ $t('suspendedSshSessions.disconnectedAt', { time: formatDateTime(session.disconnectionTimestamp) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-status-actions flex flex-col items-end space-y-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-1 text-xs font-semibold rounded-full',
|
||||
session.backendSshStatus === 'hanging' ? 'bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100'
|
||||
]"
|
||||
>
|
||||
{{ session.backendSshStatus === 'hanging' ? $t('suspendedSshSessions.status.hanging') : $t('suspendedSshSessions.status.disconnected') }}
|
||||
</span>
|
||||
<div class="actions flex space-x-2">
|
||||
<button
|
||||
v-if="session.backendSshStatus === 'hanging'"
|
||||
@click="resumeSession(session)"
|
||||
:title="$t('suspendedSshSessions.action.resume')"
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-150 inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-play mr-1.5"></i>
|
||||
{{ $t('suspendedSshSessions.action.resume') }}
|
||||
</button>
|
||||
<button
|
||||
@click="removeSession(session)"
|
||||
:title="$t('suspendedSshSessions.action.remove')"
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-150 inline-flex items-center"
|
||||
>
|
||||
<i class="fas fa-trash-alt mr-1.5"></i>
|
||||
{{ $t('suspendedSshSessions.action.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia'; // +++ 导入 storeToRefs +++
|
||||
// PrimeVue components (InputText, Button, Tag) are assumed to be globally registered
|
||||
// based on the structure of other views like QuickCommandsView.vue
|
||||
// and the nature of the 'Cannot find module' errors which might indicate
|
||||
// they are not meant to be imported directly here if globally available.
|
||||
|
||||
// 假设 sessionStore 存在并且有以下类型和方法
|
||||
import { useSessionStore } from '../stores/session.store'; // 使用真实的 store
|
||||
import type { SuspendedSshSession } from '../types/ssh-suspend.types'; // 确保 SuspendedSshSession 类型从正确的位置导入
|
||||
|
||||
const { t } = useI18n();
|
||||
// 模拟类型,实际应从 ssh-suspend.types.ts 导入 (保持这个类型扩展)
|
||||
interface SuspendedSshSessionUIData extends SuspendedSshSession {
|
||||
isEditingName?: boolean;
|
||||
editingNameValue?: string;
|
||||
}
|
||||
|
||||
|
||||
// // 模拟 sessionStore (注释掉)
|
||||
// const mockSessionStore = {
|
||||
// suspendedSshSessions: ref<SuspendedSshSessionUIData[]>([
|
||||
// // ... mock data ...
|
||||
// ]),
|
||||
// fetchSuspendedSshSessions: async () => {
|
||||
// console.log('[SuspendedSshSessionsView] Requesting suspended SSH sessions...');
|
||||
// // 模拟 API 调用延迟
|
||||
// return new Promise(resolve => setTimeout(() => {
|
||||
// mockSessionStore.suspendedSshSessions.value = [
|
||||
// // ... mock data ...
|
||||
// ];
|
||||
// isLoading.value = false;
|
||||
// console.log('[SuspendedSshSessionsView] Mock sessions loaded:', mockSessionStore.suspendedSshSessions.value);
|
||||
// resolve(true);
|
||||
// }, 1500));
|
||||
// },
|
||||
// resumeSshSession: async (suspendSessionId: string, newFrontendSessionId: string) => {
|
||||
// console.log(`[SuspendedSshSessionsView] Action: resumeSshSession(${suspendSessionId}, ${newFrontendSessionId})`);
|
||||
// alert(`模拟恢复会话: ${suspendSessionId}`);
|
||||
// },
|
||||
// terminateAndRemoveSshSession: async (suspendSessionId: string) => {
|
||||
// console.log(`[SuspendedSshSessionsView] Action: terminateAndRemoveSshSession(${suspendSessionId})`);
|
||||
// mockSessionStore.suspendedSshSessions.value = mockSessionStore.suspendedSshSessions.value.filter(s => s.suspendSessionId !== suspendSessionId);
|
||||
// alert(`模拟终止并移除会话: ${suspendSessionId}`);
|
||||
// },
|
||||
// removeSshSessionEntry: async (suspendSessionId: string) => {
|
||||
// console.log(`[SuspendedSshSessionsView] Action: removeSshSessionEntry(${suspendSessionId})`);
|
||||
// mockSessionStore.suspendedSshSessions.value = mockSessionStore.suspendedSshSessions.value.filter(s => s.suspendSessionId !== suspendSessionId);
|
||||
// alert(`模拟移除已断开会话条目: ${suspendSessionId}`);
|
||||
// },
|
||||
// editSshSessionName: async (suspendSessionId: string, newName: string) => {
|
||||
// console.log(`[SuspendedSshSessionsView] Action: editSshSessionName(${suspendSessionId}, ${newName})`);
|
||||
// const session = mockSessionStore.suspendedSshSessions.value.find(s => s.suspendSessionId === suspendSessionId);
|
||||
// if (session) {
|
||||
// session.customSuspendName = newName;
|
||||
// }
|
||||
// alert(`模拟编辑名称: ${suspendSessionId} -> ${newName}`);
|
||||
// },
|
||||
// };
|
||||
const sessionStore = useSessionStore(); // 使用真实的 store
|
||||
// const sessionStore = mockSessionStore; // 使用模拟 store (注释掉)
|
||||
|
||||
// +++ 使用 storeToRefs 获取响应式状态,并将 isLoadingSuspendedSessions 重命名为 isLoading +++
|
||||
const { suspendedSshSessions: storeSuspendedSshSessions, isLoadingSuspendedSessions: isLoading } = storeToRefs(sessionStore);
|
||||
|
||||
const searchTerm = ref('');
|
||||
// const isLoading = ref(true); // 现在从 store 的 isLoading 获取
|
||||
|
||||
const allSuspendedSshSessions = computed(() => storeSuspendedSshSessions.value.map((s: SuspendedSshSession) => ({ // 显式为 s 添加类型
|
||||
...(s as SuspendedSshSessionUIData), // 断言为包含 UI 状态的类型
|
||||
isEditingName: (s as SuspendedSshSessionUIData).isEditingName ?? false,
|
||||
editingNameValue: (s as SuspendedSshSessionUIData).editingNameValue ?? s.customSuspendName ?? s.connectionName,
|
||||
})));
|
||||
|
||||
const filteredSessions = computed(() => {
|
||||
if (!searchTerm.value.trim()) {
|
||||
return allSuspendedSshSessions.value; // allSuspendedSshSessions 已经是 .value 之后的结果
|
||||
}
|
||||
const lowerSearchTerm = searchTerm.value.toLowerCase();
|
||||
return allSuspendedSshSessions.value.filter((session: SuspendedSshSessionUIData) => // 为 session 添加类型
|
||||
(session.customSuspendName?.toLowerCase() || '').includes(lowerSearchTerm) ||
|
||||
session.connectionName.toLowerCase().includes(lowerSearchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
const filterSessions = () => {
|
||||
// 计算属性会自动处理过滤
|
||||
};
|
||||
|
||||
const formatDateTime = (isoString?: string) => {
|
||||
if (!isoString) return t('time.unknown');
|
||||
try {
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
return t('time.invalidDate');
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingName = (session: SuspendedSshSessionUIData) => {
|
||||
// 确保同一时间只有一个会话处于编辑状态(可选优化)
|
||||
allSuspendedSshSessions.value.forEach((s: SuspendedSshSessionUIData) => s.isEditingName = false); // 为 s 添加类型
|
||||
session.isEditingName = true;
|
||||
session.editingNameValue = session.customSuspendName || session.connectionName;
|
||||
};
|
||||
|
||||
const finishEditingName = (session: SuspendedSshSessionUIData) => {
|
||||
if (!session.isEditingName) return;
|
||||
session.isEditingName = false;
|
||||
const newName = session.editingNameValue?.trim();
|
||||
// 仅当名称有变化且不为空时才提交
|
||||
if (newName && newName !== (session.customSuspendName || session.connectionName)) {
|
||||
sessionStore.editSshSessionName(session.suspendSessionId, newName);
|
||||
} else {
|
||||
// 如果名称未变或变为空,则恢复显示原始值或之前的自定义名
|
||||
session.editingNameValue = session.customSuspendName || session.connectionName;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEditingName = (session: SuspendedSshSessionUIData) => {
|
||||
session.isEditingName = false;
|
||||
session.editingNameValue = session.customSuspendName || session.connectionName; // 恢复原值
|
||||
};
|
||||
|
||||
|
||||
const resumeSession = (session: SuspendedSshSessionUIData) => {
|
||||
// 实际应用中,newFrontendSessionId 可能需要由 sessionStore 或其他服务生成
|
||||
// const newFrontendSessionId = `new-session-${Date.now()}`; // newFrontendSessionId 由 action 内部生成
|
||||
sessionStore.resumeSshSession(session.suspendSessionId); // +++ 只传递 suspendSessionId +++
|
||||
};
|
||||
|
||||
const removeSession = (session: SuspendedSshSessionUIData) => {
|
||||
if (session.backendSshStatus === 'hanging') {
|
||||
sessionStore.terminateAndRemoveSshSession(session.suspendSessionId);
|
||||
} else if (session.backendSshStatus === 'disconnected_by_backend') {
|
||||
sessionStore.removeSshSessionEntry(session.suspendSessionId);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// isLoading.value = true; // storeIsLoading 会自动更新
|
||||
await sessionStore.fetchSuspendedSshSessions();
|
||||
// isLoading.value = false; // fetchSuspendedSshSessions 内部应更新 storeIsLoading
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.suspended-ssh-sessions-view {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
.session-item {
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
.session-item:hover {
|
||||
background-color: var(--surface-hover); /* PrimeVue hover color */
|
||||
}
|
||||
|
||||
/* 保持与 QuickCommandsView 类似的简洁风格 */
|
||||
.p-inputtext-sm {
|
||||
padding: 0.375rem 0.5rem; /* 调整输入框大小 */
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user