258 lines
8.1 KiB
TypeScript
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
|
|
}
|