560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
/**
|
||
* 特殊批量上传UI组件
|
||
* 包含音乐、视频、3D场景、品牌VI、买家秀、小红书的批量上传界面
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import Image from 'next/image'
|
||
import { formatBytes } from '@/components/upload/upload-utils'
|
||
import { handleDropZoneKeyDown, hasDragLeftDropZone, prepareFileDrag } from './drag-drop-utils'
|
||
|
||
/**
|
||
* 音乐/视频/品牌VI 批量文件项
|
||
*/
|
||
export interface BatchFileWithSpu {
|
||
file: File
|
||
productModel: string
|
||
}
|
||
|
||
/**
|
||
* 3D场景批量文件项(带预览图)
|
||
*/
|
||
export interface Batch3DFile {
|
||
file: File
|
||
productModel: string
|
||
previewImage?: File
|
||
}
|
||
|
||
/**
|
||
* 买家秀/小红书批量文件项(多图)
|
||
*/
|
||
export interface BatchImageGroup {
|
||
productModel: string
|
||
images: File[]
|
||
}
|
||
|
||
// ========== 音乐批量上传列表 ==========
|
||
|
||
interface BatchMusicListProps {
|
||
files: BatchFileWithSpu[]
|
||
onUpdateProductModel: (index: number, value: string) => void
|
||
onDelete: (index: number) => void
|
||
}
|
||
|
||
export function BatchMusicList({ files, onUpdateProductModel, onDelete }: BatchMusicListProps) {
|
||
if (files.length === 0) return null
|
||
|
||
return (
|
||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 视频批量上传列表 ==========
|
||
|
||
interface BatchVideoFile extends BatchFileWithSpu {
|
||
productCategoryName?: string
|
||
}
|
||
|
||
interface BatchVideoListProps {
|
||
files: BatchVideoFile[]
|
||
productCategories: Array<{ id: string; name: string }>
|
||
onUpdateProductModel: (index: number, value: string) => void
|
||
onUpdateProductCategory?: (index: number, value: string) => void
|
||
onDelete: (index: number) => void
|
||
}
|
||
|
||
export function BatchVideoList({
|
||
files,
|
||
productCategories,
|
||
onUpdateProductModel,
|
||
onUpdateProductCategory,
|
||
onDelete
|
||
}: BatchVideoListProps) {
|
||
if (files.length === 0) return null
|
||
|
||
return (
|
||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex flex-col space-y-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
{/* 品类字段 */}
|
||
{productCategories.length > 0 && onUpdateProductCategory && (
|
||
<div>
|
||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||
品类(可选)
|
||
</label>
|
||
<select
|
||
value={item.productCategoryName || ''}
|
||
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
|
||
className="w-full px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
|
||
>
|
||
<option value="">不选择品类</option>
|
||
{productCategories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 3D场景批量上传列表 ==========
|
||
|
||
interface Batch3DListProps {
|
||
files: Batch3DFile[]
|
||
onUpdateProductModel: (index: number, value: string) => void
|
||
onUpdatePreviewImage: (index: number, file: File) => void
|
||
onDelete: (index: number) => void
|
||
}
|
||
|
||
export function Batch3DList({ files, onUpdateProductModel, onUpdatePreviewImage, onDelete }: Batch3DListProps) {
|
||
const [draggingPreviewIndex, setDraggingPreviewIndex] = useState<number | null>(null)
|
||
|
||
if (files.length === 0) return null
|
||
|
||
return (
|
||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
onUpdatePreviewImage(index, file)
|
||
}
|
||
e.currentTarget.value = ''
|
||
}}
|
||
className="hidden"
|
||
id={`3d-preview-${index}`}
|
||
/>
|
||
<label
|
||
htmlFor={`3d-preview-${index}`}
|
||
role="group"
|
||
tabIndex={0}
|
||
aria-label={`为第 ${index + 1} 个 3D 文件选择预览图`}
|
||
className={`rounded border border-dashed px-2 py-1 text-xs cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
|
||
draggingPreviewIndex === index
|
||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/20 dark:text-blue-300'
|
||
: 'border-transparent text-blue-600 dark:text-blue-400'
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
prepareFileDrag(event)
|
||
setDraggingPreviewIndex(index)
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (hasDragLeftDropZone(event) && draggingPreviewIndex === index) setDraggingPreviewIndex(null)
|
||
}}
|
||
onDragOver={prepareFileDrag}
|
||
onDrop={(event) => {
|
||
prepareFileDrag(event)
|
||
setDraggingPreviewIndex(null)
|
||
const file = event.dataTransfer.files?.[0]
|
||
if (file) onUpdatePreviewImage(index, file)
|
||
}}
|
||
onKeyDown={(event) =>
|
||
handleDropZoneKeyDown(event, () => document.getElementById(`3d-preview-${index}`)?.click())
|
||
}
|
||
>
|
||
{item.previewImage ? '已选预览图' : '拖拽或选择预览图'}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 品牌VI批量上传列表 ==========
|
||
|
||
interface BatchBrandVIListProps {
|
||
files: BatchFileWithSpu[]
|
||
onUpdateProductModel: (index: number, value: string) => void
|
||
onDelete: (index: number) => void
|
||
}
|
||
|
||
export function BatchBrandVIList({ files, onUpdateProductModel, onDelete }: BatchBrandVIListProps) {
|
||
if (files.length === 0) return null
|
||
|
||
return (
|
||
<div className="mb-4 space-y-3 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||
{item.file.name}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className="flex-1 px-3 py-1.5 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// ========== 买家秀批量上传UI ==========
|
||
|
||
interface BatchBuyerShowUIProps {
|
||
batches: BatchImageGroup[]
|
||
onUpdateProductModel: (batchIndex: number, value: string) => void
|
||
onImageSelect: (files: FileList | File[], batchIndex: number) => void
|
||
onRemoveImage: (batchIndex: number, imageIndex: number) => void
|
||
onDeleteBatch: (batchIndex: number) => void
|
||
onAddBatch: () => void
|
||
}
|
||
|
||
export function BatchBuyerShowUI({
|
||
batches,
|
||
onUpdateProductModel,
|
||
onImageSelect,
|
||
onRemoveImage,
|
||
onDeleteBatch,
|
||
onAddBatch,
|
||
}: BatchBuyerShowUIProps) {
|
||
const [draggingBatchIndex, setDraggingBatchIndex] = useState<number | null>(null)
|
||
const imageUrls = useBatchImagePreviewUrls(batches)
|
||
|
||
return (
|
||
<div className="mb-4 space-y-4">
|
||
{batches.map((batch, batchIndex) => (
|
||
<div
|
||
key={batchIndex}
|
||
role="group"
|
||
tabIndex={0}
|
||
aria-label={`买家秀批次 ${batchIndex + 1}`}
|
||
className={`rounded-apple border p-4 transition-colors bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
|
||
draggingBatchIndex === batchIndex
|
||
? 'border-blue-500 bg-blue-50/70 dark:border-blue-400 dark:bg-blue-900/20'
|
||
: 'border-gray-200 dark:border-gray-700'
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
prepareFileDrag(event)
|
||
setDraggingBatchIndex(batchIndex)
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (hasDragLeftDropZone(event) && draggingBatchIndex === batchIndex) setDraggingBatchIndex(null)
|
||
}}
|
||
onDragOver={prepareFileDrag}
|
||
onDrop={(event) => {
|
||
prepareFileDrag(event)
|
||
setDraggingBatchIndex(null)
|
||
onImageSelect(event.dataTransfer.files, batchIndex)
|
||
}}
|
||
onKeyDown={(event) =>
|
||
handleDropZoneKeyDown(event, () => document.getElementById(`buyer-show-images-${batchIndex}`)?.click())
|
||
}
|
||
>
|
||
<div className="flex items-center space-x-3 mb-3">
|
||
<input
|
||
type="text"
|
||
value={batch.productModel}
|
||
onChange={(e) => onUpdateProductModel(batchIndex, e.target.value)}
|
||
className="flex-1 px-3 py-2 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号 *"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => {
|
||
onImageSelect(e.target.files || [], batchIndex)
|
||
e.currentTarget.value = ''
|
||
}}
|
||
className="hidden"
|
||
id={`buyer-show-images-${batchIndex}`}
|
||
/>
|
||
<label
|
||
htmlFor={`buyer-show-images-${batchIndex}`}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
选择图片(至少8张)
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDeleteBatch(batchIndex)}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除批次
|
||
</button>
|
||
</div>
|
||
{batch.images.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||
{batch.images.map((img, imgIndex) => (
|
||
<div key={imgIndex} className="relative group">
|
||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={imageUrls[batchIndex]?.[imgIndex] || ''}
|
||
alt={`图片 ${imgIndex + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemoveImage(batchIndex, imgIndex)}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity text-xs"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{batch.images.length > 0 && batch.images.length < 8 && (
|
||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||
每批次至少需要8张图片(当前:{batch.images.length}张)
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={onAddBatch}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
+ 添加新批次
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 小红书批量上传UI ==========
|
||
|
||
interface BatchXiaohongshuUIProps {
|
||
batches: BatchImageGroup[]
|
||
onUpdateProductModel: (batchIndex: number, value: string) => void
|
||
onImageSelect: (files: FileList | File[], batchIndex: number) => void
|
||
onRemoveImage: (batchIndex: number, imageIndex: number) => void
|
||
onDeleteBatch: (batchIndex: number) => void
|
||
onAddBatch: () => void
|
||
}
|
||
|
||
export function BatchXiaohongshuUI({
|
||
batches,
|
||
onUpdateProductModel,
|
||
onImageSelect,
|
||
onRemoveImage,
|
||
onDeleteBatch,
|
||
onAddBatch,
|
||
}: BatchXiaohongshuUIProps) {
|
||
const [draggingBatchIndex, setDraggingBatchIndex] = useState<number | null>(null)
|
||
const imageUrls = useBatchImagePreviewUrls(batches)
|
||
|
||
return (
|
||
<div className="mb-4 space-y-4">
|
||
{batches.map((batch, batchIndex) => (
|
||
<div
|
||
key={batchIndex}
|
||
role="group"
|
||
tabIndex={0}
|
||
aria-label={`小红书批次 ${batchIndex + 1}`}
|
||
className={`rounded-apple border p-4 transition-colors bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500/40 ${
|
||
draggingBatchIndex === batchIndex
|
||
? 'border-blue-500 bg-blue-50/70 dark:border-blue-400 dark:bg-blue-900/20'
|
||
: 'border-gray-200 dark:border-gray-700'
|
||
}`}
|
||
onDragEnter={(event) => {
|
||
prepareFileDrag(event)
|
||
setDraggingBatchIndex(batchIndex)
|
||
}}
|
||
onDragLeave={(event) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (hasDragLeftDropZone(event) && draggingBatchIndex === batchIndex) setDraggingBatchIndex(null)
|
||
}}
|
||
onDragOver={prepareFileDrag}
|
||
onDrop={(event) => {
|
||
prepareFileDrag(event)
|
||
setDraggingBatchIndex(null)
|
||
onImageSelect(event.dataTransfer.files, batchIndex)
|
||
}}
|
||
onKeyDown={(event) =>
|
||
handleDropZoneKeyDown(event, () => document.getElementById(`xiaohongshu-images-${batchIndex}`)?.click())
|
||
}
|
||
>
|
||
<div className="flex items-center space-x-3 mb-3">
|
||
<input
|
||
type="text"
|
||
value={batch.productModel}
|
||
onChange={(e) => onUpdateProductModel(batchIndex, e.target.value)}
|
||
className="flex-1 px-3 py-2 text-sm rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||
placeholder="SPU型号 *"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => {
|
||
onImageSelect(e.target.files || [], batchIndex)
|
||
e.currentTarget.value = ''
|
||
}}
|
||
className="hidden"
|
||
id={`xiaohongshu-images-${batchIndex}`}
|
||
/>
|
||
<label
|
||
htmlFor={`xiaohongshu-images-${batchIndex}`}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
选择图片
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDeleteBatch(batchIndex)}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||
>
|
||
删除批次
|
||
</button>
|
||
</div>
|
||
{batch.images.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-2 mt-3">
|
||
{batch.images.map((img, imgIndex) => (
|
||
<div key={imgIndex} className="relative group">
|
||
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||
<Image
|
||
src={imageUrls[batchIndex]?.[imgIndex] || ''}
|
||
alt={`图片 ${imgIndex + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemoveImage(batchIndex, imgIndex)}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity text-xs"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={onAddBatch}
|
||
className="btn-secondary text-sm"
|
||
>
|
||
+ 添加新批次
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function useBatchImagePreviewUrls(batches: BatchImageGroup[]): string[][] {
|
||
const imageUrls = useMemo(
|
||
() => batches.map((batch) => batch.images.map((file) => URL.createObjectURL(file))),
|
||
[batches],
|
||
)
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
imageUrls.flat().forEach((url) => URL.revokeObjectURL(url))
|
||
}
|
||
}, [imageUrls])
|
||
|
||
return imageUrls
|
||
}
|