mirror of
https://github.com/GSManagerXZ/GameServerManager.git
synced 2026-06-02 02:49:33 +08:00
支持上传文件夹
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user