@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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 存在
|
||||
|
||||
@@ -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<void> =
|
||||
}
|
||||
|
||||
const userSftpSession = targetState.sftp; // 获取正确的 SFTP 实例
|
||||
// --- 结束修改 ---
|
||||
|
||||
|
||||
try {
|
||||
// 获取文件状态以确定文件大小(可选,但有助于设置 Content-Length)
|
||||
@@ -158,7 +159,7 @@ export const downloadDirectory = async (req: Request, res: Response): Promise<vo
|
||||
}
|
||||
|
||||
const userSftpSession = targetState.sftp; // 获取正确的 SFTP 实例
|
||||
// --- 结束修改 ---
|
||||
|
||||
|
||||
try {
|
||||
// 1. 验证路径是否为目录
|
||||
@@ -271,3 +272,293 @@ export const downloadDirectory = async (req: Request, res: Response): Promise<vo
|
||||
};
|
||||
|
||||
|
||||
|
||||
// --- WebSocket Message Handlers (to be called by WebSocket router) ---
|
||||
|
||||
/**
|
||||
* 发送通用 WebSocket 错误消息的辅助函数
|
||||
*/
|
||||
const sendWebSocketError = (ws: AuthenticatedWebSocket | undefined, type: string, message: string, requestId: string, details?: any) => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } }));
|
||||
|
||||
@@ -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.
|
||||
// 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<SftpManagerInstance>,
|
||||
// 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,
|
||||
});
|
||||
|
||||
// --- 目录加载与导航 ---
|
||||
|
||||
@@ -38,19 +38,21 @@ const handleItemClick = (item: ContextMenuItem) => {
|
||||
@click.stop
|
||||
>
|
||||
<ul class="list-none p-1 m-0">
|
||||
<li
|
||||
v-for="(menuItem, index) in items"
|
||||
:key="index"
|
||||
@click.stop="handleItemClick(menuItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded',
|
||||
menuItem.disabled
|
||||
? 'text-text-secondary cursor-not-allowed opacity-60 bg-background'
|
||||
: 'hover:bg-border' // Changed hover background for better visibility
|
||||
]"
|
||||
>
|
||||
{{ menuItem.label }}
|
||||
</li>
|
||||
<template v-for="(menuItem, index) in items" :key="index">
|
||||
<li v-if="menuItem.separator" class="border-t border-border/50 my-1 mx-1"></li>
|
||||
<li
|
||||
v-else
|
||||
@click.stop="handleItemClick(menuItem)"
|
||||
:class="[
|
||||
'px-4 py-1.5 cursor-pointer text-foreground text-sm flex items-center transition-colors duration-150 rounded mx-1', // Added mx-1 for consistency
|
||||
menuItem.disabled
|
||||
? 'text-text-secondary cursor-not-allowed opacity-60' // Removed bg-background for disabled
|
||||
: 'hover:bg-primary/10 hover:text-primary' // Use primary hover like TabBarContextMenu
|
||||
]"
|
||||
>
|
||||
{{ menuItem.label }}
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,7 +33,6 @@ const props = defineProps({
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
// +++ 添加 isMobile prop +++
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -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. 新建、上传 (这些更像空白处操作,但保留)
|
||||
|
||||
@@ -189,8 +189,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
|
||||
});
|
||||
}
|
||||
};
|
||||
// --- 结束新增 ---
|
||||
|
||||
|
||||
|
||||
// 处理蒙版上的 Drop 事件
|
||||
const handleOverlayDrop = (event: DragEvent) => {
|
||||
|
||||
@@ -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<Ref<boolean>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface SftpManagerInstance
|
||||
* @description Defines the shape of the object returned by createSftpActionsManager.
|
||||
*/
|
||||
export interface SftpManagerInstance {
|
||||
// State
|
||||
fileList: Readonly<ComputedRef<FileListItem[]>>;
|
||||
isLoading: Readonly<Ref<boolean>>;
|
||||
fileTree: Readonly<FileTreeNode>;
|
||||
initialLoadDone: Readonly<Ref<boolean>>;
|
||||
currentPath: Readonly<Ref<string>>;
|
||||
|
||||
// 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<SftpReadFileSuccessPayload>;
|
||||
writeFile: (path: string, content: string, encoding?: string) => Promise<void>;
|
||||
copyItems: (sourcePaths: string[], destinationDir: string) => void;
|
||||
moveItems: (sourcePaths: string[], destinationDir: string) => void;
|
||||
compressItems: (items: FileListItem[], format: 'zip' | 'targz' | 'tarbz2') => Promise<void>; // Assume async
|
||||
decompressItem: (item: FileListItem) => Promise<void>; // 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<string>,
|
||||
wsDeps: WebSocketDependencies,
|
||||
t: Function
|
||||
) {
|
||||
): SftpManagerInstance { // Add explicit return type
|
||||
const { sendMessage, onMessage, isConnected, isSftpReady } = wsDeps; // 使用注入的依赖
|
||||
|
||||
// const fileList = ref<FileListItem[]>([]); // 不再直接使用 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<void> => {
|
||||
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<void> => {
|
||||
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<ComputedRef>)
|
||||
isLoading: isLoading, // (类型已在接口中定义为 Readonly<Ref>)
|
||||
// error: readonly(error), // 移除 error
|
||||
fileTree: fileTree, // (类型已在接口中定义为 Readonly<FileTreeNode>)
|
||||
initialLoadDone: initialLoadDone, // (类型已在接口中定义为 Readonly<Ref>)
|
||||
|
||||
// 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<Ref>)
|
||||
setInitialLoadDone: (value: boolean) => { initialLoadDone.value = value; }, // +++ 暴露设置初始加载状态的方法 +++
|
||||
|
||||
// Cleanup function
|
||||
// Cleanup function
|
||||
|
||||
@@ -21,7 +21,7 @@ export function createWebSocketConnectionManager(
|
||||
sessionId: string,
|
||||
dbConnectionId: string,
|
||||
t: ReturnType<typeof useI18n>['t'],
|
||||
options?: { isResumeFlow?: boolean; getIsMarkedForSuspend?: () => boolean } // +++ 添加 getIsMarkedForSuspend 回调 +++
|
||||
options?: { isResumeFlow?: boolean; getIsMarkedForSuspend?: () => boolean }
|
||||
) {
|
||||
// --- Instance State ---
|
||||
// 每个实例拥有独立的 WebSocket 对象、状态和消息处理器
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}\" を削除しますか?この操作は元に戻せません。",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ const showLayoutConfigurator = ref(false); // 控制布局配置器可见性
|
||||
|
||||
// --- 搜索状态 ---
|
||||
const currentSearchTerm = ref(''); // 当前搜索的关键词
|
||||
const mobileTerminalRef = ref<InstanceType<typeof Terminal> | null>(null); // +++ 添加 mobileTerminalRef +++
|
||||
const mobileTerminalRef = ref<InstanceType<typeof Terminal> | null>(null);
|
||||
const isVirtualKeyboardVisible = ref(true); // +++ State for virtual keyboard visibility +++
|
||||
|
||||
// --- 处理全局键盘事件 ---
|
||||
|
||||
Reference in New Issue
Block a user