import { CloudUploadOutlined, PlusOutlined } from '@ant-design/icons' import { App, Typography, Upload } from 'antd' import type { UploadFile, UploadProps } from 'antd' import { useEffect, useRef, useState } from 'react' import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react' import { uploadFile } from '@/services/files' import type { UploadedFile } from '@/types/worker-platform' type ImageUploadProps = { value?: UploadedFile[] onChange?: (files: UploadedFile[]) => void scene: string scope: 'admin' | 'worker' | 'collect' maxCount?: number showPasteHint?: boolean /** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */ captureGlobalPaste?: boolean } type GlobalPasteTarget = { id: number uploadImages: (files: File[]) => void } type UploadSource = 'default' | 'drag' let nextTargetId = 1 const globalPasteTargets: GlobalPasteTarget[] = [] let globalPasteListenerAttached = false function handleDocumentPaste(event: DocumentEventMap['paste']) { const files = Array.from(event.clipboardData?.files || []) const images = files.filter((file) => file.type.startsWith('image/')) if (images.length === 0) return const target = globalPasteTargets[globalPasteTargets.length - 1] if (!target || event.defaultPrevented) return event.preventDefault() target.uploadImages(images) } function ensureGlobalPasteListener() { if (globalPasteListenerAttached) return globalPasteListenerAttached = true document.addEventListener('paste', handleDocumentPaste) } function registerGlobalPasteTarget(target: GlobalPasteTarget): () => void { globalPasteTargets.push(target) ensureGlobalPasteListener() return () => { const index = globalPasteTargets.findIndex((item) => item.id === target.id) if (index >= 0) globalPasteTargets.splice(index, 1) } } export default function ImageUpload({ value = [], onChange, scene, scope, maxCount = 6, showPasteHint = false, 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 const fileList: UploadFile[] = value.map((file) => ({ uid: file.objectKey || file.url, name: file.filename || '图片', status: 'done', url: file.thumbnailUrl || file.url, thumbUrl: file.thumbnailUrl || file.url, })) async function uploadImages( images: File[], source: UploadSource = 'default', ) { const remaining = maxCount - valueRef.current.length if (remaining <= 0) { message.warning(`最多上传 ${maxCount} 张图片`) return } const next = [...valueRef.current] for (const file of images.slice(0, remaining)) { try { const uploaded = await uploadFile(file, scene, scope) next.push(uploaded) } catch (error) { message.error(resolveUploadErrorMessage(error, source)) } } if (next.length > valueRef.current.length) { valueRef.current = next onChange?.(next) } } 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, source) } function handlePaste(event: ClipboardEvent) { const files = Array.from(event.clipboardData?.files || []) if (files.length === 0) return event.preventDefault() 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, 'drag') } } function activateGlobalPasteTarget() { const index = globalPasteTargets.findIndex((item) => item.id === targetIdRef.current) if (index < 0) return const [target] = globalPasteTargets.splice(index, 1) globalPasteTargets.push(target) } useEffect(() => { if (!captureGlobalPaste) return const targetId = nextTargetId++ targetIdRef.current = targetId const unregister = registerGlobalPasteTarget({ id: targetId, uploadImages: (files) => void uploadImages(files), }) return unregister // eslint-disable-next-line react-hooks/exhaustive-deps }, [captureGlobalPaste]) function handleWrapperFocus(event: FocusEvent) { if (captureGlobalPaste) activateGlobalPasteTarget() } function handleWrapperClick(event: MouseEvent) { if (captureGlobalPaste) activateGlobalPasteTarget() } const customRequest: UploadProps['customRequest'] = async (options) => { try { const uploaded = await uploadFile(options.file as File, scene, scope) // 多文件选择会并发执行 customRequest,必须以 ref 中的最新列表累加,避免后完成的上传覆盖前面的图片。 const next = [...valueRef.current, uploaded].slice(0, maxCount) valueRef.current = next onChange?.(next) options.onSuccess?.(uploaded) } catch (error) { message.error(error instanceof Error ? error.message : '上传失败') options.onError?.(error instanceof Error ? error : new Error('上传失败')) } } return (
{isDragging && (
松开鼠标即可上传图片 支持单张或批量拖入多张图片
)} { const next = valueRef.current.filter( (item) => (item.objectKey || item.url) !== file.uid, ) valueRef.current = next onChange?.(next) return true }} showUploadList={{ showPreviewIcon: false }} > {value.length >= maxCount ? null : ( )} {showPasteHint ? ( 点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处 ) : null}
) } function resolveUploadErrorMessage(error: unknown, source: UploadSource) { const errorMessage = error instanceof Error ? error.message : '上传失败' if (source === 'drag' && /网络连接错误|Network Error/i.test(errorMessage)) { return '拖拽图片读取失败,请复制图片后粘贴,或保存到本地后点击上传' } return errorMessage }