diff --git a/components/AIChatWidget.drag-drop.test.tsx b/components/AIChatWidget.drag-drop.test.tsx new file mode 100644 index 0000000..6a3c23c --- /dev/null +++ b/components/AIChatWidget.drag-drop.test.tsx @@ -0,0 +1,86 @@ +import { createEvent, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import AIChatWidget from '@/components/AIChatWidget' + +jest.mock('next-auth/react', () => ({ + useSession: () => ({ + data: { + user: { + id: 'user-1', + name: '测试用户', + }, + }, + }), +})) + +describe('AIChatWidget 图片拖拽', () => { + beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: jest.fn((file: File) => `blob:${file.name}`), + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: jest.fn(), + }) + }) + + test('经过内部元素不应关闭拖拽提示', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'AI助手' })) + + const welcome = screen.getByText('👋 你好!我是AI助手') + const dropZone = welcome.parentElement?.parentElement as HTMLDivElement + Object.defineProperty(dropZone, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left: 0, + right: 400, + top: 0, + bottom: 600, + }), + }) + + fireEvent.dragOver(dropZone, { + clientX: 100, + clientY: 100, + dataTransfer: { files: [], types: ['Files'], dropEffect: 'none' }, + }) + expect(screen.getByText('松开鼠标上传图片')).toBeInTheDocument() + + const internalDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(internalDragLeave, { + clientX: { value: 120 }, + clientY: { value: 120 }, + relatedTarget: { value: dropZone.firstElementChild }, + dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } }, + }) + fireEvent(dropZone, internalDragLeave) + + expect(screen.getByText('松开鼠标上传图片')).toBeInTheDocument() + }) + + test('最多接收5张图片并明确提示被忽略数量', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'AI助手' })) + + const welcome = screen.getByText('👋 你好!我是AI助手') + const dropZone = welcome.parentElement?.parentElement as HTMLDivElement + const images = Array.from({ length: 6 }, (_, index) => ( + new File([`${index}`], `${index + 1}.png`, { type: 'image/png' }) + )) + + fireEvent.drop(dropZone, { + dataTransfer: { files: images, types: ['Files'], dropEffect: 'copy' }, + }) + + expect(screen.getAllByAltText(/待发送图片/)).toHaveLength(5) + expect(screen.getByRole('alert')).toHaveTextContent('最多只能选择 5 张图片,已忽略 1 张') + expect(screen.getByRole('button', { name: '发送' })).toBeEnabled() + }) +}) diff --git a/components/AIChatWidget.tsx b/components/AIChatWidget.tsx index 8008cbb..aa6fcfa 100644 --- a/components/AIChatWidget.tsx +++ b/components/AIChatWidget.tsx @@ -1,8 +1,16 @@ 'use client' -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useSession } from 'next-auth/react' import Image from 'next/image' +import { + hasDragLeftDropZone, + prepareFileDrag, + validateImageFiles, +} from '@/components/upload/drag-drop-utils' + +const AI_CHAT_MAX_IMAGES = 5 +const AI_CHAT_MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 添加动画样式 const shimmerStyle = ` @@ -69,14 +77,29 @@ interface Message { sourceImages?: string[] // 用户发送的原始图片(用于显示模糊占位符) } +interface PendingImage { + id: string + file: File + previewUrl: string +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(new Error(`无法读取图片:${file.name}`)) + reader.onload = () => resolve(reader.result as string) + reader.readAsDataURL(file) + }) +} + export default function AIChatWidget() { const { data: session } = useSession() const [isOpen, setIsOpen] = useState(false) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [sending, setSending] = useState(false) - const [images, setImages] = useState([]) - const [imagePreviews, setImagePreviews] = useState([]) + const [pendingImages, setPendingImages] = useState([]) + const [imageError, setImageError] = useState('') const [isDragging, setIsDragging] = useState(false) const [windowDragging, setWindowDragging] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) @@ -86,6 +109,7 @@ export default function AIChatWidget() { const containerRef = useRef(null) const dropZoneRef = useRef(null) const headerRef = useRef(null) + const pendingImagesRef = useRef([]) // 窗口拖拽功能 const handleMouseDown = (e: React.MouseEvent) => { @@ -140,108 +164,141 @@ export default function AIChatWidget() { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) + const replacePendingImages = useCallback((nextImages: PendingImage[]) => { + pendingImagesRef.current = nextImages + setPendingImages(nextImages) + }, []) + + const addImageFiles = useCallback((selectedFiles: FileList | File[]) => { + if (sending) { + setImageError('消息正在发送,请等待完成后再添加图片') + return + } + + const { acceptedFiles, errors } = validateImageFiles(selectedFiles, { + maxBytes: AI_CHAT_MAX_IMAGE_BYTES, + maxCount: AI_CHAT_MAX_IMAGES, + existingCount: pendingImagesRef.current.length, + }) + + if (acceptedFiles.length > 0) { + const nextImages = [ + ...pendingImagesRef.current, + ...acceptedFiles.map((file, index) => ({ + id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, + file, + previewUrl: URL.createObjectURL(file), + })), + ] + replacePendingImages(nextImages) + } + + setImageError(errors.join(';')) + }, [replacePendingImages, sending]) + + useEffect(() => { + return () => { + pendingImagesRef.current.forEach((image) => URL.revokeObjectURL(image.previewUrl)) + } + }, []) + // 处理粘贴图片 useEffect(() => { const handlePaste = (e: ClipboardEvent) => { - if (!isOpen) return + if (!isOpen || sending) return const items = e.clipboardData?.items if (!items) return - + + const pastedFiles: File[] = [] for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile() if (file) { - setImages(prev => [...prev, file].slice(0, 5)) - - const reader = new FileReader() - reader.onloadend = () => { - setImagePreviews(prev => [...prev, reader.result as string]) - } - reader.readAsDataURL(file) + pastedFiles.push(file) } } } + + if (pastedFiles.length > 0) { + e.preventDefault() + addImageFiles(pastedFiles) + } } if (isOpen) { document.addEventListener('paste', handlePaste) } return () => document.removeEventListener('paste', handlePaste) - }, [isOpen]) + }, [addImageFiles, isOpen, sending]) // 处理图片选择 const handleImageSelect = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []) - if (files.length > 0) { - setImages(prev => [...prev, ...files].slice(0, 5)) // 最多5张图片 - - // 生成预览 - files.forEach(file => { - const reader = new FileReader() - reader.onloadend = () => { - setImagePreviews(prev => [...prev, reader.result as string]) - } - reader.readAsDataURL(file) - }) - } + addImageFiles(e.target.files || []) + e.currentTarget.value = '' } // 移除图片 const removeImage = (index: number) => { - setImages(prev => prev.filter((_, i) => i !== index)) - setImagePreviews(prev => prev.filter((_, i) => i !== index)) + if (sending) return + const removedImage = pendingImagesRef.current[index] + if (removedImage) { + URL.revokeObjectURL(removedImage.previewUrl) + } + replacePendingImages(pendingImagesRef.current.filter((_, imageIndex) => imageIndex !== index)) + setImageError('') } // 处理拖拽上传 const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() + prepareFileDrag(e, sending) + if (sending) return setIsDragging(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() - setIsDragging(false) + e.stopPropagation() + if (hasDragLeftDropZone(e)) { + setIsDragging(false) + } } const handleDrop = (e: React.DragEvent) => { - e.preventDefault() + prepareFileDrag(e, sending) setIsDragging(false) - - const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/')) - if (files.length > 0) { - setImages(prev => [...prev, ...files].slice(0, 5)) - - files.forEach(file => { - const reader = new FileReader() - reader.onloadend = () => { - setImagePreviews(prev => [...prev, reader.result as string]) - } - reader.readAsDataURL(file) - }) + if (sending) { + setImageError('消息正在发送,请等待完成后再添加图片') + return } + addImageFiles(e.dataTransfer.files) } // 发送消息 const handleSend = async () => { - if ((!input.trim() && images.length === 0) || sending || !session) return + const selectedImages = pendingImagesRef.current + if ((!input.trim() && selectedImages.length === 0) || sending || !session) return setSending(true) - const userMessage: Message = { - id: Date.now().toString(), - role: 'user', - content: input.trim(), - images: imagePreviews, - timestamp: new Date() - } - - setMessages(prev => [...prev, userMessage]) - setInput('') - const currentImages = imagePreviews - setImagePreviews([]) - setImages([]) try { + const currentImages = await Promise.all( + selectedImages.map((image) => readFileAsDataUrl(image.file)), + ) + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + images: currentImages, + timestamp: new Date() + } + + setMessages(prev => [...prev, userMessage]) + setInput('') + selectedImages.forEach((image) => URL.revokeObjectURL(image.previewUrl)) + replacePendingImages([]) + setImageError('') + // 检测是否需要生成图片 const shouldShowImagePlaceholder = currentImages.length > 0 || userMessage.content.includes('生成') || @@ -452,7 +509,7 @@ export default function AIChatWidget() { > {/* 拖拽提示 */} {isDragging && ( -
+
@@ -606,20 +663,21 @@ export default function AIChatWidget() {
{/* 图片预览区 */} - {imagePreviews.length > 0 && ( + {pendingImages.length > 0 && (
- {imagePreviews.map((preview, idx) => ( -
+ {pendingImages.map((image, idx) => ( +
{`Preview @@ -639,11 +697,13 @@ export default function AIChatWidget() { accept="image/*" multiple className="hidden" + disabled={sending} /> {/* 上传按钮 */}
+ {imageError && ( +

+ {imageError} +

+ )}

- 支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张 + 支持上传、拖拽或粘贴图片 · 最多5张,单张不超过10MB

@@ -714,4 +779,3 @@ export default function AIChatWidget() {
) } - diff --git a/components/SkuEditModal.tsx b/components/SkuEditModal.tsx index 91c419a..89ea48a 100644 --- a/components/SkuEditModal.tsx +++ b/components/SkuEditModal.tsx @@ -27,6 +27,11 @@ import { import { CSS } from '@dnd-kit/utilities' import { parseApiError, parseFetchError } from '@/lib/error-handler' import { formatTransferProgress } from '@/components/upload/upload-utils' +import { + hasAllowedFileExtension, + prepareFileDrag, + validateImageFiles, +} from '@/components/upload/drag-drop-utils' import { STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME, STANDARD_MODEL_WHITE_BG_VIEWS, @@ -249,14 +254,18 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` } - // 处理新增 SKU 的压缩包选择(立即显示,不上传) - const handleNewZipSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + const applyNewSkuZipFile = (file: File) => { + if (uploading) { + showToast('文件正在上传,请等待完成后再更换压缩包', { variant: 'error' }) + return + } + if (!hasAllowedFileExtension(file, ['zip', 'rar', '7z'])) { + showToast('压缩包仅支持 ZIP、RAR、7Z 格式', { variant: 'error' }) + return + } - // 立即存储文件对象,不立即上传 setNewSkuZipFile(file) - + // 如果没有填写名称,用文件名(去掉扩展名)作为默认名称 if (!newSkuName) { const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '') @@ -264,10 +273,25 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S } } - // 处理新增 SKU 的预览图选择(立即显示,不上传) - const handleNewPreviewSelect = (e: React.ChangeEvent) => { + // 处理新增 SKU 的压缩包选择(立即显示,不上传) + const handleNewZipSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] - if (!file) return + if (file) { + applyNewSkuZipFile(file) + } + e.currentTarget.value = '' + } + + const applyNewSkuPreviewFile = (file: File) => { + if (uploading) { + showToast('文件正在上传,请等待完成后再更换预览图', { variant: 'error' }) + return + } + const { acceptedFiles, errors } = validateImageFiles([file]) + if (acceptedFiles.length === 0) { + showToast(errors.join(';') || '请选择有效的图片文件', { variant: 'error' }) + return + } // ⚠️ 内存泄漏修复:释放旧的 Object URL(R17) if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { @@ -280,11 +304,30 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S setNewSkuPreviewUrl(previewUrl) } + // 处理新增 SKU 的预览图选择(立即显示,不上传) + const handleNewPreviewSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + applyNewSkuPreviewFile(file) + } + e.currentTarget.value = '' + } + const handleSelectWhiteBackgroundImage = (key: StandardModelWhiteBgKey, file: File) => { - setWhiteBgError('') + if (whiteBgUploading) { + setWhiteBgError('图片正在上传,请等待完成后再更换') + return + } + const { acceptedFiles, errors } = validateImageFiles([file]) + if (acceptedFiles.length === 0) { + setWhiteBgError(errors.join(';') || '请选择有效的图片文件') + return + } + + setWhiteBgError(errors.join(';')) setWhiteBgFiles((prev) => ({ ...prev, - [key]: file, + [key]: acceptedFiles[0], })) setWhiteBgPreviewUrls((prev) => { @@ -295,7 +338,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S return { ...prev, - [key]: URL.createObjectURL(file), + [key]: URL.createObjectURL(acceptedFiles[0]), } }) } @@ -320,15 +363,17 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S } const handleSelectWhiteBackgroundOthers = (files: FileList | File[]) => { - const fileList = Array.from(files || []).filter((file) => file.type.startsWith('image/')) - if (fileList.length === 0) { + if (whiteBgUploading) { + setWhiteBgError('图片正在上传,请等待完成后再添加') return } + const { acceptedFiles, errors } = validateImageFiles(files) + setWhiteBgError(errors.join(';')) + if (acceptedFiles.length === 0) return - setWhiteBgError('') setWhiteBgOtherImages((prev) => [ ...prev, - ...fileList.map((file, index) => ({ + ...acceptedFiles.map((file, index) => ({ id: `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`, file, previewUrl: URL.createObjectURL(file), @@ -1240,18 +1285,12 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S
{/* 预览图(支持拖拽) */}
e.preventDefault()} + onDragOver={(e) => prepareFileDrag(e, uploading)} onDrop={(e) => { - e.preventDefault() + prepareFileDrag(e, uploading) const file = e.dataTransfer.files?.[0] - if (file && file.type.startsWith('image/')) { - // ⚠️ 内存泄漏修复:释放旧的 Object URL(R17) - if (newSkuPreviewUrl && newSkuPreviewUrl.startsWith('blob:')) { - URL.revokeObjectURL(newSkuPreviewUrl) - } - setNewSkuPreviewFile(file) - const previewUrl = URL.createObjectURL(file) - setNewSkuPreviewUrl(previewUrl) + if (file) { + applyNewSkuPreviewFile(file) } }} > @@ -1292,22 +1331,18 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S accept="image/*" onChange={handleNewPreviewSelect} className="hidden" + disabled={uploading} />
{/* 压缩包(支持拖拽) */}
e.preventDefault()} + onDragOver={(e) => prepareFileDrag(e, uploading)} onDrop={(e) => { - e.preventDefault() + prepareFileDrag(e, uploading) const file = e.dataTransfer.files?.[0] - if (file && ['.zip', '.rar', '.7z'].some(ext => file.name.toLowerCase().endsWith(ext))) { - setNewSkuZipFile(file) - // 如果没有填写名称,用文件名(去掉扩展名)作为默认名称 - if (!newSkuName) { - const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '') - setNewSkuName(nameWithoutExt) - } + if (file) { + applyNewSkuZipFile(file) } }} > @@ -1338,6 +1373,7 @@ export default function SkuEditModal({ materialId, isOpen, onClose, onSaved }: S accept=".zip,.rar,.7z" onChange={handleNewZipSelect} className="hidden" + disabled={uploading} />
diff --git a/components/UploadModal.task-other.test.tsx b/components/UploadModal.task-other.test.tsx index 03bb369..c6d3796 100644 --- a/components/UploadModal.task-other.test.tsx +++ b/components/UploadModal.task-other.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import UploadModal from '@/components/UploadModal' @@ -71,4 +71,29 @@ describe('UploadModal - 任务-其他', () => { expect(screen.queryByText('压缩包源文件 *')).not.toBeInTheDocument() expect(screen.queryByText('预览图 *')).not.toBeInTheDocument() }) + + test('主素材区混合拖入有效与非法文件时应保留有效文件并提示被忽略项', async () => { + const user = userEvent.setup() + + render() + + const materialTypeSelect = (await screen.findAllByRole('combobox'))[0] + await user.selectOptions(materialTypeSelect, '任务-其他') + + const selectButton = await screen.findByRole('button', { name: '选择素材文件' }) + const dropZone = selectButton.closest('[aria-disabled]') as HTMLDivElement + const zipFile = new File(['zip'], 'task-source.zip', { type: 'application/zip' }) + const invalidFile = new File(['text'], 'readme.txt', { type: 'text/plain' }) + + fireEvent.drop(dropZone, { + dataTransfer: { + files: [zipFile, invalidFile], + types: ['Files'], + dropEffect: 'copy', + }, + }) + + expect(await screen.findByText('task-source.zip')).toBeInTheDocument() + expect(screen.getByText(/readme\.txt/)).toBeInTheDocument() + }) }) diff --git a/components/UploadModal.tsx b/components/UploadModal.tsx index 855d833..9070e89 100644 --- a/components/UploadModal.tsx +++ b/components/UploadModal.tsx @@ -60,12 +60,43 @@ import ProductPackageUploader from '@/components/upload/ProductPackageUploader' import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader' import TaskPageUploader from '@/components/upload/TaskPageUploader' import CertificateReportUploader from '@/components/upload/CertificateReportUploader' +import { + hasDragLeftDropZone, + prepareFileDrag, + validateImageFiles, +} from '@/components/upload/drag-drop-utils' interface UploadModalProps { onClose: () => void onSuccess: () => void } +function getSupportedMainFileDescription(materialType: string): string { + if (materialType === '音乐') { + return '音频文件(.mp3/.wav/.flac/.m4a/.aac/.ogg)' + } + if (isVideoMaterialType(materialType)) { + return '视频文件(.mp4/.mov/.avi/.mkv/.mts/.m4v)' + } + if (materialType === '品牌VI灵感') { + return 'PDF 文件(.pdf)' + } + if (materialType === '详情模板') { + return 'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)' + } + return '压缩包格式(.zip / .rar / .7z)' +} + +function getIgnoredMainFileError(files: File[], materialType: string): string { + const invalidNames = files + .filter((file) => !isValidMainFileType(file, materialType)) + .map((file) => file.name) + + return invalidNames.length > 0 + ? `以下文件不支持当前素材类型,已忽略:${invalidNames.join('、')}` + : '' +} + export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { const SPECIAL_UPLOAD_TYPES = ['任务-详情', '任务-视频截图', '任务-页面', '证书-检测报告'] as const const [previewImages, setPreviewImages] = useState([]) // 预览图片数组(支持多图) @@ -220,8 +251,22 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { return regex.test(value) } - const handlePreviewChange = async (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []) + const handlePreviewChange = async ( + e: React.ChangeEvent, + preservedErrors: string[] = [], + ) => { + if (uploading) { + setError('文件正在上传,请等待完成后再添加预览图') + e.currentTarget.value = '' + return + } + const { acceptedFiles: files, errors } = validateImageFiles(e.target.files || []) + const combinedErrors = [...preservedErrors, ...errors] + if (files.length === 0) { + setError(combinedErrors.join(';') || '请选择有效的图片文件') + e.currentTarget.value = '' + return + } if (isTaskOtherMaterial && files.length > 1) { setError('任务-其他一次只能上传一张预览图') if (e.target) { @@ -240,7 +285,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setPreviewImages((prev) => [...prev, ...compressedFiles]) setPreviewUrls((prev) => [...prev, ...urls]) } - setError('') + setError(combinedErrors.join(';')) // 清空 input,允许重复选择同一文件 if (e.target) { e.target.value = '' @@ -250,16 +295,31 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { ) } - const handleDisplayImageChange = async (e: React.ChangeEvent) => { + const handleDisplayImageChange = async ( + e: React.ChangeEvent, + preservedErrors: string[] = [], + ) => { + if (uploading) { + setError('文件正在上传,请等待完成后再更换展示图') + e.currentTarget.value = '' + return + } const file = e.target.files?.[0] if (!file) return + const { acceptedFiles, errors } = validateImageFiles([file], { maxCount: 1 }) + const combinedErrors = [...preservedErrors, ...errors] + if (acceptedFiles.length === 0) { + setError(combinedErrors.join(';') || '请选择有效的图片文件') + e.currentTarget.value = '' + return + } if (displayImageUrl) URL.revokeObjectURL(displayImageUrl) await handleDisplayImageInput( - file, + acceptedFiles[0], (file, url) => { setDisplayImage(file) setDisplayImageUrl(url) - setError('') + setError(combinedErrors.join(';')) }, (error) => setError(error) ) @@ -294,28 +354,20 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 根据文件扩展名自动识别素材类型 const handleZipChange = (e: React.ChangeEvent) => { + if (uploading) { + setError('文件正在上传,请等待完成后再添加素材') + e.currentTarget.value = '' + return + } + const files = Array.from(e.target.files || []) if (files.length === 0) return // 过滤有效文件 const validFiles = files.filter(f => isValidMainFileType(f, materialType)) + const ignoredFileError = getIgnoredMainFileError(files, materialType) if (validFiles.length === 0) { - const common = '压缩包格式(.zip / .rar / .7z)' - const videoTip = '视频文件(.mp4/.mov/.avi/.mkv/.mts/.m4v)' - const audioTip = '音频文件(.mp3/.wav/.flac/.m4a/.aac/.ogg)' - const brandTip = 'PDF 文件(.pdf)' - const detailTip = 'PSD/TIF/PSB 文件或压缩包(.psd / .tif / .tiff / .psb / .zip / .rar / .7z)' - const message = - materialType === '音乐' - ? audioTip - : isVideoMaterialType(materialType) - ? videoTip - : materialType === '品牌VI灵感' - ? brandTip - : materialType === '详情模板' - ? detailTip - : common - setError(`请选择有效的素材文件,支持:${message}`) + setError(`请选择有效的素材文件,支持:${getSupportedMainFileDescription(materialType)}`) // 清空input,允许重复选择 if (e.target) { e.target.value = '' @@ -335,7 +387,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setZipFile(validFiles[0]) setBatchFiles([]) setIsBatchMode(false) - setError('') + setError(ignoredFileError) if (e.target) { e.target.value = '' } @@ -359,7 +411,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setZipFile(null) // 清空单文件 } // 追加新文件到批量模式 - handleBatchFilesAdd(validFiles) + handleBatchFilesAdd(validFiles, ignoredFileError) // 清空input,允许重复选择 if (e.target) { e.target.value = '' @@ -369,7 +421,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 如果选择多个文件,进入批量上传模式 if (validFiles.length > 1) { - handleBatchFilesAdd(validFiles) + handleBatchFilesAdd(validFiles, ignoredFileError) // 清空input,允许重复选择 if (e.target) { e.target.value = '' @@ -388,7 +440,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setMaterialType(detectedType) } - setError('') + setError(ignoredFileError) // 清空input,允许重复选择 if (e.target) { e.target.value = '' @@ -397,8 +449,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 拖拽处理 - 修复闪屏问题 const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) + if (uploading) return setIsDragging(true) } @@ -406,25 +458,22 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { e.preventDefault() e.stopPropagation() - // 只有当鼠标真正离开容器时才设置为false,避免经过子元素时闪屏 - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const x = e.clientX - const y = e.clientY - - if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + if (hasDragLeftDropZone(e)) { setIsDragging(false) } } const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) } const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) setIsDragging(false) + if (uploading) { + setError('文件正在上传,请等待完成后再添加素材') + return + } const files = Array.from(e.dataTransfer.files) if (files.length === 0) return @@ -432,6 +481,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 只处理素材文件(包括压缩包/视频/音频/PDF 视类型而定) // 图片文件应该拖拽到对应的预览图或展示图区域 const materialFiles = files.filter((f) => isValidMainFileType(f, materialType)) + const ignoredFileError = getIgnoredMainFileError(files, materialType) if (materialFiles.length > 0) { if (isTaskOtherMaterial) { if (materialFiles.length > 1) { @@ -441,7 +491,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setZipFile(materialFiles[0]) setBatchFiles([]) setIsBatchMode(false) - setError('') + setError(ignoredFileError) return } @@ -465,13 +515,13 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { setZipFile(null) // 清空单文件 } // 追加新文件到批量模式 - handleBatchFilesAdd(materialFiles) + handleBatchFilesAdd(materialFiles, ignoredFileError) return } // 如果拖入多个文件,进入批量上传模式 if (materialFiles.length > 1) { - handleBatchFilesAdd(materialFiles) + handleBatchFilesAdd(materialFiles, ignoredFileError) } else { // 单个文件,且没有已有文件,使用单文件模式 const file = materialFiles[0] @@ -483,7 +533,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { if (detectedType) { setMaterialType(detectedType) } - setError('') + setError(ignoredFileError) } } else { setError('请拖拽素材文件到此区域,图片请拖拽到对应的图片上传区域') @@ -491,18 +541,21 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { } // 处理批量文件添加 - const handleBatchFilesAdd = (files: File[]) => { - // 检查文件数量限制 - const limit = getBatchLimit(materialType) - if (limit !== Infinity && files.length > limit) { - setError(`最多只能上传 ${limit} 个文件`) - return - } - + const handleBatchFilesAdd = (files: File[], preservedError = '') => { // 过滤掉无效文件 const validFiles = files.filter(f => isValidMainFileType(f, materialType)) + const ignoredFileError = getIgnoredMainFileError(files, materialType) + const validationMessage = [preservedError, ignoredFileError].filter(Boolean).join(';') if (validFiles.length === 0) { - setError('请选择有效的素材文件') + setError(`请选择有效的素材文件,支持:${getSupportedMainFileDescription(materialType)}`) + return + } + + // 检查文件数量限制,追加时也要计算已经选择的文件 + const limit = getBatchLimit(materialType) + const currentCount = batchFiles.length + (zipFile ? 1 : 0) + if (limit !== Infinity && currentCount + validFiles.length > limit) { + setError(`最多只能上传 ${limit} 个文件,当前已有 ${currentCount} 个`) return } @@ -517,17 +570,17 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { renderer ) - setBatchFiles([...batchFiles, ...newBatchFiles]) + setBatchFiles((current) => [...current, ...newBatchFiles]) setIsBatchMode(true) - setError('') + setError(validationMessage) } // 预览图片拖拽处理 const handlePreviewDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) + if (uploading) return setIsPreviewDragging(true) } @@ -535,29 +588,24 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { e.preventDefault() e.stopPropagation() - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const x = e.clientX - const y = e.clientY - - if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + if (hasDragLeftDropZone(e)) { setIsPreviewDragging(false) } } const handlePreviewDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) } const handlePreviewDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) setIsPreviewDragging(false) + if (uploading) { + setError('文件正在上传,请等待完成后再添加预览图') + return + } - const files = Array.from(e.dataTransfer.files) - if (files.length === 0) return - - const imageFiles = files.filter(f => f.type.startsWith('image/')) + const { acceptedFiles: imageFiles, errors } = validateImageFiles(e.dataTransfer.files) if (imageFiles.length > 0) { if (isTaskOtherMaterial && imageFiles.length > 1) { setError('任务-其他一次只能上传一张预览图') @@ -565,10 +613,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { } // 支持多图拖拽 const dt = { target: { files: imageFiles } } as unknown as React.ChangeEvent - void handlePreviewChange(dt) - setError('') + void handlePreviewChange(dt, errors) } else { - setError('请拖拽图片文件') + setError(errors.join(';') || '请拖拽图片文件') } } @@ -586,8 +633,8 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 详情模板展示图片拖拽处理 const handleDisplayImageDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) + if (uploading) return setIsDisplayImageDragging(true) } @@ -595,37 +642,34 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { e.preventDefault() e.stopPropagation() - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() - const x = e.clientX - const y = e.clientY - - if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + if (hasDragLeftDropZone(e)) { setIsDisplayImageDragging(false) } } const handleDisplayImageDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) } const handleDisplayImageDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, uploading) setIsDisplayImageDragging(false) + if (uploading) { + setError('文件正在上传,请等待完成后再更换展示图') + return + } - const files = Array.from(e.dataTransfer.files) - if (files.length === 0) return - - const imageFiles = files.filter(f => f.type.startsWith('image/')) + const { acceptedFiles: imageFiles, errors } = validateImageFiles( + e.dataTransfer.files, + { maxCount: 1 }, + ) if (imageFiles.length > 0) { const imgFile = imageFiles[0] // 复用选择逻辑(包含压缩 + 切片) const dt = { target: { files: [imgFile] } } as unknown as React.ChangeEvent - void handleDisplayImageChange(dt) - setError('') + void handleDisplayImageChange(dt, errors) } else { - setError('请拖拽图片文件') + setError(errors.join(';') || '请拖拽图片文件') } } @@ -688,6 +732,12 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { // 批量文件选择处理函数(统一走新的批量上传逻辑,与拖拽一致) const handleBatchFileSelect = (e: React.ChangeEvent) => { + if (uploading) { + setError('文件正在上传,请等待完成后再添加素材') + e.currentTarget.value = '' + return + } + const files = Array.from(e.target.files || []) if (files.length === 0) return @@ -1845,6 +1895,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { platforms={platforms} productCategories={productCategories} renderer={renderer} + disabled={uploading} batchPreviewDragging={batchPreviewDragging} onToggleExpand={(index) => { const newFiles = [...batchFiles] @@ -1960,6 +2011,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { onPreviewDragLeave={(itemId) => { setBatchPreviewDragging({ ...batchPreviewDragging, [itemId]: false }) }} + onValidationError={setError} /> )} @@ -1972,6 +2024,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { isPreviewDragging={isPreviewDragging} previewInputRef={previewInputRef} previewDropZoneRef={previewDropZoneRef} + disabled={uploading} onPreviewChange={handlePreviewChange} onPreviewDragEnter={handlePreviewDragEnter} onPreviewDragLeave={handlePreviewDragLeave} @@ -1987,6 +2040,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { displayImageUrl={displayImageUrl} isDisplayImageDragging={isDisplayImageDragging} displayImageDropZoneRef={displayImageDropZoneRef} + disabled={uploading} onDisplayImageChange={handleDisplayImageChange} onDisplayImageDragEnter={handleDisplayImageDragEnter} onDisplayImageDragLeave={handleDisplayImageDragLeave} @@ -2241,7 +2295,10 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { ) && materialType !== '标准模型' && (
setZipFile(null)} - className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" + className="text-sm text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300" > 移除 @@ -2278,6 +2336,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { onChange={handleZipChange} accept={getMainFileAccept(materialType)} className="hidden" + disabled={uploading} /> {/* 批量上传文件选择 */} {!isTaskOtherMaterial && (materialType === '音乐' || isBatchVideoMaterialType(materialType) || materialType === '3D场景' || materialType === '品牌VI灵感') && ( @@ -2289,9 +2348,11 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { accept={getMainFileAccept(materialType)} multiple className="hidden" + disabled={uploading} /> @@ -96,4 +103,3 @@ export default function DisplayImageUpload({
) } - diff --git a/components/upload/PreviewImageUpload.tsx b/components/upload/PreviewImageUpload.tsx index 904acb7..2afc136 100644 --- a/components/upload/PreviewImageUpload.tsx +++ b/components/upload/PreviewImageUpload.tsx @@ -10,6 +10,7 @@ interface PreviewImageUploadProps { isPreviewDragging: boolean previewInputRef: React.RefObject previewDropZoneRef: React.RefObject + disabled?: boolean onPreviewChange: (e: React.ChangeEvent) => void onPreviewDragEnter: (e: React.DragEvent) => void onPreviewDragLeave: (e: React.DragEvent) => void @@ -25,6 +26,7 @@ export default function PreviewImageUpload({ isPreviewDragging, previewInputRef, previewDropZoneRef, + disabled = false, onPreviewChange, onPreviewDragEnter, onPreviewDragLeave, @@ -40,7 +42,10 @@ export default function PreviewImageUpload({
{previewUrls.map((url, index) => ( @@ -78,6 +84,7 @@ export default function PreviewImageUpload({