diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 0e4039dd..88fd280d 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -1,7 +1,7 @@ -import { PlusOutlined } from '@ant-design/icons' +import { CloudUploadOutlined, PlusOutlined } from '@ant-design/icons' import { App, Typography, Upload } from 'antd' import type { UploadFile, UploadProps } from 'antd' -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react' import { uploadFile } from '@/services/files' @@ -23,6 +23,8 @@ type GlobalPasteTarget = { uploadImages: (files: File[]) => void } +type UploadSource = 'default' | 'drag' + let nextTargetId = 1 const globalPasteTargets: GlobalPasteTarget[] = [] let globalPasteListenerAttached = false @@ -62,6 +64,8 @@ export default function ImageUpload({ captureGlobalPaste = false, }: ImageUploadProps) { const { message } = App.useApp() + const [isDragging, setIsDragging] = useState(false) + const dragCounterRef = useRef(0) const targetIdRef = useRef(0) const valueRef = useRef(value) valueRef.current = value @@ -74,7 +78,10 @@ export default function ImageUpload({ thumbUrl: file.thumbnailUrl || file.url, })) - async function uploadImages(images: File[]) { + async function uploadImages( + images: File[], + source: UploadSource = 'default', + ) { const remaining = maxCount - valueRef.current.length if (remaining <= 0) { message.warning(`最多上传 ${maxCount} 张图片`) @@ -86,7 +93,7 @@ export default function ImageUpload({ const uploaded = await uploadFile(file, scene, scope) next.push(uploaded) } catch (error) { - message.error(error instanceof Error ? error.message : '上传失败') + message.error(resolveUploadErrorMessage(error, source)) } } if (next.length > valueRef.current.length) { @@ -95,10 +102,13 @@ export default function ImageUpload({ } } - async function uploadFiles(files: File[]) { + async function uploadFiles( + files: File[], + source: UploadSource = 'default', + ) { const images = files.filter((file) => file.type.startsWith('image/')) if (images.length === 0) return - await uploadImages(images) + await uploadImages(images, source) } function handlePaste(event: ClipboardEvent) { @@ -108,10 +118,42 @@ export default function ImageUpload({ void uploadFiles(files) } + function handleDragEnter(event: DragEvent) { + event.preventDefault() + event.stopPropagation() + dragCounterRef.current += 1 + if (event.dataTransfer?.types?.includes('Files')) { + setIsDragging(true) + } + } + + function handleDragOver(event: DragEvent) { + event.preventDefault() + event.stopPropagation() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy' + } + } + + function handleDragLeave(event: DragEvent) { + event.preventDefault() + event.stopPropagation() + dragCounterRef.current -= 1 + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0 + setIsDragging(false) + } + } + function handleDrop(event: DragEvent) { event.preventDefault() + event.stopPropagation() + dragCounterRef.current = 0 + setIsDragging(false) const files = Array.from(event.dataTransfer?.files || []) - if (files.length > 0) void uploadFiles(files) + if (files.length > 0) { + void uploadFiles(files, 'drag') + } } function activateGlobalPasteTarget() { @@ -157,13 +199,22 @@ export default function ImageUpload({ return (
event.preventDefault()} + onDropCapture={handleDrop} + onDragEnterCapture={handleDragEnter} + onDragOverCapture={handleDragOver} + onDragLeaveCapture={handleDragLeave} onFocusCapture={handleWrapperFocus} onClickCapture={handleWrapperClick} > + {isDragging && ( +
+ + 松开鼠标即可上传图片 + 支持单张或批量拖入多张图片 +
+ )} ) } + +function resolveUploadErrorMessage(error: unknown, source: UploadSource) { + const errorMessage = error instanceof Error ? error.message : '上传失败' + if (source === 'drag' && /网络连接错误|Network Error/i.test(errorMessage)) { + return '拖拽图片读取失败,请复制图片后粘贴,或保存到本地后点击上传' + } + return errorMessage +} diff --git a/apps/frontend/src/styles/base.css b/apps/frontend/src/styles/base.css index 6a4502dd..4a725157 100644 --- a/apps/frontend/src/styles/base.css +++ b/apps/frontend/src/styles/base.css @@ -232,9 +232,74 @@ select { } .image-upload-droppable { + position: relative; display: grid; gap: 8px; justify-items: start; + border-radius: 10px; + padding: 6px; + margin: -6px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.image-upload-droppable.is-dragging { + background: var(--theme-primary-soft, #fff3e8); +} + +.image-upload-drag-overlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + background: rgba(255, 255, 255, 0.94); + backdrop-filter: blur(4px); + border: 2px dashed var(--theme-primary, #ff6a00); + border-radius: 10px; + color: var(--theme-primary, #ff6a00); + pointer-events: none; + animation: uploadFadeIn 0.15s ease-out; + box-shadow: 0 8px 24px var(--theme-primary-shadow, rgba(255, 106, 0, 0.14)); +} + +.image-upload-drag-icon { + font-size: 32px; + color: var(--theme-primary, #ff6a00); + animation: uploadBounce 0.8s infinite alternate ease-in-out; +} + +.image-upload-drag-text { + font-size: 13px; + font-weight: 600; + color: #1e293b; +} + +.image-upload-drag-subtext { + font-size: 11px; + color: #64748b; +} + +@keyframes uploadFadeIn { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes uploadBounce { + from { + transform: translateY(0); + } + to { + transform: translateY(-4px); + } } .image-upload-hint {