155 lines
5.6 KiB
TypeScript
155 lines
5.6 KiB
TypeScript
'use client'
|
||
|
||
import Image from 'next/image'
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
|
||
const MAX_FILE_COUNT = 60
|
||
const MAX_FILE_BYTES = 200 * 1024 * 1024
|
||
|
||
function fileKey(file: File) {
|
||
return `${file.name}-${file.size}-${file.lastModified}`
|
||
}
|
||
|
||
export function WorkReviewImageDropzone({
|
||
files,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
files: File[]
|
||
onChange: (files: File[]) => void
|
||
disabled?: boolean
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||
const dragDepthRef = useRef(0)
|
||
const [dragging, setDragging] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const previewUrls = useMemo(() => files.map((file) => URL.createObjectURL(file)), [files])
|
||
|
||
useEffect(() => () => {
|
||
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||
}, [previewUrls])
|
||
|
||
const addFiles = (selected: FileList | File[]) => {
|
||
if (disabled) return
|
||
const candidates = [...files, ...Array.from(selected)]
|
||
const deduplicated = candidates.filter((file, index, items) => (
|
||
items.findIndex((candidate) => fileKey(candidate) === fileKey(file)) === index
|
||
))
|
||
const invalid = deduplicated.find((file) => !file.type.startsWith('image/'))
|
||
if (invalid) {
|
||
setError(`文件「${invalid.name}」不是图片`)
|
||
return
|
||
}
|
||
const oversized = deduplicated.find((file) => file.size > MAX_FILE_BYTES)
|
||
if (oversized) {
|
||
setError(`图片「${oversized.name}」超过 200MB`)
|
||
return
|
||
}
|
||
if (deduplicated.length > MAX_FILE_COUNT) {
|
||
setError(`一次最多选择 ${MAX_FILE_COUNT} 张图片`)
|
||
return
|
||
}
|
||
setError(null)
|
||
onChange(deduplicated)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||
multiple
|
||
disabled={disabled}
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
addFiles(event.currentTarget.files || [])
|
||
event.currentTarget.value = ''
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => inputRef.current?.click()}
|
||
onDragEnter={(event) => {
|
||
event.preventDefault()
|
||
if (disabled) return
|
||
dragDepthRef.current += 1
|
||
setDragging(true)
|
||
}}
|
||
onDragOver={(event) => {
|
||
event.preventDefault()
|
||
if (!disabled) event.dataTransfer.dropEffect = 'copy'
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
if (disabled) return
|
||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||
if (dragDepthRef.current === 0) setDragging(false)
|
||
}}
|
||
onDrop={(event) => {
|
||
event.preventDefault()
|
||
dragDepthRef.current = 0
|
||
setDragging(false)
|
||
addFiles(event.dataTransfer.files)
|
||
}}
|
||
className={`flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed px-4 py-5 text-center transition-all disabled:cursor-not-allowed disabled:opacity-60 ${
|
||
dragging
|
||
? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]'
|
||
: 'border-[#aebbd0] bg-[#fbfcfe] hover:border-[#0050cb] hover:bg-[#f7faff]'
|
||
}`}
|
||
>
|
||
<span className={`flex h-9 w-9 items-center justify-center rounded-full text-[21px] ${
|
||
dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eaf2ff] text-[#0050cb]'
|
||
}`}>↑</span>
|
||
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">
|
||
{dragging ? '松开即可添加' : '拖拽图片到这里,或点击选择'}
|
||
</span>
|
||
<span className="mt-1 text-[11px] leading-5 text-[#7b8494]">
|
||
支持 PNG、JPG、GIF、WebP;长图会自动生成预览与切片
|
||
</span>
|
||
</button>
|
||
|
||
{error && <div className="mt-2 text-[12px] font-semibold text-[#b42318]" role="alert">{error}</div>}
|
||
|
||
{files.length > 0 && (
|
||
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{files.map((file, index) => (
|
||
<div key={fileKey(file)} className="group relative overflow-hidden rounded-lg border border-[#e1e7ef] bg-white">
|
||
<div className="relative aspect-[4/3]">
|
||
<Image
|
||
src={previewUrls[index]}
|
||
alt={file.name}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<span className="absolute left-2 top-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
|
||
{index + 1}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
|
||
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/65 text-[18px] text-white opacity-90 transition-opacity hover:bg-[#b42318] disabled:cursor-not-allowed"
|
||
aria-label={`移除 ${file.name}`}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="min-w-0 px-2.5 py-2">
|
||
<div className="truncate text-[11px] font-semibold text-[#344054]" title={file.name}>{file.name}</div>
|
||
<div className="mt-0.5 text-[10px] text-[#98a2b3]">
|
||
{file.size < 1024 * 1024
|
||
? `${Math.max(1, Math.round(file.size / 1024))} KB`
|
||
: `${(file.size / 1024 / 1024).toFixed(1)} MB`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|