This commit is contained in:
Baobhan Sith
2025-04-15 23:16:00 +08:00
parent 6ee18743ad
commit 1a6ea421e6
16 changed files with 1435 additions and 915 deletions
@@ -1,5 +1,5 @@
import { ref, reactive, nextTick, onUnmounted, type Ref } from 'vue';
import { useWebSocketConnection } from './useWebSocketConnection'; // 导入 hook
import { ref, reactive, nextTick, onUnmounted, readonly, type Ref } from 'vue'; // 再次修正导入,移除大写 Readonly
import { createWebSocketConnectionManager } from './useWebSocketConnection'; // 导入工厂函数
import { useI18n } from 'vue-i18n';
import type { FileListItem } from '../types/sftp.types'; // 从 sftp 类型文件导入
import type { UploadItem } from '../types/upload.types'; // 从 upload 类型文件导入
@@ -20,14 +20,16 @@ const joinPath = (base: string, name: string): string => {
return `${base}/${name}`;
};
export function useFileUploader(
currentPathRef: Ref<string>,
fileListRef: Ref<Readonly<FileListItem[]>>, // 传入 fileList 用于检查覆盖
refreshDirectory: () => void // 上传成功后刷新目录的回调函数
fileListRef: Readonly<Ref<readonly FileListItem[]>>, // 使用 Readonly 类型
refreshDirectory: () => void, // 上传成功后刷新目录的回调函数
sessionId: string,
dbConnectionId: string
) {
const { t } = useI18n();
const { sendMessage, onMessage, isConnected } = useWebSocketConnection();
// 使用工厂函数创建WebSocket连接管理器,并传入t函数
const { sendMessage, onMessage, isConnected } = createWebSocketConnectionManager(sessionId, dbConnectionId, t);
// 对 uploads 字典使用 reactive 以获得更好的深度响应性
const uploads = reactive<Record<string, UploadItem>>({});
@@ -128,8 +130,8 @@ export function useFileUploader(
const remotePath = joinPath(currentPathRef.value, file.name);
// 使用传入的 fileListRef 检查是否覆盖
// 为 item 添加显式类型 FileListItem
if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) {
// fileListRef.value 现在是 readonly FileListItem[]
if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) { // 添加 item 类型注解
if (!confirm(t('fileManager.prompts.confirmOverwrite', { name: file.name }))) {
console.log(`[文件上传模块] 用户取消了 ${file.name} 的上传`);
return; // 用户取消覆盖
@@ -1,43 +1,68 @@
import { ref, readonly, type Ref, onUnmounted } from 'vue';
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook
import { useI18n } from 'vue-i18n';
// 确保从类型文件导入所有需要的类型
import type { FileListItem, FileAttributes, EditorFileContent } from '../types/sftp.types';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
import { ref, readonly, type Ref, type ComputedRef } from 'vue'; // Removed onUnmounted, added ComputedRef
import type { FileListItem, EditorFileContent } from '../types/sftp.types';
import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types'; // 从类型文件导入
// --- 接口定义 (已移至 sftp.types.ts) ---
/**
* @interface WebSocketDependencies
* @description Defines the necessary functions and state required from a WebSocket manager instance.
*/
export interface WebSocketDependencies {
sendMessage: (message: WebSocketMessage) => void;
onMessage: (type: string, handler: MessageHandler) => () => void;
isConnected: ComputedRef<boolean>;
isSftpReady: Readonly<Ref<boolean>>;
}
// Helper function (Copied from FileManager.vue)
const generateRequestId = (): string => {
return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
// Helper function
const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Helper function (Copied from FileManager.vue)
// Helper function
const joinPath = (base: string, name: string): string => {
if (base === '/') return `/${name}`;
// Handle cases where base might end with '/' already
if (base.endsWith('/')) return `${base}${name}`;
return `${base}/${name}`;
return base.endsWith('/') ? `${base}${name}` : `${base}/${name}`;
};
// Helper function (Copied from FileManager.vue)
// Helper function
const sortFiles = (a: FileListItem, b: FileListItem): number => {
if (a.attrs.isDirectory && !b.attrs.isDirectory) return -1;
if (!a.attrs.isDirectory && b.attrs.isDirectory) return 1;
return a.filename.localeCompare(b.filename);
};
export function useSftpActions(currentPathRef: Ref<string>) {
const { t } = useI18n();
// Import isSftpReady along with other needed functions/state
const { sendMessage, onMessage, isConnected, isSftpReady } = useWebSocketConnection();
/**
* 创建并管理单个 SFTP 会话的操作。
* 每个实例对应一个会话 (Session) 并依赖于一个 WebSocket 管理器实例。
*
* @param {string} sessionId - 此 SFTP 管理器关联的会话 ID (用于日志记录)。
* @param {Ref<string>} currentPathRef - 一个外部 ref,用于跟踪和更新当前目录路径。
* @param {WebSocketDependencies} wsDeps - 从对应的 WebSocket 管理器实例注入的依赖项。
* @param {Function} t - i18n 翻译函数,从父组件传入
* @returns 一个包含状态、方法和清理函数的 SFTP 操作管理器对象。
*/
export function createSftpActionsManager(
sessionId: string,
currentPathRef: Ref<string>,
wsDeps: WebSocketDependencies,
t: Function
) {
const { sendMessage, onMessage, isConnected, isSftpReady } = wsDeps; // 使用注入的依赖
const fileList = ref<FileListItem[]>([]);
const isLoading = ref<boolean>(false);
const error = ref<string | null>(null);
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
// Function to clear the error state
// 用于存储注销函数的数组
const unregisterCallbacks: (() => void)[] = [];
// 清理函数,用于注销所有消息处理器
const cleanup = () => {
console.log(`[SFTP ${instanceSessionId}] Cleaning up message handlers.`);
unregisterCallbacks.forEach(cb => cb());
unregisterCallbacks.length = 0; // 清空数组
};
// 清除错误状态的函数
const clearSftpError = () => {
error.value = null;
};
@@ -45,42 +70,37 @@ export function useSftpActions(currentPathRef: Ref<string>) {
// --- Action Methods ---
const loadDirectory = (path: string) => {
// Check if SFTP is ready first
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady'); // Use a specific error message
error.value = t('fileManager.errors.sftpNotReady');
isLoading.value = false;
fileList.value = []; // Clear list if not ready
console.warn(`[useSftpActions] Attempted to load directory ${path} but SFTP is not ready.`);
fileList.value = [];
console.warn(`[SFTP ${instanceSessionId}] Attempted to load directory ${path} but SFTP is not ready.`);
return;
}
// Original isConnected check might still be relevant as a fallback, but isSftpReady implies isConnected
// if (!isConnected.value) { ... } // Can likely be removed if isSftpReady logic is robust
console.log(`[useSftpActions] Loading directory: ${path}`);
console.log(`[SFTP ${instanceSessionId}] Loading directory: ${path}`);
isLoading.value = true;
error.value = null;
currentPathRef.value = path; // Update the external ref passed in
currentPathRef.value = path; // 更新外部 ref
const requestId = generateRequestId();
sendMessage({ type: 'sftp:readdir', requestId: requestId, payload: { path } });
// Response handled by onSftpReaddirSuccess/Error
};
const createDirectory = (newDirName: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to create directory ${newDirName} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to create directory ${newDirName} but SFTP is not ready.`);
return;
}
const newFolderPath = joinPath(currentPathRef.value, newDirName);
const requestId = generateRequestId();
sendMessage({ type: 'sftp:mkdir', requestId: requestId, payload: { path: newFolderPath } });
// Response handled by onSftpMkdirSuccess/Error
};
const createFile = (newFileName: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to create file ${newFileName} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to create file ${newFileName} but SFTP is not ready.`);
return;
}
const newFilePath = joinPath(currentPathRef.value, newFileName);
@@ -88,15 +108,14 @@ export function useSftpActions(currentPathRef: Ref<string>) {
sendMessage({
type: 'sftp:writefile',
requestId: requestId,
payload: { path: newFilePath, content: '', encoding: 'utf8' } // Create by writing empty content
payload: { path: newFilePath, content: '', encoding: 'utf8' }
});
// Response handled by onSftpWriteFileSuccess/Error (will trigger refresh)
};
const deleteItems = (items: FileListItem[]) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to delete items but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to delete items but SFTP is not ready.`);
return;
}
if (items.length === 0) return;
@@ -106,13 +125,12 @@ export function useSftpActions(currentPathRef: Ref<string>) {
const requestId = generateRequestId();
sendMessage({ type: actionType, requestId: requestId, payload: { path: targetPath } });
});
// Responses handled by onSftpRmdirSuccess/Error, onSftpUnlinkSuccess/Error (will trigger refresh)
};
const renameItem = (item: FileListItem, newName: string) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to rename item ${item.filename} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to rename item ${item.filename} but SFTP is not ready.`);
return;
}
if (!newName || item.filename === newName) return;
@@ -120,82 +138,96 @@ export function useSftpActions(currentPathRef: Ref<string>) {
const newPath = joinPath(currentPathRef.value, newName);
const requestId = generateRequestId();
sendMessage({ type: 'sftp:rename', requestId: requestId, payload: { oldPath, newPath } });
// Response handled by onSftpRenameSuccess/Error (will trigger refresh)
};
const changePermissions = (item: FileListItem, mode: number) => {
if (!isSftpReady.value) {
error.value = t('fileManager.errors.sftpNotReady');
console.warn(`[useSftpActions] Attempted to change permissions for ${item.filename} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to change permissions for ${item.filename} but SFTP is not ready.`);
return;
}
const targetPath = joinPath(currentPathRef.value, item.filename);
const requestId = generateRequestId();
sendMessage({ type: 'sftp:chmod', requestId: requestId, payload: { path: targetPath, mode: mode } });
// Response handled by onSftpChmodSuccess/Error (will trigger refresh)
};
// 注意: readFile 和 writeFile 的核心逻辑将由 useFileEditor 管理,
// 但 useSftpActions 可以提供基础的发送/接收机制(如果其他地方需要),
// 或者 useFileEditor 可以直接调用 sendMessage。暂时保留这些方法在这里。
const readFile = (path: string): Promise<EditorFileContent> => { // 使用导入的 EditorFileContent 类型
// readFile 和 writeFile 仍然返回 Promise,并在内部处理自己的消息监听器注销
const readFile = (path: string): Promise<EditorFileContent> => {
return new Promise((resolve, reject) => {
if (!isSftpReady.value) {
console.warn(`[useSftpActions] Attempted to read file ${path} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to read file ${path} but SFTP is not ready.`);
return reject(new Error(t('fileManager.errors.sftpNotReady')));
}
const requestId = generateRequestId();
let unregisterSuccess: (() => void) | null = null;
let unregisterError: (() => void) | null = null;
const unregisterSuccess = onMessage('sftp:readfile:success', (payload, message) => {
const timeoutId = setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.readFileTimeout')));
}, 20000); // 20 秒超时
unregisterSuccess = onMessage('sftp:readfile:success', (payload: MessagePayload, message: WebSocketMessage) => {
// 确保 payload 是期望的类型
const successPayload = payload as { content: string; encoding: 'utf8' | 'base64' };
if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
resolve({ content: payload.content, encoding: payload.encoding });
resolve({ content: successPayload.content, encoding: successPayload.encoding });
}
});
const unregisterError = onMessage('sftp:readfile:error', (payload, message) => {
unregisterError = onMessage('sftp:readfile:error', (payload: MessagePayload, message: WebSocketMessage) => {
// 确保 payload 是期望的类型 (string)
const errorPayload = payload as string;
if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
reject(new Error(payload || 'Failed to read file'));
reject(new Error(errorPayload || 'Failed to read file'));
}
});
sendMessage({ type: 'sftp:readfile', requestId: requestId, payload: { path } });
// Timeout for the request
setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.readFileTimeout')));
}, 20000); // 20 second timeout
});
};
const writeFile = (path: string, content: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (!isSftpReady.value) {
console.warn(`[useSftpActions] Attempted to write file ${path} but SFTP is not ready.`);
console.warn(`[SFTP ${instanceSessionId}] Attempted to write file ${path} but SFTP is not ready.`);
return reject(new Error(t('fileManager.errors.sftpNotReady')));
}
const requestId = generateRequestId();
const encoding: 'utf8' | 'base64' = 'utf8'; // Assuming always sending utf8
const encoding: 'utf8' | 'base64' = 'utf8'; // 假设总是 utf8
let unregisterSuccess: (() => void) | null = null;
let unregisterError: (() => void) | null = null;
const unregisterSuccess = onMessage('sftp:writefile:success', (payload, message) => {
const timeoutId = setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.saveTimeout')));
}, 20000); // 20 秒超时
unregisterSuccess = onMessage('sftp:writefile:success', (payload: MessagePayload, message: WebSocketMessage) => {
if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
resolve();
}
});
const unregisterError = onMessage('sftp:writefile:error', (payload, message) => {
unregisterError = onMessage('sftp:writefile:error', (payload: MessagePayload, message: WebSocketMessage) => {
// 确保 payload 是期望的类型 (string)
const errorPayload = payload as string;
if (message.requestId === requestId && message.path === path) {
clearTimeout(timeoutId);
unregisterSuccess?.();
unregisterError?.();
reject(new Error(payload || 'Failed to write file'));
reject(new Error(errorPayload || 'Failed to write file'));
}
});
@@ -204,112 +236,80 @@ export function useSftpActions(currentPathRef: Ref<string>) {
requestId: requestId,
payload: { path, content, encoding }
});
// Timeout for the request
setTimeout(() => {
unregisterSuccess?.();
unregisterError?.();
reject(new Error(t('fileManager.errors.saveTimeout')));
}, 20000); // 20 second timeout
});
};
// --- Message Handlers ---
const onSftpReaddirSuccess = (payload: FileListItem[], message: WebSocketMessage) => {
// Only update if the path matches the current path this composable instance is tracking
const onSftpReaddirSuccess = (payload: MessagePayload, message: WebSocketMessage) => {
// 类型断言,因为我们知道 readdir:success 的 payload 是 FileListItem[]
const fileListPayload = payload as FileListItem[];
if (message.path === currentPathRef.value) {
console.log(`[useSftpActions] Received file list for ${message.path}`);
fileList.value = payload.sort(sortFiles);
console.log(`[SFTP ${instanceSessionId}] Received file list for ${message.path}`);
fileList.value = fileListPayload.sort(sortFiles);
isLoading.value = false;
error.value = null;
} else {
console.log(`[useSftpActions] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`);
console.log(`[SFTP ${instanceSessionId}] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`);
}
};
const onSftpReaddirError = (payload: string, message: WebSocketMessage) => {
const onSftpReaddirError = (payload: MessagePayload, message: WebSocketMessage) => {
// 类型断言,因为我们知道 readdir:error 的 payload 是 string
const errorPayload = payload as string;
if (message.path === currentPathRef.value) {
console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload);
error.value = payload; // Set the error message
console.error(`[SFTP ${instanceSessionId}] Error loading directory ${message.path}:`, errorPayload);
error.value = errorPayload;
isLoading.value = false;
// Do NOT clear fileList.value here, keep the previous list visible
}
};
// Generic handler for actions that should trigger a refresh on success
const onActionSuccessRefresh = (payload: MessagePayload, message: WebSocketMessage) => {
// Simplify: Always refresh the current directory on any relevant success action.
// This avoids potential issues with path comparison logic.
console.log(`[useSftpActions] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`);
loadDirectory(currentPathRef.value); // Refresh the current directory
error.value = null; // Clear previous errors on success
console.log(`[SFTP ${instanceSessionId}] Action ${message.type} successful. Refreshing current directory: ${currentPathRef.value}`);
loadDirectory(currentPathRef.value);
error.value = null;
};
// Generic handler for action errors
const onActionError = (payload: string, message: WebSocketMessage) => {
console.error(`[useSftpActions] Action ${message.type} failed:`, payload);
// Display a generic error or use specific messages based on type
const onActionError = (payload: MessagePayload, message: WebSocketMessage) => {
// 类型断言,因为我们知道这些错误的 payload 是 string
const errorPayload = payload as string;
console.error(`[SFTP ${instanceSessionId}] Action ${message.type} failed:`, errorPayload);
const actionTypeMap: Record<string, string> = {
'sftp:mkdir:error': t('fileManager.errors.createFolderFailed'),
'sftp:rmdir:error': t('fileManager.errors.deleteFailed'),
'sftp:unlink:error': t('fileManager.errors.deleteFailed'),
'sftp:rename:error': t('fileManager.errors.renameFailed'),
'sftp:chmod:error': t('fileManager.errors.chmodFailed'),
'sftp:writefile:error': t('fileManager.errors.saveFailed'), // Added writefile error
'sftp:writefile:error': t('fileManager.errors.saveFailed'),
};
const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic');
error.value = `${prefix}: ${payload}`;
// Optionally stop loading indicator if one was active for this action
error.value = `${prefix}: ${errorPayload}`;
};
// --- Register Handlers ---
const unregisterReaddirSuccess = onMessage('sftp:readdir:success', onSftpReaddirSuccess);
const unregisterReaddirError = onMessage('sftp:readdir:error', onSftpReaddirError);
// --- Register Handlers & Store Unregister Callbacks ---
unregisterCallbacks.push(onMessage('sftp:readdir:success', onSftpReaddirSuccess));
unregisterCallbacks.push(onMessage('sftp:readdir:error', onSftpReaddirError));
unregisterCallbacks.push(onMessage('sftp:mkdir:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:rmdir:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:unlink:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:rename:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:chmod:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:writefile:success', onActionSuccessRefresh));
unregisterCallbacks.push(onMessage('sftp:mkdir:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:rmdir:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:unlink:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:rename:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:chmod:error', onActionError));
unregisterCallbacks.push(onMessage('sftp:writefile:error', onActionError));
// Register generic handlers for actions that trigger refresh on success
const unregisterMkdirSuccess = onMessage('sftp:mkdir:success', onActionSuccessRefresh);
const unregisterRmdirSuccess = onMessage('sftp:rmdir:success', onActionSuccessRefresh);
const unregisterUnlinkSuccess = onMessage('sftp:unlink:success', onActionSuccessRefresh);
const unregisterRenameSuccess = onMessage('sftp:rename:success', onActionSuccessRefresh);
const unregisterChmodSuccess = onMessage('sftp:chmod:success', onActionSuccessRefresh);
const unregisterWritefileSuccess = onMessage('sftp:writefile:success', onActionSuccessRefresh); // Refresh on successful write too
// Register generic error handlers
const unregisterMkdirError = onMessage('sftp:mkdir:error', onActionError);
const unregisterRmdirError = onMessage('sftp:rmdir:error', onActionError);
const unregisterUnlinkError = onMessage('sftp:unlink:error', onActionError);
const unregisterRenameError = onMessage('sftp:rename:error', onActionError);
const unregisterChmodError = onMessage('sftp:chmod:error', onActionError);
const unregisterWritefileError = onMessage('sftp:writefile:error', onActionError); // Handle writefile error display
// Unregister handlers when the composable's scope is destroyed
onUnmounted(() => {
console.log('[useSftpActions] Unmounting and unregistering handlers.');
unregisterReaddirSuccess?.();
unregisterReaddirError?.();
unregisterMkdirSuccess?.();
unregisterRmdirSuccess?.();
unregisterUnlinkSuccess?.();
unregisterRenameSuccess?.();
unregisterChmodSuccess?.();
unregisterWritefileSuccess?.();
unregisterMkdirError?.();
unregisterRmdirError?.();
unregisterUnlinkError?.();
unregisterRenameError?.();
unregisterChmodError?.();
unregisterWritefileError?.();
// Note: readFile/writeFile promise handlers are unregistered within the promise logic
});
// 移除 onUnmounted 块
return {
// State
fileList: readonly(fileList),
isLoading: readonly(isLoading),
error: readonly(error),
// currentPath: readonly(currentPath), // Path is managed via the passed ref
// Methods
loadDirectory,
@@ -318,9 +318,12 @@ export function useSftpActions(currentPathRef: Ref<string>) {
deleteItems,
renameItem,
changePermissions,
readFile, // Expose if needed by editor composable
writeFile, // Expose if needed by editor composable
joinPath, // Expose helper if needed externally
clearSftpError, // Expose the clear error function
readFile,
writeFile,
joinPath, // 暴露辅助函数
clearSftpError,
// Cleanup function
cleanup,
};
}
@@ -1,12 +1,25 @@
import { ref, onUnmounted, type Ref } from 'vue';
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook 本身
import { useI18n } from 'vue-i18n';
import { ref, readonly, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
import type { Terminal } from 'xterm';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
export function useSshTerminal() {
const { t } = useI18n();
const { sendMessage, onMessage, isConnected } = useWebSocketConnection();
// 定义与 WebSocket 相关的依赖接口
export interface SshTerminalDependencies {
sendMessage: (message: WebSocketMessage) => void;
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
isConnected: ComputedRef<boolean>;
}
/**
* 创建一个 SSH 终端管理器实例
* @param sessionId 会话唯一标识符
* @param wsDeps WebSocket 依赖对象
* @param t i18n 翻译函数,从父组件传入
* @returns SSH 终端管理器实例
*/
export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalDependencies, t: Function) {
// 使用依赖注入的 WebSocket 函数
const { sendMessage, onMessage, isConnected } = wsDeps;
const terminalInstance = ref<Terminal | null>(null);
const terminalOutputBuffer = ref<string[]>([]); // 缓冲 WebSocket 消息直到终端准备好
@@ -22,7 +35,7 @@ export function useSshTerminal() {
// --- 终端事件处理 ---
const handleTerminalReady = (term: Terminal) => {
console.log('[SSH终端模块] 终端实例已就绪。');
console.log(`[会话 ${sessionId}][SSH终端模块] 终端实例已就绪。`);
terminalInstance.value = term;
// 将缓冲区的输出写入终端
terminalOutputBuffer.value.forEach(data => term.write(data));
@@ -32,31 +45,36 @@ export function useSshTerminal() {
};
const handleTerminalData = (data: string) => {
// console.debug('[SSH终端模块] 接收到终端输入:', data);
sendMessage({ type: 'ssh:input', payload: { data } });
// console.debug(`[会话 ${sessionId}][SSH终端模块] 接收到终端输入:`, data);
sendMessage({ type: 'ssh:input', sessionId, payload: { data } });
};
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
console.log('[SSH终端模块] 发送终端大小调整:', dimensions);
sendMessage({ type: 'ssh:resize', payload: dimensions });
console.log(`[会话 ${sessionId}][SSH终端模块] 发送终端大小调整:`, dimensions);
sendMessage({ type: 'ssh:resize', sessionId, payload: dimensions });
};
// --- WebSocket 消息处理 ---
const handleSshOutput = (payload: MessagePayload, message: WebSocketMessage) => {
const handleSshOutput = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
let outputData = payload;
// 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段)
if (message.encoding === 'base64' && typeof outputData === 'string') {
if (message?.encoding === 'base64' && typeof outputData === 'string') {
try {
outputData = atob(outputData); // 在浏览器环境中使用 atob
} catch (e) {
console.error('[SSH终端模块] Base64 解码失败:', e, '原始数据:', message.payload);
console.error(`[会话 ${sessionId}][SSH终端模块] Base64 解码失败:`, e, '原始数据:', message.payload);
outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误
}
}
// 如果不是 base64 或解码失败,确保它是字符串
else if (typeof outputData !== 'string') {
console.warn('[SSH终端模块] 收到非字符串 ssh:output payload:', outputData);
console.warn(`[会话 ${sessionId}][SSH终端模块] 收到非字符串 ssh:output payload:`, outputData);
try {
outputData = JSON.stringify(outputData); // 尝试序列化
} catch {
@@ -64,6 +82,19 @@ export function useSshTerminal() {
}
}
// 尝试过滤掉非标准的 OSC 184 序列
// 注意:这个正则表达式可能需要根据实际序列进行调整
// 它尝试匹配 \x1b]184;... 直到 \x1b\\ 或 \x07
const osc184Regex = /\x1b]184;[^\x1b\x07]*(\x1b\\|\x07)/g;
if (typeof outputData === 'string') {
const originalLength = outputData.length;
outputData = outputData.replace(osc184Regex, '');
if (outputData.length < originalLength) {
console.warn(`[会话 ${sessionId}][SSH终端模块] 过滤掉 OSC 184 序列。`);
}
}
if (terminalInstance.value) {
terminalInstance.value.write(outputData);
} else {
@@ -72,50 +103,80 @@ export function useSshTerminal() {
}
};
const handleSshConnected = () => {
console.log('[SSH终端模块] SSH 会话已连接。');
const handleSshConnected = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已连接。`);
// 连接成功后聚焦终端
terminalInstance.value?.focus();
// 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了)
if (terminalOutputBuffer.value.length > 0) {
console.warn('[SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...');
console.warn(`[会话 ${sessionId}][SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...`);
terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data));
terminalOutputBuffer.value = [];
}
};
const handleSshDisconnected = (payload: MessagePayload) => {
const handleSshDisconnected = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本
console.log('[SSH终端模块] SSH 会话已断开:', reason);
console.log(`[会话 ${sessionId}][SSH终端模块] SSH 会话已断开:`, reason);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`);
// 可以在这里添加其他清理逻辑,例如禁用输入
};
const handleSshError = (payload: MessagePayload) => {
const handleSshError = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n
console.error('[SSH终端模块] SSH 错误:', errorMsg);
console.error(`[会话 ${sessionId}][SSH终端模块] SSH 错误:`, errorMsg);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`);
};
const handleSshStatus = (payload: MessagePayload) => {
const handleSshStatus = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
// 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息
// 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要)
const statusKey = payload?.key || 'unknown';
const statusParams = payload?.params || {};
console.log('[SSH终端模块] 收到 SSH 状态更新:', statusKey, statusParams);
console.log(`[会话 ${sessionId}][SSH终端模块] 收到 SSH 状态更新:`, statusKey, statusParams);
// 可以在终端打印一些状态信息吗?
// terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`);
};
const handleInfoMessage = (payload: MessagePayload) => {
console.log('[SSH终端模块] 收到后端信息:', payload);
const handleInfoMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
console.log(`[会话 ${sessionId}][SSH终端模块] 收到后端信息:`, payload);
terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${payload}\x1b[0m`);
};
const handleErrorMessage = (payload: MessagePayload) => {
const handleErrorMessage = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
// 通用错误也可能需要显示在终端
const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n
console.error('[SSH终端模块] 收到后端通用错误:', errorMsg);
console.error(`[会话 ${sessionId}][SSH终端模块] 收到后端通用错误:`, errorMsg);
terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`);
};
@@ -131,30 +192,62 @@ export function useSshTerminal() {
unregisterHandlers.push(onMessage('ssh:status', handleSshStatus));
unregisterHandlers.push(onMessage('info', handleInfoMessage));
unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误
console.log('[SSH终端模块] 已注册 SSH 相关消息处理器。');
console.log(`[会话 ${sessionId}][SSH终端模块] 已注册 SSH 相关消息处理器。`);
};
const unregisterAllSshHandlers = () => {
console.log('[SSH终端模块] 注销 SSH 相关消息处理器...');
console.log(`[会话 ${sessionId}][SSH终端模块] 注销 SSH 相关消息处理器...`);
unregisterHandlers.forEach(unregister => unregister?.());
unregisterHandlers.length = 0; // 清空数组
};
// --- 清理 ---
onUnmounted(() => {
// 初始化时自动注册处理程序
registerSshHandlers();
// --- 清理函数 ---
const cleanup = () => {
unregisterAllSshHandlers();
// terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责
terminalInstance.value = null;
console.log('[SSH终端模块] Composable 已卸载。');
});
console.log(`[会话 ${sessionId}][SSH终端模块] 已清理。`);
};
// --- 暴露给组件的接口 ---
// 返回工厂实例
return {
terminalInstance, // 暴露终端实例 ref,以便组件可以访问(如果需要)
// 公共接口
handleTerminalReady,
handleTerminalData,
handleTerminalResize,
registerSshHandlers, // 暴露注册函数,由父组件在连接后调用
unregisterAllSshHandlers, // 暴露注销函数,在断开或卸载时调用
cleanup
};
}
// 保留兼容旧代码的函数(将在完全迁移后移除)
export function useSshTerminal(t: (key: string) => string) {
console.warn('⚠️ 使用已弃用的 useSshTerminal() 全局单例。请迁移到 createSshTerminalManager() 工厂函数。');
const terminalInstance = ref<Terminal | null>(null);
const handleTerminalReady = (term: Terminal) => {
console.log('[SSH终端模块][旧] 终端实例已就绪,但使用了已弃用的单例模式。');
terminalInstance.value = term;
};
const handleTerminalData = (data: string) => {
console.warn('[SSH终端模块][旧] 收到终端数据,但使用了已弃用的单例模式,无法发送。');
};
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
console.warn('[SSH终端模块][旧] 收到终端大小调整,但使用了已弃用的单例模式,无法发送。');
};
// 返回与旧接口兼容的空函数,以避免错误
return {
terminalInstance,
handleTerminalReady,
handleTerminalData,
handleTerminalResize,
registerSshHandlers: () => console.warn('[SSH终端模块][旧] 调用了已弃用的 registerSshHandlers'),
unregisterAllSshHandlers: () => console.warn('[SSH终端模块][旧] 调用了已弃用的 unregisterAllSshHandlers'),
};
}
@@ -1,34 +1,54 @@
import { ref, readonly, onUnmounted } from 'vue';
import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook
import type { ServerStatus } from '../types/server.types'; // 从类型文件导入
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入
import { ref, readonly, watch, type Ref, ComputedRef } from 'vue'; // 修正导入,移除大写 Readonly, 添加 watch
// import { useWebSocketConnection } from './useWebSocketConnection'; // 移除全局导入
import type { ServerStatus } from '../types/server.types';
import type { WebSocketMessage, MessagePayload } from '../types/websocket.types';
// --- 接口定义 (已移至 server.types.ts) ---
// 定义与 WebSocket 相关的依赖接口
export interface StatusMonitorDependencies {
onMessage: (type: string, handler: (payload: any, fullMessage?: WebSocketMessage) => void) => () => void;
isConnected: ComputedRef<boolean>;
}
export function useStatusMonitor() {
const { onMessage, isConnected } = useWebSocketConnection();
/**
* 创建一个状态监控管理器实例
* @param sessionId 会话唯一标识符
* @param wsDeps WebSocket 依赖对象
* @returns 状态监控管理器实例
*/
export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMonitorDependencies) {
const { onMessage, isConnected } = wsDeps;
const serverStatus = ref<ServerStatus | null>(null);
const statusError = ref<string | null>(null); // 存储状态获取错误
// --- WebSocket 消息处理 ---
const handleStatusUpdate = (payload: MessagePayload, message: WebSocketMessage) => {
// console.debug('[状态监控模块] 收到 status_update:', payload);
const handleStatusUpdate = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
// console.debug(`[会话 ${sessionId}][状态监控模块] 收到 status_update:`, JSON.stringify(payload)); // 添加日志
if (payload && payload.status) {
serverStatus.value = payload.status;
statusError.value = null; // 收到有效状态时清除错误
} else {
console.warn('[状态监控模块] 收到缺少 payload.status 的 status_update 消息');
console.warn(`[会话 ${sessionId}][状态监控模块] 收到缺少 payload.status 的 status_update 消息`);
// 可以选择设置一个错误状态,表明数据格式不正确
// statusError.value = '收到的状态数据格式无效';
}
};
// 处理可能的后端状态错误消息 (如果后端会发送的话)
const handleStatusError = (payload: MessagePayload, message: WebSocketMessage) => {
console.error('[状态监控模块] 收到状态错误消息:', payload);
statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误';
serverStatus.value = null; // 出错时清除状态数据
const handleStatusError = (payload: MessagePayload, message?: WebSocketMessage) => {
// 检查消息是否属于此会话
if (message?.sessionId && message.sessionId !== sessionId) {
return; // 忽略不属于此会话的消息
}
console.error(`[会话 ${sessionId}][状态监控模块] 收到状态错误消息:`, payload);
statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误';
serverStatus.value = null; // 出错时清除状态数据
};
// --- 注册 WebSocket 消息处理器 ---
@@ -36,36 +56,81 @@ export function useStatusMonitor() {
let unregisterError: (() => void) | null = null;
const registerStatusHandlers = () => {
// 仅在连接时注册处理器
// 防止重复注册
if (unregisterUpdate || unregisterError) {
console.log(`[会话 ${sessionId}][状态监控模块] 处理器已注册,跳过。`);
return;
}
if (isConnected.value) {
console.log('[状态监控模块] 注册状态消息处理器。');
console.log(`[会话 ${sessionId}][状态监控模块] 注册状态消息处理器。`);
unregisterUpdate = onMessage('status_update', handleStatusUpdate);
// 假设后端可能发送 'status:error' 类型的特定错误
unregisterError = onMessage('status:error', handleStatusError);
} else {
console.warn('[状态监控模块] WebSocket 未连接,无法注册状态处理器。');
console.warn(`[会话 ${sessionId}][状态监控模块] WebSocket 未连接,无法注册状态处理器。`);
}
};
const unregisterAllStatusHandlers = () => {
console.log('[状态监控模块] 注销状态消息处理器。');
unregisterUpdate?.();
unregisterError?.();
unregisterUpdate = null;
unregisterError = null;
if (unregisterUpdate || unregisterError) {
console.log(`[会话 ${sessionId}][状态监控模块] 注销状态消息处理器。`);
unregisterUpdate?.();
unregisterError?.();
unregisterUpdate = null;
unregisterError = null;
}
};
// --- 清理 ---
onUnmounted(() => {
// 监听连接状态变化以自动注册/注销处理器
watch(isConnected, (newValue, oldValue) => {
console.log(`[会话 ${sessionId}][状态监控模块] 连接状态变化: ${oldValue} -> ${newValue}`);
if (newValue) {
registerStatusHandlers();
// 连接成功后,可以考虑请求一次初始状态(如果后端支持)
// sendMessage({ type: 'status:get', sessionId });
} else {
unregisterAllStatusHandlers();
// 连接断开时清除状态
serverStatus.value = null;
statusError.value = '连接已断开'; // 或者使用 i18n
}
}, { immediate: true }); // immediate: true 确保初始状态下也会执行一次
// --- 清理函数 ---
const cleanup = () => {
unregisterAllStatusHandlers();
console.log('[状态监控模块] Composable 已卸载。');
});
console.log(`[会话 ${sessionId}][状态监控模块] 已清理。`);
};
// --- 暴露接口 ---
return {
serverStatus: readonly(serverStatus), // 只读状态
statusError: readonly(statusError), // 只读错误状态
registerStatusHandlers, // 暴露注册函数
unregisterAllStatusHandlers, // 暴露注销函数
registerStatusHandlers, // 暴露注册函数,以便在需要时可以重新注册
unregisterAllStatusHandlers, // 暴露注销函数,以便在需要时可以手动注销
cleanup, // 暴露清理函数,在会话关闭时调用
};
}
// 保留兼容旧代码的函数(将在完全迁移后移除)
export function useStatusMonitor() {
console.warn('⚠️ 使用已弃用的 useStatusMonitor() 全局单例。请迁移到 createStatusMonitorManager() 工厂函数。');
const serverStatus = ref<ServerStatus | null>(null);
const statusError = ref<string | null>(null);
const registerStatusHandlers = () => {
console.warn('[状态监控模块][旧] 调用了已弃用的 registerStatusHandlers');
};
const unregisterAllStatusHandlers = () => {
console.warn('[状态监控模块][旧] 调用了已弃用的 unregisterAllStatusHandlers');
};
// 返回与旧接口兼容的空对象,以避免错误
return {
serverStatus: readonly(serverStatus),
statusError: readonly(statusError),
registerStatusHandlers,
unregisterAllStatusHandlers,
};
}
@@ -1,215 +1,223 @@
import { ref, shallowRef, onUnmounted, computed, type Ref, readonly } from 'vue';
import { useI18n } from 'vue-i18n';
// 从类型文件导入 WebSocket 相关类型
import { ref, shallowRef, computed, readonly } from 'vue';
import type { ConnectionStatus, MessagePayload, WebSocketMessage, MessageHandler } from '../types/websocket.types';
// --- 类型定义 (已移至 websocket.types.ts) ---
// export type ConnectionStatus = ...;
// export type MessagePayload = ...;
// export interface WebSocketMessage { ... }
// export type MessageHandler = ...;
/**
* 创建并管理单个 WebSocket 连接实例。
* 每个实例对应一个会话 (Session)。
*
* @param {string} sessionId - 此 WebSocket 连接关联的会话 ID (用于日志记录)。
* @param {string} dbConnectionId - 此 WebSocket 连接关联的数据库连接 ID (用于后端识别)。
* @param {Function} t - i18n 翻译函数,从父组件传入
* @returns 一个包含状态和方法的 WebSocket 连接管理器对象。
*/
export function createWebSocketConnectionManager(sessionId: string, dbConnectionId: string, t: Function) {
// --- Instance State ---
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
const ws = shallowRef<WebSocket | null>(null); // WebSocket 实例
const connectionStatus = ref<ConnectionStatus>('disconnected'); // 连接状态
const statusMessage = ref<string>(''); // 状态描述文本
const isSftpReady = ref<boolean>(false); // SFTP 是否就绪
const messageHandlers = new Map<string, Set<MessageHandler>>(); // 此实例的消息处理器注册表
const instanceSessionId = sessionId; // 保存会话 ID 用于日志
const instanceDbConnectionId = dbConnectionId; // 保存数据库连接 ID
// --- End Instance State ---
// --- Singleton State within the module scope ---
// This ensures only one WebSocket connection and state is managed across the app.
const ws = shallowRef<WebSocket | null>(null); // Use shallowRef for the WebSocket object itself
const connectionStatus = ref<ConnectionStatus>('disconnected');
const statusMessage = ref<string>('');
const connectionIdForSession = ref<string | null>(null); // Store the connectionId used for the current session
const isSftpReady = ref<boolean>(false); // Track SFTP readiness
// Registry for message handlers
const messageHandlers = new Map<string, Set<MessageHandler>>();
// --- End Singleton State ---
export function useWebSocketConnection() {
const { t } = useI18n(); // Get t function for status messages
// Helper to get status text safely
/**
* 安全地获取状态文本的辅助函数
* @param {string} statusKey - i18n 键名 (例如 'connectingWs')
* @param {Record<string, unknown>} [params] - i18n 插值参数
* @returns {string} 翻译后的文本或键名本身 (如果翻译失败)
*/
const getStatusText = (statusKey: string, params?: Record<string, unknown>): string => {
try {
// Use a fallback key or message if translation is missing
const translated = t(`workspace.status.${statusKey}`, params || {});
// Check if the key itself was returned (indicating missing translation)
return translated === `workspace.status.${statusKey}` ? statusKey : translated;
} catch (e) {
console.warn(`[i18n] Error getting translation for workspace.status.${statusKey}:`, e);
return statusKey; // Fallback to the key itself
console.warn(`[WebSocket ${instanceSessionId}] i18n 错误 (键: workspace.status.${statusKey}):`, e);
return statusKey;
}
};
// Function to dispatch a message to all registered handlers for its type
/**
* 将收到的消息分发给已注册的处理器
* @param {string} type - 消息类型
* @param {MessagePayload} payload - 消息负载
* @param {WebSocketMessage} fullMessage - 完整的消息对象
*/
const dispatchMessage = (type: string, payload: MessagePayload, fullMessage: WebSocketMessage) => {
if (messageHandlers.has(type)) {
messageHandlers.get(type)?.forEach(handler => {
try {
handler(payload, fullMessage);
} catch (e) {
console.error(`[WebSocket] Error in message handler for type "${type}":`, e);
console.error(`[WebSocket ${instanceSessionId}] 消息处理器错误 (类型: "${type}"):`, e);
}
});
}
};
const connect = (url: string, connId: string) => {
// Prevent multiple connections or connection attempts
/**
* 建立 WebSocket 连接
* @param {string} url - WebSocket 服务器 URL
*/
const connect = (url: string) => {
// 防止重复连接同一实例
if (ws.value && (ws.value.readyState === WebSocket.OPEN || ws.value.readyState === WebSocket.CONNECTING)) {
// If it's the same connection ID and already open/connecting, do nothing
if (connectionIdForSession.value === connId) {
console.warn(`[WebSocket] Connection for ${connId} already open or connecting.`);
return;
}
// If different connection ID, close the old one first
console.log(`[WebSocket] Closing existing connection for ${connectionIdForSession.value} before connecting to ${connId}`);
disconnect(); // Ensure cleanup before new connection
console.warn(`[WebSocket ${instanceSessionId}] 连接已打开或正在连接中。`);
return;
}
console.log(`[WebSocket] Attempting to connect to: ${url} for connection ${connId}`);
connectionIdForSession.value = connId;
console.log(`[WebSocket ${instanceSessionId}] 尝试连接到: ${url} (DB Conn ID: ${instanceDbConnectionId})`);
statusMessage.value = getStatusText('connectingWs', { url });
connectionStatus.value = 'connecting';
isSftpReady.value = false; // 重置 SFTP 状态
try {
ws.value = new WebSocket(url);
ws.value.onopen = () => {
console.log('[WebSocket] Connection opened.');
console.log(`[WebSocket ${instanceSessionId}] 连接已打开。`);
statusMessage.value = getStatusText('wsConnected');
// Status remains 'connecting' until ssh:connected is received
// Send the initial connection message required by the backend
sendMessage({ type: 'ssh:connect', payload: { connectionId: connId } });
// Dispatch an internal event if needed
// dispatchMessage('internal:opened', {}, { type: 'internal:opened' });
// 状态保持 'connecting' 直到收到 ssh:connected
// 发送后端所需的初始连接消息,包含数据库连接 ID
sendMessage({ type: 'ssh:connect', payload: { connectionId: instanceDbConnectionId } });
dispatchMessage('internal:opened', {}, { type: 'internal:opened' }); // 触发内部打开事件
};
ws.value.onmessage = (event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
// console.debug('[WebSocket] Received:', message.type); // Less verbose logging
// console.debug(`[WebSocket ${instanceSessionId}] 收到:`, message.type);
// --- Update Global Connection Status based on specific messages ---
// --- 更新此实例的连接状态 ---
if (message.type === 'ssh:connected') {
if (connectionStatus.value !== 'connected') {
console.log('[WebSocket] SSH session connected.');
console.log(`[WebSocket ${instanceSessionId}] SSH 会话已连接。`);
connectionStatus.value = 'connected';
statusMessage.value = getStatusText('connected');
}
} else if (message.type === 'ssh:disconnected') {
if (connectionStatus.value !== 'disconnected') {
console.log('[WebSocket] SSH session disconnected.');
if (connectionStatus.value !== 'disconnected') {
console.log(`[WebSocket ${instanceSessionId}] SSH 会话已断开。`);
connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('disconnected', { reason: message.payload || 'Unknown reason' });
}
} else if (message.type === 'ssh:error' || message.type === 'error') { // Handle generic backend errors too
statusMessage.value = getStatusText('disconnected', { reason: message.payload || '未知原因' });
isSftpReady.value = false; // SSH 断开,SFTP 也应不可用
}
} else if (message.type === 'ssh:error' || message.type === 'error') {
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
console.error('[WebSocket] Received error message:', message.payload);
console.error(`[WebSocket ${instanceSessionId}] 收到错误消息:`, message.payload);
connectionStatus.value = 'error';
let errorMsg = message.payload || 'Unknown error';
let errorMsg = message.payload || '未知错误';
if (typeof errorMsg === 'object' && errorMsg.message) errorMsg = errorMsg.message;
statusMessage.value = getStatusText('error', { message: errorMsg });
isSftpReady.value = false; // Reset SFTP status on error
isSftpReady.value = false;
}
} else if (message.type === 'sftp_ready') {
console.log('[WebSocket] SFTP session ready.');
console.log(`[WebSocket ${instanceSessionId}] SFTP 会话已就绪。`);
isSftpReady.value = true;
}
// --- End Status Update ---
// --- 状态更新结束 ---
// Dispatch message to specific handlers
// 分发消息给此实例的处理器
dispatchMessage(message.type, message.payload, message);
} catch (e) {
console.error('[WebSocket] Error processing message:', e, 'Raw data:', event.data);
// Optionally dispatch raw data if needed by some handler
// dispatchMessage('internal:raw', event.data, { type: 'internal:raw' });
console.error(`[WebSocket ${instanceSessionId}] 处理消息时出错:`, e, '原始数据:', event.data);
dispatchMessage('internal:raw', event.data, { type: 'internal:raw' });
}
};
ws.value.onerror = (event) => {
console.error('[WebSocket] Connection error:', event);
if (connectionStatus.value !== 'disconnected') { // Avoid overwriting disconnect status
console.error(`[WebSocket ${instanceSessionId}] 连接错误:`, event);
if (connectionStatus.value !== 'disconnected') {
connectionStatus.value = 'error';
statusMessage.value = getStatusText('wsError');
}
dispatchMessage('internal:error', event, { type: 'internal:error' });
isSftpReady.value = false; // Reset SFTP status on WS error
ws.value = null; // Clean up on error
connectionIdForSession.value = null;
isSftpReady.value = false;
ws.value = null; // 清理实例
};
ws.value.onclose = (event) => {
console.log(`[WebSocket] Connection closed: Code=${event.code}, Reason=${event.reason}`);
// Update status only if not already handled by ssh:disconnected or error
console.log(`[WebSocket ${instanceSessionId}] 连接已关闭: Code=${event.code}, Reason=${event.reason}`);
if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') {
connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('wsClosed', { code: event.code });
}
dispatchMessage('internal:closed', { code: event.code, reason: event.reason }, { type: 'internal:closed' });
isSftpReady.value = false; // Reset SFTP status on close
ws.value = null; // Clean up reference
connectionIdForSession.value = null;
// Optionally clear handlers on close? Depends on desired behavior.
// messageHandlers.clear();
isSftpReady.value = false;
ws.value = null; // 清理实例引用
// 不自动清除处理器,以便在重连时可能复用
};
} catch (err) {
console.error('[WebSocket] Failed to create WebSocket instance:', err);
console.error(`[WebSocket ${instanceSessionId}] 创建 WebSocket 实例失败:`, err);
connectionStatus.value = 'error';
statusMessage.value = getStatusText('wsError'); // Or a more specific creation error
isSftpReady.value = false; // Reset SFTP status on creation error
statusMessage.value = getStatusText('wsError');
isSftpReady.value = false;
ws.value = null;
connectionIdForSession.value = null;
}
};
/**
* 手动断开此 WebSocket 连接
*/
const disconnect = () => {
if (ws.value) {
console.log('[WebSocket] Closing connection manually...');
// Set status immediately to prevent race conditions with onclose
console.log(`[WebSocket ${instanceSessionId}] 手动关闭连接...`);
if (connectionStatus.value !== 'disconnected') {
connectionStatus.value = 'disconnected';
statusMessage.value = getStatusText('disconnected', { reason: 'Manual disconnect' });
statusMessage.value = getStatusText('disconnected', { reason: '手动断开' });
}
ws.value.close(1000, 'Client initiated disconnect'); // Use standard code and reason
ws.value.close(1000, '客户端主动断开'); // 使用标准代码和原因
ws.value = null;
connectionIdForSession.value = null;
isSftpReady.value = false; // Reset SFTP status on manual disconnect
// messageHandlers.clear(); // Clear handlers on manual disconnect
isSftpReady.value = false;
// 手动断开时可以考虑清除处理器,取决于是否需要重连逻辑
// messageHandlers.clear();
} else {
console.log(`[WebSocket ${instanceSessionId}] 连接已关闭或不存在,无需断开。`);
}
};
/**
* 发送 WebSocket 消息
* @param {WebSocketMessage} message - 要发送的消息对象
*/
const sendMessage = (message: WebSocketMessage) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
try {
const messageString = JSON.stringify(message);
// console.debug('[WebSocket] Sending:', message.type); // Less verbose
// console.debug(`[WebSocket ${instanceSessionId}] 发送:`, message.type);
ws.value.send(messageString);
} catch (e) {
console.error('[WebSocket] Failed to stringify or send message:', e, message);
console.error(`[WebSocket ${instanceSessionId}] 序列化或发送消息失败:`, e, message);
}
} else {
console.warn(`[WebSocket] Cannot send message, connection not open. State: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`);
console.warn(`[WebSocket ${instanceSessionId}] 无法发送消息,连接未打开。状态: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`);
}
};
// Register a handler for a specific message type
const onMessage = (type: string, handler: MessageHandler) => {
/**
* 注册一个消息处理器
* @param {string} type - 要监听的消息类型
* @param {MessageHandler} handler - 处理函数
* @returns {Function} 一个用于注销此处理器的函数
*/
const onMessage = (type: string, handler: MessageHandler): (() => void) => {
if (!messageHandlers.has(type)) {
messageHandlers.set(type, new Set());
}
const handlersSet = messageHandlers.get(type);
if (handlersSet) {
handlersSet.add(handler);
console.debug(`[WebSocket] Handler registered for type: ${type}`);
// console.debug(`[WebSocket ${instanceSessionId}] 已注册处理器: ${type}`);
}
// Return an unregister function
// 返回注销函数
return () => {
const currentSet = messageHandlers.get(type);
if (currentSet) {
currentSet.delete(handler);
console.debug(`[WebSocket] Handler unregistered for type: ${type}`);
// console.debug(`[WebSocket ${instanceSessionId}] 已注销处理器: ${type}`);
if (currentSet.size === 0) {
messageHandlers.delete(type);
}
@@ -217,20 +225,18 @@ export function useWebSocketConnection() {
};
};
// Cleanup logic: The singleton nature means disconnect should be called explicitly
// when the connection is no longer needed (e.g., when WorkspaceView unmounts).
// onUnmounted is generally tied to the component instance using the composable.
// If useWebSocketConnection is called in WorkspaceView's setup, its onUnmounted
// will trigger disconnect, which is the desired behavior.
// 注意:没有在此处使用 onUnmounted。
// disconnect 方法需要由外部调用者 (例如 WorkspaceView) 在会话关闭时显式调用。
// 返回此实例的状态和方法
return {
// State (Exported as readonly refs where appropriate)
// 状态 (只读引用)
isConnected: computed(() => connectionStatus.value === 'connected'),
isSftpReady: readonly(isSftpReady), // Expose SFTP readiness state
isSftpReady: readonly(isSftpReady),
connectionStatus: readonly(connectionStatus),
statusMessage: readonly(statusMessage),
// Methods
// 方法
connect,
disconnect,
sendMessage,