gongxiangp2/components/upload/SpecialBatchUploadUI.tsx

560 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 特殊批量上传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
}