feat: 文件下载/上传

This commit is contained in:
bietiaop
2025-02-03 19:56:33 +08:00
parent af6e88c6f7
commit 6a7717e2a4
9 changed files with 565 additions and 72 deletions

View File

@@ -1,8 +1,13 @@
import type { RequestHandler } from 'express';
import type { RequestHandler, Request } from 'express';
import { sendError, sendSuccess } from '../utils/response';
import fs from 'fs/promises';
import fsProm from 'fs/promises';
import fs from 'fs';
import path from 'path';
import os from 'os';
import compressing from 'compressing';
import { PassThrough } from 'stream';
import multer from 'multer';
import { randomUUID } from 'crypto';
const isWindows = os.platform() === 'win32';
@@ -15,7 +20,7 @@ const getRootDirs = async (): Promise<string[]> => {
for (let i = 65; i <= 90; i++) {
const driveLetter = String.fromCharCode(i);
try {
await fs.access(`${driveLetter}:\\`);
await fsProm.access(`${driveLetter}:\\`);
drives.push(`${driveLetter}:`);
} catch {
// 如果驱动器不存在或无法访问,跳过
@@ -48,7 +53,7 @@ const SYSTEM_FILES = new Set(['pagefile.sys', 'swapfile.sys', 'hiberfil.sys', 'S
// 检查同类型的文件或目录是否存在
const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): Promise<boolean> => {
try {
const stat = await fs.stat(pathToCheck);
const stat = await fsProm.stat(pathToCheck);
// 只有当类型相同时才认为是冲突
return stat.isDirectory() === isDirectory;
} catch {
@@ -59,9 +64,9 @@ const checkSameTypeExists = async (pathToCheck: string, isDirectory: boolean): P
// 获取目录内容
export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const requestPath = (req.query.path as string) || (isWindows ? 'C:\\' : '/');
const requestPath = (req.query['path'] as string) || (isWindows ? 'C:\\' : '/');
const normalizedPath = normalizePath(requestPath);
const onlyDirectory = req.query.onlyDirectory === 'true';
const onlyDirectory = req.query['onlyDirectory'] === 'true';
// 如果是根路径且在Windows系统上返回盘符列表
if (isWindows && (!requestPath || requestPath === '/' || requestPath === '\\')) {
@@ -69,7 +74,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
const driveInfos: FileInfo[] = await Promise.all(
drives.map(async (drive) => {
try {
const stat = await fs.stat(`${drive}\\`);
const stat = await fsProm.stat(`${drive}\\`);
return {
name: drive,
isDirectory: true,
@@ -89,7 +94,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
return sendSuccess(res, driveInfos);
}
const files = await fs.readdir(normalizedPath);
const files = await fsProm.readdir(normalizedPath);
let fileInfos: FileInfo[] = [];
for (const file of files) {
@@ -98,7 +103,7 @@ export const ListFilesHandler: RequestHandler = async (req, res) => {
try {
const fullPath = path.join(normalizedPath, file);
const stat = await fs.stat(fullPath);
const stat = await fsProm.stat(fullPath);
fileInfos.push({
name: file,
isDirectory: stat.isDirectory(),
@@ -135,7 +140,7 @@ export const CreateDirHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名目录已存在');
}
await fs.mkdir(normalizedPath, { recursive: true });
await fsProm.mkdir(normalizedPath, { recursive: true });
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建目录失败');
@@ -147,11 +152,11 @@ export const DeleteHandler: RequestHandler = async (req, res) => {
try {
const { path: targetPath } = req.body;
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
await fsProm.unlink(normalizedPath);
}
return sendSuccess(res, true);
} catch (error) {
@@ -165,11 +170,11 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
const { paths } = req.body;
for (const targetPath of paths) {
const normalizedPath = normalizePath(targetPath);
const stat = await fs.stat(normalizedPath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
await fs.rm(normalizedPath, { recursive: true });
await fsProm.rm(normalizedPath, { recursive: true });
} else {
await fs.unlink(normalizedPath);
await fsProm.unlink(normalizedPath);
}
}
return sendSuccess(res, true);
@@ -181,8 +186,8 @@ export const BatchDeleteHandler: RequestHandler = async (req, res) => {
// 读取文件内容
export const ReadFileHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query.path as string);
const content = await fs.readFile(filePath, 'utf-8');
const filePath = normalizePath(req.query['path'] as string);
const content = await fsProm.readFile(filePath, 'utf-8');
return sendSuccess(res, content);
} catch (error) {
return sendError(res, '读取文件失败');
@@ -194,7 +199,7 @@ export const WriteFileHandler: RequestHandler = async (req, res) => {
try {
const { path: filePath, content } = req.body;
const normalizedPath = normalizePath(filePath);
await fs.writeFile(normalizedPath, content, 'utf-8');
await fsProm.writeFile(normalizedPath, content, 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '写入文件失败');
@@ -212,7 +217,7 @@ export const CreateFileHandler: RequestHandler = async (req, res) => {
return sendError(res, '同名文件已存在');
}
await fs.writeFile(normalizedPath, '', 'utf-8');
await fsProm.writeFile(normalizedPath, '', 'utf-8');
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '创建文件失败');
@@ -225,7 +230,7 @@ export const RenameHandler: RequestHandler = async (req, res) => {
const { oldPath, newPath } = req.body;
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
await fs.rename(normalizedOldPath, normalizedNewPath);
await fsProm.rename(normalizedOldPath, normalizedNewPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '重命名失败');
@@ -238,7 +243,7 @@ export const MoveHandler: RequestHandler = async (req, res) => {
const { sourcePath, targetPath } = req.body;
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '移动失败');
@@ -252,10 +257,140 @@ export const BatchMoveHandler: RequestHandler = async (req, res) => {
for (const { sourcePath, targetPath } of items) {
const normalizedSourcePath = normalizePath(sourcePath);
const normalizedTargetPath = normalizePath(targetPath);
await fs.rename(normalizedSourcePath, normalizedTargetPath);
await fsProm.rename(normalizedSourcePath, normalizedTargetPath);
}
return sendSuccess(res, true);
} catch (error) {
return sendError(res, '批量移动失败');
}
};
// 新增:文件下载处理方法(注意流式传输,不将整个文件读入内存)
export const DownloadHandler: RequestHandler = async (req, res) => {
try {
const filePath = normalizePath(req.query['path'] as string);
const stat = await fsProm.stat(filePath);
res.setHeader('Content-Type', 'application/octet-stream');
let filename = path.basename(filePath);
if (stat.isDirectory()) {
filename = path.basename(filePath) + '.zip';
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const zipStream = new PassThrough();
compressing.zip.compressDir(filePath, zipStream as unknown as fs.WriteStream).catch((err) => {
console.error('压缩目录失败:', err);
res.end();
});
zipStream.pipe(res);
return;
}
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
const stream = fs.createReadStream(filePath);
stream.pipe(res);
} catch (error) {
return sendError(res, '下载失败');
}
};
// 批量下载:将多个文件/目录打包为 zip 文件下载
export const BatchDownloadHandler: RequestHandler = async (req, res) => {
try {
const { paths } = req.body as { paths: string[] };
if (!paths || !Array.isArray(paths) || paths.length === 0) {
return sendError(res, '参数错误');
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=files.zip');
const zipStream = new compressing.zip.Stream();
// 修改:根据文件类型设置 relativePath
for (const filePath of paths) {
const normalizedPath = normalizePath(filePath);
const stat = await fsProm.stat(normalizedPath);
if (stat.isDirectory()) {
zipStream.addEntry(normalizedPath, { relativePath: '' });
} else {
zipStream.addEntry(normalizedPath, { relativePath: path.basename(normalizedPath) });
}
}
zipStream.pipe(res);
res.on('finish', () => {
zipStream.destroy();
});
} catch (error) {
return sendError(res, '下载失败');
}
};
// 修改:使用 Buffer 转码文件名,解决文件上传时乱码问题
const decodeFileName = (fileName: string): string => {
try {
return Buffer.from(fileName, 'binary').toString('utf8');
} catch {
return fileName;
}
};
// 修改上传处理方法
export const UploadHandler: RequestHandler = (req, res) => {
const uploadPath = (req.query['path'] || '') as string;
const storage = multer.diskStorage({
destination: (
_: Request,
file: Express.Multer.File,
cb: (error: Error | null, destination: string) => void
) => {
try {
const decodedName = decodeFileName(file.originalname);
if (!uploadPath) {
return cb(new Error('上传路径不能为空'), '');
}
if (isWindows && uploadPath === '\\') {
return cb(new Error('根目录不允许上传文件'), '');
}
// 处理文件夹上传的情况
if (decodedName.includes('/') || decodedName.includes('\\')) {
const fullPath = path.join(uploadPath, path.dirname(decodedName));
fs.mkdirSync(fullPath, { recursive: true });
cb(null, fullPath);
} else {
cb(null, uploadPath);
}
} catch (error) {
cb(error as Error, '');
}
},
filename: (_: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
try {
const decodedName = decodeFileName(file.originalname);
const fileName = path.basename(decodedName);
// 检查文件是否存在
const fullPath = path.join(uploadPath, decodedName);
if (fs.existsSync(fullPath)) {
const ext = path.extname(fileName);
const name = path.basename(fileName, ext);
cb(null, `${name}-${randomUUID()}${ext}`);
} else {
cb(null, fileName);
}
} catch (error) {
cb(error as Error, '');
}
},
});
const upload = multer({ storage }).array('files');
upload(req, res, (err: any) => {
if (err) {
return sendError(res, err.message || '文件上传失败');
}
return sendSuccess(res, true);
});
};

View File

@@ -11,6 +11,9 @@ import {
RenameHandler,
MoveHandler,
BatchMoveHandler,
DownloadHandler,
BatchDownloadHandler, // 新增下载处理方法
UploadHandler, // 添加上传处理器
} from '../api/File';
const router = Router();
@@ -32,5 +35,7 @@ router.post('/batchDelete', BatchDeleteHandler);
router.post('/rename', RenameHandler);
router.post('/move', MoveHandler);
router.post('/batchMove', BatchMoveHandler);
router.post('/download', DownloadHandler);
router.post('/batchDownload', BatchDownloadHandler);
router.post('/upload', UploadHandler); // 添加上传处理路由
export { router as FileRouter };