支持上传文件夹

This commit is contained in:
yxsj245
2026-01-02 11:00:19 +08:00
parent a733309443
commit 13d10b281a
4 changed files with 170 additions and 37 deletions

View File

@@ -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<UploadDialogProps> = ({
visible,
targetPath,
onConfirm,
onCancel
onCancel,
directory = false
}) => {
const [fileList, setFileList] = useState<UploadFile[]>([])
const [uploadProgress, setUploadProgress] = useState<FileUploadProgress | null>(null)
@@ -269,10 +275,35 @@ export const UploadDialog: React.FC<UploadDialogProps> = ({
const [conflictFiles, setConflictFiles] = useState<FileConflict[]>([])
const pendingFilesRef = useRef<FileList | null>(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<UploadDialogProps> = ({
// 检查文件冲突
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<UploadDialogProps> = ({
},
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<UploadDialogProps> = ({
<InboxOutlined className="text-4xl text-blue-500" />
</p>
<p className="ant-upload-text text-lg font-medium">
{directory ? '点击选择文件夹上传' : '点击或拖拽文件到此区域上传'}
</p>
<p className="ant-upload-hint text-gray-500">
使
{directory
? `支持上传整个文件夹,但文件夹内文件数量不得超过${MAX_FOLDER_FILES}个,超过请先压缩后上传压缩包`
: '支持单个或批量上传文件,大文件将自动使用分片上传'}
</p>
</Dragger>
@@ -876,12 +915,19 @@ export const UploadDialog: React.FC<UploadDialogProps> = ({
<div className="max-h-48 overflow-y-auto border rounded-lg dark:border-gray-600">
{conflictFiles.map((conflict, index) => (
<div
key={conflict.fileName}
key={conflict.relativePath || conflict.fileName}
className={`flex items-center justify-between p-3 ${index !== conflictFiles.length - 1 ? 'border-b dark:border-gray-600' : ''
}`}
>
<div className="flex items-center min-w-0 flex-1">
<span className="truncate font-medium">{conflict.fileName}</span>
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate font-medium">
{conflict.relativePath || conflict.fileName}
</span>
{conflict.relativePath && (
<span className="text-xs text-gray-400 truncate">
: {conflict.fileName}
</span>
)}
</div>
{conflict.existingSize !== undefined && (
<span className="text-xs text-gray-500 ml-2 whitespace-nowrap">

View File

@@ -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 && "新建文件"}
</Button>
</Tooltip>
<Tooltip title="上传文件">
<Button
icon={<UploadOutlined />}
onClick={() => setUploadDialog(true)}
>
{!touchAdaptation.shouldShowMobileUI && "上传"}
</Button>
</Tooltip>
<Dropdown
menu={{
items: [
{
key: 'file',
icon: <UploadOutlined />,
label: '上传文件',
onClick: () => setUploadDialog({ visible: true, directory: false })
},
{
key: 'folder',
icon: <FolderAddOutlined />,
label: '上传文件夹',
onClick: () => setUploadDialog({ visible: true, directory: true })
}
]
}}
trigger={['click']}
>
<Tooltip title="上传">
<Button icon={<UploadOutlined />}>
{!touchAdaptation.shouldShowMobileUI && "上传"}
</Button>
</Tooltip>
</Dropdown>
{/* 文件操作按钮 - 小屏模式下隐藏 */}
{!touchAdaptation.shouldShowMobileUI && (
@@ -1839,10 +1861,11 @@ const FileManagerPage: React.FC = () => {
/>
<UploadDialog
visible={uploadDialog}
visible={uploadDialog.visible}
targetPath={currentPath}
onConfirm={handleUploadConfirm}
onCancel={() => setUploadDialog(false)}
onCancel={() => setUploadDialog({ visible: false, directory: false })}
directory={uploadDialog.directory}
/>
<DeleteConfirmDialog

View File

@@ -357,10 +357,18 @@ export class FileApiClient {
formData.append('targetPath', targetPath)
formData.append('conflictStrategy', conflictStrategy)
// 收集文件的相对路径信息(用于保留文件夹结构)
const filePaths: string[] = []
// 处理文件名编码,确保中文文件名正确传输
for (let i = 0; i < files.length; i++) {
const file = files[i]
// 获取文件的相对路径(用于文件夹上传)
// webkitRelativePath 格式如folderName/subFolder/file.txt
const relativePath = (file as any).webkitRelativePath || ''
filePaths.push(relativePath)
// 检查文件名是否包含中文字符
const hasChineseChars = /[\u4e00-\u9fa5]/.test(file.name)
@@ -372,12 +380,18 @@ export class FileApiClient {
lastModified: file.lastModified
})
formData.append('files', newFile, file.name)
console.log('Uploading Chinese filename:', file.name)
console.log('Uploading Chinese filename:', file.name, 'relativePath:', relativePath)
} else {
formData.append('files', file)
}
}
// 如果存在相对路径信息,说明是文件夹上传
if (filePaths.some(p => p !== '')) {
formData.append('filePaths', JSON.stringify(filePaths))
console.log('Uploading folder with structure, file paths:', filePaths)
}
// 创建可取消的请求
const controller = new AbortController()

View File

@@ -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 })