From 13d10b281a2d5bb8426b1d1f60bb7183d8b4e48e Mon Sep 17 00:00:00 2001
From: yxsj245 <17737475682@163.com>
Date: Fri, 2 Jan 2026 11:00:19 +0800
Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=8A=E4=BC=A0=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E5=A4=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
client/src/components/FileDialogs.tsx | 64 ++++++++++++++++++----
client/src/pages/FileManagerPage.tsx | 49 ++++++++++++-----
client/src/utils/fileApi.ts | 16 +++++-
server/src/routes/files.ts | 78 ++++++++++++++++++++++-----
4 files changed, 170 insertions(+), 37 deletions(-)
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 && "新建文件"}
-
- }
- onClick={() => setUploadDialog(true)}
- >
- {!touchAdaptation.shouldShowMobileUI && "上传"}
-
-
+ ,
+ label: '上传文件',
+ onClick: () => setUploadDialog({ visible: true, directory: false })
+ },
+ {
+ key: 'folder',
+ icon: ,
+ label: '上传文件夹',
+ onClick: () => setUploadDialog({ visible: true, directory: true })
+ }
+ ]
+ }}
+ trigger={['click']}
+ >
+
+ }>
+ {!touchAdaptation.shouldShowMobileUI && "上传"}
+
+
+
{/* 文件操作按钮 - 小屏模式下隐藏 */}
{!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 })