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) => ( + removeImage(idx)} + disabled={sending} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600" - aria-label="删除图片" + aria-label={`删除待发送图片 ${idx + 1}`} > × @@ -639,11 +697,13 @@ export default function AIChatWidget() { accept="image/*" multiple className="hidden" + disabled={sending} /> {/* 上传按钮 */} fileInputRef.current?.click()} - className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" + disabled={sending} + className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:cursor-not-allowed disabled:opacity-50" title="上传图片" aria-label="上传图片" > @@ -669,7 +729,7 @@ export default function AIChatWidget() { {/* 发送按钮 */} {sending ? ( @@ -687,8 +747,13 @@ export default function AIChatWidget() { )} + {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} /> batchFileInputRef.current?.click()} className="btn-secondary mr-2" > @@ -2304,6 +2365,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) { )} zipInputRef.current?.click()} className="btn-secondary" > diff --git a/components/upload/AIOriginalUploader.tsx b/components/upload/AIOriginalUploader.tsx index 0e82318..7d57f97 100644 --- a/components/upload/AIOriginalUploader.tsx +++ b/components/upload/AIOriginalUploader.tsx @@ -3,6 +3,11 @@ import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import { parseSPU } from '@/lib/spu-parser'; +import { + hasDragLeftDropZone, + prepareFileDrag, + validateImageFiles, +} from './drag-drop-utils'; interface AssetFile { file: File; @@ -135,36 +140,25 @@ export default function AIOriginalUploader({ // 处理原图文件选择(可多选) const handleMainImageSelect = (files: FileList | null) => { if (!files) return; - - const newFiles: AssetFile[] = []; - const currentList = mainImages; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const ext = file.name.toLowerCase(); - - if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) { - setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`); - continue; - } - - if (file.size > 100 * 1024 * 1024) { - setError(`图片 ${file.name} 超过100MB,请选择较小的文件`); - continue; - } - - const preview = URL.createObjectURL(file); - newFiles.push({ - file, - preview, - sortOrder: currentList.length + newFiles.length, - }); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; } + const currentList = mainImages; + const { acceptedFiles, errors } = validateImageFiles(files, { + maxBytes: 100 * 1024 * 1024, + }); + const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ + file, + preview: URL.createObjectURL(file), + sortOrder: currentList.length + index, + })); + if (newFiles.length > 0) { setMainImages([...currentList, ...newFiles]); - setError(''); } + setError(errors.join(';')); }; // 删除原图 @@ -176,18 +170,26 @@ export default function AIOriginalUploader({ // 拖拽处理 const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); + if (uploading) return; setIsMainDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); - setIsMainDragging(false); + e.stopPropagation(); + if (hasDragLeftDropZone(e)) { + setIsMainDragging(false); + } }; const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); setIsMainDragging(false); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; + } handleMainImageSelect(e.dataTransfer.files); }; @@ -504,7 +506,10 @@ export default function AIOriginalUploader({ 原图 * (至少1张,支持多选,后台自动生成预览图和压缩包) productCategories: Array<{ id: string; name: string }> renderer: string + disabled?: boolean batchPreviewDragging: Record onToggleExpand: (index: number) => void onDelete: (index: number) => void @@ -29,6 +35,7 @@ interface BatchFileListProps { onExitBatchMode: () => void onPreviewDragEnter: (itemId: string) => void onPreviewDragLeave: (itemId: string) => void + onValidationError: (message: string) => void } export default function BatchFileList({ @@ -39,6 +46,7 @@ export default function BatchFileList({ platforms, productCategories, renderer, + disabled = false, batchPreviewDragging, onToggleExpand, onDelete, @@ -53,22 +61,33 @@ export default function BatchFileList({ onExitBatchMode, onPreviewDragEnter, onPreviewDragLeave, + onValidationError, }: BatchFileListProps) { - const handlePreviewDrop = (e: React.DragEvent, itemId: string, index: number) => { - e.preventDefault() - e.stopPropagation() - const files = Array.from(e.dataTransfer.files) - const imageFile = files.find(f => f.type.startsWith('image/')) - if (imageFile) { - onUpdatePreviewImage(index, imageFile) + const handlePreviewDrop = (e: React.DragEvent, index: number) => { + prepareFileDrag(e, disabled) + if (disabled) { + onValidationError('文件正在上传,请等待完成后再更换预览图') + return + } + const { acceptedFiles, errors } = validateImageFiles(e.dataTransfer.files, { maxCount: 1 }) + onValidationError(errors.join(';')) + if (acceptedFiles[0]) { + onUpdatePreviewImage(index, acceptedFiles[0]) } } const handlePreviewFileSelect = (e: React.ChangeEvent, index: number) => { const file = e.target.files?.[0] - if (file) { - onUpdatePreviewImage(index, file) + if (disabled) { + onValidationError('文件正在上传,请等待完成后再更换预览图') + } else if (file) { + const { acceptedFiles, errors } = validateImageFiles([file], { maxCount: 1 }) + onValidationError(errors.join(';')) + if (acceptedFiles[0]) { + onUpdatePreviewImage(index, acceptedFiles[0]) + } } + e.currentTarget.value = '' } return ( @@ -169,36 +188,34 @@ export default function BatchFileList({ ) : ( { - e.preventDefault() - e.stopPropagation() + prepareFileDrag(e, disabled) + if (disabled) return onPreviewDragEnter(item.id) }} onDragLeave={(e) => { 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)) { onPreviewDragLeave(item.id) } }} - onDragOver={(e) => { - e.preventDefault() - e.stopPropagation() - }} + onDragOver={(e) => prepareFileDrag(e, disabled)} onDrop={(e) => { - handlePreviewDrop(e, item.id, index) + handlePreviewDrop(e, index) onPreviewDragLeave(item.id) }} onClick={(e) => { e.stopPropagation() + if (disabled) return document.getElementById(`batch-preview-${item.id}`)?.click() }} > @@ -209,6 +226,7 @@ export default function BatchFileList({ className="hidden" id={`batch-preview-${item.id}`} onClick={(e) => e.stopPropagation()} + disabled={disabled} /> 拖拽或点击选择图片 diff --git a/components/upload/DedicatedImageUploaders.drag-drop.test.tsx b/components/upload/DedicatedImageUploaders.drag-drop.test.tsx new file mode 100644 index 0000000..d47a68c --- /dev/null +++ b/components/upload/DedicatedImageUploaders.drag-drop.test.tsx @@ -0,0 +1,108 @@ +import { createEvent, fireEvent, render, screen } from '@testing-library/react' + +import AIOriginalUploader from '@/components/upload/AIOriginalUploader' +import RealPhotoUploader from '@/components/upload/RealPhotoUploader' +import TaskPageUploader from '@/components/upload/TaskPageUploader' +import ThreeDOriginalUploader from '@/components/upload/ThreeDOriginalUploader' +import VideoScreenshotUploader from '@/components/upload/VideoScreenshotUploader' + +const defaultProps = { + onSuccess: jest.fn(), + onCancel: jest.fn(), + categories: [], + scenes: [], + platforms: [], + productCategories: [], + onRefreshCategories: jest.fn(), + onRefreshScenes: jest.fn(), +} + +const uploaders = [ + ['AI 原图', AIOriginalUploader], + ['实拍原图', RealPhotoUploader], + ['3D 原图', ThreeDOriginalUploader], + ['视频截图', VideoScreenshotUploader], + ['任务页面', TaskPageUploader], +] as const + +describe.each(uploaders)('%s拖拽上传', (_name, Uploader) => { + beforeEach(() => { + jest.clearAllMocks() + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: jest.fn(() => 'blob:preview'), + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: jest.fn(), + }) + }) + + test('经过上传框内部按钮时应保持拖拽高亮', () => { + const { container } = render() + const input = container.querySelector('input[type="file"][accept="image/*"][multiple]') as HTMLInputElement + const dropZone = input.parentElement as HTMLDivElement + const childButton = dropZone.querySelector('button') as HTMLButtonElement + + Object.defineProperty(dropZone, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100, + toJSON: () => ({}), + }), + }) + + const dragData = { + clientX: 20, + clientY: 20, + dataTransfer: { files: [], types: ['Files'], dropEffect: 'none' }, + } + fireEvent.dragEnter(dropZone, dragData) + fireEvent.dragOver(dropZone, dragData) + expect(dropZone).toHaveClass('border-blue-500') + + const internalDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(internalDragLeave, { + clientX: { value: 50 }, + clientY: { value: 50 }, + relatedTarget: { value: childButton }, + dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } }, + }) + fireEvent(dropZone, internalDragLeave) + expect(dropZone).toHaveClass('border-blue-500') + + const outerDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(outerDragLeave, { + clientX: { value: 120 }, + clientY: { value: 50 }, + relatedTarget: { value: document.body }, + dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } }, + }) + fireEvent(dropZone, outerDragLeave) + expect(dropZone).not.toHaveClass('border-blue-500') + }) +}) + +test('混合拖入有效图片和非法文件时应保留图片并显示错误', () => { + const { container } = render() + const input = container.querySelector('input[type="file"][accept="image/*"][multiple]') as HTMLInputElement + const dropZone = input.parentElement as HTMLDivElement + const image = new File(['image'], 'valid.png', { type: 'image/png' }) + const invalid = new File(['text'], 'invalid.txt', { type: 'text/plain' }) + + fireEvent.drop(dropZone, { + dataTransfer: { files: [image, invalid], types: ['Files'], dropEffect: 'copy' }, + }) + + expect(screen.getByAltText('原图 1')).toBeInTheDocument() + expect(screen.getByText(/invalid\.txt/)).toBeInTheDocument() +}) diff --git a/components/upload/DetailImageSorter.test.tsx b/components/upload/DetailImageSorter.test.tsx new file mode 100644 index 0000000..46ae2c9 --- /dev/null +++ b/components/upload/DetailImageSorter.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import DetailImageSorter from '@/components/upload/DetailImageSorter' + +const images = [ + { + id: 'detail-1', + file: new File(['first'], '1.jpg', { type: 'image/jpeg' }), + preview: 'blob:first', + sortOrder: 0, + }, + { + id: 'detail-2', + file: new File(['second'], '2.jpg', { type: 'image/jpeg' }), + preview: 'blob:second', + sortOrder: 1, + }, +] + +describe('DetailImageSorter', () => { + test('外部文件拖到已有详情图上时应继续交给外层上传区域处理', () => { + const onOuterDrop = jest.fn() + + render( + + + , + ) + + const externalFile = new File(['new-detail'], '3.jpg', { type: 'image/jpeg' }) + const thumbnail = screen.getByAltText('缩略图 1') + + fireEvent.dragOver(thumbnail, { + dataTransfer: { + files: [externalFile], + types: ['Files'], + dropEffect: 'copy', + }, + }) + fireEvent.drop(thumbnail, { + dataTransfer: { + files: [externalFile], + types: ['Files'], + dropEffect: 'copy', + }, + }) + + expect(onOuterDrop).toHaveBeenCalledTimes(1) + }) + + test('详情图内部拖拽仍应完成排序且不触发外层文件上传', () => { + const onChange = jest.fn() + const onOuterDrop = jest.fn() + + render( + + + , + ) + + const firstThumbnail = screen.getByAltText('缩略图 1') + const secondThumbnail = screen.getByAltText('缩略图 2') + const dataTransfer = { + files: [], + types: [], + effectAllowed: 'none', + dropEffect: 'none', + } + + fireEvent.dragStart(firstThumbnail, { dataTransfer }) + fireEvent.dragOver(secondThumbnail, { dataTransfer }) + fireEvent.drop(secondThumbnail, { dataTransfer }) + + expect(onChange).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'detail-2', sortOrder: 0 }), + expect.objectContaining({ id: 'detail-1', sortOrder: 1 }), + ]) + expect(onOuterDrop).not.toHaveBeenCalled() + }) +}) diff --git a/components/upload/DetailImageSorter.tsx b/components/upload/DetailImageSorter.tsx index d6386ee..2dcafdb 100644 --- a/components/upload/DetailImageSorter.tsx +++ b/components/upload/DetailImageSorter.tsx @@ -19,9 +19,15 @@ interface DetailImageSorterProps { images: DetailImage[]; onChange: (images: DetailImage[]) => void; onRemove: (id: string) => void; + disabled?: boolean; } -export default function DetailImageSorter({ images, onChange, onRemove }: DetailImageSorterProps) { +export default function DetailImageSorter({ + images, + onChange, + onRemove, + disabled = false, +}: DetailImageSorterProps) { const [draggedIndex, setDraggedIndex] = useState(null); const [hoveredIndex, setHoveredIndex] = useState(null); const previewRef = useRef(null); @@ -31,6 +37,7 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail // 拖拽开始 const handleDragStart = (e: React.DragEvent, index: number) => { + if (disabled) return; setDraggedIndex(index); e.dataTransfer.effectAllowed = 'move'; e.stopPropagation(); // 阻止事件冒泡,避免触发外层文件拖拽 @@ -38,6 +45,15 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail // 拖拽经过 const handleDragOver = (e: React.DragEvent, index: number) => { + if (disabled) { + return; + } + // 没有内部排序项时,说明是从资源管理器拖入的新文件。 + // 此时不能拦截事件,交给外层详情图上传区域处理。 + if (draggedIndex === null) { + return; + } + e.preventDefault(); e.stopPropagation(); // 阻止事件冒泡 e.dataTransfer.dropEffect = 'move'; @@ -46,16 +62,32 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail // 拖拽离开 const handleDragLeave = (e: React.DragEvent) => { + if (disabled) { + return; + } + if (draggedIndex === null) { + return; + } + e.stopPropagation(); // 阻止事件冒泡 setHoveredIndex(null); }; // 放置 const handleDrop = (e: React.DragEvent, dropIndex: number) => { + if (disabled) { + return; + } + // 外部文件放置必须继续冒泡到 ProductPackageUploader, + // 否则已有详情图区域会变成无法追加图片的“死区”。 + if (draggedIndex === null) { + return; + } + e.preventDefault(); e.stopPropagation(); // 阻止事件冒泡,避免触发外层文件拖拽 - if (draggedIndex === null || draggedIndex === dropIndex) { + if (draggedIndex === dropIndex) { setDraggedIndex(null); setHoveredIndex(null); return; @@ -101,8 +133,20 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail return ( e.stopPropagation()} - onDrop={(e) => e.stopPropagation()} + onDragOver={(e) => { + if (draggedIndex !== null) { + e.preventDefault(); + e.stopPropagation(); + } + }} + onDrop={(e) => { + if (draggedIndex !== null) { + e.preventDefault(); + e.stopPropagation(); + setDraggedIndex(null); + setHoveredIndex(null); + } + }} > {/* 左侧:实时预览(占2/3宽度) */} @@ -134,7 +178,7 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail {sortedImages.map((img, index) => ( handleDragStart(e, index)} onDragOver={(e) => handleDragOver(e, index)} onDragLeave={handleDragLeave} @@ -142,7 +186,8 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail onDragEnd={handleDragEnd} onClick={() => scrollToImage(index)} className={` - relative cursor-move border-2 rounded-lg overflow-hidden + relative border-2 rounded-lg overflow-hidden + ${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-move'} transition-all duration-200 ${draggedIndex === index ? 'opacity-50 scale-95' : ''} ${hoveredIndex === index && draggedIndex !== null ? 'border-blue-500 scale-105' : 'border-gray-200 dark:border-gray-600'} @@ -162,6 +207,9 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail {/* 删除按钮 */} { e.stopPropagation(); onRemove(img.id); diff --git a/components/upload/DisplayImageUpload.tsx b/components/upload/DisplayImageUpload.tsx index b55dd73..948f085 100644 --- a/components/upload/DisplayImageUpload.tsx +++ b/components/upload/DisplayImageUpload.tsx @@ -6,6 +6,7 @@ interface DisplayImageUploadProps { displayImageUrl: string isDisplayImageDragging: boolean displayImageDropZoneRef: React.RefObject + disabled?: boolean onDisplayImageChange: (e: React.ChangeEvent) => void onDisplayImageDragEnter: (e: React.DragEvent) => void onDisplayImageDragLeave: (e: React.DragEvent) => void @@ -18,6 +19,7 @@ export default function DisplayImageUpload({ displayImageUrl, isDisplayImageDragging, displayImageDropZoneRef, + disabled = false, onDisplayImageChange, onDisplayImageDragEnter, onDisplayImageDragLeave, @@ -39,7 +41,10 @@ export default function DisplayImageUpload({ document.getElementById('displayImageInput')?.click()} className="btn-secondary" + disabled={disabled} > 选择展示图片 @@ -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({ onRemovePreviewImage(index)} + disabled={disabled} className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" > × @@ -87,6 +94,7 @@ export default function PreviewImageUpload({ previewInputRef.current?.click()} + disabled={disabled} className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors" > diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 1913bf4..4bbb796 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { createEvent, fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ProductPackageUploader from '@/components/upload/ProductPackageUploader' @@ -132,4 +132,57 @@ describe('ProductPackageUploader', () => { expect(screen.getByText('请选择需求运营人员')).toBeInTheDocument() expect(global.fetch).toHaveBeenCalledTimes(1) }) + + test('文件经过主图上传框内部按钮时应保持拖拽高亮', async () => { + render() + await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') + + const input = screen.getByLabelText('主图') as HTMLInputElement + const dropZone = input.parentElement as HTMLDivElement + const addButton = dropZone.querySelector('button') + + expect(addButton).not.toBeNull() + + Object.defineProperty(dropZone, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100, + toJSON: () => ({}), + }), + }) + + fireEvent.dragEnter(dropZone, { + clientX: 20, + clientY: 20, + dataTransfer: { files: [], types: ['Files'] }, + }) + expect(dropZone).toHaveClass('border-blue-500') + + const internalDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(internalDragLeave, { + clientX: { value: 50 }, + clientY: { value: 50 }, + relatedTarget: { value: addButton }, + dataTransfer: { value: { files: [], types: ['Files'] } }, + }) + fireEvent(dropZone, internalDragLeave) + expect(dropZone).toHaveClass('border-blue-500') + + const outerDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(outerDragLeave, { + clientX: { value: 120 }, + clientY: { value: 50 }, + relatedTarget: { value: document.body }, + dataTransfer: { value: { files: [], types: ['Files'] } }, + }) + fireEvent(dropZone, outerDragLeave) + expect(dropZone).not.toHaveClass('border-blue-500') + }) }) diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index 76a455c..e929c6b 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -13,6 +13,11 @@ import { TASK_DETAIL_SOURCE_TYPE_OPTIONS, type TaskDetailSourceType, } from '@/lib/task-detail-source-type'; +import { + hasDragLeftDropZone, + prepareFileDrag, + validateImageFiles, +} from './drag-drop-utils'; const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const; const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z'; @@ -202,20 +207,22 @@ export default function ProductPackageUploader({ // 拖拽处理函数 const handleDragEnter = (e: React.DragEvent, setter: (value: boolean) => void) => { - e.preventDefault(); - e.stopPropagation(); + prepareFileDrag(e, uploading); + if (uploading) return; setter(true); }; const handleDragLeave = (e: React.DragEvent, setter: (value: boolean) => void) => { e.preventDefault(); e.stopPropagation(); - setter(false); + + if (hasDragLeftDropZone(e)) { + setter(false); + } }; const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); + prepareFileDrag(e, uploading); }; const handleDrop = ( @@ -224,18 +231,16 @@ export default function ProductPackageUploader({ dragSetter: (value: boolean) => void, accept: 'image' | 'file' | 'psd' ) => { - e.preventDefault(); - e.stopPropagation(); + prepareFileDrag(e, uploading); dragSetter(false); + if (uploading) { + setError('文件正在上传,请等待完成后再添加'); + return; + } const files = Array.from(e.dataTransfer.files); if (accept === 'image') { - const { validFiles, emptyFileNames } = splitEmptyFiles(files); - const imageFiles = validFiles.filter(f => f.type && f.type.startsWith('image/')); - if (emptyFileNames.length > 0) { - setError(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`); - } - handleImageSelect(imageFiles, setter); + handleImageSelect(files, setter); } else if (accept === 'psd') { // PSD文件拖拽(支持多个文件) const psdFiles: File[] = []; @@ -280,31 +285,29 @@ export default function ProductPackageUploader({ const handleImageSelect = ( files: FileList | File[] | null, setter: React.Dispatch>, - isDetail: boolean = false ) => { if (!files) return; + if (uploading) { + setError('文件正在上传,请等待完成后再添加'); + return; + } const { validFiles, emptyFileNames } = splitEmptyFiles(files); + const { acceptedFiles, errors } = validateImageFiles(validFiles); + const validationErrors = [...errors]; if (emptyFileNames.length > 0) { - setError(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`); - } else { - setError(''); + validationErrors.unshift(`以下文件为空,无法上传:${emptyFileNames.join('、')}`); } + setError(validationErrors.join(';')); setter((prev) => { const startOrder = prev.length; - const newFiles: AssetFile[] = []; - - validFiles.forEach((file, index) => { - if (file && file.type && file.type.startsWith('image/')) { - newFiles.push({ - file, - preview: URL.createObjectURL(file), - id: Math.random().toString(36).substring(2, 11), - sortOrder: startOrder + index, - }); - } - }); + const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ + file, + preview: URL.createObjectURL(file), + id: Math.random().toString(36).substring(2, 11), + sortOrder: startOrder + index, + })); return [...prev, ...newFiles]; }); @@ -333,6 +336,10 @@ export default function ProductPackageUploader({ // 处理 PSD 文件选择 const handlePsdSelect = (files: FileList | null) => { if (!files || files.length === 0) return; + if (uploading) { + setError('文件正在上传,请等待完成后再添加'); + return; + } const validFiles: File[] = []; const invalidFiles: string[] = []; @@ -776,7 +783,10 @@ export default function ProductPackageUploader({ {/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */} {/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */} {/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */} handleImageSelect(e.target.files, setDetailImages, true)} + onChange={(e) => handleImageSelect(e.target.files, setDetailImages)} className="hidden" disabled={uploading} /> @@ -961,6 +977,7 @@ export default function ProductPackageUploader({ images={detailImages} onChange={setDetailImages} onRemove={(id) => handleRemoveImage(id, setDetailImages)} + disabled={uploading} /> )} @@ -973,7 +990,10 @@ export default function ProductPackageUploader({ {/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */} { if (!files) return; - - const newFiles: AssetFile[] = []; - const currentList = mainImages; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const ext = file.name.toLowerCase(); - - if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) { - setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`); - continue; - } - - if (file.size > 100 * 1024 * 1024) { - setError(`图片 ${file.name} 超过100MB,请选择较小的文件`); - continue; - } - - const preview = URL.createObjectURL(file); - newFiles.push({ - file, - preview, - sortOrder: currentList.length + newFiles.length, - }); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; } + const currentList = mainImages; + const { acceptedFiles, errors } = validateImageFiles(files, { + maxBytes: 100 * 1024 * 1024, + }); + const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ + file, + preview: URL.createObjectURL(file), + sortOrder: currentList.length + index, + })); + if (newFiles.length > 0) { setMainImages([...currentList, ...newFiles]); - setError(''); } + setError(errors.join(';')); }; // 删除原图 @@ -176,18 +170,26 @@ export default function RealPhotoUploader({ // 拖拽处理 const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); + if (uploading) return; setIsMainDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); - setIsMainDragging(false); + e.stopPropagation(); + if (hasDragLeftDropZone(e)) { + setIsMainDragging(false); + } }; const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); setIsMainDragging(false); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; + } handleMainImageSelect(e.dataTransfer.files); }; @@ -501,7 +503,10 @@ export default function RealPhotoUploader({ 原图 * (至少1张,支持多选,后台自动生成预览图和压缩包) lowerName.endsWith(ext)) + return hasAllowedFileExtension(file, ['zip', 'rar', '7z']) } export default function StandardModelSkuZipUpload({ @@ -38,19 +42,25 @@ export default function StandardModelSkuZipUpload({ onRemoveFile, }: StandardModelSkuZipUploadProps) { const [isDragging, setIsDragging] = useState(false) + const [validationError, setValidationError] = useState('') const hasExistingFile = !!existingFileName.trim() const handleDrop = (event: React.DragEvent) => { - event.preventDefault() - event.stopPropagation() + prepareFileDrag(event, uploading) setIsDragging(false) - - const file = event.dataTransfer.files?.[0] - if (!file || !isSupportedZipFile(file)) { + if (uploading) { + setValidationError('文件正在上传,请等待完成后再更换') return } + const file = event.dataTransfer.files?.[0] + if (!file || !isSupportedZipFile(file)) { + setValidationError('压缩包仅支持 ZIP、RAR、7Z 格式') + return + } + + setValidationError('') onSelectFile(file) } @@ -75,9 +85,9 @@ export default function StandardModelSkuZipUpload({ : '当前还没有模型压缩包,请先选择要上传的文件。'} - {error && ( + {(error || validationError) && ( - {error} + {error || validationError} )} @@ -87,10 +97,14 @@ export default function StandardModelSkuZipUpload({ type="file" accept=".zip,.rar,.7z" className="hidden" + disabled={uploading} onChange={(event) => { const file = event.target.files?.[0] if (file && isSupportedZipFile(file)) { + setValidationError('') onSelectFile(file) + } else if (file) { + setValidationError('压缩包仅支持 ZIP、RAR、7Z 格式') } event.currentTarget.value = '' }} @@ -99,30 +113,27 @@ export default function StandardModelSkuZipUpload({ {/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */} { - event.preventDefault() - event.stopPropagation() + prepareFileDrag(event, uploading) + if (uploading) return setIsDragging(true) }} onDragLeave={(event) => { event.preventDefault() event.stopPropagation() - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const { clientX, clientY } = event - if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) { + if (hasDragLeftDropZone(event)) { setIsDragging(false) } }} - onDragOver={(event) => { - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'copy' - }} + onDragOver={(event) => prepareFileDrag(event, uploading)} onDrop={handleDrop} > {selectedFile ? ( @@ -131,6 +142,7 @@ export default function StandardModelSkuZipUpload({ { event.preventDefault() diff --git a/components/upload/StandardModelUploaders.drag-drop.test.tsx b/components/upload/StandardModelUploaders.drag-drop.test.tsx new file mode 100644 index 0000000..b133ef2 --- /dev/null +++ b/components/upload/StandardModelUploaders.drag-drop.test.tsx @@ -0,0 +1,112 @@ +import { createEvent, fireEvent, render, screen } from '@testing-library/react' + +import StandardModelSkuZipUpload from '@/components/upload/StandardModelSkuZipUpload' +import StandardModelWhiteBackgroundUpload from '@/components/upload/StandardModelWhiteBackgroundUpload' + +describe('标准模型拖拽上传', () => { + test('模型压缩包拖入非法格式时应显示错误且不替换文件', () => { + const onSelectFile = jest.fn() + const { container } = render( + , + ) + const dropZone = container.querySelector('label[for="standard-model-sku-zip-upload"]') as HTMLLabelElement + const invalidFile = new File(['text'], 'readme.txt', { type: 'text/plain' }) + + fireEvent.drop(dropZone, { + dataTransfer: { files: [invalidFile], types: ['Files'], dropEffect: 'copy' }, + }) + + expect(onSelectFile).not.toHaveBeenCalled() + expect(screen.getByText('压缩包仅支持 ZIP、RAR、7Z 格式')).toBeInTheDocument() + }) + + test('模型压缩包上传中不应接受新的拖入文件', () => { + const onSelectFile = jest.fn() + const { container } = render( + , + ) + const dropZone = container.querySelector('label[for="standard-model-sku-zip-upload"]') as HTMLLabelElement + const zipFile = new File(['zip'], 'model.zip', { type: 'application/zip' }) + + fireEvent.drop(dropZone, { + dataTransfer: { files: [zipFile], types: ['Files'], dropEffect: 'copy' }, + }) + + expect(onSelectFile).not.toHaveBeenCalled() + expect(screen.getByText('文件正在上传,请等待完成后再更换')).toBeInTheDocument() + }) + + test('白底图拖拽经过槽位内部元素时应保持高亮,离开槽位后清除', () => { + const { container } = render( + , + ) + const dropZone = container.querySelector('label[for="white-bg-front"]') as HTMLLabelElement + const child = dropZone.querySelector('span') as HTMLSpanElement + + fireEvent.dragEnter(dropZone, { + dataTransfer: { files: [], types: ['Files'], dropEffect: 'none' }, + }) + expect(dropZone).toHaveClass('border-blue-500') + + const internalDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(internalDragLeave, { + relatedTarget: { value: child }, + dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } }, + }) + fireEvent(dropZone, internalDragLeave) + expect(dropZone).toHaveClass('border-blue-500') + + const outerDragLeave = createEvent.dragLeave(dropZone) + Object.defineProperties(outerDragLeave, { + relatedTarget: { value: document.body }, + dataTransfer: { value: { files: [], types: ['Files'], dropEffect: 'copy' } }, + }) + fireEvent(dropZone, outerDragLeave) + expect(dropZone).not.toHaveClass('border-blue-500') + }) + + test('白底图上传中不应接受固定角度或其他图片拖入', () => { + const onSelectImage = jest.fn() + const onSelectOtherImages = jest.fn() + const { container } = render( + , + ) + const imageFile = new File(['image'], 'front.png', { type: 'image/png' }) + const fixedDropZone = container.querySelector('label[for="white-bg-front"]') as HTMLLabelElement + const otherDropZone = container.querySelector('label[for="white-bg-other-images"]') as HTMLLabelElement + + fireEvent.drop(fixedDropZone, { + dataTransfer: { files: [imageFile], types: ['Files'], dropEffect: 'none' }, + }) + fireEvent.drop(otherDropZone, { + dataTransfer: { files: [imageFile], types: ['Files'], dropEffect: 'none' }, + }) + + expect(onSelectImage).not.toHaveBeenCalled() + expect(onSelectOtherImages).not.toHaveBeenCalled() + }) +}) diff --git a/components/upload/StandardModelWhiteBackgroundUpload.tsx b/components/upload/StandardModelWhiteBackgroundUpload.tsx index 1aae447..9b9b10e 100644 --- a/components/upload/StandardModelWhiteBackgroundUpload.tsx +++ b/components/upload/StandardModelWhiteBackgroundUpload.tsx @@ -6,6 +6,7 @@ import { STANDARD_MODEL_WHITE_BG_VIEWS, type StandardModelWhiteBgKey, } from '@/lib/standard-model-white-background' +import { hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils' interface OtherImagePreview { id: string @@ -51,8 +52,8 @@ export default function StandardModelWhiteBackgroundUpload({ ) const handleDragEnter = (event: React.DragEvent, key: StandardModelWhiteBgKey) => { - event.preventDefault() - event.stopPropagation() + prepareFileDrag(event, uploading) + if (uploading) return setDraggingKey(key) } @@ -60,9 +61,7 @@ export default function StandardModelWhiteBackgroundUpload({ event.preventDefault() event.stopPropagation() - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const { clientX, clientY } = event - if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) { + if (hasDragLeftDropZone(event)) { if (draggingKey === key) { setDraggingKey(null) } @@ -70,15 +69,13 @@ export default function StandardModelWhiteBackgroundUpload({ } const handleDragOver = (event: React.DragEvent) => { - event.preventDefault() - event.stopPropagation() - event.dataTransfer.dropEffect = 'copy' + prepareFileDrag(event, uploading) } const handleDrop = (event: React.DragEvent, key: StandardModelWhiteBgKey) => { - event.preventDefault() - event.stopPropagation() + prepareFileDrag(event, uploading) setDraggingKey(null) + if (uploading) return const file = event.dataTransfer.files?.[0] if (!file) { @@ -89,9 +86,9 @@ export default function StandardModelWhiteBackgroundUpload({ } const handleOtherDrop = (event: React.DragEvent) => { - event.preventDefault() - event.stopPropagation() + prepareFileDrag(event, uploading) setIsOtherDragging(false) + if (uploading) return const files = event.dataTransfer.files if (!files || files.length === 0) { @@ -160,6 +157,7 @@ export default function StandardModelWhiteBackgroundUpload({ type="file" accept="image/*" className="hidden" + disabled={uploading} onChange={(event) => { const file = event.target.files?.[0] if (file) { @@ -172,7 +170,10 @@ export default function StandardModelWhiteBackgroundUpload({ {/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */} { event.preventDefault() @@ -236,6 +238,7 @@ export default function StandardModelWhiteBackgroundUpload({ accept="image/*" multiple className="hidden" + disabled={uploading} onChange={(event) => { if (event.target.files && event.target.files.length > 0) { onSelectOtherImages(event.target.files) @@ -247,22 +250,23 @@ export default function StandardModelWhiteBackgroundUpload({ {/* biome-ignore lint/a11y/noStaticElementInteractions: 其他图片区需要拖拽上传 */} { - event.preventDefault() - event.stopPropagation() + prepareFileDrag(event, uploading) + if (uploading) return setIsOtherDragging(true) }} onDragLeave={(event) => { event.preventDefault() event.stopPropagation() - const rect = (event.currentTarget as HTMLElement).getBoundingClientRect() - const { clientX, clientY } = event - if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) { + if (hasDragLeftDropZone(event)) { setIsOtherDragging(false) } }} @@ -285,6 +289,7 @@ export default function StandardModelWhiteBackgroundUpload({ { event.preventDefault() diff --git a/components/upload/TaskPageUploader.tsx b/components/upload/TaskPageUploader.tsx index 8a8c491..0e9a5f8 100644 --- a/components/upload/TaskPageUploader.tsx +++ b/components/upload/TaskPageUploader.tsx @@ -3,6 +3,11 @@ import { useState, useRef, useEffect } from 'react'; import Image from 'next/image'; import { parseSPU, type SPUParseResult } from '@/lib/spu-parser'; +import { + hasDragLeftDropZone, + prepareFileDrag, + validateImageFiles, +} from './drag-drop-utils'; interface AssetFile { file: File; @@ -136,25 +141,21 @@ export default function TaskPageUploader({ // 处理预览图文件选择(可多选) const handlePreviewSelect = (files: FileList | null) => { if (!files) return; - - const newFiles: AssetFile[] = []; - const currentList = previewImages; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const ext = file.name.toLowerCase(); - - if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) { - newFiles.push({ - file, - preview: URL.createObjectURL(file), - sortOrder: currentList.length + i, - }); - } + if (uploading) { + setError('文件正在上传,请等待完成后再添加预览图'); + return; } + const currentList = previewImages; + const { acceptedFiles, errors } = validateImageFiles(files); + const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ + file, + preview: URL.createObjectURL(file), + sortOrder: currentList.length + index, + })); + setPreviewImages([...currentList, ...newFiles]); - setError(''); + setError(errors.join(';')); }; // 删除预览图 @@ -532,40 +533,35 @@ export default function TaskPageUploader({ 预览图 * { - e.preventDefault(); - e.stopPropagation(); + prepareFileDrag(e, uploading); + if (uploading) return; setIsPreviewDragging(true); }} onDragLeave={(e) => { 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); } }} - onDragOver={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} + onDragOver={(e) => prepareFileDrag(e, uploading)} onDrop={(e) => { - e.preventDefault(); - e.stopPropagation(); + prepareFileDrag(e, uploading); setIsPreviewDragging(false); - const files = Array.from(e.dataTransfer.files); - const imageFiles = files.filter(f => f.type && f.type.startsWith('image/')); - if (imageFiles.length > 0) { - handlePreviewSelect(e.dataTransfer.files as FileList); - setError(''); + if (uploading) { + setError('文件正在上传,请等待完成后再添加预览图'); + return; } + handlePreviewSelect(e.dataTransfer.files); }} > { if (!files) return; - - const newFiles: AssetFile[] = []; - const currentList = mainImages; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const ext = file.name.toLowerCase(); - - if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) { - setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`); - continue; - } - - if (file.size > 100 * 1024 * 1024) { - setError(`图片 ${file.name} 超过100MB,请选择较小的文件`); - continue; - } - - const preview = URL.createObjectURL(file); - newFiles.push({ - file, - preview, - sortOrder: currentList.length + newFiles.length, - }); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; } + const currentList = mainImages; + const { acceptedFiles, errors } = validateImageFiles(files, { + maxBytes: 100 * 1024 * 1024, + }); + const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ + file, + preview: URL.createObjectURL(file), + sortOrder: currentList.length + index, + })); + if (newFiles.length > 0) { setMainImages([...currentList, ...newFiles]); - setError(''); } + setError(errors.join(';')); }; // 删除原图 @@ -176,18 +170,26 @@ export default function ThreeDOriginalUploader({ // 拖拽处理 const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); + if (uploading) return; setIsMainDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); - setIsMainDragging(false); + e.stopPropagation(); + if (hasDragLeftDropZone(e)) { + setIsMainDragging(false); + } }; const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); setIsMainDragging(false); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; + } handleMainImageSelect(e.dataTransfer.files); }; @@ -504,7 +506,10 @@ export default function ThreeDOriginalUploader({ 原图 * (至少1张,支持多选,后台自动生成预览图和压缩包) { if (!files) return; - - const newFiles: AssetFile[] = []; - const currentList = screenshots; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const ext = file.name.toLowerCase(); - - if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) { - setError(`文件 ${file.name} 不是支持的图片格式(仅支持 JPG、PNG、WebP、GIF)`); - continue; - } - - if (file.size > 100 * 1024 * 1024) { - setError(`图片 ${file.name} 超过100MB,请选择较小的文件`); - continue; - } - - const preview = URL.createObjectURL(file); - newFiles.push({ - file, - preview, - sortOrder: currentList.length + newFiles.length, - }); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; } + const currentList = screenshots; + const { acceptedFiles, errors } = validateImageFiles(files, { + maxBytes: 100 * 1024 * 1024, + }); + const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({ + file, + preview: URL.createObjectURL(file), + sortOrder: currentList.length + index, + })); + if (newFiles.length > 0) { setScreenshots([...currentList, ...newFiles]); - setError(''); } + setError(errors.join(';')); }; // 删除截图 @@ -177,18 +171,26 @@ export default function VideoScreenshotUploader({ // 拖拽处理 const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); + if (uploading) return; setIsScreenshotDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); - setIsScreenshotDragging(false); + e.stopPropagation(); + if (hasDragLeftDropZone(e)) { + setIsScreenshotDragging(false); + } }; const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); + prepareFileDrag(e, uploading); setIsScreenshotDragging(false); + if (uploading) { + setError('文件正在上传,请等待完成后再添加图片'); + return; + } handleScreenshotSelect(e.dataTransfer.files); }; @@ -519,7 +521,10 @@ export default function VideoScreenshotUploader({ 视频截图 * (至少1张,支持多选,后台自动生成预览图和压缩包) { + const dataTransfer = { + dropEffect: options.dropEffect || 'none', + } + + return { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget, + relatedTarget: options.relatedTarget || null, + clientX: options.clientX ?? 50, + clientY: options.clientY ?? 50, + dataTransfer, + } as unknown as React.DragEvent +} + +describe('drag-drop-utils', () => { + test('经过上传区内部子元素时不应判定为离开', () => { + const dropZone = document.createElement('div') + const child = document.createElement('button') + dropZone.appendChild(child) + + const event = createDragEvent(dropZone, { relatedTarget: child }) + + expect(hasDragLeftDropZone(event)).toBe(false) + }) + + test('指针越过上传区边界时应判定为离开', () => { + const dropZone = document.createElement('div') + Object.defineProperty(dropZone, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left: 0, + right: 100, + top: 0, + bottom: 100, + }), + }) + + const event = createDragEvent(dropZone, { clientX: 120, clientY: 50 }) + + expect(hasDragLeftDropZone(event)).toBe(true) + }) + + test('禁用状态应阻止默认拖放并显示禁止效果', () => { + const dropZone = document.createElement('div') + const event = createDragEvent(dropZone) + + prepareFileDrag(event, true) + + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + expect(event.dataTransfer.dropEffect).toBe('none') + }) + + test('Windows 未提供 MIME 时应按常见图片扩展名识别', () => { + const file = new File(['image'], 'preview.PNG', { type: '' }) + + expect(isSupportedImageFile(file)).toBe(true) + }) + + test('应同时保留有效图片并返回非法及超大文件错误', () => { + const valid = new File(['image'], 'valid.png', { type: 'image/png' }) + const invalid = new File(['text'], 'invalid.txt', { type: 'text/plain' }) + const oversized = new File([new Uint8Array(11)], 'large.jpg', { type: 'image/jpeg' }) + + const result = validateImageFiles([valid, invalid, oversized], { maxBytes: 10 }) + + expect(result.acceptedFiles).toEqual([valid]) + expect(result.errors.join(';')).toContain('invalid.txt') + expect(result.errors.join(';')).toContain('large.jpg') + }) + + test('图片数量限制应考虑已经选择的图片', () => { + const files = [ + new File(['1'], '1.png', { type: 'image/png' }), + new File(['2'], '2.png', { type: 'image/png' }), + new File(['3'], '3.png', { type: 'image/png' }), + ] + + const result = validateImageFiles(files, { maxCount: 5, existingCount: 4 }) + + expect(result.acceptedFiles).toEqual([files[0]]) + expect(result.errors).toContain('最多只能选择 5 张图片,已忽略 2 张') + }) + + test('压缩包扩展名校验应忽略大小写和前导点', () => { + const file = new File(['zip'], 'model.ZIP', { type: 'application/zip' }) + + expect(hasAllowedFileExtension(file, ['.zip', '.rar', '.7z'])).toBe(true) + }) +}) diff --git a/components/upload/drag-drop-utils.ts b/components/upload/drag-drop-utils.ts new file mode 100644 index 0000000..f435e59 --- /dev/null +++ b/components/upload/drag-drop-utils.ts @@ -0,0 +1,101 @@ +import type React from 'react' + +const FALLBACK_IMAGE_EXTENSIONS = new Set([ + 'avif', + 'bmp', + 'gif', + 'jpeg', + 'jpg', + 'png', + 'webp', +]) + +export interface ImageFileValidationOptions { + maxBytes?: number + maxCount?: number + existingCount?: number +} + +export interface ImageFileValidationResult { + acceptedFiles: File[] + errors: string[] +} + +export function hasDragLeftDropZone(event: React.DragEvent): boolean { + const nextTarget = event.relatedTarget as Node | null + if (nextTarget && nextTarget !== event.currentTarget) { + return !event.currentTarget.contains(nextTarget) + } + + const rect = event.currentTarget.getBoundingClientRect() + const { clientX, clientY } = event + return clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom +} + +export function prepareFileDrag(event: React.DragEvent, disabled = false): void { + event.preventDefault() + event.stopPropagation() + event.dataTransfer.dropEffect = disabled ? 'none' : 'copy' +} + +export function isSupportedImageFile(file: File): boolean { + if (file.type) { + return file.type.startsWith('image/') + } + + const extension = getFileExtension(file) + return FALLBACK_IMAGE_EXTENSIONS.has(extension) +} + +export function getFileExtension(file: File): string { + return file.name.split('.').pop()?.toLowerCase() || '' +} + +export function hasAllowedFileExtension(file: File, allowedExtensions: readonly string[]): boolean { + const extension = getFileExtension(file) + return allowedExtensions.some((allowedExtension) => allowedExtension.replace(/^\./, '').toLowerCase() === extension) +} + +export function validateImageFiles( + files: FileList | File[], + options: ImageFileValidationOptions = {}, +): ImageFileValidationResult { + const { maxBytes, maxCount, existingCount = 0 } = options + const unsupportedNames: string[] = [] + const oversizedNames: string[] = [] + const validFiles: File[] = [] + + for (const file of Array.from(files)) { + if (!isSupportedImageFile(file)) { + unsupportedNames.push(file.name) + continue + } + + if (maxBytes !== undefined && file.size > maxBytes) { + oversizedNames.push(file.name) + continue + } + + validFiles.push(file) + } + + const remainingCount = maxCount === undefined + ? validFiles.length + : Math.max(0, maxCount - existingCount) + const acceptedFiles = validFiles.slice(0, remainingCount) + const ignoredByCount = Math.max(0, validFiles.length - acceptedFiles.length) + const errors: string[] = [] + + if (unsupportedNames.length > 0) { + errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}`) + } + if (oversizedNames.length > 0 && maxBytes !== undefined) { + const maxMegabytes = Math.round(maxBytes / 1024 / 1024) + errors.push(`以下图片超过 ${maxMegabytes}MB:${oversizedNames.join('、')}`) + } + if (ignoredByCount > 0 && maxCount !== undefined) { + errors.push(`最多只能选择 ${maxCount} 张图片,已忽略 ${ignoredByCount} 张`) + } + + return { acceptedFiles, errors } +} diff --git a/hooks/useDragHandlers.ts b/hooks/useDragHandlers.ts index d90a422..7e23535 100644 --- a/hooks/useDragHandlers.ts +++ b/hooks/useDragHandlers.ts @@ -1,53 +1,31 @@ /** - * 拖拽处理 Hook - * 封装素材文件、预览图片、展示图片的拖拽事件处理 + * 通用拖拽状态 Hook。 + * 业务组件可以保留各自的文件处理逻辑,但拖拽边界统一使用同一套判断。 */ -import { useState, useCallback } from 'react' +import { useCallback, useState } from 'react' +import { + hasDragLeftDropZone, + prepareFileDrag, + validateImageFiles, +} from '@/components/upload/drag-drop-utils' -/** - * 拖拽状态 - */ export interface DragState { - /** 素材文件拖拽状态 */ isDragging: boolean - /** 预览图片拖拽状态 */ isPreviewDragging: boolean - /** 详情页展示图片拖拽状态 */ isDisplayImageDragging: boolean - /** 批量模式下每个文件的预览图拖拽状态 */ batchPreviewDragging: Record } -/** - * 拖拽处理 Hook 参数 - */ export interface UseDragHandlersOptions { - /** 验证是否为有效的素材文件 */ isValidMainFile: (file: File) => boolean - /** 处理素材文件拖放 */ onMaterialFileDrop: (files: File[]) => void - /** 处理预览图片拖放 */ onPreviewImageDrop: (files: File[]) => void - /** 处理展示图片拖放 */ onDisplayImageDrop: (file: File) => void - /** 设置错误信息 */ setError: (error: string) => void + disabled?: boolean } -/** - * 判断鼠标是否真正离开了元素(避免经过子元素时闪屏) - */ -function isMouseOutsideElement(e: React.DragEvent, element: HTMLElement): boolean { - const rect = element.getBoundingClientRect() - const x = e.clientX - const y = e.clientY - return x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom -} - -/** - * 拖拽处理 Hook - */ export function useDragHandlers(options: UseDragHandlersOptions) { const { isValidMainFile, @@ -55,169 +33,156 @@ export function useDragHandlers(options: UseDragHandlersOptions) { onPreviewImageDrop, onDisplayImageDrop, setError, + disabled = false, } = options - // 拖拽状态 const [isDragging, setIsDragging] = useState(false) const [isPreviewDragging, setIsPreviewDragging] = useState(false) const [isDisplayImageDragging, setIsDisplayImageDragging] = useState(false) const [batchPreviewDragging, setBatchPreviewDragging] = useState>({}) - // ========== 素材文件拖拽处理 ========== - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(true) + const handleDragEnter = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) + if (!disabled) setIsDragging(true) + }, [disabled]) + + const handleDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + if (hasDragLeftDropZone(event)) setIsDragging(false) }, []) - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (isMouseOutsideElement(e, e.currentTarget as HTMLElement)) { - setIsDragging(false) - } - }, []) + const handleDragOver = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) + }, [disabled]) - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - }, []) - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + const handleDrop = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) setIsDragging(false) + if (disabled) { + setError('文件正在上传,请等待完成后再添加') + return + } - const files = Array.from(e.dataTransfer.files) + const files = Array.from(event.dataTransfer.files) if (files.length === 0) return - // 只处理素材文件(包括压缩包/视频/音频/PDF 视类型而定) - const materialFiles = files.filter(f => isValidMainFile(f)) + const materialFiles = files.filter(isValidMainFile) if (materialFiles.length > 0) { + const invalidNames = files + .filter((file) => !isValidMainFile(file)) + .map((file) => file.name) + setError( + invalidNames.length > 0 + ? `以下文件不支持当前素材类型,已忽略:${invalidNames.join('、')}` + : '', + ) onMaterialFileDrop(materialFiles) } else { setError('请拖拽素材文件到此区域,图片请拖拽到对应的图片上传区域') } - }, [isValidMainFile, onMaterialFileDrop, setError]) + }, [disabled, isValidMainFile, onMaterialFileDrop, setError]) - // ========== 预览图片拖拽处理 ========== + const handlePreviewDragEnter = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) + if (!disabled) setIsPreviewDragging(true) + }, [disabled]) - const handlePreviewDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsPreviewDragging(true) + const handlePreviewDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + if (hasDragLeftDropZone(event)) setIsPreviewDragging(false) }, []) - const handlePreviewDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (isMouseOutsideElement(e, e.currentTarget as HTMLElement)) { - setIsPreviewDragging(false) - } - }, []) + const handlePreviewDragOver = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) + }, [disabled]) - const handlePreviewDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - }, []) - - const handlePreviewDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + const handlePreviewDrop = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) setIsPreviewDragging(false) + if (disabled) { + setError('文件正在上传,请等待完成后再添加预览图') + return + } - const files = Array.from(e.dataTransfer.files) + const files = Array.from(event.dataTransfer.files) if (files.length === 0) return - const imageFiles = files.filter(f => f.type.startsWith('image/')) - if (imageFiles.length > 0) { - onPreviewImageDrop(imageFiles) + const { acceptedFiles, errors } = validateImageFiles(files) + if (acceptedFiles.length > 0) { + setError(errors.join(';')) + onPreviewImageDrop(acceptedFiles) } else { setError('请拖拽图片文件') } - }, [onPreviewImageDrop, setError]) + }, [disabled, onPreviewImageDrop, setError]) - // ========== 详情页展示图片拖拽处理 ========== + const handleDisplayImageDragEnter = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) + if (!disabled) setIsDisplayImageDragging(true) + }, [disabled]) - const handleDisplayImageDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDisplayImageDragging(true) + const handleDisplayImageDragLeave = useCallback((event: React.DragEvent) => { + event.preventDefault() + event.stopPropagation() + if (hasDragLeftDropZone(event)) setIsDisplayImageDragging(false) }, []) - const handleDisplayImageDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - if (isMouseOutsideElement(e, e.currentTarget as HTMLElement)) { - setIsDisplayImageDragging(false) - } - }, []) + const handleDisplayImageDragOver = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) + }, [disabled]) - const handleDisplayImageDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - }, []) - - const handleDisplayImageDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() + const handleDisplayImageDrop = useCallback((event: React.DragEvent) => { + prepareFileDrag(event, disabled) setIsDisplayImageDragging(false) + if (disabled) { + setError('文件正在上传,请等待完成后再更换展示图') + return + } - const files = Array.from(e.dataTransfer.files) + const files = Array.from(event.dataTransfer.files) if (files.length === 0) return - const imageFiles = files.filter(f => f.type.startsWith('image/')) - if (imageFiles.length > 0) { - onDisplayImageDrop(imageFiles[0]) + const { acceptedFiles, errors } = validateImageFiles(files, { maxCount: 1 }) + if (acceptedFiles[0]) { + setError(errors.join(';')) + onDisplayImageDrop(acceptedFiles[0]) } else { setError('请拖拽图片文件') } - }, [onDisplayImageDrop, setError]) - - // ========== 批量模式预览图拖拽处理 ========== + }, [disabled, onDisplayImageDrop, setError]) const handleBatchPreviewDragEnter = useCallback((itemId: string) => { - setBatchPreviewDragging(prev => ({ ...prev, [itemId]: true })) - }, []) + if (disabled) return + setBatchPreviewDragging((prev) => ({ ...prev, [itemId]: true })) + }, [disabled]) const handleBatchPreviewDragLeave = useCallback((itemId: string) => { - setBatchPreviewDragging(prev => ({ ...prev, [itemId]: false })) + setBatchPreviewDragging((prev) => ({ ...prev, [itemId]: false })) }, []) const resetBatchPreviewDragging = useCallback((itemId: string) => { - setBatchPreviewDragging(prev => ({ ...prev, [itemId]: false })) + setBatchPreviewDragging((prev) => ({ ...prev, [itemId]: false })) }, []) return { - // 拖拽状态 isDragging, isPreviewDragging, isDisplayImageDragging, batchPreviewDragging, - - // 素材文件拖拽处理 handleDragEnter, handleDragLeave, handleDragOver, handleDrop, - - // 预览图片拖拽处理 handlePreviewDragEnter, handlePreviewDragLeave, handlePreviewDragOver, handlePreviewDrop, - - // 详情页展示图片拖拽处理 handleDisplayImageDragEnter, handleDisplayImageDragLeave, handleDisplayImageDragOver, handleDisplayImageDrop, - - // 批量模式预览图拖拽处理 handleBatchPreviewDragEnter, handleBatchPreviewDragLeave, resetBatchPreviewDragging,
+ {imageError} +
- 支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张 + 支持上传、拖拽或粘贴图片 · 最多5张,单张不超过10MB