fix: 统一修复拖拽上传交互

This commit is contained in:
chenpei 2026-07-24 18:15:27 +08:00
parent 7925ca74de
commit 9b442574fa
24 changed files with 1495 additions and 562 deletions

View File

@ -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(<AIChatWidget />)
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(<AIChatWidget />)
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()
})
})

View File

@ -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<string> {
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<Message[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const [images, setImages] = useState<File[]>([])
const [imagePreviews, setImagePreviews] = useState<string[]>([])
const [pendingImages, setPendingImages] = useState<PendingImage[]>([])
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<HTMLDivElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLDivElement>(null)
const pendingImagesRef = useRef<PendingImage[]>([])
// 窗口拖拽功能
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<HTMLInputElement>) => {
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 && (
<div className="absolute inset-0 bg-blue-500/20 dark:bg-blue-400/20 backdrop-blur-sm z-10 flex items-center justify-center border-2 border-dashed border-blue-500 dark:border-blue-400 m-2 rounded-lg">
<div className="pointer-events-none absolute inset-0 bg-blue-500/20 dark:bg-blue-400/20 backdrop-blur-sm z-10 flex items-center justify-center border-2 border-dashed border-blue-500 dark:border-blue-400 m-2 rounded-lg">
<div className="text-center">
<svg className="w-16 h-16 mx-auto text-blue-500 dark:text-blue-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
@ -606,20 +663,21 @@ export default function AIChatWidget() {
</div>
{/* 图片预览区 */}
{imagePreviews.length > 0 && (
{pendingImages.length > 0 && (
<div className="px-4 py-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
{imagePreviews.map((preview, idx) => (
<div key={idx} className="relative flex-shrink-0 group">
{pendingImages.map((image, idx) => (
<div key={image.id} className="relative flex-shrink-0 group">
<img
src={preview}
alt={`Preview ${idx}`}
src={image.previewUrl}
alt={`待发送图片 ${idx + 1}`}
className="w-16 h-16 object-cover rounded-lg border border-gray-200 dark:border-gray-700"
/>
<button
onClick={() => 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}`}
>
×
</button>
@ -639,11 +697,13 @@ export default function AIChatWidget() {
accept="image/*"
multiple
className="hidden"
disabled={sending}
/>
{/* 上传按钮 */}
<button
onClick={() => 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() {
{/* 发送按钮 */}
<button
onClick={handleSend}
disabled={(!input.trim() && images.length === 0) || sending}
disabled={(!input.trim() && pendingImages.length === 0) || sending}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm h-[72px] flex items-center justify-center min-w-[70px]"
>
{sending ? (
@ -687,8 +747,13 @@ export default function AIChatWidget() {
)}
</button>
</div>
{imageError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
{imageError}
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
支持文字+图片聊天 · 可上传/拖拽/粘贴图片 · 最多5张
支持上传、拖拽或粘贴图片 · 最多5张,单张不超过10MB
</p>
</div>
</div>
@ -714,4 +779,3 @@ export default function AIChatWidget() {
</div>
)
}

View File

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 处理新增 SKU 的压缩包选择(立即显示,不上传)
const handleNewZipSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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
<div className="flex items-center gap-2 flex-shrink-0">
{/* 预览图(支持拖拽) */}
<div
onDragOver={(e) => 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}
/>
</div>
{/* 压缩包(支持拖拽) */}
<div
onDragOver={(e) => 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}
/>
</div>

View File

@ -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(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
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()
})
})

View File

@ -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<File[]>([]) // 预览图片数组(支持多图)
@ -220,8 +251,22 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
return regex.test(value)
}
const handlePreviewChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
const handlePreviewChange = async (
e: React.ChangeEvent<HTMLInputElement>,
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<HTMLInputElement>) => {
const handleDisplayImageChange = async (
e: React.ChangeEvent<HTMLInputElement>,
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<HTMLInputElement>) => {
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<HTMLInputElement>
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<HTMLInputElement>
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<HTMLInputElement>) => {
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 !== '标准模型' && (
<div
ref={dropZoneRef}
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-6 text-center transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
@ -2264,8 +2321,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
)}
<button
type="button"
disabled={uploading}
onClick={() => 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"
>
移除
</button>
@ -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}
/>
<button
type="button"
disabled={uploading}
onClick={() => batchFileInputRef.current?.click()}
className="btn-secondary mr-2"
>
@ -2304,6 +2365,7 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
)}
<button
type="button"
disabled={uploading}
onClick={() => zipInputRef.current?.click()}
className="btn-secondary"
>

View File

@ -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({
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'

View File

@ -6,6 +6,11 @@ import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer } from '@/lib/material-validation'
import { formatBytes } from './upload-utils'
import { parseSPU } from '@/lib/spu-parser'
import {
hasDragLeftDropZone,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils'
interface BatchFileListProps {
batchFiles: BatchFileItem[]
@ -15,6 +20,7 @@ interface BatchFileListProps {
platforms: Array<{ id: string; name: string }>
productCategories: Array<{ id: string; name: string }>
renderer: string
disabled?: boolean
batchPreviewDragging: Record<string, boolean>
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<HTMLInputElement>, 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({
</div>
) : (
<div
className={`border-2 border-dashed rounded-apple h-20 flex items-center justify-center transition-colors cursor-pointer ${
aria-disabled={disabled}
className={`border-2 border-dashed rounded-apple h-20 flex items-center justify-center transition-colors ${
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
batchPreviewDragging[item.id]
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => {
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}
/>
<span className="text-sm text-gray-500 dark:text-gray-400">拖拽或点击选择图片</span>
</div>

View File

@ -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(<Uploader {...defaultProps} />)
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(<AIOriginalUploader {...defaultProps} />)
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()
})

View File

@ -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(
<div onDrop={onOuterDrop}>
<DetailImageSorter images={images} onChange={jest.fn()} onRemove={jest.fn()} />
</div>,
)
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(
<div onDrop={onOuterDrop}>
<DetailImageSorter images={images} onChange={onChange} onRemove={jest.fn()} />
</div>,
)
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()
})
})

View File

@ -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<number | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const previewRef = useRef<HTMLDivElement>(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 (
<div
className="grid grid-cols-3 gap-4"
onDragOver={(e) => 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宽度) */}
<div className="col-span-2 border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
@ -134,7 +178,7 @@ export default function DetailImageSorter({ images, onChange, onRemove }: Detail
{sortedImages.map((img, index) => (
<div
key={img.id}
draggable
draggable={!disabled}
onDragStart={(e) => 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
{/* 删除按钮 */}
<button
type="button"
aria-label={`删除详情图 ${index + 1}`}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onRemove(img.id);

View File

@ -6,6 +6,7 @@ interface DisplayImageUploadProps {
displayImageUrl: string
isDisplayImageDragging: boolean
displayImageDropZoneRef: React.RefObject<HTMLDivElement>
disabled?: boolean
onDisplayImageChange: (e: React.ChangeEvent<HTMLInputElement>) => 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({
</label>
<div
ref={displayImageDropZoneRef}
aria-disabled={disabled}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
disabled ? 'cursor-not-allowed opacity-60' : ''
} ${
isDisplayImageDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
@ -74,11 +79,13 @@ export default function DisplayImageUpload({
accept="image/*"
className="hidden"
id="displayImageInput"
disabled={disabled}
/>
<button
type="button"
onClick={() => document.getElementById('displayImageInput')?.click()}
className="btn-secondary"
disabled={disabled}
>
选择展示图片
</button>
@ -96,4 +103,3 @@ export default function DisplayImageUpload({
</div>
)
}

View File

@ -10,6 +10,7 @@ interface PreviewImageUploadProps {
isPreviewDragging: boolean
previewInputRef: React.RefObject<HTMLInputElement>
previewDropZoneRef: React.RefObject<HTMLDivElement>
disabled?: boolean
onPreviewChange: (e: React.ChangeEvent<HTMLInputElement>) => 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({
</label>
<div
ref={previewDropZoneRef}
aria-disabled={disabled}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
disabled ? 'cursor-not-allowed opacity-60' : ''
} ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
@ -57,6 +62,7 @@ export default function PreviewImageUpload({
accept="image/*"
multiple
className="hidden"
disabled={disabled}
/>
<div className="grid grid-cols-4 gap-4">
{previewUrls.map((url, index) => (
@ -78,6 +84,7 @@ export default function PreviewImageUpload({
<button
type="button"
onClick={() => 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({
<button
type="button"
onClick={() => 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"
>
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -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(<ProductPackageUploader {...defaultProps} />)
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')
})
})

View File

@ -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<React.SetStateAction<AssetFile[]>>,
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({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
@ -846,7 +856,10 @@ export default function ProductPackageUploader({
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isSkuDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
@ -922,7 +935,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isDetailDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
@ -938,7 +954,7 @@ export default function ProductPackageUploader({
type="file"
accept="image/*"
multiple
onChange={(e) => 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}
/>
)}
</div>
@ -973,7 +990,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isPsdDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'

View File

@ -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 RealPhotoUploader({
// 处理原图文件选择(可多选)
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 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({
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'

View File

@ -1,6 +1,11 @@
'use client'
import { useState } from 'react'
import {
hasAllowedFileExtension,
hasDragLeftDropZone,
prepareFileDrag,
} from './drag-drop-utils'
interface StandardModelSkuZipUploadProps {
selectedFile: File | null
@ -24,8 +29,7 @@ function formatFileSize(bytes: string | number | undefined): string {
}
function isSupportedZipFile(file: File): boolean {
const lowerName = file.name.toLowerCase()
return ['.zip', '.rar', '.7z'].some((ext) => 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<HTMLLabelElement>) => {
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({
: '当前还没有模型压缩包,请先选择要上传的文件。'}
</div>
{error && (
{(error || validationError) && (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
{error}
{error || validationError}
</div>
)}
@ -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: 上传槽位需要拖拽和点击交互 */}
<label
htmlFor="standard-model-sku-zip-upload"
className={`group block min-h-40 cursor-pointer rounded-2xl border-2 border-dashed p-4 transition-colors ${
aria-disabled={uploading}
className={`group block min-h-40 rounded-2xl border-2 border-dashed p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => {
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({
<button
type="button"
aria-label="移除已选择的模型压缩包"
disabled={uploading}
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
onClick={(event) => {
event.preventDefault()

View File

@ -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(
<StandardModelSkuZipUpload
selectedFile={null}
onSelectFile={onSelectFile}
onRemoveFile={jest.fn()}
/>,
)
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(
<StandardModelSkuZipUpload
selectedFile={null}
uploading
onSelectFile={onSelectFile}
onRemoveFile={jest.fn()}
/>,
)
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(
<StandardModelWhiteBackgroundUpload
imageUrls={{}}
otherImages={[]}
onSelectImage={jest.fn()}
onRemoveImage={jest.fn()}
onSelectOtherImages={jest.fn()}
onRemoveOtherImage={jest.fn()}
/>,
)
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(
<StandardModelWhiteBackgroundUpload
imageUrls={{}}
otherImages={[]}
uploading
onSelectImage={onSelectImage}
onRemoveImage={jest.fn()}
onSelectOtherImages={onSelectOtherImages}
onRemoveOtherImage={jest.fn()}
/>,
)
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()
})
})

View File

@ -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: 上传槽位需要拖拽和点击交互 */}
<label
htmlFor={inputId}
className={`group block cursor-pointer rounded-2xl border-2 border-dashed p-3 transition-colors ${
aria-disabled={uploading}
className={`group block rounded-2xl border-2 border-dashed p-3 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: imageUrl
@ -197,6 +198,7 @@ export default function StandardModelWhiteBackgroundUpload({
<button
type="button"
aria-label={`删除${view.label}`}
disabled={uploading}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
onClick={(event) => {
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: 其他图片区需要拖拽上传 */}
<label
htmlFor="white-bg-other-images"
className={`block min-h-28 cursor-pointer rounded-2xl border-2 border-dashed px-4 py-5 transition-colors ${
aria-disabled={uploading}
className={`block min-h-28 rounded-2xl border-2 border-dashed px-4 py-5 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
isOtherDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
}`}
onDragEnter={(event) => {
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({
<button
type="button"
aria-label={`删除${image.name}`}
disabled={uploading}
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
onClick={(event) => {
event.preventDefault()

View File

@ -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({
预览图 *
</label>
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => {
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);
}}
>
<input

View File

@ -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 ThreeDOriginalUploader({
// 处理原图文件选择(可多选)
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 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({
原图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'

View File

@ -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;
@ -136,36 +141,25 @@ export default function VideoScreenshotUploader({
// 处理截图文件选择(可多选)
const handleScreenshotSelect = (files: FileList | null) => {
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({
视频截图 * <span className="text-gray-500 text-xs">(至少1张,支持多选,后台自动生成预览图和压缩包)</span>
</label>
<div
aria-disabled={uploading}
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
isScreenshotDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'

View File

@ -0,0 +1,108 @@
import {
hasAllowedFileExtension,
hasDragLeftDropZone,
isSupportedImageFile,
prepareFileDrag,
validateImageFiles,
} from '@/components/upload/drag-drop-utils'
function createDragEvent(
currentTarget: HTMLElement,
options: {
clientX?: number
clientY?: number
relatedTarget?: EventTarget | null
dropEffect?: DataTransfer['dropEffect']
} = {},
): React.DragEvent<HTMLElement> {
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<HTMLElement>
}
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)
})
})

View File

@ -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<Element>): 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<Element>, 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 }
}

View File

@ -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<string, boolean>
}
/**
* 拖拽处理 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<Record<string, boolean>>({})
// ========== 素材文件拖拽处理 ==========
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,