281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
'use client'
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import Image from 'next/image'
|
||
import { Plus } from 'lucide-react'
|
||
import {
|
||
STANDARD_MODEL_WHITE_BG_VIEWS,
|
||
type StandardModelWhiteBgKey,
|
||
} from '@/lib/standard-model-white-background'
|
||
import { hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils'
|
||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass, uploadAreaTileClass } from './upload-field-styles'
|
||
|
||
interface OtherImagePreview {
|
||
id: string
|
||
url: string
|
||
name: string
|
||
}
|
||
|
||
interface StandardModelWhiteBackgroundUploadProps {
|
||
imageUrls: Partial<Record<StandardModelWhiteBgKey, string>>
|
||
otherImages: OtherImagePreview[]
|
||
uploading?: boolean
|
||
error?: string
|
||
onSelectImage: (key: StandardModelWhiteBgKey, file: File) => void
|
||
onRemoveImage: (key: StandardModelWhiteBgKey) => void
|
||
onSelectOtherImages: (files: FileList | File[]) => void
|
||
onRemoveOtherImage: (id: string) => void
|
||
}
|
||
|
||
export default function StandardModelWhiteBackgroundUpload({
|
||
imageUrls,
|
||
otherImages,
|
||
uploading = false,
|
||
error,
|
||
onSelectImage,
|
||
onRemoveImage,
|
||
onSelectOtherImages,
|
||
onRemoveOtherImage,
|
||
}: StandardModelWhiteBackgroundUploadProps) {
|
||
const [draggingKey, setDraggingKey] = useState<StandardModelWhiteBgKey | null>(null)
|
||
const [isOtherDragging, setIsOtherDragging] = useState(false)
|
||
|
||
const uploadedRequiredCount = useMemo(
|
||
() => STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => !!imageUrls[view.key]).length,
|
||
[imageUrls],
|
||
)
|
||
|
||
const missingLabels = useMemo(
|
||
() =>
|
||
STANDARD_MODEL_WHITE_BG_VIEWS
|
||
.filter((view) => !imageUrls[view.key])
|
||
.map((view) => view.label),
|
||
[imageUrls],
|
||
)
|
||
|
||
const handleDragEnter = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
|
||
prepareFileDrag(event, uploading)
|
||
if (uploading) return
|
||
setDraggingKey(key)
|
||
}
|
||
|
||
const handleDragLeave = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
|
||
if (hasDragLeftDropZone(event)) {
|
||
if (draggingKey === key) {
|
||
setDraggingKey(null)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleDragOver = (event: React.DragEvent) => {
|
||
prepareFileDrag(event, uploading)
|
||
}
|
||
|
||
const handleDrop = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
|
||
prepareFileDrag(event, uploading)
|
||
setDraggingKey(null)
|
||
if (uploading) return
|
||
|
||
const file = event.dataTransfer.files?.[0]
|
||
if (!file) {
|
||
return
|
||
}
|
||
|
||
onSelectImage(key, file)
|
||
}
|
||
|
||
const handleOtherDrop = (event: React.DragEvent) => {
|
||
prepareFileDrag(event, uploading)
|
||
setIsOtherDragging(false)
|
||
if (uploading) return
|
||
|
||
const files = event.dataTransfer.files
|
||
if (!files || files.length === 0) {
|
||
return
|
||
}
|
||
|
||
onSelectOtherImages(files)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
|
||
{error && (
|
||
<div className="rounded-[8px] border border-[#fecdca] bg-[#fff1f0] px-3 py-2 text-sm text-[#b42318]">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||
<h4 className={fieldLabelClass}>固定角度</h4>
|
||
<span className={fieldHintClass}>已传 {uploadedRequiredCount} / {STANDARD_MODEL_WHITE_BG_VIEWS.length} · 可分次补传</span>
|
||
</div>
|
||
<p className={`mb-2 ${fieldHintClass}`}>
|
||
{missingLabels.length > 0 ? `待补:${missingLabels.join('、')}` : '固定角度已齐'}
|
||
</p>
|
||
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{STANDARD_MODEL_WHITE_BG_VIEWS.map((view) => {
|
||
const inputId = `white-bg-${view.key}`
|
||
const imageUrl = imageUrls[view.key]
|
||
const isDragging = draggingKey === view.key
|
||
|
||
return (
|
||
<div key={view.key}>
|
||
<p className="mb-1 text-sm font-medium text-[#1a1c1d]">{view.label}</p>
|
||
|
||
<input
|
||
id={inputId}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
disabled={uploading}
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0]
|
||
if (file) {
|
||
onSelectImage(view.key, file)
|
||
}
|
||
event.currentTarget.value = ''
|
||
}}
|
||
/>
|
||
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 上传槽位需要拖拽和点击交互 */}
|
||
<label
|
||
htmlFor={inputId}
|
||
aria-disabled={uploading}
|
||
className={`group block p-1.5 ${
|
||
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||
} ${isDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||
onDragEnter={(event) => handleDragEnter(event, view.key)}
|
||
onDragLeave={(event) => handleDragLeave(event, view.key)}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(event) => handleDrop(event, view.key)}
|
||
>
|
||
{imageUrl ? (
|
||
<div>
|
||
<div className="relative h-16 overflow-hidden rounded-[6px]">
|
||
<Image
|
||
src={imageUrl}
|
||
alt={view.label}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={`删除${view.label}`}
|
||
disabled={uploading}
|
||
className="absolute right-1 top-1 inline-flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-sm font-semibold text-white transition hover:bg-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
onClick={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
onRemoveImage(view.key)
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-16 items-center justify-center gap-1 text-sm text-[#6b7075]">
|
||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||
<span>添加图片</span>
|
||
</div>
|
||
)}
|
||
</label>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-baseline justify-between gap-2">
|
||
<h4 className={fieldLabelClass}>其他图片</h4>
|
||
<span className={fieldHintClass}>可选 · 多张{otherImages.length > 0 ? ` · 已选 ${otherImages.length} 张` : ''}</span>
|
||
</div>
|
||
|
||
<input
|
||
id="white-bg-other-images"
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
disabled={uploading}
|
||
onChange={(event) => {
|
||
if (event.target.files && event.target.files.length > 0) {
|
||
onSelectOtherImages(event.target.files)
|
||
}
|
||
event.currentTarget.value = ''
|
||
}}
|
||
/>
|
||
|
||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 其他图片区需要拖拽上传 */}
|
||
<label
|
||
htmlFor="white-bg-other-images"
|
||
aria-disabled={uploading}
|
||
className={`block p-3 ${
|
||
uploading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
||
} ${isOtherDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||
onDragEnter={(event) => {
|
||
prepareFileDrag(event, uploading)
|
||
if (uploading) return
|
||
setIsOtherDragging(true)
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (hasDragLeftDropZone(event)) {
|
||
setIsOtherDragging(false)
|
||
}
|
||
}}
|
||
onDragOver={handleDragOver}
|
||
onDrop={handleOtherDrop}
|
||
>
|
||
{otherImages.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{otherImages.map((image) => (
|
||
<div key={image.id} className={uploadAreaTileClass} title={image.name}>
|
||
<Image
|
||
src={image.url}
|
||
alt={image.name}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={`删除${image.name}`}
|
||
disabled={uploading}
|
||
className="absolute right-1 top-1 inline-flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-sm font-semibold text-white transition hover:bg-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
onClick={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
onRemoveOtherImage(image.id)
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
<div className="flex h-20 w-20 items-center justify-center rounded-[8px] border border-dashed border-[#cbd5e1] bg-white text-sm text-[#1a1c1d]">
|
||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||
<span className="sr-only">继续添加</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex min-h-12 items-center gap-2 text-sm font-medium text-[#1a1c1d]">
|
||
<Plus className="h-5 w-5 text-[#6b7075]" aria-hidden="true" />
|
||
<span>添加其他图片,或拖入文件</span>
|
||
</div>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|