update
This commit is contained in:
Generated
+798
-7
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,11 @@
|
||||
"dev": "cross-env NODE_ENV=development npx ts-node-dev --respawn --transpile-only src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.9.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { clientStates } from '../websocket';
|
||||
import { clientStates } from '../websocket';
|
||||
import archiver from 'archiver'; // +++ 引入 archiver +++
|
||||
import { SFTPWrapper, Stats } from 'ssh2'; // +++ 移除 Dirent 导入 +++
|
||||
// 移除 ssh2-streams 导入
|
||||
|
||||
/**
|
||||
* 处理文件下载请求 (GET /api/v1/sftp/download)
|
||||
@@ -103,3 +106,152 @@ export const downloadFile = async (req: Request, res: Response): Promise<void> =
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 处理文件夹下载请求 (GET /api/v1/sftp/download-directory)
|
||||
*/
|
||||
export const downloadDirectory = async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.session.userId;
|
||||
const connectionId = req.query.connectionId as string; // 从查询参数获取
|
||||
const remotePath = req.query.remotePath as string; // 从查询参数获取
|
||||
|
||||
// 参数验证
|
||||
if (!userId) {
|
||||
res.status(401).json({ message: '未授权:需要登录。' });
|
||||
return;
|
||||
}
|
||||
if (!connectionId || !remotePath) {
|
||||
res.status(400).json({ message: '缺少必要的查询参数 (connectionId, remotePath)。' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`SFTP 文件夹下载请求:用户 ${userId}, 连接 ${connectionId}, 路径 ${remotePath}`);
|
||||
|
||||
// 查找与当前用户会话关联的活动 WebSocket 连接和 SFTP 会话 (与 downloadFile 逻辑相同)
|
||||
let userSftpSession = null;
|
||||
for (const [sessionId, state] of clientStates.entries()) {
|
||||
const ws = state.ws;
|
||||
const connData = state;
|
||||
if (ws.userId === userId && connData.sftp) {
|
||||
// TODO: 关联特定的 connectionId
|
||||
userSftpSession = connData.sftp;
|
||||
console.log(`找到用户 ${userId} 的活动 SFTP 会话。`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userSftpSession) {
|
||||
console.warn(`SFTP 文件夹下载失败:未找到用户 ${userId} 的活动 SFTP 会话。`);
|
||||
res.status(404).json({ message: '未找到活动的 SFTP 会话。请确保您已通过 WebSocket 连接到目标服务器。' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 验证路径是否为目录
|
||||
const stats = await new Promise<import('ssh2').Stats>((resolve, reject) => {
|
||||
userSftpSession!.lstat(remotePath, (err, stats) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stats);
|
||||
});
|
||||
});
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(400).json({ message: '指定的路径不是一个目录。' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 设置响应头
|
||||
// --- 修正:更健壮地生成压缩包名称 ---
|
||||
let baseName = path.basename(remotePath);
|
||||
// 处理根目录或路径以斜杠结尾的特殊情况
|
||||
if (!baseName || baseName === '/') {
|
||||
baseName = 'download'; // 如果 basename 为空或只是 '/',使用默认名称
|
||||
}
|
||||
// 确保 basename 本身不包含尾部斜杠(尽管 path.basename 通常会处理)
|
||||
baseName = baseName.replace(/\/$/, '');
|
||||
const archiveName = `${baseName}.zip`; // 拼接 .zip 后缀
|
||||
// --- 结束修正 ---
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`); // 使用修正后的名称
|
||||
|
||||
// 3. 创建 Archiver 实例
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 } // 设置压缩级别 (可选)
|
||||
});
|
||||
|
||||
// 监听错误事件
|
||||
archive.on('warning', (err) => {
|
||||
console.warn(`Archiver warning (用户 ${userId}, 路径 ${remotePath}):`, err);
|
||||
});
|
||||
archive.on('error', (err) => {
|
||||
console.error(`Archiver error (用户 ${userId}, 路径 ${remotePath}):`, err);
|
||||
// 尝试发送错误响应,如果头还没发送
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ message: `创建压缩文件时出错: ${err.message}` });
|
||||
} else {
|
||||
res.end(); // 否则尝试结束响应
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Archiver 输出流 pipe 到 HTTP 响应流
|
||||
archive.pipe(res);
|
||||
|
||||
// 4. 递归添加文件/目录到 archive (核心逻辑)
|
||||
// 这部分需要一个辅助函数来处理 SFTP 递归和 Archiver 添加
|
||||
const addDirectoryToArchive = async (sftp: SFTPWrapper, dirPath: string, archivePath: string) => { // 使用导入的 SFTPWrapper
|
||||
// 移除 list 的显式类型注解 FileEntry[],让 TypeScript 推断
|
||||
const entries = await new Promise<any[]>((resolve, reject) => { // 使用 any[] 作为 Promise 类型,或更具体的推断类型
|
||||
sftp.readdir(dirPath, (err: Error | undefined, list) => { // 移除 list 的类型注解
|
||||
if (err) return reject(err);
|
||||
// 可以在这里检查 list 的结构,但暂时依赖推断
|
||||
resolve(list);
|
||||
});
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const currentRemotePath = path.posix.join(dirPath, entry.filename); // 使用 posix.join 处理路径
|
||||
const currentArchivePath = path.posix.join(archivePath, entry.filename);
|
||||
|
||||
if (entry.attrs.isDirectory()) {
|
||||
// 递归添加子目录
|
||||
// 使用 Buffer.from('') 代替 null
|
||||
archive.append(Buffer.from(''), { name: currentArchivePath + '/' });
|
||||
await addDirectoryToArchive(sftp, currentRemotePath, currentArchivePath);
|
||||
} else if (entry.attrs.isFile()) {
|
||||
// 添加文件流
|
||||
const fileStream = sftp.createReadStream(currentRemotePath);
|
||||
archive.append(fileStream, { name: currentArchivePath });
|
||||
// 注意:需要处理 fileStream 的错误事件吗?Archiver 应该会处理?待验证。
|
||||
fileStream.on('error', (streamErr: Error) => { // 添加类型注解
|
||||
console.error(`Error reading file stream ${currentRemotePath}:`, streamErr);
|
||||
// 如何通知 Archiver 或中断? Archiver 的 error 事件应该会捕获?
|
||||
if (!archive.destroyed) { // 检查 archive 是否已被销毁
|
||||
archive.abort(); // 尝试中止 archive
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 开始添加根目录内容
|
||||
await addDirectoryToArchive(userSftpSession, remotePath, ''); // 归档路径从根开始
|
||||
|
||||
// 5. 完成归档
|
||||
await archive.finalize();
|
||||
|
||||
console.log(`SFTP 文件夹下载完成 (用户 ${userId}, 路径 ${remotePath})`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`SFTP 文件夹下载处理失败 (用户 ${userId}, 路径 ${remotePath}):`, error);
|
||||
if (!res.headersSent) {
|
||||
if (error.code === 'ENOENT' || error.message?.includes('No such file')) { // 检查 SFTP 错误码或消息
|
||||
res.status(404).json({ message: '远程目录未找到。' });
|
||||
} else {
|
||||
res.status(500).json({ message: `处理文件夹下载请求时出错: ${error.message}` });
|
||||
}
|
||||
} else {
|
||||
res.end(); // 如果头已发送,尝试结束响应
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import { downloadFile } from './sftp.controller';
|
||||
import { downloadFile, downloadDirectory } from './sftp.controller'; // +++ 导入 downloadDirectory +++
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -10,5 +10,8 @@ router.use(isAuthenticated);
|
||||
// GET /api/v1/sftp/download?connectionId=...&remotePath=...
|
||||
router.get('/download', downloadFile);
|
||||
|
||||
// +++ 新增:GET /api/v1/sftp/download-directory?connectionId=...&remotePath=... +++
|
||||
router.get('/download-directory', downloadDirectory);
|
||||
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -470,7 +470,10 @@ const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileList
|
||||
// 为每个文件创建一个链接并点击
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.setAttribute('download', item.filename); // 使用原始文件名
|
||||
// --- 修正:移除文件名中的双引号以兼容 Chrome ---
|
||||
const safeFilename = item.filename.replace(/"/g, ''); // 移除所有双引号
|
||||
link.setAttribute('download', safeFilename);
|
||||
// --- 结束修正 ---
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -482,6 +485,95 @@ const triggerDownload = (items: FileListItem[]) => { // 修改:接受 FileList
|
||||
};
|
||||
|
||||
|
||||
// +++ 新增:文件夹下载触发器 +++
|
||||
const triggerDownloadDirectory = (item: FileListItem) => {
|
||||
if (!props.wsDeps.isConnected.value) {
|
||||
alert(t('fileManager.errors.notConnected'));
|
||||
return;
|
||||
}
|
||||
const currentConnectionId = props.dbConnectionId;
|
||||
if (!currentConnectionId) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot download directory: Missing connection ID.`);
|
||||
alert(t('fileManager.errors.missingConnectionId'));
|
||||
return;
|
||||
}
|
||||
if (!currentSftpManager.value) {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Cannot download directory: SFTP manager is not available.`);
|
||||
alert(t('fileManager.errors.sftpManagerNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保是目录
|
||||
if (!item.attrs.isDirectory) {
|
||||
console.warn(`[FileManager ${props.sessionId}-${props.instanceId}] Skipping directory download for non-directory item: ${item.filename}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryPath = currentSftpManager.value.joinPath(currentSftpManager.value.currentPath.value, item.filename);
|
||||
// 定义新的后端 API 端点 URL (稍后实现)
|
||||
const downloadUrl = `/api/v1/sftp/download-directory?connectionId=${currentConnectionId}&remotePath=${encodeURIComponent(directoryPath)}`;
|
||||
|
||||
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Attempting directory download for ${item.filename}: ${downloadUrl}`);
|
||||
|
||||
// --- 修改:使用 fetch 尝试下载,并处理后端未实现的情况 ---
|
||||
fetch(downloadUrl)
|
||||
.then(async response => {
|
||||
if (response.ok) {
|
||||
// 后端实现成功,尝试触发下载
|
||||
const blob = await response.blob();
|
||||
// 从 Content-Disposition 头获取文件名 (需要后端设置)
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
let filename = `${item.filename}.zip`; // 默认文件名
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
|
||||
if (filenameMatch && filenameMatch.length > 1) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
// --- 修正:移除 ZIP 文件名中的双引号以兼容 Chrome ---
|
||||
const safeZipFilename = filename.replace(/"/g, '');
|
||||
link.setAttribute('download', safeZipFilename);
|
||||
// --- 结束修正 ---
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href); // 释放对象 URL
|
||||
console.log(`[FileManager ${props.sessionId}-${props.instanceId}] Directory download triggered for: ${filename}`);
|
||||
} else {
|
||||
// 处理错误,例如 404 Not Found
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Directory download failed: ${response.status} ${response.statusText}`);
|
||||
// 尝试读取错误信息体
|
||||
let errorMsg = `Server responded with status ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json(); // 假设后端返回 JSON 错误
|
||||
errorMsg = errorData.message || errorMsg;
|
||||
} catch (e) {
|
||||
// 如果响应体不是 JSON 或读取失败
|
||||
try {
|
||||
const textError = await response.text();
|
||||
if (textError) errorMsg = textError;
|
||||
} catch (e2) { /* ignore */}
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
alert(t('fileManager.errors.downloadDirectoryNotImplemented', 'Directory download feature is not yet implemented on the server.'));
|
||||
} else {
|
||||
alert(`${t('fileManager.errors.downloadDirectoryFailed', 'Failed to download directory')}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`[FileManager ${props.sessionId}-${props.instanceId}] Network error during directory download:`, error);
|
||||
alert(`${t('fileManager.errors.downloadDirectoryFailed', 'Failed to download directory')}: Network error.`);
|
||||
});
|
||||
// --- 结束修改 ---
|
||||
};
|
||||
// --- 结束新增 ---
|
||||
|
||||
|
||||
// --- 上下文菜单逻辑 (使用 Composable, 需要 Selection 和 Action Handlers) ---
|
||||
const {
|
||||
contextMenuVisible,
|
||||
@@ -517,6 +609,7 @@ const {
|
||||
onCopy: handleCopy, // +++ 传递复制回调 +++
|
||||
onCut: handleCut, // +++ 传递剪切回调 +++
|
||||
onPaste: handlePaste, // +++ 传递粘贴回调 +++
|
||||
onDownloadDirectory: triggerDownloadDirectory, // +++ 传递文件夹下载回调 +++
|
||||
});
|
||||
|
||||
// --- 目录加载与导航 ---
|
||||
|
||||
@@ -30,7 +30,8 @@ export interface UseFileManagerContextMenuOptions {
|
||||
// --- 回调函数 ---
|
||||
onRefresh: () => void;
|
||||
onUpload: () => void;
|
||||
onDownload: (items: FileListItem[]) => void; // 修改:接受 FileListItem 数组
|
||||
onDownload: (items: FileListItem[]) => void; // 文件下载回调
|
||||
onDownloadDirectory: (item: FileListItem) => void; // +++ 新增:文件夹下载回调 +++
|
||||
onDelete: () => void; // 删除操作现在由外部处理
|
||||
onRename: (item: FileListItem) => void;
|
||||
onChangePermissions: (item: FileListItem) => void;
|
||||
@@ -62,6 +63,7 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
onCopy, // +++ 解构复制回调 +++
|
||||
onCut, // +++ 解构剪切回调 +++
|
||||
onPaste, // +++ 解构粘贴回调 +++
|
||||
onDownloadDirectory, // +++ 解构文件夹下载回调 +++
|
||||
} = options;
|
||||
|
||||
const contextMenuVisible = ref(false);
|
||||
@@ -110,9 +112,13 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
];
|
||||
|
||||
// --- 新增:多选下载 ---
|
||||
if (allFilesSelected) {
|
||||
menu.push({ label: t('fileManager.actions.downloadMultiple', { count: selectionSize }), action: () => onDownload(selectedFileItems), disabled: !canPerformActions });
|
||||
}
|
||||
// 多选时暂时禁用文件夹下载,只允许下载文件
|
||||
// 如果需要支持多选文件夹下载或混合下载,需要更复杂的逻辑和后端支持(例如打包成 zip)
|
||||
// 目前仅在 allFilesSelected 为 true 时启用多文件下载
|
||||
if (allFilesSelected) {
|
||||
menu.push({ label: t('fileManager.actions.downloadMultiple', { count: selectionSize }), action: () => onDownload(selectedFileItems), disabled: !canPerformActions });
|
||||
}
|
||||
|
||||
|
||||
menu.push(
|
||||
// --- 分隔符 (视觉) ---
|
||||
@@ -122,13 +128,16 @@ export function useFileManagerContextMenu(options: UseFileManagerContextMenuOpti
|
||||
);
|
||||
} else if (targetItem && targetItem.filename !== '..') {
|
||||
// Single item (not '..') menu
|
||||
// --- 修改:单选下载也调用接收数组的回调 ---
|
||||
menu = [];
|
||||
|
||||
// 1. 主要操作 (下载 - 如果是文件)
|
||||
// --- 修改:区分文件和文件夹下载 ---
|
||||
if (targetItem.attrs.isFile) {
|
||||
menu.push({ label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload([targetItem]), disabled: !canPerformActions }); // 传递包含单个项的数组
|
||||
menu.push({ label: t('fileManager.actions.download', { name: targetItem.filename }), action: () => onDownload([targetItem]), disabled: !canPerformActions }); // 文件下载
|
||||
} else if (targetItem.attrs.isDirectory) {
|
||||
menu.push({ label: t('fileManager.actions.downloadFolder', { name: targetItem.filename }), action: () => onDownloadDirectory(targetItem), disabled: !canPerformActions }); // 文件夹下载
|
||||
}
|
||||
// --- 结束修改 ---
|
||||
|
||||
|
||||
// 2. 剪切、复制、粘贴 (粘贴 - 如果是文件夹)
|
||||
menu.push({ label: t('fileManager.actions.cut'), action: onCut, disabled: !canPerformActions });
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"deleteMultiple": "Delete {count} items",
|
||||
"download": "Download",
|
||||
"downloadMultiple": "Download {count} items",
|
||||
"downloadFolder": "Download Folder",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"closeTab": "Close Tab",
|
||||
@@ -291,7 +292,9 @@
|
||||
"sftpManagerNotFound": "SFTP manager not found",
|
||||
"noActiveSession": "No active session found",
|
||||
"terminalManagerNotFound": "Terminal manager not found",
|
||||
"sendCommandFailed": "Failed to send command"
|
||||
"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",
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"deleteMultiple": "删除 {count} 个项目",
|
||||
"download": "下载",
|
||||
"downloadMultiple": "下载 {count} 个项目",
|
||||
"downloadFolder": "下载文件夹",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"closeTab": "关闭标签页",
|
||||
@@ -291,7 +292,9 @@
|
||||
"sftpManagerNotFound": "SFTP 管理器未找到",
|
||||
"noActiveSession": "未找到活动会话",
|
||||
"terminalManagerNotFound": "未找到终端管理器",
|
||||
"sendCommandFailed": "发送命令失败"
|
||||
"sendCommandFailed": "发送命令失败",
|
||||
"downloadDirectoryFailed": "下载文件夹失败",
|
||||
"downloadDirectoryNotImplemented": "服务器尚未实现文件夹下载功能。"
|
||||
},
|
||||
"notifications": {
|
||||
"copySuccess": "复制成功",
|
||||
|
||||
Reference in New Issue
Block a user