diff --git a/packages/backend/src/services/sftp.service.ts b/packages/backend/src/services/sftp.service.ts index 29a70a3..54a167a 100644 --- a/packages/backend/src/services/sftp.service.ts +++ b/packages/backend/src/services/sftp.service.ts @@ -1,4 +1,4 @@ -import { Client, SFTPWrapper, Stats } from 'ssh2'; +import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream import { WebSocket } from 'ws'; import { ClientState } from '../websocket'; // 导入统一的 ClientState @@ -37,11 +37,22 @@ interface NetworkStats { const DEFAULT_POLLING_INTERVAL = 1000; const previousNetStats = new Map(); +// Interface for tracking active uploads +interface ActiveUpload { + remotePath: string; + totalSize: number; + bytesWritten: number; + stream: WriteStream; + sessionId: string; // Link back to the session for cleanup +} + export class SftpService { private clientStates: Map; // 使用导入的 ClientState + private activeUploads: Map; // Map constructor(clientStates: Map) { this.clientStates = clientStates; + this.activeUploads = new Map(); // Initialize the map } /** @@ -98,6 +109,13 @@ export class SftpService { state.sftp.end(); state.sftp = undefined; } + // Also clean up any active uploads associated with this session + this.activeUploads.forEach((upload, uploadId) => { + if (upload.sessionId === sessionId) { + console.warn(`[SFTP] Cleaning up active upload ${uploadId} for session ${sessionId} due to SFTP session cleanup.`); + this.cancelUploadInternal(uploadId, 'SFTP session ended'); // Internal cancel without sending message + } + }); } // --- SFTP 操作方法 --- @@ -368,4 +386,208 @@ export class SftpService { // async uploadFile(...) // async downloadFile(...) + /** 获取路径的绝对表示 */ + async realpath(sessionId: string, path: string, requestId: string): Promise { + const state = this.clientStates.get(sessionId); + if (!state || !state.sftp) { + console.warn(`[SFTP] SFTP 未准备好,无法在 ${sessionId} 上执行 realpath (ID: ${requestId})`); + state?.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: 'SFTP 会话未就绪', requestId: requestId })); + return; + } + console.debug(`[SFTP ${sessionId}] Received realpath request for ${path} (ID: ${requestId})`); + try { + state.sftp.realpath(path, (err, absPath) => { + if (err) { + console.error(`[SFTP ${sessionId}] realpath ${path} failed (ID: ${requestId}):`, err); + state.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: `获取绝对路径失败: ${err.message}`, requestId: requestId })); + } else { + console.log(`[SFTP ${sessionId}] realpath ${path} -> ${absPath} success (ID: ${requestId})`); + // 在 payload 中同时发送请求的路径和绝对路径 + state.ws.send(JSON.stringify({ type: 'sftp:realpath:success', path: path, payload: { requestedPath: path, absolutePath: absPath }, requestId: requestId })); + } + }); + } catch (error: any) { + console.error(`[SFTP ${sessionId}] realpath ${path} caught unexpected error (ID: ${requestId}):`, error); + state.ws.send(JSON.stringify({ type: 'sftp:realpath:error', path: path, payload: `获取绝对路径时发生意外错误: ${error.message}`, requestId: requestId })); + } + } + + // --- File Upload Methods --- + + /** Start a new file upload */ + startUpload(sessionId: string, uploadId: string, remotePath: string, totalSize: number): void { + const state = this.clientStates.get(sessionId); + if (!state || !state.sftp) { + console.warn(`[SFTP Upload ${uploadId}] SFTP not ready for session ${sessionId}.`); + state?.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: 'SFTP 会话未就绪' } })); + return; + } + if (this.activeUploads.has(uploadId)) { + console.warn(`[SFTP Upload ${uploadId}] Upload already in progress for session ${sessionId}.`); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: 'Upload already started' } })); + return; + } + + console.log(`[SFTP Upload ${uploadId}] Starting upload for ${remotePath} (${totalSize} bytes) in session ${sessionId}`); + + try { + const stream = state.sftp.createWriteStream(remotePath); + const uploadState: ActiveUpload = { + remotePath, + totalSize, + bytesWritten: 0, + stream, + sessionId, + }; + this.activeUploads.set(uploadId, uploadState); + + stream.on('error', (err: Error) => { + console.error(`[SFTP Upload ${uploadId}] Write stream error for ${remotePath}:`, err); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `写入流错误: ${err.message}` } })); + this.activeUploads.delete(uploadId); // Clean up state on error + }); + + stream.on('close', () => { + // This 'close' event now primarily handles cleanup after the stream is fully closed. + // The success message is sent earlier in handleUploadChunk. + const finalState = this.activeUploads.get(uploadId); + if (finalState) { + // Check if bytes written match total size upon close, log warning if not (could indicate cancellation after success msg sent) + if (finalState.bytesWritten !== finalState.totalSize) { + console.warn(`[SFTP Upload ${uploadId}] Write stream closed for ${remotePath}, but written bytes (${finalState.bytesWritten}) != total size (${finalState.totalSize}). This might happen if cancelled after success message was sent.`); + // Optionally send an error if this state is unexpected, but success might have already been sent. + // state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '文件大小不匹配或上传未完成' } })); + } else { + console.log(`[SFTP Upload ${uploadId}] Write stream closed successfully for ${remotePath}. State cleaned up.`); + } + this.activeUploads.delete(uploadId); // Clean up state when stream is closed + } else { + console.log(`[SFTP Upload ${uploadId}] Write stream closed for ${remotePath}, but upload state was already removed.`); + } + }); + + stream.on('finish', () => { + // The 'finish' event fires when stream.end() is called and all data has been flushed to the underlying system. + // This might be a slightly earlier point than 'close'. Let's log it. + console.log(`[SFTP Upload ${uploadId}] Write stream finished for ${remotePath}. Waiting for close.`); + }); + + + // Notify client that we are ready for chunks + state.ws.send(JSON.stringify({ type: 'sftp:upload:ready', payload: { uploadId } })); + + } catch (error: any) { + console.error(`[SFTP Upload ${uploadId}] Error starting upload for ${remotePath}:`, error); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `开始上传时出错: ${error.message}` } })); + this.activeUploads.delete(uploadId); // Clean up if start failed + } + } + + /** Handle an incoming file chunk */ + handleUploadChunk(sessionId: string, uploadId: string, chunkIndex: number, dataBase64: string): void { + const state = this.clientStates.get(sessionId); + const uploadState = this.activeUploads.get(uploadId); + + if (!state || !state.sftp) { + // Session or SFTP gone, can't process chunk. Upload might be cleaned up elsewhere. + console.warn(`[SFTP Upload ${uploadId}] Received chunk ${chunkIndex}, but session ${sessionId} or SFTP is invalid.`); + this.cancelUploadInternal(uploadId, 'Session or SFTP invalid'); + return; + } + if (!uploadState) { + console.warn(`[SFTP Upload ${uploadId}] Received chunk ${chunkIndex}, but no active upload found.`); + // Send error back to client? Might flood if many chunks arrive after cancellation. + // state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '无效的上传 ID 或上传已取消/完成' } })); + return; + } + + try { + const chunkBuffer = Buffer.from(dataBase64, 'base64'); + // console.debug(`[SFTP Upload ${uploadId}] Writing chunk ${chunkIndex} (${chunkBuffer.length} bytes) to ${uploadState.remotePath}`); + + // Write the chunk. The 'drain' event is handled automatically by Node.js streams + // if the write buffer is full. We just write. + const writeSuccess = uploadState.stream.write(chunkBuffer, (err) => { + if (err) { + // This callback handles errors specifically related to *this* write operation. + console.error(`[SFTP Upload ${uploadId}] Error writing chunk ${chunkIndex} to ${uploadState.remotePath}:`, err); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `写入块 ${chunkIndex} 失败: ${err.message}` } })); + // Consider cancelling the upload on write error + this.cancelUploadInternal(uploadId, `Write error on chunk ${chunkIndex}`); + } + // else { console.debug(`[SFTP Upload ${uploadId}] Chunk ${chunkIndex} write callback success.`); } + }); + + if (!writeSuccess) { + // This indicates the buffer is full and we should wait for 'drain'. + // However, for simplicity in this WebSocket context, we might rely on TCP backpressure + // or simply continue writing, letting the stream buffer handle it. + // Adding explicit 'drain' handling can add complexity. + console.warn(`[SFTP Upload ${uploadId}] Write stream buffer full after chunk ${chunkIndex}. Waiting for drain is recommended for large files/slow connections.`); + } + + + uploadState.bytesWritten += chunkBuffer.length; + + // Send progress (optional, consider throttling) + // const progress = Math.round((uploadState.bytesWritten / uploadState.totalSize) * 100); + // state.ws.send(JSON.stringify({ type: 'sftp:upload:progress', payload: { uploadId, progress } })); + + // Check if upload is complete + if (uploadState.bytesWritten > uploadState.totalSize) { + console.error(`[SFTP Upload ${uploadId}] Bytes written (${uploadState.bytesWritten}) exceeded total size (${uploadState.totalSize}) for ${uploadState.remotePath}.`); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '写入字节数超过文件总大小' } })); + this.cancelUploadInternal(uploadId, 'Bytes written exceeded total size'); + + } else if (uploadState.bytesWritten === uploadState.totalSize) { + console.log(`[SFTP Upload ${uploadId}] All bytes (${uploadState.bytesWritten}) received for ${uploadState.remotePath}. Sending success and ending stream.`); + // Send success message IMMEDIATELY upon receiving the last expected byte + state.ws.send(JSON.stringify({ type: 'sftp:upload:success', payload: { uploadId, remotePath: uploadState.remotePath } })); + // Now end the stream. The 'close' event will handle cleanup. + uploadState.stream.end(); + } + + } catch (error: any) { + console.error(`[SFTP Upload ${uploadId}] Error handling chunk ${chunkIndex} for ${uploadState?.remotePath}:`, error); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `处理块 ${chunkIndex} 时出错: ${error.message}` } })); + this.cancelUploadInternal(uploadId, `Error handling chunk ${chunkIndex}`); + } + } + + /** Cancel an ongoing upload */ + cancelUpload(sessionId: string, uploadId: string): void { + const state = this.clientStates.get(sessionId); + const uploadState = this.activeUploads.get(uploadId); + + if (!state) { + console.warn(`[SFTP Upload ${uploadId}] Request to cancel, but session ${sessionId} not found.`); + // Can't send message back if session is gone + this.cancelUploadInternal(uploadId, 'Session not found'); // Clean up if state exists + return; + } + if (!uploadState) { + console.warn(`[SFTP Upload ${uploadId}] Request to cancel, but no active upload found.`); + state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: '无效的上传 ID 或上传已取消/完成' } })); + return; + } + + console.log(`[SFTP Upload ${uploadId}] Cancelling upload for ${uploadState.remotePath}`); + this.cancelUploadInternal(uploadId, 'User cancelled'); + state.ws.send(JSON.stringify({ type: 'sftp:upload:cancelled', payload: { uploadId } })); + } + + /** Internal helper to clean up an upload */ + private cancelUploadInternal(uploadId: string, reason: string): void { + const uploadState = this.activeUploads.get(uploadId); + if (uploadState) { + console.log(`[SFTP Upload ${uploadId}] Internal cancel (${reason}): Closing stream for ${uploadState.remotePath}`); + // End the stream. The 'close' handler should ideally detect the size mismatch or see the state is gone. + // Using destroy might be more immediate but could lead to unclosed file descriptors on the server in some cases. + uploadState.stream.end(); // Gracefully try to end + // uploadState.stream.destroy(); // More forceful, might be needed + this.activeUploads.delete(uploadId); + } else { + // console.log(`[SFTP Upload ${uploadId}] Internal cancel called, but upload state already removed.`); + } + } } diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index f76f919..dacd063 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -298,7 +298,8 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH case 'sftp:rmdir': case 'sftp:unlink': case 'sftp:rename': - case 'sftp:chmod': { + case 'sftp:chmod': + case 'sftp:realpath': { // Add realpath case if (!sessionId || !state) { console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`); // 尝试包含 requestId 发送错误,如果 requestId 存在的话 @@ -370,6 +371,11 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH sftpService.chmod(sessionId, payload.path, payload.mode, requestId); } else { throw new Error("Missing 'path' or invalid 'mode' in payload for chmod"); } break; + case 'sftp:realpath': // Add realpath handler + if (payload?.path) { + sftpService.realpath(sessionId, payload.path, requestId); + } else { throw new Error("Missing 'path' in payload for realpath"); } + break; default: // Should not happen if already checked type, but as a safeguard throw new Error(`Unhandled SFTP type: ${type}`); @@ -380,17 +386,49 @@ export const initializeWebSocket = (server: http.Server, sessionParser: RequestH } break; } - // --- SFTP 文件上传 (保持部分逻辑,因为涉及分块) --- - // TODO: 考虑将上传逻辑也移入 SftpService - case 'sftp:upload:start': - case 'sftp:upload:chunk': - case 'sftp:upload:cancel': { - console.warn(`WebSocket: SFTP 上传功能 (${type}) 尚未完全迁移到 SftpService。`); - // 可以在这里调用 SftpService 的对应方法,或者暂时保留旧逻辑 - ws.send(JSON.stringify({ type: 'error', payload: `SFTP 上传功能正在重构中。` })); - break; - } - + // --- SFTP 文件上传 (委托给 SftpService) --- + case 'sftp:upload:start': { + if (!sessionId || !state) { + console.warn(`WebSocket: 收到来自 ${ws.username} 的 SFTP 请求 (${type}),但无活动会话。`); + ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '无效的会话' } })); + return; + } + if (!payload?.uploadId || !payload?.remotePath || typeof payload?.size !== 'number') { + console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId, remotePath 或 size。`); + ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId, remotePath 或 size' } })); + return; + } + sftpService.startUpload(sessionId, payload.uploadId, payload.remotePath, payload.size); + break; + } + case 'sftp:upload:chunk': { + if (!sessionId || !state) { + // Don't warn repeatedly for chunks if session is gone + return; + } + if (!payload?.uploadId || typeof payload?.chunkIndex !== 'number' || !payload?.data) { + console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId, chunkIndex 或 data。`); + // Avoid flooding with errors for every chunk if something is wrong + // Consider sending a single error and potentially cancelling on the service side + return; + } + // Assuming data is base64 encoded string from frontend + sftpService.handleUploadChunk(sessionId, payload.uploadId, payload.chunkIndex, payload.data); + break; + } + case 'sftp:upload:cancel': { + if (!sessionId || !state) { + // Don't warn if session is already gone + return; + } + if (!payload?.uploadId) { + console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但缺少 uploadId。`); + ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId: payload?.uploadId, message: '缺少 uploadId' } })); + return; + } + sftpService.cancelUpload(sessionId, payload.uploadId); + break; + } default: console.warn(`WebSocket:收到来自 ${ws.username} (会话: ${sessionId}) 的未知消息类型: ${type}`); diff --git a/packages/frontend/src/components/FileEditorOverlay.vue b/packages/frontend/src/components/FileEditorOverlay.vue new file mode 100644 index 0000000..8c56859 --- /dev/null +++ b/packages/frontend/src/components/FileEditorOverlay.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index f913fdd..add8c45 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -1,61 +1,89 @@ + + + + diff --git a/packages/frontend/src/composables/useFileEditor.ts b/packages/frontend/src/composables/useFileEditor.ts new file mode 100644 index 0000000..a0b2fef --- /dev/null +++ b/packages/frontend/src/composables/useFileEditor.ts @@ -0,0 +1,206 @@ +import { ref, readonly, type Ref } from 'vue'; +import { useI18n } from 'vue-i18n'; +// 移除对 useSftpActions 的直接导入,因为方法是注入的 +// import { useSftpActions } from './useSftpActions'; +// 从类型文件导入所需类型 +import type { EditorFileContent, SaveStatus } from '../types/sftp.types'; + +// --- 类型定义 (已移至 sftp.types.ts) --- +// export type SaveStatus = 'idle' | 'saving' | 'success' | 'error'; +// export interface EditorFileContent { ... } + +// 辅助函数:根据文件名获取语言 (从 FileManager.vue 迁移) +const getLanguageFromFilename = (filename: string): string => { + const extension = filename.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'js': return 'javascript'; + case 'ts': return 'typescript'; + case 'json': return 'json'; + case 'html': return 'html'; + case 'css': return 'css'; + case 'scss': return 'scss'; + case 'less': return 'less'; + case 'py': return 'python'; + case 'java': return 'java'; + case 'c': return 'c'; + case 'cpp': return 'cpp'; + case 'cs': return 'csharp'; + case 'go': return 'go'; + case 'php': return 'php'; + case 'rb': return 'ruby'; + case 'rs': return 'rust'; + case 'sql': return 'sql'; + case 'sh': return 'shell'; + case 'yaml': case 'yml': return 'yaml'; + case 'md': return 'markdown'; + case 'xml': return 'xml'; + case 'ini': return 'ini'; + case 'bat': return 'bat'; + case 'dockerfile': return 'dockerfile'; + default: return 'plaintext'; + } +}; + +export function useFileEditor( + // 注入依赖:需要 SFTP 操作模块提供的读写文件方法 + sftpReadFile: (path: string) => Promise, + sftpWriteFile: (path: string, content: string) => Promise +) { + const { t } = useI18n(); + + // --- 编辑器状态 --- + const isEditorVisible = ref(false); + const editingFilePath = ref(null); + const editingFileContent = ref(''); // 用于 v-model 绑定 + const editingFileLanguage = ref('plaintext'); + const editingFileEncoding = ref<'utf8' | 'base64'>('utf8'); // 文件内容的原始编码 + const isEditorLoading = ref(false); + const editorError = ref(null); + const isSaving = ref(false); + const saveStatus = ref('idle'); + const saveError = ref(null); + + // --- 方法 --- + + const openFile = async (filePath: string) => { + console.log(`[文件编辑器模块] 尝试打开文件: ${filePath}`); + if (!filePath) return; + + // 如果已经是同一个文件,则不重新加载(除非需要强制刷新) + // if (editingFilePath.value === filePath && isEditorVisible.value) { + // console.log(`[文件编辑器模块] 文件 ${filePath} 已在编辑器中打开。`); + // return; + // } + + isEditorVisible.value = true; // 显示编辑器区域 + isEditorLoading.value = true; // 显示加载状态 + editorError.value = null; + saveStatus.value = 'idle'; // 重置保存状态 + saveError.value = null; + editingFilePath.value = filePath; + editingFileLanguage.value = getLanguageFromFilename(filePath); + editingFileContent.value = ''; // 清空旧内容 + + try { + const fileData = await sftpReadFile(filePath); // 调用注入的 readFile 方法 + console.log(`[文件编辑器模块] 文件 ${filePath} 读取成功。编码: ${fileData.encoding}`); + + // 处理可能的 Base64 编码 + if (fileData.encoding === 'base64') { + try { + editingFileContent.value = atob(fileData.content); // 解码 + editingFileEncoding.value = 'base64'; // 记录原始编码 + } catch (decodeError) { + console.error(`[文件编辑器模块] Base64 解码错误 for ${filePath}:`, decodeError); + editorError.value = t('fileManager.errors.fileDecodeError'); + editingFileContent.value = `// ${t('fileManager.errors.fileDecodeError')}\n${fileData.content}`; // 显示原始 Base64 作为后备 + } + } else { + editingFileContent.value = fileData.content; + editingFileEncoding.value = 'utf8'; + } + isEditorLoading.value = false; + } catch (err: any) { + console.error(`[文件编辑器模块] 读取文件 ${filePath} 失败:`, err); + editorError.value = `${t('fileManager.errors.readFileFailed')}: ${err.message || err}`; + editingFileContent.value = `// ${editorError.value}`; // 在编辑器中显示错误 + isEditorLoading.value = false; + } + }; + + const saveFile = async () => { + if (!editingFilePath.value || isSaving.value || isEditorLoading.value || editorError.value) { + console.warn('[文件编辑器模块] 保存条件不满足,无法保存。', { + path: editingFilePath.value, + isSaving: isSaving.value, + isLoading: isEditorLoading.value, + hasError: !!editorError.value + }); + return; + } + + console.log(`[文件编辑器模块] 开始保存文件: ${editingFilePath.value}`); + isSaving.value = true; + saveStatus.value = 'saving'; + saveError.value = null; + + const contentToSave = editingFileContent.value; // 获取当前编辑器内容 + + try { + await sftpWriteFile(editingFilePath.value, contentToSave); // 调用注入的 writeFile 方法 + console.log(`[文件编辑器模块] 文件 ${editingFilePath.value} 保存成功。`); + isSaving.value = false; + saveStatus.value = 'success'; + saveError.value = null; + + // 成功提示短暂显示后消失 + setTimeout(() => { + if (saveStatus.value === 'success') { + saveStatus.value = 'idle'; + } + }, 2000); + + } catch (err: any) { + console.error(`[文件编辑器模块] 保存文件 ${editingFilePath.value} 失败:`, err); + isSaving.value = false; + saveStatus.value = 'error'; + saveError.value = `${t('fileManager.errors.saveFailed')}: ${err.message || err}`; + + // 错误提示显示时间长一些 + setTimeout(() => { + if (saveStatus.value === 'error') { + saveStatus.value = 'idle'; + saveError.value = null; + } + }, 5000); + } + }; + + const closeEditor = () => { + console.log('[文件编辑器模块] 关闭编辑器。'); + isEditorVisible.value = false; + editingFilePath.value = null; + editingFileContent.value = ''; + editorError.value = null; + isEditorLoading.value = false; + saveStatus.value = 'idle'; + saveError.value = null; + isSaving.value = false; + }; + + // 提供一个方法来更新内容,主要用于 v-model + const updateContent = (newContent: string) => { + editingFileContent.value = newContent; + // 当用户编辑时,可以重置保存状态(如果需要) + if (saveStatus.value === 'success' || saveStatus.value === 'error') { + saveStatus.value = 'idle'; + saveError.value = null; + } + }; + + + // 注意:这个 composable 不直接处理 WebSocket 消息, + // 它依赖注入的 sftpReadFile 和 sftpWriteFile 函数, + // 这些函数(在 useSftpActions 中实现)内部处理了相应的 WebSocket 消息和请求/响应逻辑。 + + return { + // 状态 (只读的 ref) + isEditorVisible: readonly(isEditorVisible), + editingFilePath: readonly(editingFilePath), + editingFileLanguage: readonly(editingFileLanguage), + isEditorLoading: readonly(isEditorLoading), + editorError: readonly(editorError), + isSaving: readonly(isSaving), + saveStatus: readonly(saveStatus), + saveError: readonly(saveError), + + // 可写状态 (用于 v-model) + editingFileContent, // 直接暴露 ref 用于 v-model + + // 方法 + openFile, + saveFile, + closeEditor, + updateContent, // 如果需要从外部更新内容 + }; +} diff --git a/packages/frontend/src/composables/useFileUploader.ts b/packages/frontend/src/composables/useFileUploader.ts new file mode 100644 index 0000000..676cbdc --- /dev/null +++ b/packages/frontend/src/composables/useFileUploader.ts @@ -0,0 +1,318 @@ +import { ref, reactive, nextTick, onUnmounted, type Ref } from 'vue'; +import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook +import { useI18n } from 'vue-i18n'; +import type { FileListItem } from '../types/sftp.types'; // 从 sftp 类型文件导入 +import type { UploadItem } from '../types/upload.types'; // 从 upload 类型文件导入 +import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入 + +// --- 接口定义 (已移至 upload.types.ts) --- + +// 辅助函数 (从 FileManager.vue 复制) +const generateUploadId = (): string => { + // 如果需要,可以使用稍微不同的格式作为上传 ID + return `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +}; + +// 辅助函数 (从 FileManager.vue 复制) +const joinPath = (base: string, name: string): string => { + if (base === '/') return `/${name}`; + if (base.endsWith('/')) return `${base}${name}`; + return `${base}/${name}`; +}; + + +export function useFileUploader( + currentPathRef: Ref, + fileListRef: Ref>, // 传入 fileList 用于检查覆盖 + refreshDirectory: () => void // 上传成功后刷新目录的回调函数 +) { + const { t } = useI18n(); + const { sendMessage, onMessage, isConnected } = useWebSocketConnection(); + + // 对 uploads 字典使用 reactive 以获得更好的深度响应性 + const uploads = reactive>({}); + + // --- 上传逻辑 --- + + const sendFileChunks = (uploadId: string, file: File, startByte = 0) => { + const upload = uploads[uploadId]; + // 在继续之前检查连接和上传状态 + if (!isConnected.value || !upload || upload.status !== 'uploading') { + console.warn(`[文件上传模块] 无法为 ${uploadId} 发送块。连接状态: ${isConnected.value}, 上传状态: ${upload?.status}`); + return; + } + + const chunkSize = 1024 * 64; // 64KB 块大小 + const reader = new FileReader(); + let offset = startByte; + let chunkIndex = 0; // Initialize chunk index counter + + reader.onload = (e) => { + const currentUpload = uploads[uploadId]; + // *发送前* 再次检查连接和状态 + if (!isConnected.value || !currentUpload || currentUpload.status !== 'uploading') { + console.warn(`[文件上传模块] 上传 ${uploadId} 在发送偏移量 ${offset} 的块之前状态已更改或连接已断开。`); + return; // 如果状态改变或断开连接,则停止发送 + } + + const chunkResult = e.target?.result as string; + // 确保结果是字符串并且包含 base64 前缀 + if (typeof chunkResult === 'string' && chunkResult.startsWith('data:')) { + const chunkBase64 = chunkResult.split(',')[1]; + const isLast = offset + chunkSize >= file.size; + + sendMessage({ + type: 'sftp:upload:chunk', + payload: { uploadId, chunkIndex: chunkIndex++, data: chunkBase64, isLast } // Add and increment chunkIndex + }); + + // 注意:直接使用 base64 长度估算字节大小并不完全准确,但对于进度条来说足够了 + offset += chunkBase64.length * 3 / 4; + currentUpload.progress = Math.min(100, Math.round((offset / file.size) * 100)); + + if (!isLast) { + // 使用 requestAnimationFrame 或 nextTick 在块之间添加轻微延迟 + // 以潜在地改善 UI 响应性并减少负载。 + nextTick(readNextChunk); + } else { + console.log(`[文件上传模块] 已发送 ${uploadId} 的最后一个块`); + // 后端将在收到最后一个块后发送 sftp:upload:success + } + } else { + console.error(`[文件上传模块] FileReader 为 ${uploadId} 返回了意外结果:`, chunkResult); + // 处理错误:更新上传状态,也许重试? + currentUpload.status = 'error'; + currentUpload.error = t('fileManager.errors.readFileError'); + } + }; + + reader.onerror = () => { + console.error(`[文件上传模块] FileReader 错误,上传 ID: ${uploadId}`); + const failedUpload = uploads[uploadId]; + if (failedUpload) { + failedUpload.status = 'error'; + failedUpload.error = t('fileManager.errors.readFileError'); + } + }; + + const readNextChunk = () => { + // 读取下一个块之前再次检查状态 + if (offset < file.size && uploads[uploadId]?.status === 'uploading') { + const slice = file.slice(offset, offset + chunkSize); + reader.readAsDataURL(slice); + } + }; + + // 开始读取第一个块(或恢复时的下一个块) + if (file.size > 0) { + readNextChunk(); + } else { + // 立即处理零字节文件 + console.log(`[文件上传模块] 处理零字节文件 ${uploadId}`); + // Send chunkIndex 0 for zero-byte file + sendMessage({ type: 'sftp:upload:chunk', payload: { uploadId, chunkIndex: 0, data: '', isLast: true } }); + upload.progress = 100; + // Backend should send success message shortly after this + } + }; + + + const startFileUpload = (file: File) => { + if (!isConnected.value) { + console.warn('[文件上传模块] 无法开始上传:WebSocket 未连接。'); + // 可以选择向用户显示错误消息 + return; + } + + const uploadId = generateUploadId(); + const remotePath = joinPath(currentPathRef.value, file.name); + + // 使用传入的 fileListRef 检查是否覆盖 + // 为 item 添加显式类型 FileListItem + if (fileListRef.value.some((item: FileListItem) => item.filename === file.name && !item.attrs.isDirectory)) { + if (!confirm(t('fileManager.prompts.confirmOverwrite', { name: file.name }))) { + console.log(`[文件上传模块] 用户取消了 ${file.name} 的上传`); + return; // 用户取消覆盖 + } + } + + // 添加到响应式 uploads 字典 + uploads[uploadId] = { + id: uploadId, + file, + filename: file.name, + progress: 0, + status: 'pending' // 初始状态 + }; + + console.log(`[文件上传模块] 开始上传 ${uploadId} 到 ${remotePath}`); + sendMessage({ + type: 'sftp:upload:start', + payload: { uploadId, remotePath, size: file.size } + }); + // 后端应该响应 sftp:upload:ready + }; + + const cancelUpload = (uploadId: string, notifyBackend = true) => { + const upload = uploads[uploadId]; + if (upload && ['pending', 'uploading', 'paused'].includes(upload.status)) { + console.log(`[文件上传模块] 取消上传 ${uploadId}`); + upload.status = 'cancelled'; // 立即更新状态 + + if (notifyBackend && isConnected.value) { + sendMessage({ type: 'sftp:upload:cancel', payload: { uploadId } }); + } + + // 短暂延迟后从列表中移除,以显示取消状态 + setTimeout(() => { + if (uploads[uploadId]?.status === 'cancelled') { + delete uploads[uploadId]; + } + }, 3000); + } + }; + + // --- 消息处理器 --- + + const onUploadReady = (payload: MessagePayload, message: WebSocketMessage) => { + const uploadId = message.uploadId || payload?.uploadId; + if (!uploadId) return; + + const upload = uploads[uploadId]; + if (upload && upload.status === 'pending') { + console.log(`[文件上传模块] 上传 ${uploadId} 已就绪,开始发送块。`); + upload.status = 'uploading'; + sendFileChunks(uploadId, upload.file); // 开始发送块 + } else { + console.warn(`[文件上传模块] 收到未知或非待处理状态的上传 ID 的 upload:ready 消息: ${uploadId}`); + } + }; + + const onUploadSuccess = (payload: MessagePayload, message: WebSocketMessage) => { + const uploadId = message.uploadId || payload?.uploadId; + if (!uploadId) return; + + const upload = uploads[uploadId]; + if (upload) { + console.log(`[文件上传模块] 上传 ${uploadId} 成功`); + upload.status = 'success'; + upload.progress = 100; + + // 使用回调刷新目录 + refreshDirectory(); + + // 延迟后从列表中移除 + setTimeout(() => { + if (uploads[uploadId]?.status === 'success') { + delete uploads[uploadId]; + } + }, 2000); // 成功状态显示时间短一些 + } else { + console.warn(`[文件上传模块] 收到未知上传 ID 的 upload:success 消息: ${uploadId}`); + } + }; + + const onUploadError = (payload: MessagePayload, message: WebSocketMessage) => { + // 从 message 中获取 uploadId,因为 payload 此时是错误字符串 + const uploadId = message.uploadId; + if (!uploadId) { + console.warn(`[文件上传模块] 收到缺少 uploadId 的 upload:error 消息:`, message); + return; + } + + const upload = uploads[uploadId]; + if (upload) { + const errorMessage = typeof payload === 'string' ? payload : t('fileManager.errors.uploadFailed'); + console.error(`[文件上传模块] 上传 ${uploadId} 出错:`, errorMessage); + upload.status = 'error'; + upload.error = errorMessage; // 使用 payload 作为错误消息 + + // 让错误消息可见时间长一些 + setTimeout(() => { + if (uploads[uploadId]?.status === 'error') { + delete uploads[uploadId]; + } + }, 5000); + } else { + console.warn(`[文件上传模块] 收到未知上传 ID 的 upload:error 消息: ${uploadId}`); + } + }; + + const onUploadPause = (payload: MessagePayload, message: WebSocketMessage) => { + const uploadId = message.uploadId || payload?.uploadId; + if (!uploadId) return; + const upload = uploads[uploadId]; + if (upload && upload.status === 'uploading') { + console.log(`[文件上传模块] 上传 ${uploadId} 已暂停`); + upload.status = 'paused'; + } + }; + + const onUploadResume = (payload: MessagePayload, message: WebSocketMessage) => { + const uploadId = message.uploadId || payload?.uploadId; + if (!uploadId) return; + const upload = uploads[uploadId]; + if (upload && upload.status === 'paused') { + console.log(`[文件上传模块] 恢复上传 ${uploadId}`); + upload.status = 'uploading'; + // 恢复发送块(后端应该告知从哪里恢复, + // 但现在假设我们重新开始或后端处理了它) + // 更健壮的实现需要后端发送最后接收到的字节偏移量。 + sendFileChunks(uploadId, upload.file); // 为简单起见,现在重新开始发送块 + } + }; + + const onUploadCancelled = (payload: MessagePayload, message: WebSocketMessage) => { + const uploadId = message.uploadId || payload?.uploadId; + if (!uploadId) return; + const upload = uploads[uploadId]; + if (upload) { + console.log(`[文件上传模块] 后端确认上传 ${uploadId} 已取消。`); + // 状态可能已经由用户操作设置为 'cancelled' + if (upload.status !== 'cancelled') { + upload.status = 'cancelled'; + } + // 确保它会被移除(如果尚未计划移除) + setTimeout(() => { + if (uploads[uploadId]?.status === 'cancelled') { + delete uploads[uploadId]; + } + }, 3000); + } + }; + + + // --- 注册处理器 --- + const unregisterUploadReady = onMessage('sftp:upload:ready', onUploadReady); + const unregisterUploadSuccess = onMessage('sftp:upload:success', onUploadSuccess); + const unregisterUploadError = onMessage('sftp:upload:error', onUploadError); + const unregisterUploadPause = onMessage('sftp:upload:pause', onUploadPause); + const unregisterUploadResume = onMessage('sftp:upload:resume', onUploadResume); + const unregisterUploadCancelled = onMessage('sftp:upload:cancelled', onUploadCancelled); + + // --- 清理 --- + onUnmounted(() => { + console.log('[文件上传模块] 卸载并注销处理器。'); + unregisterUploadReady?.(); + unregisterUploadSuccess?.(); + unregisterUploadError?.(); + unregisterUploadPause?.(); + unregisterUploadResume?.(); + unregisterUploadCancelled?.(); + + // 当使用此 composable 的组件卸载时,取消任何正在进行的上传 + Object.keys(uploads).forEach(uploadId => { + cancelUpload(uploadId, true); // 卸载时通知后端 + }); + }); + + return { + uploads, // 暴露响应式字典 + startFileUpload, + cancelUpload, + // 如果拖放/选择处理程序要在这里管理,则暴露它们, + // 或者将它们保留在组件中并调用 startFileUpload。 + // 为简单起见,假设组件处理 UI 事件 + // 并为每个文件调用 startFileUpload(file)。 + }; +} diff --git a/packages/frontend/src/composables/useSftpActions.ts b/packages/frontend/src/composables/useSftpActions.ts new file mode 100644 index 0000000..9dd1457 --- /dev/null +++ b/packages/frontend/src/composables/useSftpActions.ts @@ -0,0 +1,320 @@ +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'; // 从类型文件导入 + +// --- 接口定义 (已移至 sftp.types.ts) --- + +// Helper function (Copied from FileManager.vue) +const generateRequestId = (): string => { + return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +}; + +// Helper function (Copied from FileManager.vue) +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}`; +}; + +// Helper function (Copied from FileManager.vue) +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) { + const { t } = useI18n(); + // Import isSftpReady along with other needed functions/state + const { sendMessage, onMessage, isConnected, isSftpReady } = useWebSocketConnection(); + + const fileList = ref([]); + const isLoading = ref(false); + const error = ref(null); + + // --- 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 + isLoading.value = false; + fileList.value = []; // Clear list if not ready + console.warn(`[useSftpActions] 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}`); + isLoading.value = true; + error.value = null; + currentPathRef.value = path; // Update the external ref passed in + 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.`); + 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.`); + return; + } + const newFilePath = joinPath(currentPathRef.value, newFileName); + const requestId = generateRequestId(); + sendMessage({ + type: 'sftp:writefile', + requestId: requestId, + payload: { path: newFilePath, content: '', encoding: 'utf8' } // Create by writing empty content + }); + // 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.`); + return; + } + if (items.length === 0) return; + items.forEach(item => { + const targetPath = joinPath(currentPathRef.value, item.filename); + const actionType = item.attrs.isDirectory ? 'sftp:rmdir' : 'sftp:unlink'; + 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.`); + return; + } + if (!newName || item.filename === newName) return; + const oldPath = joinPath(currentPathRef.value, item.filename); + 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.`); + 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 类型 + return new Promise((resolve, reject) => { + if (!isSftpReady.value) { + console.warn(`[useSftpActions] Attempted to read file ${path} but SFTP is not ready.`); + return reject(new Error(t('fileManager.errors.sftpNotReady'))); + } + const requestId = generateRequestId(); + + const unregisterSuccess = onMessage('sftp:readfile:success', (payload, message) => { + if (message.requestId === requestId && message.path === path) { + unregisterSuccess?.(); + unregisterError?.(); + resolve({ content: payload.content, encoding: payload.encoding }); + } + }); + + const unregisterError = onMessage('sftp:readfile:error', (payload, message) => { + if (message.requestId === requestId && message.path === path) { + unregisterSuccess?.(); + unregisterError?.(); + reject(new Error(payload || '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 => { + return new Promise((resolve, reject) => { + if (!isSftpReady.value) { + console.warn(`[useSftpActions] 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 unregisterSuccess = onMessage('sftp:writefile:success', (payload, message) => { + if (message.requestId === requestId && message.path === path) { + unregisterSuccess?.(); + unregisterError?.(); + resolve(); + } + }); + + const unregisterError = onMessage('sftp:writefile:error', (payload, message) => { + if (message.requestId === requestId && message.path === path) { + unregisterSuccess?.(); + unregisterError?.(); + reject(new Error(payload || 'Failed to write file')); + } + }); + + sendMessage({ + type: 'sftp:writefile', + 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 + if (message.path === currentPathRef.value) { + console.log(`[useSftpActions] Received file list for ${message.path}`); + fileList.value = payload.sort(sortFiles); + isLoading.value = false; + error.value = null; + } else { + console.log(`[useSftpActions] Ignoring readdir success for ${message.path} (current: ${currentPathRef.value})`); + } + }; + + const onSftpReaddirError = (payload: string, message: WebSocketMessage) => { + if (message.path === currentPathRef.value) { + console.error(`[useSftpActions] Error loading directory ${message.path}:`, payload); + error.value = payload; + isLoading.value = false; + fileList.value = []; // Clear list on error + } + }; + + // 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 + }; + + // 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 actionTypeMap: Record = { + '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 + }; + const prefix = actionTypeMap[message.type] || t('fileManager.errors.generic'); + error.value = `${prefix}: ${payload}`; + // Optionally stop loading indicator if one was active for this action + }; + + // --- Register Handlers --- + const unregisterReaddirSuccess = onMessage('sftp:readdir:success', onSftpReaddirSuccess); + const unregisterReaddirError = onMessage('sftp:readdir:error', onSftpReaddirError); + + // 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 + }); + + return { + // State + fileList: readonly(fileList), + isLoading: readonly(isLoading), + error: readonly(error), + // currentPath: readonly(currentPath), // Path is managed via the passed ref + + // Methods + loadDirectory, + createDirectory, + createFile, + deleteItems, + renameItem, + changePermissions, + readFile, // Expose if needed by editor composable + writeFile, // Expose if needed by editor composable + joinPath, // Expose helper if needed externally + }; +} diff --git a/packages/frontend/src/composables/useSshTerminal.ts b/packages/frontend/src/composables/useSshTerminal.ts new file mode 100644 index 0000000..667e3c6 --- /dev/null +++ b/packages/frontend/src/composables/useSshTerminal.ts @@ -0,0 +1,160 @@ +import { ref, onUnmounted, type Ref } from 'vue'; +import { useWebSocketConnection } from './useWebSocketConnection'; // 只导入 hook 本身 +import { useI18n } from 'vue-i18n'; +import type { Terminal } from 'xterm'; +import type { WebSocketMessage, MessagePayload } from '../types/websocket.types'; // 从类型文件导入 + +export function useSshTerminal() { + const { t } = useI18n(); + const { sendMessage, onMessage, isConnected } = useWebSocketConnection(); + + const terminalInstance = ref(null); + const terminalOutputBuffer = ref([]); // 缓冲 WebSocket 消息直到终端准备好 + + // 辅助函数:获取终端消息文本 + const getTerminalText = (key: string, params?: Record): string => { + // 确保 i18n key 存在,否则返回原始 key + const translationKey = `workspace.terminal.${key}`; + const translated = t(translationKey, params || {}); + return translated === translationKey ? key : translated; + }; + + // --- 终端事件处理 --- + + const handleTerminalReady = (term: Terminal) => { + console.log('[SSH终端模块] 终端实例已就绪。'); + terminalInstance.value = term; + // 将缓冲区的输出写入终端 + terminalOutputBuffer.value.forEach(data => term.write(data)); + terminalOutputBuffer.value = []; // 清空缓冲区 + // 可以在这里自动聚焦或执行其他初始化操作 + // term.focus(); // 也许在 ssh:connected 时聚焦更好 + }; + + const handleTerminalData = (data: string) => { + // console.debug('[SSH终端模块] 接收到终端输入:', data); + sendMessage({ type: 'ssh:input', payload: { data } }); + }; + + const handleTerminalResize = (dimensions: { cols: number; rows: number }) => { + console.log('[SSH终端模块] 发送终端大小调整:', dimensions); + sendMessage({ type: 'ssh:resize', payload: dimensions }); + }; + + // --- WebSocket 消息处理 --- + + const handleSshOutput = (payload: MessagePayload, message: WebSocketMessage) => { + let outputData = payload; + // 检查是否为 Base64 编码 (需要后端配合发送 encoding 字段) + if (message.encoding === 'base64' && typeof outputData === 'string') { + try { + outputData = atob(outputData); // 在浏览器环境中使用 atob + } catch (e) { + console.error('[SSH终端模块] Base64 解码失败:', e, '原始数据:', message.payload); + outputData = `\r\n[解码错误: ${e}]\r\n`; // 在终端显示解码错误 + } + } + // 如果不是 base64 或解码失败,确保它是字符串 + else if (typeof outputData !== 'string') { + console.warn('[SSH终端模块] 收到非字符串 ssh:output payload:', outputData); + try { + outputData = JSON.stringify(outputData); // 尝试序列化 + } catch { + outputData = String(outputData); // 最后手段:强制转字符串 + } + } + + if (terminalInstance.value) { + terminalInstance.value.write(outputData); + } else { + // 如果终端还没准备好,先缓冲输出 + terminalOutputBuffer.value.push(outputData); + } + }; + + const handleSshConnected = () => { + console.log('[SSH终端模块] SSH 会话已连接。'); + // 连接成功后聚焦终端 + terminalInstance.value?.focus(); + // 清空可能存在的旧缓冲(虽然理论上此时应该已经 ready 了) + if (terminalOutputBuffer.value.length > 0) { + console.warn('[SSH终端模块] SSH 连接时仍有缓冲数据,正在写入...'); + terminalOutputBuffer.value.forEach(data => terminalInstance.value?.write(data)); + terminalOutputBuffer.value = []; + } + }; + + const handleSshDisconnected = (payload: MessagePayload) => { + const reason = payload || t('workspace.terminal.unknownReason'); // 使用 i18n 获取未知原因文本 + console.log('[SSH终端模块] SSH 会话已断开:', reason); + terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('disconnectMsg', { reason })}\x1b[0m`); + // 可以在这里添加其他清理逻辑,例如禁用输入 + }; + + const handleSshError = (payload: MessagePayload) => { + const errorMsg = payload || t('workspace.terminal.unknownSshError'); // 使用 i18n + console.error('[SSH终端模块] SSH 错误:', errorMsg); + terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('genericErrorMsg', { message: errorMsg })}\x1b[0m`); + }; + + const handleSshStatus = (payload: MessagePayload) => { + // 这个消息现在由 useWebSocketConnection 处理以更新全局状态栏消息 + // 这里可以保留日志或用于其他特定于终端的 UI 更新(如果需要) + const statusKey = payload?.key || 'unknown'; + const statusParams = payload?.params || {}; + console.log('[SSH终端模块] 收到 SSH 状态更新:', statusKey, statusParams); + // 可以在终端打印一些状态信息吗? + // terminalInstance.value?.writeln(`\r\n\x1b[34m[状态: ${statusKey}]\x1b[0m`); + }; + + const handleInfoMessage = (payload: MessagePayload) => { + console.log('[SSH终端模块] 收到后端信息:', payload); + terminalInstance.value?.writeln(`\r\n\x1b[34m${getTerminalText('infoPrefix')} ${payload}\x1b[0m`); + }; + + const handleErrorMessage = (payload: MessagePayload) => { + // 通用错误也可能需要显示在终端 + const errorMsg = payload || t('workspace.terminal.unknownGenericError'); // 使用 i18n + console.error('[SSH终端模块] 收到后端通用错误:', errorMsg); + terminalInstance.value?.writeln(`\r\n\x1b[31m${getTerminalText('errorPrefix')} ${errorMsg}\x1b[0m`); + }; + + + // --- 注册 WebSocket 消息处理器 --- + const unregisterHandlers: (() => void)[] = []; + + const registerSshHandlers = () => { + unregisterHandlers.push(onMessage('ssh:output', handleSshOutput)); + unregisterHandlers.push(onMessage('ssh:connected', handleSshConnected)); + unregisterHandlers.push(onMessage('ssh:disconnected', handleSshDisconnected)); + unregisterHandlers.push(onMessage('ssh:error', handleSshError)); + unregisterHandlers.push(onMessage('ssh:status', handleSshStatus)); + unregisterHandlers.push(onMessage('info', handleInfoMessage)); + unregisterHandlers.push(onMessage('error', handleErrorMessage)); // 也处理通用错误 + console.log('[SSH终端模块] 已注册 SSH 相关消息处理器。'); + }; + + const unregisterAllSshHandlers = () => { + console.log('[SSH终端模块] 注销 SSH 相关消息处理器...'); + unregisterHandlers.forEach(unregister => unregister?.()); + unregisterHandlers.length = 0; // 清空数组 + }; + + // --- 清理 --- + onUnmounted(() => { + unregisterAllSshHandlers(); + // terminalInstance.value?.dispose(); // 终端实例的销毁由 TerminalComponent 负责 + terminalInstance.value = null; + console.log('[SSH终端模块] Composable 已卸载。'); + }); + + // --- 暴露给组件的接口 --- + return { + terminalInstance, // 暴露终端实例 ref,以便组件可以访问(如果需要) + handleTerminalReady, + handleTerminalData, + handleTerminalResize, + registerSshHandlers, // 暴露注册函数,由父组件在连接后调用 + unregisterAllSshHandlers, // 暴露注销函数,在断开或卸载时调用 + }; +} diff --git a/packages/frontend/src/composables/useStatusMonitor.ts b/packages/frontend/src/composables/useStatusMonitor.ts new file mode 100644 index 0000000..1c75ffd --- /dev/null +++ b/packages/frontend/src/composables/useStatusMonitor.ts @@ -0,0 +1,71 @@ +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'; // 从类型文件导入 + +// --- 接口定义 (已移至 server.types.ts) --- + +export function useStatusMonitor() { + const { onMessage, isConnected } = useWebSocketConnection(); + + const serverStatus = ref(null); + const statusError = ref(null); // 存储状态获取错误 + + // --- WebSocket 消息处理 --- + const handleStatusUpdate = (payload: MessagePayload, message: WebSocketMessage) => { + // console.debug('[状态监控模块] 收到 status_update:', payload); + if (payload && payload.status) { + serverStatus.value = payload.status; + statusError.value = null; // 收到有效状态时清除错误 + } else { + console.warn('[状态监控模块] 收到缺少 payload.status 的 status_update 消息'); + // 可以选择设置一个错误状态,表明数据格式不正确 + // statusError.value = '收到的状态数据格式无效'; + } + }; + + // 处理可能的后端状态错误消息 (如果后端会发送的话) + const handleStatusError = (payload: MessagePayload, message: WebSocketMessage) => { + console.error('[状态监控模块] 收到状态错误消息:', payload); + statusError.value = typeof payload === 'string' ? payload : '获取服务器状态时发生未知错误'; + serverStatus.value = null; // 出错时清除状态数据 + }; + + // --- 注册 WebSocket 消息处理器 --- + let unregisterUpdate: (() => void) | null = null; + let unregisterError: (() => void) | null = null; + + const registerStatusHandlers = () => { + // 仅在连接时注册处理器 + if (isConnected.value) { + console.log('[状态监控模块] 注册状态消息处理器。'); + unregisterUpdate = onMessage('status_update', handleStatusUpdate); + // 假设后端可能发送 'status:error' 类型的特定错误 + unregisterError = onMessage('status:error', handleStatusError); + } else { + console.warn('[状态监控模块] WebSocket 未连接,无法注册状态处理器。'); + } + }; + + const unregisterAllStatusHandlers = () => { + console.log('[状态监控模块] 注销状态消息处理器。'); + unregisterUpdate?.(); + unregisterError?.(); + unregisterUpdate = null; + unregisterError = null; + }; + + // --- 清理 --- + onUnmounted(() => { + unregisterAllStatusHandlers(); + console.log('[状态监控模块] Composable 已卸载。'); + }); + + // --- 暴露接口 --- + return { + serverStatus: readonly(serverStatus), // 只读状态 + statusError: readonly(statusError), // 只读错误状态 + registerStatusHandlers, // 暴露注册函数 + unregisterAllStatusHandlers, // 暴露注销函数 + }; +} diff --git a/packages/frontend/src/composables/useWebSocketConnection.ts b/packages/frontend/src/composables/useWebSocketConnection.ts new file mode 100644 index 0000000..cb4c35c --- /dev/null +++ b/packages/frontend/src/composables/useWebSocketConnection.ts @@ -0,0 +1,239 @@ +import { ref, shallowRef, onUnmounted, computed, type Ref, readonly } from 'vue'; +import { useI18n } from 'vue-i18n'; +// 从类型文件导入 WebSocket 相关类型 +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 = ...; + +// --- Singleton State within the module scope --- +// This ensures only one WebSocket connection and state is managed across the app. +const ws = shallowRef(null); // Use shallowRef for the WebSocket object itself +const connectionStatus = ref('disconnected'); +const statusMessage = ref(''); +const connectionIdForSession = ref(null); // Store the connectionId used for the current session +const isSftpReady = ref(false); // Track SFTP readiness + +// Registry for message handlers +const messageHandlers = new Map>(); +// --- End Singleton State --- + + +export function useWebSocketConnection() { + const { t } = useI18n(); // Get t function for status messages + + // Helper to get status text safely + const getStatusText = (statusKey: string, params?: Record): 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 + } + }; + + // Function to dispatch a message to all registered handlers for its type + 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); + } + }); + } + }; + + + const connect = (url: string, connId: string) => { + // Prevent multiple connections or connection attempts + 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.log(`[WebSocket] Attempting to connect to: ${url} for connection ${connId}`); + connectionIdForSession.value = connId; + statusMessage.value = getStatusText('connectingWs', { url }); + connectionStatus.value = 'connecting'; + + try { + ws.value = new WebSocket(url); + + ws.value.onopen = () => { + console.log('[WebSocket] Connection opened.'); + 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' }); + }; + + ws.value.onmessage = (event: MessageEvent) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + // console.debug('[WebSocket] Received:', message.type); // Less verbose logging + + // --- Update Global Connection Status based on specific messages --- + if (message.type === 'ssh:connected') { + if (connectionStatus.value !== 'connected') { + console.log('[WebSocket] SSH session connected.'); + connectionStatus.value = 'connected'; + statusMessage.value = getStatusText('connected'); + } + } else if (message.type === 'ssh:disconnected') { + if (connectionStatus.value !== 'disconnected') { + console.log('[WebSocket] SSH session disconnected.'); + 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 + if (connectionStatus.value !== 'disconnected' && connectionStatus.value !== 'error') { + console.error('[WebSocket] Received error message:', message.payload); + connectionStatus.value = 'error'; + let errorMsg = message.payload || 'Unknown error'; + if (typeof errorMsg === 'object' && errorMsg.message) errorMsg = errorMsg.message; + statusMessage.value = getStatusText('error', { message: errorMsg }); + isSftpReady.value = false; // Reset SFTP status on error + } + } else if (message.type === 'sftp_ready') { + console.log('[WebSocket] SFTP session ready.'); + 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' }); + } + }; + + ws.value.onerror = (event) => { + console.error('[WebSocket] Connection error:', event); + if (connectionStatus.value !== 'disconnected') { // Avoid overwriting disconnect status + 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; + }; + + 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 + 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(); + }; + } catch (err) { + console.error('[WebSocket] Failed to create WebSocket instance:', err); + connectionStatus.value = 'error'; + statusMessage.value = getStatusText('wsError'); // Or a more specific creation error + isSftpReady.value = false; // Reset SFTP status on creation error + ws.value = null; + connectionIdForSession.value = null; + } + }; + + const disconnect = () => { + if (ws.value) { + console.log('[WebSocket] Closing connection manually...'); + // Set status immediately to prevent race conditions with onclose + if (connectionStatus.value !== 'disconnected') { + connectionStatus.value = 'disconnected'; + statusMessage.value = getStatusText('disconnected', { reason: 'Manual disconnect' }); + } + ws.value.close(1000, 'Client initiated disconnect'); // Use standard code and reason + ws.value = null; + connectionIdForSession.value = null; + isSftpReady.value = false; // Reset SFTP status on manual disconnect + // messageHandlers.clear(); // Clear handlers on manual disconnect + } + }; + + 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 + ws.value.send(messageString); + } catch (e) { + console.error('[WebSocket] Failed to stringify or send message:', e, message); + } + } else { + console.warn(`[WebSocket] Cannot send message, connection not open. State: ${connectionStatus.value}, ReadyState: ${ws.value?.readyState}`); + } + }; + + // Register a handler for a specific message type + const onMessage = (type: string, handler: MessageHandler) => { + 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}`); + } + + + // Return an unregister function + return () => { + const currentSet = messageHandlers.get(type); + if (currentSet) { + currentSet.delete(handler); + console.debug(`[WebSocket] Handler unregistered for type: ${type}`); + if (currentSet.size === 0) { + messageHandlers.delete(type); + } + } + }; + }; + + // 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. + + return { + // State (Exported as readonly refs where appropriate) + isConnected: computed(() => connectionStatus.value === 'connected'), + isSftpReady: readonly(isSftpReady), // Expose SFTP readiness state + connectionStatus: readonly(connectionStatus), + statusMessage: readonly(statusMessage), + + // Methods + connect, + disconnect, + sendMessage, + onMessage, + }; +} diff --git a/packages/frontend/src/types/server.types.ts b/packages/frontend/src/types/server.types.ts new file mode 100644 index 0000000..5c0866d --- /dev/null +++ b/packages/frontend/src/types/server.types.ts @@ -0,0 +1,15 @@ +// 类型定义:用于服务器状态监控数据 (从 useStatusMonitor 迁移) +export interface ServerStatus { + cpuPercent?: number; + memPercent?: number; + memUsed?: number; // MB + memTotal?: number; // MB + diskPercent?: number; + diskUsed?: number; // KB + diskTotal?: number; // KB + cpuModel?: string; + // 可以根据后端实际发送的数据添加更多字段 + // 例如:swapPercent?, swapUsed?, swapTotal?, netRxRate?, netTxRate?, netInterface?, osName?, loadAvg?, timestamp? +} + +// 可以根据需要添加其他与服务器或连接状态相关的类型 diff --git a/packages/frontend/src/types/sftp.types.ts b/packages/frontend/src/types/sftp.types.ts new file mode 100644 index 0000000..8773773 --- /dev/null +++ b/packages/frontend/src/types/sftp.types.ts @@ -0,0 +1,28 @@ +// 类型定义:用于 SFTP 文件和目录属性 +export interface FileAttributes { + size: number; + uid: number; + gid: number; + mode: number; // 文件模式 (例如 0o755) + atime: number; // 最后访问时间 (毫秒时间戳) + mtime: number; // 最后修改时间 (毫秒时间戳) + isDirectory: boolean; + isFile: boolean; + isSymbolicLink: boolean; +} + +// 类型定义:用于文件列表中的单个条目 +export interface FileListItem { + filename: string; // 文件或目录名 + longname: string; // ls -l 风格的长名称字符串 + attrs: FileAttributes; // 文件属性 +} + +// 类型定义:用于编辑器文件内容和编码 (从 useFileEditor 迁移) +export interface EditorFileContent { + content: string; + encoding: 'utf8' | 'base64'; +} + +// 类型定义:编辑器保存状态 (从 useFileEditor 迁移) +export type SaveStatus = 'idle' | 'saving' | 'success' | 'error'; diff --git a/packages/frontend/src/types/upload.types.ts b/packages/frontend/src/types/upload.types.ts new file mode 100644 index 0000000..224fb07 --- /dev/null +++ b/packages/frontend/src/types/upload.types.ts @@ -0,0 +1,11 @@ +// 类型定义:用于文件上传任务 +export interface UploadItem { + id: string; // 上传任务的唯一标识符 + file: File; // 要上传的文件对象 + filename: string; // 文件名 + progress: number; // 上传进度 (0-100) + error?: string; // 错误信息 + status: 'pending' | 'uploading' | 'paused' | 'success' | 'error' | 'cancelled'; // 上传状态 +} + +// 可以根据需要添加其他与上传相关的类型 diff --git a/packages/frontend/src/types/websocket.types.ts b/packages/frontend/src/types/websocket.types.ts new file mode 100644 index 0000000..449c03c --- /dev/null +++ b/packages/frontend/src/types/websocket.types.ts @@ -0,0 +1,17 @@ +// WebSocket 连接状态类型 +export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; + +// 通用消息负载类型定义 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type MessagePayload = any; + +// WebSocket 消息结构接口 +export interface WebSocketMessage { + type: string; // 消息类型 + payload?: MessagePayload; // 消息负载 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; // 允许其他属性,如 requestId, encoding 等 +} + +// 消息处理器函数类型 +export type MessageHandler = (payload: MessagePayload, message: WebSocketMessage) => void; diff --git a/packages/frontend/src/views/WorkspaceView.vue b/packages/frontend/src/views/WorkspaceView.vue index e1c93f3..29d4ccc 100644 --- a/packages/frontend/src/views/WorkspaceView.vue +++ b/packages/frontend/src/views/WorkspaceView.vue @@ -1,232 +1,115 @@ +// 监听 connectionId 变化 (例如,在工作区之间导航) +watch(connectionId, (newId, oldId) => { + if (newId && newId !== oldId) { + console.log(`[工作区视图] 连接 ID 从 ${oldId} 更改为 ${newId}。正在重新连接...`); + // 断开现有连接,注销处理器,然后为新 ID 连接并注册 + disconnect(); + unregisterAllSshHandlers(); + unregisterAllStatusHandlers(); // 使用状态监控模块的注销函数 + // serverStatus 和 statusError 由 useStatusMonitor 自动管理,无需手动重置 + + // 重新连接 + const wsUrl = `ws://${window.location.hostname}:3001`; + connect(wsUrl, newId); + // registerSshHandlers(); // 注册移至 isConnected watch + // registerStatusHandlers(); // 注册移至 isConnected watch + } else if (!newId && oldId) { + // 导航离开工作区视图 + disconnect(); // isConnected 会变为 false,自动触发清理 + // unregisterAllSshHandlers(); // 注销移至 isConnected watch + // unregisterAllStatusHandlers(); // 注销移至 isConnected watch + } + }); + +// 监听 WebSocket 连接状态变化来注册/注销处理器 +watch(isConnected, (connected) => { + if (connected) { + console.log('[工作区视图] WebSocket 已连接,注册 SSH 和状态处理器。'); + registerSshHandlers(); + registerStatusHandlers(); + } else { + console.log('[工作区视图] WebSocket 已断开,注销 SSH 和状态处理器。'); + // isConnected 变为 false 时,确保清理 + unregisterAllSshHandlers(); + unregisterAllStatusHandlers(); + // 注意:disconnect() 应该在 connectionId 变化或组件卸载时调用, + // isConnected 变为 false 是结果,而不是原因。 + } +}); + + // 辅助函数:获取终端消息文本 (已移至 useSshTerminal) + +