Files
order_site/apps/frontend/src/components/files/ImageUpload.tsx
T
2026-08-16 14:38:10 +08:00

258 lines
8.1 KiB
TypeScript

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<HTMLDivElement>) {
const files = Array.from(event.clipboardData?.files || [])
if (files.length === 0) return
event.preventDefault()
void uploadFiles(files)
}
function handleDragEnter(event: DragEvent<HTMLDivElement>) {
event.preventDefault()
event.stopPropagation()
dragCounterRef.current += 1
if (event.dataTransfer?.types?.includes('Files')) {
setIsDragging(true)
}
}
function handleDragOver(event: DragEvent<HTMLDivElement>) {
event.preventDefault()
event.stopPropagation()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}
function handleDragLeave(event: DragEvent<HTMLDivElement>) {
event.preventDefault()
event.stopPropagation()
dragCounterRef.current -= 1
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0
setIsDragging(false)
}
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
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<HTMLDivElement>) {
if (captureGlobalPaste) activateGlobalPasteTarget()
}
function handleWrapperClick(event: MouseEvent<HTMLDivElement>) {
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 (
<div
className={`image-upload-droppable ${isDragging ? 'is-dragging' : ''}`}
onPaste={handlePaste}
onDropCapture={handleDrop}
onDragEnterCapture={handleDragEnter}
onDragOverCapture={handleDragOver}
onDragLeaveCapture={handleDragLeave}
onFocusCapture={handleWrapperFocus}
onClickCapture={handleWrapperClick}
>
{isDragging && (
<div className="image-upload-drag-overlay">
<CloudUploadOutlined className="image-upload-drag-icon" />
<span className="image-upload-drag-text">松开鼠标即可上传图片</span>
<span className="image-upload-drag-subtext">支持单张或批量拖入多张图片</span>
</div>
)}
<Upload
accept="image/jpeg,image/png,image/webp"
multiple
listType="picture-card"
fileList={fileList}
maxCount={maxCount}
customRequest={customRequest}
onRemove={(file) => {
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 : (
<button className="image-upload-trigger" type="button">
<PlusOutlined />
<span>上传</span>
</button>
)}
</Upload>
{showPasteHint ? (
<Typography.Text type="secondary" className="image-upload-hint">
点击选择图片上传;截图后可直接按 Ctrl+V / V 粘贴,或拖拽图片到此处
</Typography.Text>
) : null}
</div>
)
}
function resolveUploadErrorMessage(error: unknown, source: UploadSource) {
const errorMessage = error instanceof Error ? error.message : '上传失败'
if (source === 'drag' && /网络连接错误|Network Error/i.test(errorMessage)) {
return '拖拽图片读取失败,请复制图片后粘贴,或保存到本地后点击上传'
}
return errorMessage
}