324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useMemo, useState } from 'react'
|
||
import Image from 'next/image'
|
||
import {
|
||
STANDARD_MODEL_WHITE_BG_VIEWS,
|
||
type StandardModelWhiteBgKey,
|
||
} from '@/lib/standard-model-white-background'
|
||
|
||
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) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
setDraggingKey(key)
|
||
}
|
||
|
||
const handleDragLeave = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
|
||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const { clientX, clientY } = event
|
||
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
|
||
if (draggingKey === key) {
|
||
setDraggingKey(null)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleDragOver = (event: React.DragEvent) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
event.dataTransfer.dropEffect = 'copy'
|
||
}
|
||
|
||
const handleDrop = (event: React.DragEvent, key: StandardModelWhiteBgKey) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
setDraggingKey(null)
|
||
|
||
const file = event.dataTransfer.files?.[0]
|
||
if (!file) {
|
||
return
|
||
}
|
||
|
||
onSelectImage(key, file)
|
||
}
|
||
|
||
const handleOtherDrop = (event: React.DragEvent) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
setIsOtherDragging(false)
|
||
|
||
const files = event.dataTransfer.files
|
||
if (!files || files.length === 0) {
|
||
return
|
||
}
|
||
|
||
onSelectOtherImages(files)
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||
固定角度 {uploadedRequiredCount} / {STANDARD_MODEL_WHITE_BG_VIEWS.length}
|
||
</span>
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||
可分次补传
|
||
</span>
|
||
{otherImages.length > 0 && (
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||
其他图片 {otherImages.length} 张
|
||
</span>
|
||
)}
|
||
{uploading && (
|
||
<span className="inline-flex items-center rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||
上传处理中
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/30 dark:text-gray-300">
|
||
{missingLabels.length > 0
|
||
? `未上传:${missingLabels.join('、')}(可先保存,后续再补)`
|
||
: '8 张固定角度已齐,可直接保存。'}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-5">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">固定角度</h4>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">可选,建议补齐 8 张</span>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 xl: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} className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs font-medium text-gray-700 dark:text-gray-200">{view.label}</p>
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||
可选
|
||
</span>
|
||
</div>
|
||
|
||
<input
|
||
id={inputId}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
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}
|
||
className={`group block cursor-pointer rounded-2xl border-2 border-dashed p-3 transition-colors ${
|
||
isDragging
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: imageUrl
|
||
? 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/30'
|
||
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/50 hover:border-gray-400 dark:hover:border-gray-500'
|
||
}`}
|
||
onDragEnter={(event) => handleDragEnter(event, view.key)}
|
||
onDragLeave={(event) => handleDragLeave(event, view.key)}
|
||
onDragOver={handleDragOver}
|
||
onDrop={(event) => handleDrop(event, view.key)}
|
||
>
|
||
{imageUrl ? (
|
||
<div className="space-y-2">
|
||
<div className="relative h-36 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||
<Image
|
||
src={imageUrl}
|
||
alt={view.label}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={`删除${view.label}`}
|
||
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
|
||
onClick={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
onRemoveImage(view.key)
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<p className="text-[11px] text-gray-500 dark:text-gray-400">点击或拖拽可重新上传</p>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-36 flex-col items-center justify-center rounded-xl bg-white text-center dark:bg-gray-800">
|
||
<svg className="h-6 w-6 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
<span className="mt-2 text-xs text-gray-500 dark:text-gray-400">拖拽或点击上传</span>
|
||
</div>
|
||
)}
|
||
</label>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 border-t border-gray-200 pt-5 dark:border-gray-700">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">其他图片</h4>
|
||
<span className="text-xs text-gray-500 dark:text-gray-400">可选,多张</span>
|
||
</div>
|
||
|
||
<input
|
||
id="white-bg-other-images"
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
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"
|
||
className={`block min-h-28 cursor-pointer rounded-2xl border-2 border-dashed px-4 py-5 transition-colors ${
|
||
isOtherDragging
|
||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||
: 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800/40 hover:border-gray-400 dark:hover:border-gray-500'
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
setIsOtherDragging(true)
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const { clientX, clientY } = event
|
||
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
|
||
setIsOtherDragging(false)
|
||
}
|
||
}}
|
||
onDragOver={handleDragOver}
|
||
onDrop={handleOtherDrop}
|
||
>
|
||
{otherImages.length > 0 ? (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||
{otherImages.map((image) => (
|
||
<div key={image.id} className="rounded-2xl border border-gray-200 bg-white/80 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||
<div className="relative h-32 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||
<Image
|
||
src={image.url}
|
||
alt={image.name}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={`删除${image.name}`}
|
||
className="absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-sm font-semibold text-white transition hover:bg-black/70"
|
||
onClick={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
onRemoveOtherImage(image.id)
|
||
}}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<p className="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">{image.name}</p>
|
||
</div>
|
||
))}
|
||
<div className="flex h-full min-h-[10rem] flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 bg-white/70 px-4 text-center dark:border-gray-600 dark:bg-gray-800/50">
|
||
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
<span className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">继续添加</span>
|
||
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">拖拽或点击追加</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex min-h-28 flex-col items-center justify-center text-center">
|
||
<svg className="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
<span className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">拖拽图片到这里,或点击追加</span>
|
||
<span className="mt-1 text-xs text-gray-500 dark:text-gray-400">支持一次添加多张其他图片</span>
|
||
</div>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|