diff --git a/packages/backend/src/services/sftp.service.ts b/packages/backend/src/services/sftp.service.ts index 1fd8fdf..40f3d67 100644 --- a/packages/backend/src/services/sftp.service.ts +++ b/packages/backend/src/services/sftp.service.ts @@ -1,9 +1,18 @@ import { Client, SFTPWrapper, Stats, WriteStream } from 'ssh2'; // Import WriteStream (Removed Dirent) import { WebSocket } from 'ws'; -import { ClientState } from '../websocket'; // 导入统一的 ClientState +import { ClientState, AuthenticatedWebSocket } from '../websocket/types'; // 导入统一的 ClientState 和 AuthenticatedWebSocket import * as pathModule from 'path'; // +++ Import path module +++ import * as jschardet from 'jschardet'; // +++ Import jschardet +++ import * as iconv from 'iconv-lite'; // +++ Import iconv-lite +++ +// +++ 导入新类型 +++ +import { + SftpCompressRequestPayload, + SftpCompressSuccessPayload, + SftpCompressErrorPayload, + SftpDecompressRequestPayload, + SftpDecompressSuccessPayload, + SftpDecompressErrorPayload +} from '../websocket/types'; // +++ Define local interface for readdir results +++ interface SftpDirEntry { @@ -796,8 +805,7 @@ export class SftpService { console.error(`[SFTP ${sessionId}] Move failed: Target path ${newPath} already exists (ID: ${requestId})`); throw new Error(`目标路径 ${pathModule.basename(newPath)} 已存在`); } - // --- 结束新增 --- - + console.log(`[SFTP ${sessionId}] Moving ${oldPath} to ${newPath} (ID: ${requestId})`); await this.performRename(sftp, oldPath, newPath); // Use helper for rename logic @@ -1017,6 +1025,374 @@ export class SftpService { } + // --- Compress/Decompress Methods --- +/** + * 压缩远程服务器上的文件/目录 + * @param sessionId 会话 ID + * @param payload 压缩请求的 payload + */ + async compress(sessionId: string, payload: SftpCompressRequestPayload): Promise { + const state = this.clientStates.get(sessionId); + const { sources, destinationArchiveName, format, targetDirectory, requestId } = payload; + + if (!state || !state.sshClient) { + console.warn(`[SFTP Compress] SSH 客户端未准备好,无法在 ${sessionId} 上执行 compress (ID: ${requestId})`); + this.sendCompressError(state?.ws, 'SSH 会话未就绪', requestId); + return; + } + + // 命令检查 + const requiredCommand = format === 'zip' ? 'zip' : 'tar'; + try { + const commandExists = await this.checkCommandExists(state, sessionId, requiredCommand); // 传递 sessionId + if (!commandExists) { + this.sendCompressError(state.ws, `命令 '${requiredCommand}' 在服务器上未找到`, requestId, `Command '${requiredCommand}' not found on server.`); + return; + } + } catch (checkError: any) { + this.sendCompressError(state.ws, `检查命令 '${requiredCommand}' 时出错`, requestId, checkError.message); + return; + } + + console.debug(`[SFTP Compress ${sessionId}] Received request (ID: ${requestId}). Sources: ${sources.join(', ')}, Dest: ${destinationArchiveName}, Format: ${format}, Dir: ${targetDirectory}`); + + // 构建目标压缩包的完整路径 + const destinationArchivePath = pathModule.posix.join(targetDirectory, destinationArchiveName); + + // --- 构建 Shell 命令 --- + let command: string; + // --- 修改:计算相对路径并引用 --- + const relativeSources = sources.map((s: string) => { + // 计算相对于 targetDirectory 的路径 + const relativePath = pathModule.posix.relative(targetDirectory, s); + // 如果计算出的相对路径为空或'.', 表示源文件就在目标目录下,直接使用文件名 + // 否则使用计算出的相对路径 + return (relativePath === '' || relativePath === '.') ? pathModule.posix.basename(s) : relativePath; + }); + const quotedRelativeSources = relativeSources.map((s: string) => `"${s.replace(/"/g, '\\"')}"`).join(' '); + + // 确保目标目录和压缩包路径被正确引用 + const quotedTargetDir = `"${targetDirectory.replace(/"/g, '\\"')}"`; + // const quotedDestPath = `"${destinationArchivePath.replace(/"/g, '\\"')}"`; // 目标路径在命令中不直接使用,使用相对名称 + const quotedDestName = `"${destinationArchiveName.replace(/"/g, '\\"')}"`; + + const cdCommand = `cd ${quotedTargetDir}`; + + switch (format) { + case 'zip': + // zip -r [归档名] [源文件/目录列表] + // 需要在目标目录执行 + command = `${cdCommand} && zip -r ${quotedDestName} ${quotedRelativeSources}`; // 使用相对路径 + break; + case 'targz': + // tar -czvf [归档名] [源文件/目录列表] + // 需要在目标目录执行 + command = `${cdCommand} && tar -czvf ${quotedDestName} ${quotedRelativeSources}`; // 使用相对路径 + break; + case 'tarbz2': + // tar -cjvf [归档名] [源文件/目录列表] + // 需要在目标目录执行 + command = `${cdCommand} && tar -cjvf ${quotedDestName} ${quotedRelativeSources}`; // 使用相对路径 + break; + default: + this.sendCompressError(state.ws, `不支持的压缩格式: ${format}`, requestId); + return; + } + + console.log(`[SFTP Compress ${sessionId}] Executing command: ${command} (ID: ${requestId})`); + + // --- 执行命令 --- + try { + state.sshClient.exec(command, (err, stream) => { + if (err) { + console.error(`[SFTP Compress ${sessionId}] Failed to start exec for compress (ID: ${requestId}):`, err); + this.sendCompressError(state.ws, `执行压缩命令失败: ${err.message}`, requestId); + return; + } + + let stdoutData = ''; + let stderrData = ''; + let code: number | null = null; // Track exit code + + stream.on('data', (data: Buffer) => { + stdoutData += data.toString(); + // console.debug(`[SFTP Compress ${sessionId}] stdout: ${data.toString()}`); + }); + stream.stderr.on('data', (data: Buffer) => { + stderrData += data.toString(); + // console.debug(`[SFTP Compress ${sessionId}] stderr: ${data.toString()}`); + }); + + stream.on('close', (exitCode: number | null) => { + code = exitCode; // Store exit code + console.log(`[SFTP Compress ${sessionId}] Command finished with code ${code} (ID: ${requestId}). Stderr: ${stderrData.trim()}`); + if (code === 0 && !this.isErrorInStdErr(stderrData)) { // 检查退出码和 stderr + console.log(`[SFTP Compress ${sessionId}] Compression successful (ID: ${requestId}).`); + const successPayload: SftpCompressSuccessPayload = { + message: '压缩成功', + requestId: requestId + }; + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: 'sftp:compress:success', requestId: requestId, payload: successPayload })); // Ensure requestId is included + } + } else { + const errorDetails = stderrData.trim() || `压缩命令退出,代码: ${code ?? 'N/A'}`; + console.error(`[SFTP Compress ${sessionId}] Compression failed (ID: ${requestId}): ${errorDetails}`); + this.sendCompressError(state.ws, '压缩失败', requestId, errorDetails); + } + }); + stream.on('error', (streamErr: Error) => { + console.error(`[SFTP Compress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr); + // 避免重复发送错误 + if (!stderrData && code === undefined) { // 仅当 close 事件未触发且 stderr 为空时发送 + this.sendCompressError(state.ws, '压缩命令流错误', requestId, streamErr.message); + } + }); + }); + } catch (execError: any) { + console.error(`[SFTP Compress ${sessionId}] Compress command caught unexpected error during exec setup (ID: ${requestId}):`, execError); + this.sendCompressError(state.ws, `执行压缩时发生意外错误: ${execError.message}`, requestId); + } + } + + /** + * 解压远程服务器上的压缩文件 + * @param sessionId 会话 ID + * @param payload 解压请求的 payload + */ + async decompress(sessionId: string, payload: SftpDecompressRequestPayload): Promise { + const state = this.clientStates.get(sessionId); + const { archivePath, requestId } = payload; + + if (!state || !state.sshClient) { + console.warn(`[SFTP Decompress] SSH 客户端未准备好,无法在 ${sessionId} 上执行 decompress (ID: ${requestId})`); + this.sendDecompressError(state?.ws, 'SSH 会话未就绪', requestId); + return; + } + + const lowerArchivePath = archivePath.toLowerCase(); // 在此声明一次 + + // 命令检查 + let requiredCommand = ''; + // 使用已经声明的 lowerArchivePath + if (lowerArchivePath.endsWith('.zip')) { + requiredCommand = 'unzip'; + } else if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz') || lowerArchivePath.endsWith('.tar.bz2') || lowerArchivePath.endsWith('.tbz2')) { + requiredCommand = 'tar'; + } else { + this.sendDecompressError(state.ws, `不支持的压缩文件格式: ${archivePath}`, requestId); + return; + } + + try { + const commandExists = await this.checkCommandExists(state, sessionId, requiredCommand); // 传递 sessionId + if (!commandExists) { + this.sendDecompressError(state.ws, `命令 '${requiredCommand}' 在服务器上未找到`, requestId, `Command '${requiredCommand}' not found on server.`); + return; + } + } catch (checkError: any) { + this.sendDecompressError(state.ws, `检查命令 '${requiredCommand}' 时出错`, requestId, checkError.message); + return; + } + + console.debug(`[SFTP Decompress ${sessionId}] Received request for ${archivePath} (ID: ${requestId})`); + + const extractDir = pathModule.posix.dirname(archivePath); + const archiveBasename = pathModule.posix.basename(archivePath); + + // --- 构建 Shell 命令 --- + let command: string; + // 确保路径被正确引用 + const quotedExtractDir = `"${extractDir.replace(/"/g, '\\"')}"`; + const quotedArchiveBasename = `"${archiveBasename.replace(/"/g, '\\"')}"`; + + const cdCommand = `cd ${quotedExtractDir}`; + + // 使用在方法开始处声明的 lowerArchivePath + if (lowerArchivePath.endsWith('.zip')) { + // unzip -o [压缩包名] + // 需要在目标目录执行 + command = `${cdCommand} && unzip -o ${quotedArchiveBasename}`; + } else if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz')) { + // tar -xzvf [压缩包名] + // 需要在目标目录执行 + command = `${cdCommand} && tar -xzvf ${quotedArchiveBasename}`; + } else if (lowerArchivePath.endsWith('.tar.bz2') || lowerArchivePath.endsWith('.tbz2')) { + // tar -xjvf [压缩包名] + // 需要在目标目录执行 + command = `${cdCommand} && tar -xjvf ${quotedArchiveBasename}`; + } else { + this.sendDecompressError(state.ws, `不支持的压缩文件格式: ${archivePath}`, requestId); + return; + } + + console.log(`[SFTP Decompress ${sessionId}] Executing command: ${command} (ID: ${requestId})`); + + // --- 执行命令 --- + try { + state.sshClient.exec(command, (err, stream) => { + if (err) { + console.error(`[SFTP Decompress ${sessionId}] Failed to start exec for decompress (ID: ${requestId}):`, err); + this.sendDecompressError(state.ws, `执行解压命令失败: ${err.message}`, requestId); + return; + } + + let stdoutData = ''; + let stderrData = ''; + let code: number | null = null; // Track exit code + + stream.on('data', (data: Buffer) => { + stdoutData += data.toString(); + // console.debug(`[SFTP Decompress ${sessionId}] stdout: ${data.toString()}`); + }); + stream.stderr.on('data', (data: Buffer) => { + stderrData += data.toString(); + // console.debug(`[SFTP Decompress ${sessionId}] stderr: ${data.toString()}`); + }); + + stream.on('close', (exitCode: number | null) => { + code = exitCode; // Store exit code + console.log(`[SFTP Decompress ${sessionId}] Command finished with code ${code} (ID: ${requestId}). Stderr: ${stderrData.trim()}`); + if (code === 0 && !this.isErrorInStdErr(stderrData)) { // 检查退出码和 stderr + console.log(`[SFTP Decompress ${sessionId}] Decompression successful (ID: ${requestId}).`); + const successPayload: SftpDecompressSuccessPayload = { + message: '解压成功', + requestId: requestId + }; + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: 'sftp:decompress:success', requestId: requestId, payload: successPayload })); // Ensure requestId is included + } + } else { + const errorDetails = stderrData.trim() || `解压命令退出,代码: ${code ?? 'N/A'}`; + console.error(`[SFTP Decompress ${sessionId}] Decompression failed (ID: ${requestId}): ${errorDetails}`); + this.sendDecompressError(state.ws, '解压失败', requestId, errorDetails); + } + }); + stream.on('error', (streamErr: Error) => { + console.error(`[SFTP Decompress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr); + // 避免重复发送错误 + if (!stderrData && code === undefined) { // 仅当 close 事件未触发且 stderr 为空时发送 + this.sendDecompressError(state.ws, '解压命令流错误', requestId, streamErr.message); + } + }); + }); + } catch (execError: any) { + console.error(`[SFTP Decompress ${sessionId}] Decompress command caught unexpected error during exec setup (ID: ${requestId}):`, execError); + this.sendDecompressError(state.ws, `执行解压时发生意外错误: ${execError.message}`, requestId); + } + } + + // --- 辅助方法 --- + + /** 检查远程服务器上是否存在指定的命令 */ + private checkCommandExists(state: ClientState, sessionId: string, commandName: string): Promise { + return new Promise((resolve, reject) => { + if (!state.sshClient) { + return reject(new Error('SSH client is not available.')); + } + // 优先使用 command -v, 其次 which + const checkCommands = [`command -v ${commandName}`, `which ${commandName}`]; + let currentCheckIndex = 0; + + const tryCommand = () => { + if (currentCheckIndex >= checkCommands.length) { + resolve(false); // 所有检查命令都尝试过了,未找到 + return; + } + const checkCmd = checkCommands[currentCheckIndex]; + console.log(`[SFTP Command Check ${sessionId}] Executing: ${checkCmd}`); + state.sshClient.exec(checkCmd, (err, stream) => { + if (err) { + console.error(`[SFTP Command Check ${sessionId}] Failed to start exec for "${checkCmd}":`, err); + currentCheckIndex++; + tryCommand(); // 尝试下一个检查命令 + return; + } + let output = ''; + stream.on('data', (data: Buffer) => { + output += data.toString(); + }); + stream.on('close', (code: number | null) => { + if (code === 0 && output.trim() !== '') { + console.log(`[SFTP Command Check ${sessionId}] Command '${commandName}' found using "${checkCmd}". Output: ${output.trim()}`); + resolve(true); + } else { + console.log(`[SFTP Command Check ${sessionId}] Command '${commandName}' not found with "${checkCmd}" (code: ${code}, output: "${output.trim()}").`); + currentCheckIndex++; + tryCommand(); // 尝试下一个检查命令 + } + }); + stream.stderr.on('data', (data: Buffer) => { + // console.debug(`[SFTP Command Check ${sessionId}] stderr for "${checkCmd}": ${data.toString()}`); + }); + stream.on('error', (streamErr: Error) => { + console.error(`[SFTP Command Check ${sessionId}] Stream error for "${checkCmd}":`, streamErr); + currentCheckIndex++; + tryCommand(); // 尝试下一个检查命令 + }); + }); + }; + tryCommand(); + }); + } + + + /** 发送压缩错误消息 */ + private sendCompressError(ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string): void { + if (ws && ws.readyState === WebSocket.OPEN) { + const payload: SftpCompressErrorPayload = { error, requestId }; + if (details) payload.details = details; + // 检查是否是命令未找到的特定错误 + if (error.includes('在服务器上未找到')) { + ws.send(JSON.stringify({ type: 'sftp:command_not_found', payload: { operation: 'compress', command: error.match(/'([^']+)'/)?.[1] || 'unknown', message: details || error }, requestId })); + } else { + ws.send(JSON.stringify({ type: 'sftp:compress:error', payload })); + } + } else { + console.warn(`[SFTP Compress] WebSocket closed or invalid, cannot send error for request ${requestId}.`); + } + } + + /** 发送解压错误消息 */ + private sendDecompressError(ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string): void { + if (ws && ws.readyState === WebSocket.OPEN) { + const payload: SftpDecompressErrorPayload = { error, requestId }; + if (details) payload.details = details; + // 检查是否是命令未找到的特定错误 + if (error.includes('在服务器上未找到')) { + ws.send(JSON.stringify({ type: 'sftp:command_not_found', payload: { operation: 'decompress', command: error.match(/'([^']+)'/)?.[1] || 'unknown', message: details || error }, requestId })); + } else { + ws.send(JSON.stringify({ type: 'sftp:decompress:error', payload })); + } + } else { + console.warn(`[SFTP Decompress] WebSocket closed or invalid, cannot send error for request ${requestId}.`); + } + } + + /** 检查 stderr 输出是否包含表示错误的常见模式 */ + private isErrorInStdErr(stderr: string): boolean { + if (!stderr || stderr.trim().length === 0) { + return false; // 空 stderr 不是错误 + } + const lowerStderr = stderr.toLowerCase(); + // 常见的错误关键词或模式 + const errorPatterns = [ + 'error', 'fail', 'cannot', 'not found', 'no such file', 'permission denied', 'invalid', '不支持' + ]; + // tar/zip 进度信息通常包含百分比或文件名,不应视为错误 + if (/[\d.]+%/.test(stderr) || /adding:/.test(lowerStderr) || /inflating:/.test(lowerStderr) || /extracting:/.test(lowerStderr)) { + // 忽略一些明确的非错误输出 + if (errorPatterns.some(pattern => lowerStderr.includes(pattern))) { + // 如果进度信息中包含错误关键词,则可能真的是错误 + return true; + } + return false; + } + + return errorPatterns.some(pattern => lowerStderr.includes(pattern)); + } + + // --- File Upload Methods --- /** Start a new file upload */ @@ -1053,8 +1429,7 @@ export class SftpService { return; // Stop the upload process } } - // --- 结束新增 --- - + // --- 预检查文件是否可写 --- console.log(`[SFTP Upload ${uploadId}] Pre-checking writability for: ${remotePath}`); try { @@ -1083,8 +1458,7 @@ export class SftpService { state.ws.send(JSON.stringify({ type: 'sftp:upload:error', payload: { uploadId, message: `文件不可写或创建失败: ${preCheckError.message}` } })); return; // Stop if pre-check fails } - // --- 结束新增 --- - + console.log(`[SFTP Upload ${uploadId}] Creating write stream for: ${remotePath}`); // 确保 state.sftp 存在 diff --git a/packages/backend/src/sftp/sftp.controller.ts b/packages/backend/src/sftp/sftp.controller.ts index 27c1636..e3d54c8 100644 --- a/packages/backend/src/sftp/sftp.controller.ts +++ b/packages/backend/src/sftp/sftp.controller.ts @@ -1,10 +1,11 @@ import { Request, Response } from 'express'; import path from 'path'; -import { clientStates, ClientState } from '../websocket'; // +++ 导入 ClientState +++ -import * as archiver from 'archiver'; // +++ 引入 archiver +++ (尝试修复 TS2349) -import { SFTPWrapper, Stats } from 'ssh2'; // +++ 移除 Dirent 导入 +++ -// 移除 ssh2-streams 导入 - +import { clientStates } from '../websocket'; +import * as archiver from 'archiver'; +import { SFTPWrapper, Stats } from 'ssh2'; +import { WebSocket } from 'ws'; +import { ClientState, AuthenticatedWebSocket } from '../websocket/types'; +import { SftpCompressRequestPayload, SftpDecompressRequestPayload, SftpCompressSuccessPayload, SftpCompressErrorPayload, SftpDecompressSuccessPayload, SftpDecompressErrorPayload } from '../websocket/types'; // Import payload types /** * 处理文件下载请求 (GET /api/v1/sftp/download) */ @@ -51,7 +52,7 @@ export const downloadFile = async (req: Request, res: Response): Promise = } const userSftpSession = targetState.sftp; // 获取正确的 SFTP 实例 - // --- 结束修改 --- + try { // 获取文件状态以确定文件大小(可选,但有助于设置 Content-Length) @@ -158,7 +159,7 @@ export const downloadDirectory = async (req: Request, res: Response): Promise { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type, payload: { error: message, details, requestId } })); + } else { + console.warn(`WebSocket closed or invalid, cannot send error for request ${requestId}. Type: ${type}, Message: ${message}`); + } +}; + +/** + * 发送压缩错误消息 + */ +const sendCompressError = (ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string) => { + const payload: SftpCompressErrorPayload = { error, requestId }; + if (details) payload.details = details; + sendWebSocketError(ws, 'sftp:compress:error', error, requestId, payload); +}; + +/** + * 发送解压错误消息 + */ +const sendDecompressError = (ws: AuthenticatedWebSocket | undefined, error: string, requestId: string, details?: string) => { + const payload: SftpDecompressErrorPayload = { error, requestId }; + if (details) payload.details = details; + sendWebSocketError(ws, 'sftp:decompress:error', error, requestId, payload); +}; + + +/** + * 检查 stderr 输出是否包含表示错误的常见模式 (从 SftpService 复制过来) + */ +const isErrorInStdErr = (stderr: string): boolean => { + if (!stderr || stderr.trim().length === 0) { + return false; // 空 stderr 不是错误 + } + const lowerStderr = stderr.toLowerCase(); + // 常见的错误关键词或模式 + const errorPatterns = [ + 'error', 'fail', 'cannot', 'not found', 'no such file', 'permission denied', 'invalid', '不支持' + ]; + // tar/zip 进度信息通常包含百分比或文件名,不应视为错误 + if (/[\d.]+%/.test(stderr) || /adding:/.test(lowerStderr) || /inflating:/.test(lowerStderr) || /extracting:/.test(lowerStderr)) { + // 忽略一些明确的非错误输出 + if (errorPatterns.some(pattern => lowerStderr.includes(pattern))) { + // 如果进度信息中包含错误关键词,则可能真的是错误 + return true; + } + return false; + } + + return errorPatterns.some(pattern => lowerStderr.includes(pattern)); +}; + + +/** + * 处理 'sftp:compress' WebSocket 消息 + * @param ws WebSocket 连接实例 + * @param payload 消息负载 + */ +export const handleCompressRequest = async (ws: AuthenticatedWebSocket, payload: SftpCompressRequestPayload): Promise => { + const { sources, destinationArchiveName, format, targetDirectory, requestId } = payload; + const sessionId = ws.sessionId; // 从 AuthenticatedWebSocket 获取 sessionId + + if (!sessionId) { + console.error(`[WS SFTP Compress] Missing sessionId on WebSocket for request (ID: ${requestId}).`); + sendCompressError(ws, '内部错误:缺少会话 ID', requestId); + return; + } + + + const state = clientStates.get(sessionId); + + console.log(`[WS SFTP Compress ${sessionId}] Received request (ID: ${requestId}).`); + + if (!state || !state.sshClient) { + console.warn(`[WS SFTP Compress ${sessionId}] SSH client not ready (ID: ${requestId})`); + sendCompressError(ws, 'SSH 会话未就绪', requestId); + return; + } + + console.debug(`[WS SFTP Compress ${sessionId}] Processing compress request (ID: ${requestId}). Sources: ${sources.join(', ')}, Dest: ${destinationArchiveName}, Format: ${format}, Dir: ${targetDirectory}`); + + // 构建目标压缩包的完整路径 (使用 posix 风格) + const destinationArchivePath = path.posix.join(targetDirectory, destinationArchiveName); + + // --- 构建 Shell 命令 --- + let command: string; + // 确保源路径被正确引用,特别是包含空格或特殊字符时 + // 注意:源路径是相对于 targetDirectory 的 + const quotedSources = sources.map((s: string) => `"${s.replace(/"/g, '\\"')}"`).join(' '); + // 确保目标目录和压缩包名称被正确引用 + const quotedTargetDir = `"${targetDirectory.replace(/"/g, '\\"')}"`; + const quotedDestName = `"${destinationArchiveName.replace(/"/g, '\\"')}"`; + + const cdCommand = `cd ${quotedTargetDir}`; + + switch (format) { + case 'zip': + // zip -r [归档名] [源文件/目录列表] + command = `${cdCommand} && zip -qr ${quotedDestName} ${quotedSources}`; // -q for quiet to reduce stderr noise + break; + case 'targz': + // tar -czvf [归档名] [源文件/目录列表] + command = `${cdCommand} && tar -czf ${quotedDestName} ${quotedSources}`; // removed -v for less noise + break; + case 'tarbz2': + // tar -cjvf [归档名] [源文件/目录列表] + command = `${cdCommand} && tar -cjf ${quotedDestName} ${quotedSources}`; // removed -v for less noise + break; + default: + sendCompressError(ws, `不支持的压缩格式: ${format}`, requestId); + return; + } + + console.log(`[WS SFTP Compress ${sessionId}] Executing command: ${command} (ID: ${requestId})`); + + // --- 执行命令 --- + try { + state.sshClient.exec(command, (err, stream) => { + if (err) { + console.error(`[WS SFTP Compress ${sessionId}] Failed to start exec (ID: ${requestId}):`, err); + sendCompressError(ws, `执行压缩命令失败: ${err.message}`, requestId); + return; + } + + let stderrData = ''; + let stdoutData = ''; // Capture stdout for debugging if needed + let exitCode: number | null = null; + + stream.on('data', (data: Buffer) => { + stdoutData += data.toString(); + // console.debug(`[WS SFTP Compress ${sessionId}] stdout: ${data}`); + }); + stream.stderr.on('data', (data: Buffer) => { + stderrData += data.toString(); + console.debug(`[WS SFTP Compress ${sessionId}] stderr: ${data}`); // Log stderr for debugging + }); + + stream.on('close', (code: number | null) => { + exitCode = code; + console.log(`[WS SFTP Compress ${sessionId}] Command finished with code ${exitCode} (ID: ${requestId}). Stderr length: ${stderrData.length}`); + if (exitCode === 0 && !isErrorInStdErr(stderrData)) { + console.log(`[WS SFTP Compress ${sessionId}] Compression successful (ID: ${requestId}).`); + const successPayload: SftpCompressSuccessPayload = { + message: '压缩成功', + requestId: requestId, + // Optionally add archive path or details here + // archivePath: destinationArchivePath + }; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'sftp:compress:success', payload: successPayload })); + } + } else { + const errorDetails = stderrData.trim() || `压缩命令退出,代码: ${exitCode ?? 'N/A'}`; + console.error(`[WS SFTP Compress ${sessionId}] Compression failed (ID: ${requestId}): ${errorDetails}`); + sendCompressError(ws, '压缩失败', requestId, errorDetails); + } + }); + + stream.on('error', (streamErr: Error) => { + console.error(`[WS SFTP Compress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr); + // Avoid sending duplicate errors if 'close' already indicated failure + if (exitCode === null) { + sendCompressError(ws, '压缩命令流错误', requestId, streamErr.message); + } + }); + }); + } catch (execError: any) { + console.error(`[WS SFTP Compress ${sessionId}] Unexpected error setting up exec (ID: ${requestId}):`, execError); + sendCompressError(ws, `执行压缩时发生意外错误: ${execError.message}`, requestId); + } +}; + +/** + * 处理 'sftp:decompress' WebSocket 消息 + * @param ws WebSocket 连接实例 + * @param payload 消息负载 + */ +export const handleDecompressRequest = async (ws: AuthenticatedWebSocket, payload: SftpDecompressRequestPayload): Promise => { + const { archivePath, requestId } = payload; + const sessionId = ws.sessionId; + + if (!sessionId) { + console.error(`[WS SFTP Decompress] Missing sessionId on WebSocket for request (ID: ${requestId}).`); + sendDecompressError(ws, '内部错误:缺少会话 ID', requestId); + return; + } + + + const state = clientStates.get(sessionId); + + console.log(`[WS SFTP Decompress ${sessionId}] Received request for ${archivePath} (ID: ${requestId}).`); + + if (!state || !state.sshClient) { + console.warn(`[WS SFTP Decompress ${sessionId}] SSH client not ready (ID: ${requestId})`); + sendDecompressError(ws, 'SSH 会话未就绪', requestId); + return; + } + + console.debug(`[WS SFTP Decompress ${sessionId}] Processing decompress request for ${archivePath} (ID: ${requestId})`); + + const extractDir = path.posix.dirname(archivePath); + const archiveBasename = path.posix.basename(archivePath); + + // --- 构建 Shell 命令 --- + let command: string; + // 确保路径被正确引用 + const quotedExtractDir = `"${extractDir.replace(/"/g, '\\"')}"`; + const quotedArchiveBasename = `"${archiveBasename.replace(/"/g, '\\"')}"`; + + const cdCommand = `cd ${quotedExtractDir}`; + + const lowerArchivePath = archivePath.toLowerCase(); + + if (lowerArchivePath.endsWith('.zip')) { + // unzip -o [压缩包名] + command = `${cdCommand} && unzip -oq ${quotedArchiveBasename}`; // -o: overwrite, -q: quiet + } else if (lowerArchivePath.endsWith('.tar.gz') || lowerArchivePath.endsWith('.tgz')) { + // tar -xzvf [压缩包名] + command = `${cdCommand} && tar -xzf ${quotedArchiveBasename}`; // removed -v + } else if (lowerArchivePath.endsWith('.tar.bz2') || lowerArchivePath.endsWith('.tbz2')) { + // tar -xjvf [压缩包名] + command = `${cdCommand} && tar -xjf ${quotedArchiveBasename}`; // removed -v + } else { + sendDecompressError(ws, `不支持的压缩文件格式: ${archivePath}`, requestId); + return; + } + + console.log(`[WS SFTP Decompress ${sessionId}] Executing command: ${command} (ID: ${requestId})`); + + // --- 执行命令 --- + try { + state.sshClient.exec(command, (err, stream) => { + if (err) { + console.error(`[WS SFTP Decompress ${sessionId}] Failed to start exec (ID: ${requestId}):`, err); + sendDecompressError(ws, `执行解压命令失败: ${err.message}`, requestId); + return; + } + + let stderrData = ''; + let stdoutData = ''; + let exitCode: number | null = null; + + stream.on('data', (data: Buffer) => { + stdoutData += data.toString(); + // console.debug(`[WS SFTP Decompress ${sessionId}] stdout: ${data}`); + }); + stream.stderr.on('data', (data: Buffer) => { + stderrData += data.toString(); + console.debug(`[WS SFTP Decompress ${sessionId}] stderr: ${data}`); // Log stderr + }); + + stream.on('close', (code: number | null) => { + exitCode = code; + console.log(`[WS SFTP Decompress ${sessionId}] Command finished with code ${exitCode} (ID: ${requestId}). Stderr length: ${stderrData.length}`); + if (exitCode === 0 && !isErrorInStdErr(stderrData)) { + console.log(`[WS SFTP Decompress ${sessionId}] Decompression successful (ID: ${requestId}).`); + const successPayload: SftpDecompressSuccessPayload = { + message: '解压成功', + requestId: requestId, + // Optionally add target directory + // targetDirectory: extractDir + }; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'sftp:decompress:success', payload: successPayload })); + } + } else { + const errorDetails = stderrData.trim() || `解压命令退出,代码: ${exitCode ?? 'N/A'}`; + console.error(`[WS SFTP Decompress ${sessionId}] Decompression failed (ID: ${requestId}): ${errorDetails}`); + sendDecompressError(ws, '解压失败', requestId, errorDetails); + } + }); + + stream.on('error', (streamErr: Error) => { + console.error(`[WS SFTP Decompress ${sessionId}] Command stream error (ID: ${requestId}):`, streamErr); + if (exitCode === null) { + sendDecompressError(ws, '解压命令流错误', requestId, streamErr.message); + } + }); + }); + } catch (execError: any) { + console.error(`[WS SFTP Decompress ${sessionId}] Unexpected error setting up exec (ID: ${requestId}):`, execError); + sendDecompressError(ws, `执行解压时发生意外错误: ${execError.message}`, requestId); + } +}; diff --git a/packages/backend/src/websocket/connection.ts b/packages/backend/src/websocket/connection.ts index 3b9b63f..23b9c0a 100644 --- a/packages/backend/src/websocket/connection.ts +++ b/packages/backend/src/websocket/connection.ts @@ -117,6 +117,8 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ case 'sftp:realpath': case 'sftp:copy': case 'sftp:move': + case 'sftp:compress': + case 'sftp:decompress': await handleSftpOperation(ws, type, payload, requestId); break; @@ -132,8 +134,6 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ break; // --- SSH Suspend Cases --- - // 旧的 SSH_SUSPEND_START 逻辑已被新的 SSH_MARK_FOR_SUSPEND 和 SshSuspendService.takeOverMarkedSession 取代 - // case 'SSH_SUSPEND_START': { ... } // Removed case 'SSH_SUSPEND_LIST_REQUEST': { if (!ws.userId) { diff --git a/packages/backend/src/websocket/handlers/sftp.handler.ts b/packages/backend/src/websocket/handlers/sftp.handler.ts index 752b4ff..cfd8bb7 100644 --- a/packages/backend/src/websocket/handlers/sftp.handler.ts +++ b/packages/backend/src/websocket/handlers/sftp.handler.ts @@ -84,6 +84,35 @@ export async function handleSftpOperation( sftpService.move(sessionId, payload.sources, payload.destination, requestId); } else throw new Error("Missing 'sources' (array) or 'destination' in payload for move"); break; + case 'sftp:compress': + if (Array.isArray(payload?.sources) && payload?.destination && payload?.format && requestId) { + const destinationPath = payload.destination as string; + // 从 destinationPath 中提取 targetDirectory 和 destinationArchiveName + // pathModule.posix 总是使用 / 作为分隔符 + const pathModule = await import('path'); // 动态导入 path 模块 + const targetDirectory = pathModule.posix.dirname(destinationPath); + const destinationArchiveName = pathModule.posix.basename(destinationPath); + + const compressPayload = { + sources: payload.sources as string[], + destinationArchiveName: destinationArchiveName, + format: payload.format as 'zip' | 'targz' | 'tarbz2', + targetDirectory: targetDirectory, + requestId: requestId + }; + sftpService.compress(sessionId, compressPayload); + } else throw new Error("Missing 'sources' (array), 'destination', 'format', or 'requestId' in payload for compress"); + break; + case 'sftp:decompress': + if (payload?.source && requestId) { + const decompressPayload = { + archivePath: payload.source as string, + // destinationDirectory: payload.destination as string, // sftpService.decompress 目前不使用此参数 + requestId: requestId + }; + sftpService.decompress(sessionId, decompressPayload); + } else throw new Error("Missing 'source' or 'requestId' in payload for decompress"); + break; default: console.warn(`WebSocket: Received unhandled SFTP message type in sftp.handler: ${type}`); if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'sftp_error', payload: { message: `内部未处理的 SFTP 类型: ${type}`, requestId } })); diff --git a/packages/backend/src/websocket/types.ts b/packages/backend/src/websocket/types.ts index 41f8ff8..2e2801f 100644 --- a/packages/backend/src/websocket/types.ts +++ b/packages/backend/src/websocket/types.ts @@ -251,4 +251,46 @@ export type SshSuspendServerToClientMessages = // export type WebSocketMessage = BaseMessageType | SshSuspendClientToServerMessages | OtherFeatureMessages; // And for outgoing: // export type WebSocketResponse = BaseResponseType | SshSuspendServerToClientMessages | OtherFeatureResponses; -// This part depends on the existing structure, so I'm providing the specific types for now. \ No newline at end of file +// This part depends on the existing structure, so I'm providing the specific types for now. +// --- SFTP Compress/Decompress Message Types --- + +// C -> S: Request to compress files/directories +export interface SftpCompressRequestPayload { + sources: string[]; // Array of source paths (relative to targetDirectory) + destinationArchiveName: string; // Desired name for the archive file + format: 'zip' | 'targz' | 'tarbz2'; // Archive format + targetDirectory: string; // The directory where sources are located and where the archive will be created + requestId: string; +} + +// S -> C: Compression success +export interface SftpCompressSuccessPayload { + message: string; + requestId: string; +} + +// S -> C: Compression error +export interface SftpCompressErrorPayload { + error: string; + details?: string; // Stderr output or specific error details + requestId: string; +} + +// C -> S: Request to decompress an archive +export interface SftpDecompressRequestPayload { + archivePath: string; // Full path to the archive file + requestId: string; +} + +// S -> C: Decompression success +export interface SftpDecompressSuccessPayload { + message: string; + requestId: string; +} + +// S -> C: Decompression error +export interface SftpDecompressErrorPayload { + error: string; + details?: string; // Stderr output or specific error details + requestId: string; +} \ No newline at end of file diff --git a/packages/backend/src/websocket/upgrade.ts b/packages/backend/src/websocket/upgrade.ts index c9332a6..6433a11 100644 --- a/packages/backend/src/websocket/upgrade.ts +++ b/packages/backend/src/websocket/upgrade.ts @@ -44,7 +44,7 @@ export function initializeUpgradeHandler( // 确保 ipAddress 不是 undefined 或空字符串,否则设为 'unknown' ipAddress = ipAddress || 'unknown'; console.log(`[WebSocket Upgrade] Determined IP Address: ${ipAddress}`); - // --- 结束修改 --- + console.log(`WebSocket: 升级请求来自 IP: ${ipAddress}, Path: ${pathname}`); // 使用新获取的 ipAddress diff --git a/packages/frontend/src/components/FileManager.vue b/packages/frontend/src/components/FileManager.vue index 64d076c..3faf5bb 100644 --- a/packages/frontend/src/components/FileManager.vue +++ b/packages/frontend/src/components/FileManager.vue @@ -5,13 +5,13 @@ import { useRoute } from 'vue-router'; import { storeToRefs } from 'pinia'; import { createSftpActionsManager, type WebSocketDependencies } from '../composables/useSftpActions'; import { useFileUploader } from '../composables/useFileUploader'; -import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store'; // 确保已导入 +import { useFileEditorStore, type FileInfo } from '../stores/fileEditor.store'; import { useSessionStore } from '../stores/session.store'; import { useSettingsStore } from '../stores/settings.store'; import { useFocusSwitcherStore } from '../stores/focusSwitcher.store'; -import { useFileManagerContextMenu, type ClipboardState } from '../composables/file-manager/useFileManagerContextMenu'; -import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection'; -import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop'; +import { useFileManagerContextMenu, type ClipboardState, type CompressFormat } from '../composables/file-manager/useFileManagerContextMenu'; +import { useFileManagerSelection } from '../composables/file-manager/useFileManagerSelection'; +import { useFileManagerDragAndDrop } from '../composables/file-manager/useFileManagerDragAndDrop'; import { useFileManagerKeyboardNavigation } from '../composables/file-manager/useFileManagerKeyboardNavigation'; import FileUploadPopup from './FileUploadPopup.vue'; import FileManagerContextMenu from './FileManagerContextMenu.vue'; @@ -34,11 +34,6 @@ const props = defineProps({ type: String, required: true, }, - // // 注入此会话特定的 SFTP 管理器实例 (移除) - // sftpManager: { - // type: Object as PropType, - // required: true, - // }, // 注入数据库连接 ID dbConnectionId: { type: String, @@ -610,9 +605,33 @@ const triggerDownloadDirectory = (item: FileListItem) => { console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Network error during directory download:`, error); alert(`${t('fileManager.errors.downloadDirectoryFailed', 'Failed to download directory')}: Network error.`); }); - // --- 结束修改 --- + +}; + + + +// +++ 添加压缩/解压处理函数 +++ +const handleCompress = (items: FileListItem[], format: CompressFormat) => { + if (!currentSftpManager.value) { + console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot compress: SFTP manager not available.`); + // TODO: Show error notification + return; + } + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Requesting compression for ${items.length} items, format: ${format}`); + // 调用 SFTP 管理器上的新方法 (将在 useSftpActions.ts 中实现) + currentSftpManager.value.compressItems(items, format); +}; + +const handleDecompress = (item: FileListItem) => { + if (!currentSftpManager.value) { + console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot decompress: SFTP manager not available.`); + // TODO: Show error notification + return; + } + console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Requesting decompression for item: ${item.filename}`); + // 调用 SFTP 管理器上的新方法 (将在 useSftpActions.ts 中实现) + currentSftpManager.value.decompressItem(item); }; -// --- 结束新增 --- // --- 上下文菜单逻辑 (使用 Composable, 需要 Selection 和 Action Handlers) --- @@ -651,6 +670,9 @@ const { onCut: handleCut, // +++ 传递剪切回调 +++ onPaste: handlePaste, // +++ 传递粘贴回调 +++ onDownloadDirectory: triggerDownloadDirectory, // +++ 传递文件夹下载回调 +++ + // +++ 传递压缩/解压回调 +++ + onCompressRequest: handleCompress, + onDecompressRequest: handleDecompress, }); // --- 目录加载与导航 --- diff --git a/packages/frontend/src/components/FileManagerContextMenu.vue b/packages/frontend/src/components/FileManagerContextMenu.vue index 7e82a47..b451b99 100644 --- a/packages/frontend/src/components/FileManagerContextMenu.vue +++ b/packages/frontend/src/components/FileManagerContextMenu.vue @@ -38,19 +38,21 @@ const handleItemClick = (item: ContextMenuItem) => { @click.stop >
    -
  • - {{ menuItem.label }} -
  • +
diff --git a/packages/frontend/src/components/TerminalTabBar.vue b/packages/frontend/src/components/TerminalTabBar.vue index 06080c8..4a2152d 100644 --- a/packages/frontend/src/components/TerminalTabBar.vue +++ b/packages/frontend/src/components/TerminalTabBar.vue @@ -33,7 +33,6 @@ const props = defineProps({ required: false, default: null, }, - // +++ 添加 isMobile prop +++ isMobile: { type: Boolean, default: false, diff --git a/packages/frontend/src/composables/file-manager/useFileManagerContextMenu.ts b/packages/frontend/src/composables/file-manager/useFileManagerContextMenu.ts index 29abd0b..ee71f96 100644 --- a/packages/frontend/src/composables/file-manager/useFileManagerContextMenu.ts +++ b/packages/frontend/src/composables/file-manager/useFileManagerContextMenu.ts @@ -8,8 +8,12 @@ export interface ContextMenuItem { label: string; action: () => void; disabled?: boolean; + separator?: boolean; // 添加分隔符类型 } +// 支持的压缩格式 +export type CompressFormat = 'zip' | 'targz' | 'tarbz2'; + // 定义剪贴板状态类型 export interface ClipboardState { hasContent: boolean; @@ -40,8 +44,19 @@ export interface UseFileManagerContextMenuOptions { onCopy: () => void; // +++ 复制回调 +++ onCut: () => void; // +++ 剪切回调 +++ onPaste: () => void; // +++ 粘贴回调 +++ + // --- 压缩/解压回调 --- + onCompressRequest: (items: FileListItem[], format: CompressFormat) => void; // +++ 压缩回调 +++ + onDecompressRequest: (item: FileListItem) => void; // +++ 解压回调 +++ } +// 辅助函数:检查文件是否为支持的压缩格式 +const SUPPORTED_ARCHIVE_EXTENSIONS = ['.zip', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2']; +function isSupportedArchive(filename: string): boolean { + const lowerCaseFilename = filename.toLowerCase(); + return SUPPORTED_ARCHIVE_EXTENSIONS.some(ext => lowerCaseFilename.endsWith(ext)); +} + + export function useFileManagerContextMenu(options: UseFileManagerContextMenuOptions) { const { selectedItems, @@ -64,6 +79,8 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti onCut, // +++ 解构剪切回调 +++ onPaste, // +++ 解构粘贴回调 +++ onDownloadDirectory, // +++ 解构文件夹下载回调 +++ + onCompressRequest, // +++ 解构压缩回调 +++ + onDecompressRequest, // +++ 解构解压回调 +++ } = options; const contextMenuVisible = ref(false); @@ -119,6 +136,16 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti } + // --- 多选压缩 --- + menu.push( + { label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest(selectedFileItems, 'zip'), disabled: !(isConnected.value && isSftpReady.value) }, + { label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest(selectedFileItems, 'targz'), disabled: !(isConnected.value && isSftpReady.value) }, + { label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest(selectedFileItems, 'tarbz2'), disabled: !(isConnected.value && isSftpReady.value) }, + ); + menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator + + + menu.push( // --- 分隔符 (视觉) --- { label: t('fileManager.actions.deleteMultiple', { count: selectionSize }), action: onDelete, disabled: !(isConnected.value && isSftpReady.value) }, @@ -135,7 +162,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti } else if (targetItem.attrs.isDirectory) { menu.push({ label: t('fileManager.actions.downloadFolder', { name: targetItem.filename }), action: () => onDownloadDirectory(targetItem), disabled: !(isConnected.value && isSftpReady.value) }); // 文件夹下载 } - // --- 结束修改 --- + // 2. 剪切、复制、粘贴 (粘贴 - 如果是文件夹) @@ -146,11 +173,35 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti } // --- 分隔符 (视觉) --- + // The invalid object literal was here and is now removed. + // The separator below handles the division correctly. + + // Ensure separator is pushed separately and correctly + menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator + // 3. 删除、重命名 menu.push({ label: t('fileManager.actions.delete'), action: onDelete, disabled: !(isConnected.value && isSftpReady.value) }); menu.push({ label: t('fileManager.actions.rename'), action: () => onRename(targetItem), disabled: !(isConnected.value && isSftpReady.value) }); + // --- 分隔符 (视觉) --- + // Ensure separator is pushed separately and correctly + menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator + + // --- 压缩 & 解压 --- + const canCompress = isConnected.value && isSftpReady.value; + const canDecompress = isConnected.value && isSftpReady.value && targetItem.attrs.isFile && isSupportedArchive(targetItem.filename); + + // menu.push({ label: t('fileManager.contextMenu.compress'), action: () => {}, disabled: true }); // Removed isSubmenuHeader + menu.push({ label: t('fileManager.contextMenu.compressZip'), action: () => onCompressRequest([targetItem], 'zip'), disabled: !canCompress }); + menu.push({ label: t('fileManager.contextMenu.compressTarGz'), action: () => onCompressRequest([targetItem], 'targz'), disabled: !canCompress }); + menu.push({ label: t('fileManager.contextMenu.compressTarBz2'), action: () => onCompressRequest([targetItem], 'tarbz2'), disabled: !canCompress }); + + menu.push({ label: t('fileManager.contextMenu.decompress'), action: () => onDecompressRequest(targetItem), disabled: !canDecompress }); + + // --- 分隔符 (视觉) --- + menu.push({ label: '', action: () => {}, disabled: true, separator: true }); // Separator + // --- 分隔符 (视觉) --- // 4. 新建、上传 (这些更像空白处操作,但保留) diff --git a/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts b/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts index 7745827..356a358 100644 --- a/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts +++ b/packages/frontend/src/composables/file-manager/useFileManagerDragAndDrop.ts @@ -189,8 +189,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti }); } }; - // --- 结束新增 --- - + // 处理蒙版上的 Drop 事件 const handleOverlayDrop = (event: DragEvent) => { diff --git a/packages/frontend/src/composables/useSftpActions.ts b/packages/frontend/src/composables/useSftpActions.ts index 142545e..1bdd3fa 100644 --- a/packages/frontend/src/composables/useSftpActions.ts +++ b/packages/frontend/src/composables/useSftpActions.ts @@ -1,5 +1,5 @@ import { ref, readonly, reactive, computed, type Ref, type ComputedRef } from 'vue'; // 引入 reactive 和 computed -import type { FileListItem, FileAttributes, EditorFileContent, SftpReadFileSuccessPayload, SftpReadFileRequestPayload } from '../types/sftp.types'; // +++ 添加 SftpReadFileRequestPayload 导入 +++ +import type { FileListItem, FileAttributes, EditorFileContent, SftpReadFileSuccessPayload, SftpReadFileRequestPayload } from '../types/sftp.types'; import type { WebSocketMessage, MessagePayload, MessageHandler } from '../types/websocket.types'; // 导入 UI 通知 store import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // 更正导入 @@ -15,6 +15,38 @@ export interface WebSocketDependencies { isSftpReady: Readonly>; } +/** +* @interface SftpManagerInstance +* @description Defines the shape of the object returned by createSftpActionsManager. +*/ +export interface SftpManagerInstance { + // State + fileList: Readonly>; + isLoading: Readonly>; + fileTree: Readonly; + initialLoadDone: Readonly>; + currentPath: Readonly>; + + // Methods + loadDirectory: (path: string, forceRefresh?: boolean) => void; + createDirectory: (newDirName: string) => void; + createFile: (newFileName: string) => void; + deleteItems: (items: FileListItem[]) => void; + renameItem: (item: FileListItem, newName: string) => void; + changePermissions: (item: FileListItem, mode: number) => void; + readFile: (path: string, encoding?: string) => Promise; + writeFile: (path: string, content: string, encoding?: string) => Promise; + copyItems: (sourcePaths: string[], destinationDir: string) => void; + moveItems: (sourcePaths: string[], destinationDir: string) => void; + compressItems: (items: FileListItem[], format: 'zip' | 'targz' | 'tarbz2') => Promise; // Assume async + decompressItem: (item: FileListItem) => Promise; // Assume async + joinPath: (base: string, name: string) => string; + setInitialLoadDone: (value: boolean) => void; + + // Cleanup function + cleanup: () => void; +} + // Helper function const generateRequestId = (): string => `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -56,7 +88,7 @@ export function createSftpActionsManager( currentPathRef: Ref, wsDeps: WebSocketDependencies, t: Function -) { +): SftpManagerInstance { // Add explicit return type const { sendMessage, onMessage, isConnected, isSftpReady } = wsDeps; // 使用注入的依赖 // const fileList = ref([]); // 不再直接使用 fileList ref @@ -455,10 +487,134 @@ export function createSftpActionsManager( }); console.log(`[SFTP ${instanceSessionId}] 发送 sftp:move 请求 (ID: ${requestId}) Sources: ${sourcePaths.join(', ')}, Dest: ${destinationDir}`); // 可选:显示一个“正在移动...”的通知 - }; + }; + + const compressItems = (items: FileListItem[], format: 'zip' | 'targz' | 'tarbz2'): Promise => { + return new Promise((resolve, reject) => { + if (!isSftpReady.value) { + const errMsg = t('fileManager.errors.sftpNotReady'); + uiNotificationsStore.showError(errMsg); + console.warn(`[SFTP ${instanceSessionId}] 尝试压缩项目但 SFTP 未就绪。`); + return reject(new Error(errMsg)); + } + const sourcePaths = items.map(item => joinPath(currentPathRef.value, item.filename)); + const requestId = generateRequestId(); + const parentDir = currentPathRef.value; + // --- 修改:使用更智能的压缩包命名 --- + let archiveBaseName = 'archive'; + if (items.length === 1) { + archiveBaseName = items[0].filename.split('.')[0]; // 使用第一个项目的文件名(不含扩展名) + } else if (items.length > 1) { + // 如果有多个项目,尝试使用共同的父目录名,或者保持 'archive' + const parentFolderName = parentDir.split('/').pop(); + if (parentFolderName && parentFolderName !== 'root' && parentFolderName !== '') { + archiveBaseName = parentFolderName; + } + } + const archiveName = `${archiveBaseName}.${format === 'targz' ? 'tar.gz' : (format === 'tarbz2' ? 'tar.bz2' : format)}`; + + const destinationPath = joinPath(parentDir, archiveName); + + let unregisterSuccess: (() => void) | null = null; + let unregisterError: (() => void) | null = null; + + const timeoutId = setTimeout(() => { + unregisterSuccess?.(); + unregisterError?.(); + const errMsg = t('fileManager.errors.compressTimeout'); // 使用 i18n + uiNotificationsStore.showError(errMsg); + reject(new Error(errMsg)); + }, 60000); // 60 秒超时 + + unregisterSuccess = onMessage('sftp:compress:success', (payload: MessagePayload, message: WebSocketMessage) => { + if (message.requestId === requestId) { + clearTimeout(timeoutId); + unregisterSuccess?.(); + unregisterError?.(); + uiNotificationsStore.showSuccess(t('fileManager.notifications.compressSuccess', { name: archiveName })); // 使用 i18n + loadDirectory(currentPathRef.value, true); // 强制刷新当前目录 + resolve(); + } + }); + + unregisterError = onMessage('sftp:compress:error', (payload: MessagePayload, message: WebSocketMessage) => { + const errorPayload = payload as { error: string, details?: string }; + if (message.requestId === requestId) { + clearTimeout(timeoutId); + unregisterSuccess?.(); + unregisterError?.(); + const errorMsg = errorPayload.details || errorPayload.error || t('fileManager.errors.compressFailed'); // 基础错误信息 + uiNotificationsStore.showError(t('fileManager.errors.compressErrorDetailed', { error: errorMsg })); // 使用 i18n 包装详细错误 + reject(new Error(errorMsg)); + } + }); + + console.log(`[SFTP ${instanceSessionId}] 发送 sftp:compress 请求 (ID: ${requestId}) Sources: ${sourcePaths.join(', ')}, Dest: ${destinationPath}, Format: ${format}`); + sendMessage({ + type: 'sftp:compress', + requestId: requestId, + payload: { sources: sourcePaths, destination: destinationPath, format: format } + }); + }); + }; + + const decompressItem = (item: FileListItem): Promise => { + return new Promise((resolve, reject) => { + if (!isSftpReady.value) { + const errMsg = t('fileManager.errors.sftpNotReady'); + uiNotificationsStore.showError(errMsg); + console.warn(`[SFTP ${instanceSessionId}] 尝试解压项目 ${item.filename} 但 SFTP 未就绪。`); + return reject(new Error(errMsg)); + } + const sourcePath = joinPath(currentPathRef.value, item.filename); + const destinationDir = currentPathRef.value; // 默认解压到当前目录 + const requestId = generateRequestId(); + + let unregisterSuccess: (() => void) | null = null; + let unregisterError: (() => void) | null = null; + + const timeoutId = setTimeout(() => { + unregisterSuccess?.(); + unregisterError?.(); + const errMsg = t('fileManager.errors.decompressTimeout'); // 使用 i18n + uiNotificationsStore.showError(errMsg); + reject(new Error(errMsg)); + }, 60000); // 60 秒超时 + + unregisterSuccess = onMessage('sftp:decompress:success', (payload: MessagePayload, message: WebSocketMessage) => { + if (message.requestId === requestId) { + clearTimeout(timeoutId); + unregisterSuccess?.(); + unregisterError?.(); + uiNotificationsStore.showSuccess(t('fileManager.notifications.decompressSuccess', { name: item.filename })); // 使用 i18n + loadDirectory(currentPathRef.value, true); // 强制刷新当前目录 + resolve(); + } + }); + + unregisterError = onMessage('sftp:decompress:error', (payload: MessagePayload, message: WebSocketMessage) => { + const errorPayload = payload as { error: string, details?: string }; + if (message.requestId === requestId) { + clearTimeout(timeoutId); + unregisterSuccess?.(); + unregisterError?.(); + const errorMsg = errorPayload.details || errorPayload.error || t('fileManager.errors.decompressFailed'); // 基础错误信息 + uiNotificationsStore.showError(t('fileManager.errors.decompressErrorDetailed', { error: errorMsg })); // 使用 i18n 包装详细错误 + reject(new Error(errorMsg)); + } + }); + + console.log(`[SFTP ${instanceSessionId}] 发送 sftp:decompress 请求 (ID: ${requestId}) Source: ${sourcePath}, Dest: ${destinationDir}`); + sendMessage({ + type: 'sftp:decompress', + requestId: requestId, + payload: { source: sourcePath, destination: destinationDir } + }); + }); + }; - // --- Message Handlers --- + // --- Message Handlers --- const onSftpReaddirSuccess = (payload: MessagePayload, message: WebSocketMessage) => { const fileListPayload = payload as FileListItem[]; @@ -603,7 +759,7 @@ export function createSftpActionsManager( const addOrUpdateNodeInTree = (parentPath: string, item: FileListItem): boolean => { // --- 修改:调用 findNodeByPath 时允许创建缺失的父节点 --- const parentNode = findNodeByPath(fileTree, parentPath, true); - // --- 结束修改 --- + // 如果父节点被成功找到或创建 if (parentNode) { @@ -957,6 +1113,29 @@ export function createSftpActionsManager( unregisterCallbacks.push(onMessage('sftp:move:success', onMoveSuccess)); unregisterCallbacks.push(onMessage('sftp:move:error', onActionError)); + // +++ 处理命令未找到错误 +++ + const onCommandNotFound = (payload: MessagePayload, message: WebSocketMessage) => { + const { operation, command, message: details } = payload as { operation: 'compress' | 'decompress', command: string, message?: string }; + console.error(`[SFTP ${instanceSessionId}] Command '${command}' not found on server for ${operation}. Details: ${details}`); + let errorMsgKey = ''; + if (operation === 'compress') { + errorMsgKey = 'fileManager.errors.commandNotFoundCompress'; + } else if (operation === 'decompress') { + errorMsgKey = 'fileManager.errors.commandNotFoundDecompress'; + } + if (errorMsgKey) { + uiNotificationsStore.showError(t(errorMsgKey, { command })); + } else { + uiNotificationsStore.showError(t('fileManager.errors.genericCommandNotFound', { command, operation })); + } + }; + unregisterCallbacks.push(onMessage('sftp:command_not_found', onCommandNotFound)); + // --- 结束处理 --- + + // 注意:sftp:compress:success, sftp:compress:error, sftp:decompress:success, sftp:decompress:error + // 的消息处理器直接在 compressItems 和 decompressItem 方法内部通过 onMessage 临时注册和注销, + // 因为它们与特定的 Promise 相关联。 + // 移除 onUnmounted 块 // *** 计算属性 fileList *** @@ -976,11 +1155,11 @@ export function createSftpActionsManager( return { // State - fileList: readonly(fileList), // 暴露计算属性 - isLoading: readonly(isLoading), - // error: readonly(error), // 移除 error - fileTree: readonly(fileTree), // 可以选择性地暴露只读的文件树 - initialLoadDone: readonly(initialLoadDone), // +++ 暴露只读的初始加载状态 +++ + fileList: fileList, // 暴露计算属性 (类型已在接口中定义为 Readonly) + isLoading: isLoading, // (类型已在接口中定义为 Readonly) + // error: readonly(error), // 移除 error + fileTree: fileTree, // (类型已在接口中定义为 Readonly) + initialLoadDone: initialLoadDone, // (类型已在接口中定义为 Readonly) // Methods loadDirectory, @@ -992,13 +1171,15 @@ export function createSftpActionsManager( readFile, writeFile, copyItems, // +++ 暴露 copyItems +++ - moveItems, // +++ 暴露 moveItems +++ - joinPath, // 暴露辅助函数 - // clearSftpError, // 移除 clearSftpError + moveItems, // +++ 暴露 moveItems +++ + compressItems, // +++ 暴露 compressItems +++ + decompressItem, // +++ 暴露 decompressItem +++ + joinPath, // 暴露辅助函数 + // clearSftpError, // 移除 clearSftpError // Cleanup function - currentPath: readonly(currentPathRef), // 暴露只读的当前路径 ref - setInitialLoadDone: (value: boolean) => { initialLoadDone.value = value; }, // +++ 暴露设置初始加载状态的方法 +++ + currentPath: currentPathRef, // (类型已在接口中定义为 Readonly) + setInitialLoadDone: (value: boolean) => { initialLoadDone.value = value; }, // +++ 暴露设置初始加载状态的方法 +++ // Cleanup function // Cleanup function diff --git a/packages/frontend/src/composables/useWebSocketConnection.ts b/packages/frontend/src/composables/useWebSocketConnection.ts index 8f61104..14e1171 100644 --- a/packages/frontend/src/composables/useWebSocketConnection.ts +++ b/packages/frontend/src/composables/useWebSocketConnection.ts @@ -21,7 +21,7 @@ export function createWebSocketConnectionManager( sessionId: string, dbConnectionId: string, t: ReturnType['t'], - options?: { isResumeFlow?: boolean; getIsMarkedForSuspend?: () => boolean } // +++ 添加 getIsMarkedForSuspend 回调 +++ + options?: { isResumeFlow?: boolean; getIsMarkedForSuspend?: () => boolean } ) { // --- Instance State --- // 每个实例拥有独立的 WebSocket 对象、状态和消息处理器 diff --git a/packages/frontend/src/locales/en-US.json b/packages/frontend/src/locales/en-US.json index 98a8c7f..183d0e9 100644 --- a/packages/frontend/src/locales/en-US.json +++ b/packages/frontend/src/locales/en-US.json @@ -330,6 +330,13 @@ "paste": "Paste", "openEditor": "Open Editor" }, +"contextMenu": { + "compress": "Compress", + "compressZip": "Compress to zip", + "compressTarGz": "Compress to tar.gz", + "compressTarBz2": "Compress to tar.bz2", + "decompress": "Decompress" + }, "headers": { "type": "Type", "name": "Name", @@ -365,14 +372,25 @@ "terminalManagerNotFound": "Terminal manager not found", "sendCommandFailed": "Failed to send command", "downloadDirectoryFailed": "Failed to download directory", - "downloadDirectoryNotImplemented": "Directory download feature is not yet implemented on the server." - }, - "notifications": { - "copySuccess": "Copy successful", - "moveSuccess": "Move successful", - "cdCommandSent": "CD command sent to terminal" - }, - "warnings": { + "downloadDirectoryNotImplemented": "Directory download feature is not yet implemented on the server.", + "compressFailed": "Compression failed", + "compressTimeout": "Compression timed out", + "compressErrorDetailed": "Compression failed: {error}", + "decompressFailed": "Decompression failed", + "decompressTimeout": "Decompression timed out", + "decompressErrorDetailed": "Decompression failed: {error}", + "commandNotFoundCompress": "Command '{command}' not found on server, cannot complete compression.", + "commandNotFoundDecompress": "Command '{command}' not found on server, cannot complete decompression.", + "genericCommandNotFound": "Command '{command}' not found on server, cannot complete '{operation}' operation." + }, + "notifications": { + "copySuccess": "Copy successful", + "moveSuccess": "Move successful", + "cdCommandSent": "CD command sent to terminal", + "compressSuccess": "Compressed {name} successfully", + "decompressSuccess": "Decompressed {name} successfully" + }, + "warnings": { "moveSameDirectory": "Cannot cut and paste in the same directory." }, "prompts": { diff --git a/packages/frontend/src/locales/ja-JP.json b/packages/frontend/src/locales/ja-JP.json index 0155a20..92bdd14 100644 --- a/packages/frontend/src/locales/ja-JP.json +++ b/packages/frontend/src/locales/ja-JP.json @@ -336,6 +336,13 @@ "upload": "アップロード", "uploadFile": "ファイルをアップロード" }, +"contextMenu": { + "compress": "圧縮", + "compressZip": "zip に圧縮", + "compressTarGz": "tar.gz に圧縮", + "compressTarBz2": "tar.bz2 に圧縮", + "decompress": "解凍" + }, "currentPath": "現在のパス", "dropFilesHere": "ファイルをここにドラッグ&ドロップしてアップロード", "editPathTooltip": "パスをクリックして編集", @@ -364,7 +371,16 @@ "sendCommandFailed": "コマンドの送信に失敗しました", "sftpManagerNotFound": "SFTP マネージャーが見つかりません", "sftpNotReady": "SFTP セッションの準備ができていません", - "terminalManagerNotFound": "ターミナルマネージャーが見つかりません" + "terminalManagerNotFound": "ターミナルマネージャーが見つかりません", + "compressFailed": "圧縮に失敗しました", + "compressTimeout": "圧縮がタイムアウトしました", + "compressErrorDetailed": "圧縮に失敗しました: {error}", + "decompressFailed": "解凍に失敗しました", + "decompressTimeout": "解凍がタイムアウトしました", + "decompressErrorDetailed": "解凍に失敗しました: {error}", + "commandNotFoundCompress": "サーバーにコマンド '{command}' が見つからないため、圧縮操作を完了できません。", + "commandNotFoundDecompress": "サーバーにコマンド '{command}' が見つからないため、解凍操作を完了できません。", + "genericCommandNotFound": "サーバーにコマンド '{command}' が見つからないため、'{operation}' 操作を完了できません。" }, "headers": { "modified": "変更日", @@ -379,7 +395,9 @@ "notifications": { "cdCommandSent": "CD コマンドがターミナルに送信されました", "copySuccess": "コピーに成功しました", - "moveSuccess": "移動に成功しました" + "moveSuccess": "移動に成功しました", + "compressSuccess": "{name} を正常に圧縮しました", + "decompressSuccess": "{name} を正常に解凍しました" }, "prompts": { "confirmDeleteFile": "ファイル \"{name}\" を削除しますか?この操作は元に戻せません。", diff --git a/packages/frontend/src/locales/zh-CN.json b/packages/frontend/src/locales/zh-CN.json index e4e1617..f81ca70 100644 --- a/packages/frontend/src/locales/zh-CN.json +++ b/packages/frontend/src/locales/zh-CN.json @@ -329,6 +329,13 @@ "paste": "粘贴", "openEditor": "打开编辑器" }, +"contextMenu": { + "compress": "压缩", + "compressZip": "压缩为 zip", + "compressTarGz": "压缩为 tar.gz", + "compressTarBz2": "压缩为 ttar.bz2", + "decompress": "解压" + }, "headers": { "type": "类型", "name": "名称", @@ -364,14 +371,25 @@ "terminalManagerNotFound": "未找到终端管理器", "sendCommandFailed": "发送命令失败", "downloadDirectoryFailed": "下载文件夹失败", - "downloadDirectoryNotImplemented": "服务器尚未实现文件夹下载功能。" - }, - "notifications": { - "copySuccess": "复制成功", - "moveSuccess": "移动成功", - "cdCommandSent": "CD 命令已发送到终端" - }, - "warnings": { + "downloadDirectoryNotImplemented": "服务器尚未实现文件夹下载功能。", + "compressFailed": "压缩失败", + "compressTimeout": "压缩超时", + "compressErrorDetailed": "压缩失败: {error}", + "decompressFailed": "解压失败", + "decompressTimeout": "解压超时", + "decompressErrorDetailed": "解压失败: {error}", + "commandNotFoundCompress": "服务器上缺少 '{command}' 命令,无法完成压缩操作。", + "commandNotFoundDecompress": "服务器上缺少 '{command}' 命令,无法完成解压操作。", + "genericCommandNotFound": "服务器上缺少 '{command}' 命令,无法完成 '{operation}' 操作。" + }, + "notifications": { + "copySuccess": "复制成功", + "moveSuccess": "移动成功", + "cdCommandSent": "CD 命令已发送到终端", + "compressSuccess": "压缩 {name} 成功", + "decompressSuccess": "解压 {name} 成功" + }, + "warnings": { "moveSameDirectory": "不能在同一目录下剪切和粘贴。" }, "prompts": { diff --git a/packages/frontend/src/stores/session/getters.ts b/packages/frontend/src/stores/session/getters.ts index d4f53b9..4fabce3 100644 --- a/packages/frontend/src/stores/session/getters.ts +++ b/packages/frontend/src/stores/session/getters.ts @@ -17,7 +17,7 @@ export const sessionTabsWithStatus = computed((): SessionTabInfoWithStatus[] => sessionId: session.sessionId, connectionName: session.connectionName, status: session.wsManager.connectionStatus.value, // 从 wsManager 获取状态 - isMarkedForSuspend: session.isMarkedForSuspend, // +++ 添加 isMarkedForSuspend 状态 +++ + isMarkedForSuspend: session.isMarkedForSuspend, })); }); diff --git a/packages/frontend/src/views/WorkspaceView.vue b/packages/frontend/src/views/WorkspaceView.vue index c74a5ff..2d081b7 100644 --- a/packages/frontend/src/views/WorkspaceView.vue +++ b/packages/frontend/src/views/WorkspaceView.vue @@ -72,7 +72,7 @@ const showLayoutConfigurator = ref(false); // 控制布局配置器可见性 // --- 搜索状态 --- const currentSearchTerm = ref(''); // 当前搜索的关键词 -const mobileTerminalRef = ref | null>(null); // +++ 添加 mobileTerminalRef +++ +const mobileTerminalRef = ref | null>(null); const isVirtualKeyboardVisible = ref(true); // +++ State for virtual keyboard visibility +++ // --- 处理全局键盘事件 ---