diff --git a/client/src/components/FileDialogs.tsx b/client/src/components/FileDialogs.tsx index e78dc70..796e14b 100644 --- a/client/src/components/FileDialogs.tsx +++ b/client/src/components/FileDialogs.tsx @@ -237,11 +237,16 @@ interface UploadDialogProps { targetPath: string // 上传目标路径 onConfirm: (files: FileList, onProgress?: (progress: FileUploadProgress) => void, signal?: AbortSignal, conflictStrategy?: 'replace' | 'rename') => void onCancel: () => void + directory?: boolean // 是否支持文件夹上传 } +// 文件夹上传时的最大文件数量限制 +const MAX_FOLDER_FILES = 100 + // 文件冲突信息接口 interface FileConflict { fileName: string + relativePath?: string // 文件夹上传时的相对路径 exists: boolean existingSize?: number existingModified?: Date @@ -251,7 +256,8 @@ export const UploadDialog: React.FC = ({ visible, targetPath, onConfirm, - onCancel + onCancel, + directory = false }) => { const [fileList, setFileList] = useState([]) const [uploadProgress, setUploadProgress] = useState(null) @@ -269,10 +275,35 @@ export const UploadDialog: React.FC = ({ const [conflictFiles, setConflictFiles] = useState([]) const pendingFilesRef = useRef(null) + // 文件夹上传超过限制的提示状态 + const [folderLimitExceeded, setFolderLimitExceeded] = useState(false) + const folderFilesCountRef = useRef(0) + const uploadProps: UploadProps = { name: 'files', multiple: true, - beforeUpload: (file) => { + directory: directory, // 支持文件夹上传 + beforeUpload: (file, allFiles) => { + // 文件夹上传时检查文件数量限制 + if (directory && allFiles.length > MAX_FOLDER_FILES) { + // 如果还没有显示过提示,才显示 + if (!folderLimitExceeded) { + setFolderLimitExceeded(true) + message.error({ + content: `文件夹内文件数量(${allFiles.length}个)超过${MAX_FOLDER_FILES}个限制,请压缩后上传压缩包`, + duration: 5 + }) + // 清空已选文件列表 + setFileList([]) + } + return Upload.LIST_IGNORE + } + + // 重置限制状态 + if (folderLimitExceeded) { + setFolderLimitExceeded(false) + } + // 验证文件名是否包含中文字符 const hasChineseChars = /[\u4e00-\u9fa5]/.test(file.name) @@ -414,7 +445,12 @@ export const UploadDialog: React.FC = ({ // 检查文件冲突 setIsCheckingConflict(true) try { - const fileNames = Array.from(files).map(f => f.name) + // 收集文件名和相对路径(用于文件夹上传) + const fileInfos = Array.from(files).map(f => ({ + name: f.name, + relativePath: (f as any).webkitRelativePath || '' + })) + const response = await fetch('/api/files/upload/check-conflict', { method: 'POST', headers: { @@ -423,7 +459,8 @@ export const UploadDialog: React.FC = ({ }, body: JSON.stringify({ targetPath, - fileNames + fileNames: fileInfos.map(f => f.name), + filePaths: fileInfos.map(f => f.relativePath) // 传递相对路径 }) }) @@ -567,10 +604,12 @@ export const UploadDialog: React.FC = ({

- 点击或拖拽文件到此区域上传 + {directory ? '点击选择文件夹上传' : '点击或拖拽文件到此区域上传'}

- 支持单个或批量上传文件,大文件将自动使用分片上传 + {directory + ? `支持上传整个文件夹,但文件夹内文件数量不得超过${MAX_FOLDER_FILES}个,超过请先压缩后上传压缩包` + : '支持单个或批量上传文件,大文件将自动使用分片上传'}

@@ -876,12 +915,19 @@ export const UploadDialog: React.FC = ({
{conflictFiles.map((conflict, index) => (
-
- {conflict.fileName} +
+ + {conflict.relativePath || conflict.fileName} + + {conflict.relativePath && ( + + 文件名: {conflict.fileName} + + )}
{conflict.existingSize !== undefined && ( diff --git a/client/src/pages/FileManagerPage.tsx b/client/src/pages/FileManagerPage.tsx index bfdbc37..ff46083 100644 --- a/client/src/pages/FileManagerPage.tsx +++ b/client/src/pages/FileManagerPage.tsx @@ -16,11 +16,13 @@ import { Progress, Badge, Drawer, - Select + Select, + Dropdown } from 'antd' import { HomeOutlined, FolderOutlined, + FolderAddOutlined, PlusOutlined, UploadOutlined, DeleteOutlined, @@ -157,7 +159,10 @@ const FileManagerPage: React.FC = () => { file: FileItem | null }>({ visible: false, file: null }) - const [uploadDialog, setUploadDialog] = useState(false) + const [uploadDialog, setUploadDialog] = useState<{ + visible: boolean + directory: boolean // 是否为文件夹上传模式 + }>({ visible: false, directory: false }) const [deleteDialog, setDeleteDialog] = useState<{ visible: boolean files: FileItem[] @@ -1149,7 +1154,7 @@ const FileManagerPage: React.FC = () => { title: '上传成功', message: `成功上传 ${files.length} 个文件` }) - setUploadDialog(false) + setUploadDialog({ visible: false, directory: false }) } else if (onProgress) { // 如果上传失败,通过进度回调通知错误状态 onProgress({ @@ -1454,14 +1459,31 @@ const FileManagerPage: React.FC = () => { {!touchAdaptation.shouldShowMobileUI && "新建文件"} - - - + , + label: '上传文件', + onClick: () => setUploadDialog({ visible: true, directory: false }) + }, + { + key: 'folder', + icon: , + label: '上传文件夹', + onClick: () => setUploadDialog({ visible: true, directory: true }) + } + ] + }} + trigger={['click']} + > + + + + {/* 文件操作按钮 - 小屏模式下隐藏 */} {!touchAdaptation.shouldShowMobileUI && ( @@ -1839,10 +1861,11 @@ const FileManagerPage: React.FC = () => { /> setUploadDialog(false)} + onCancel={() => setUploadDialog({ visible: false, directory: false })} + directory={uploadDialog.directory} /> p !== '')) { + formData.append('filePaths', JSON.stringify(filePaths)) + console.log('Uploading folder with structure, file paths:', filePaths) + } + // 创建可取消的请求 const controller = new AbortController() diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 51f63d6..32c27a1 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -1022,7 +1022,7 @@ router.get('/download', authenticateTokenFlexible, async (req: Request, res: Res // 检查文件上传冲突 router.post('/upload/check-conflict', authenticateToken, async (req: Request, res: Response) => { try { - const { targetPath, fileNames } = req.body + const { targetPath, fileNames, filePaths } = req.body if (!targetPath || !fileNames || !Array.isArray(fileNames)) { return res.status(400).json({ @@ -1042,26 +1042,45 @@ router.post('/upload/check-conflict', authenticateToken, async (req: Request, re fullTargetPath = path.resolve(process.cwd(), fixedTargetPath.replace(/^\//, '')) } - // 检查每个文件是否存在冲突 - const conflicts: Array<{ fileName: string; exists: boolean; existingSize?: number; existingModified?: Date }> = [] + // 解析文件相对路径(用于文件夹上传) + let fileRelativePaths: string[] = [] + if (filePaths && Array.isArray(filePaths)) { + fileRelativePaths = filePaths + } + + // 检查每个文件是否存在冲突 + const conflicts: Array<{ fileName: string; relativePath?: string; exists: boolean; existingSize?: number; existingModified?: Date }> = [] + + for (let i = 0; i < fileNames.length; i++) { + const fileName = fileNames[i] + const relativePath = fileRelativePaths[i] || '' + + // 根据相对路径确定实际的目标文件路径 + let targetFilePath: string + if (relativePath) { + // 相对路径格式如: "folderName/subFolder/file.txt" + // 我们需要使用整个相对路径来构建目标路径 + targetFilePath = path.join(fullTargetPath, relativePath) + } else { + targetFilePath = path.join(fullTargetPath, fileName) + } - for (const fileName of fileNames) { - const targetFilePath = path.join(fullTargetPath, fileName) try { const stats = await fs.stat(targetFilePath) if (stats.isFile()) { conflicts.push({ fileName, + relativePath: relativePath || undefined, exists: true, existingSize: stats.size, existingModified: stats.mtime }) } else { - conflicts.push({ fileName, exists: false }) + conflicts.push({ fileName, relativePath: relativePath || undefined, exists: false }) } } catch { // 文件不存在 - conflicts.push({ fileName, exists: false }) + conflicts.push({ fileName, relativePath: relativePath || undefined, exists: false }) } } @@ -1091,11 +1110,12 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req console.log('Files length:', req.files?.length) try { - const { targetPath, conflictStrategy = 'rename' } = req.body + const { targetPath, conflictStrategy = 'rename', filePaths } = req.body // conflictStrategy: 'replace' | 'rename' | 'skip' // - replace: 直接替换已存在的文件 // - rename: 自动重命名(添加序号) // - skip: 跳过已存在的文件 + // filePaths: JSON字符串,包含每个文件的相对路径(用于文件夹上传保留结构) const files = req.files as Express.Multer.File[] @@ -1109,6 +1129,17 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req return res.status(400).json({ success: false, message: 'No files uploaded' }) } + // 解析文件相对路径(用于文件夹上传) + let fileRelativePaths: string[] = [] + if (filePaths) { + try { + fileRelativePaths = JSON.parse(filePaths) + console.log('Folder upload detected, file paths:', fileRelativePaths) + } catch (e) { + console.warn('Failed to parse filePaths:', e) + } + } + // 修复Windows路径格式 const fixedTargetPath = fixWindowsPath(targetPath) console.log('Original target path:', targetPath) @@ -1131,7 +1162,8 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req // 移动文件到目标目录 const results = [] - for (const file of files) { + for (let i = 0; i < files.length; i++) { + const file = files[i] try { // 使用改进的文件名处理函数 let originalName = fixChineseFilename(file.originalname) @@ -1146,9 +1178,27 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req console.log(`Using fallback filename: ${originalName}`) } + // 处理文件夹上传的相对路径 + // 获取此文件对应的相对路径 + const relativePath = fileRelativePaths[i] || '' + let fileTargetDir = fullTargetPath + + if (relativePath) { + // 相对路径格式如: "folderName/subFolder/file.txt" + // 我们需要取父目录部分: "folderName/subFolder" + const relativeDir = path.dirname(relativePath) + if (relativeDir && relativeDir !== '.') { + // 构建完整的目标目录(目标路径 + 相对目录) + fileTargetDir = path.join(fullTargetPath, relativeDir) + // 确保子目录存在 + await fs.mkdir(fileTargetDir, { recursive: true }) + console.log(`Created folder structure: ${fileTargetDir}`) + } + } + // 检查目标文件是否已存在 let finalFileName = originalName - const targetFilePath = path.join(fullTargetPath, originalName) + const targetFilePath = path.join(fileTargetDir, originalName) const fileExists = await fs.access(targetFilePath).then(() => true).catch(() => false) if (fileExists) { @@ -1164,13 +1214,13 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req try { await fs.unlink(file.path) } catch { } - results.push({ name: originalName, success: true, skipped: true }) + results.push({ name: originalName, relativePath, success: true, skipped: true }) continue case 'rename': default: // 自动重命名:添加序号 let counter = 1 - while (await fs.access(path.join(fullTargetPath, finalFileName)).then(() => true).catch(() => false)) { + while (await fs.access(path.join(fileTargetDir, finalFileName)).then(() => true).catch(() => false)) { const ext = path.extname(originalName) const nameWithoutExt = path.basename(originalName, ext) finalFileName = `${nameWithoutExt}(${counter})${ext}` @@ -1181,7 +1231,7 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req } } - const finalTargetFilePath = path.join(fullTargetPath, finalFileName) + const finalTargetFilePath = path.join(fileTargetDir, finalFileName) console.log(`Moving file from ${file.path} to ${finalTargetFilePath}`) // 使用diskStorage时,文件已经在临时目录中,需要移动到目标目录 @@ -1197,7 +1247,7 @@ router.post('/upload', authenticateToken, upload.array('files'), async (req: Req throw renameError } } - results.push({ name: finalFileName, success: true, replaced: fileExists && conflictStrategy === 'replace' }) + results.push({ name: finalFileName, relativePath, success: true, replaced: fileExists && conflictStrategy === 'replace' }) } catch (error: any) { console.error(`Failed to move file ${file.originalname}:`, error) results.push({ name: file.originalname, success: false, error: error.message })