496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
/**
|
||
* 特殊批量上传UI组件
|
||
* 包含音乐、视频、3D场景、品牌VI、买家秀、小红书的批量上传界面
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import Image from 'next/image'
|
||
import { formatBytes } from '@/components/upload/upload-utils'
|
||
import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model'
|
||
import VideoPreviewImageUpload from './VideoPreviewImageUpload'
|
||
import { fieldControlClass, fieldHintClass, fieldLabelClass, uploadAreaButtonClass, uploadAreaTileClass } from './upload-field-styles'
|
||
|
||
/**
|
||
* 音乐/视频/品牌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="space-y-2 max-h-[65vh] overflow-y-auto">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex flex-wrap items-center gap-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||
{item.file.name}
|
||
</p>
|
||
<p className={fieldHintClass}>
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 视频批量上传列表 ==========
|
||
|
||
export interface BatchVideoFile extends BatchFileWithSpu {
|
||
productCategoryName?: string
|
||
previewImage?: File
|
||
previewImageUrl?: string
|
||
}
|
||
|
||
interface BatchVideoListProps {
|
||
files: BatchVideoFile[]
|
||
productCategories: Array<{ id: string; name: string }>
|
||
onUpdateProductModel: (index: number, value: string) => void
|
||
onUpdateProductCategory?: (index: number, value: string) => void
|
||
onUpdatePreviewImage: (index: number, file: File) => void | Promise<void>
|
||
onRemovePreviewImage: (index: number) => void
|
||
onValidationError?: (message: string) => void
|
||
onPreviewProcessingChange?: (processing: boolean) => void
|
||
disabled?: boolean
|
||
onDelete: (index: number) => void
|
||
}
|
||
|
||
export function BatchVideoList({
|
||
files,
|
||
productCategories,
|
||
onUpdateProductModel,
|
||
onUpdateProductCategory,
|
||
onUpdatePreviewImage,
|
||
onRemovePreviewImage,
|
||
onValidationError = () => undefined,
|
||
onPreviewProcessingChange,
|
||
disabled = false,
|
||
onDelete
|
||
}: BatchVideoListProps) {
|
||
if (files.length === 0) return null
|
||
|
||
return (
|
||
<div className="space-y-2 overflow-visible sm:max-h-[65vh] sm:overflow-y-auto">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex flex-col gap-2 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="flex flex-wrap items-start gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||
{item.file.name}
|
||
</p>
|
||
<p className={fieldHintClass}>
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<input
|
||
aria-label={`SPU型号:${item.file.name}`}
|
||
aria-invalid={!isPlausibleSpuProductModel(item.productModel)}
|
||
aria-describedby={!isPlausibleSpuProductModel(item.productModel) ? `batch-video-spu-error-${index}` : undefined}
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
disabled={disabled}
|
||
className={`w-full rounded-[8px] border bg-white px-4 py-2.5 text-[14px] text-[#1a1c1d] outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||
!isPlausibleSpuProductModel(item.productModel)
|
||
? 'border-[#b42318] focus:border-[#b42318]'
|
||
: 'border-[#e7e8ea] focus:border-[#1a1c1d]'
|
||
}`}
|
||
placeholder="SPU型号"
|
||
/>
|
||
{!isPlausibleSpuProductModel(item.productModel) && (
|
||
<p
|
||
id={`batch-video-spu-error-${index}`}
|
||
className="mt-1 text-[12px] text-[#b42318]"
|
||
role="alert"
|
||
>
|
||
{getSpuProductModelValidationError(item.productModel)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => onDelete(index)}
|
||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
{/* 品类字段 */}
|
||
{productCategories.length > 0 && onUpdateProductCategory && (
|
||
<div>
|
||
<label htmlFor={`batch-video-category-${index}`} className={fieldLabelClass}>
|
||
品类(可选)
|
||
</label>
|
||
<select
|
||
id={`batch-video-category-${index}`}
|
||
value={item.productCategoryName || ''}
|
||
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
|
||
disabled={disabled}
|
||
className={fieldControlClass}
|
||
>
|
||
<option value="">不选择品类</option>
|
||
{productCategories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<VideoPreviewImageUpload
|
||
previewUrl={item.previewImageUrl}
|
||
fileName={item.previewImage?.name}
|
||
fileSize={item.previewImage?.size}
|
||
disabled={disabled}
|
||
compact
|
||
onFileSelect={(file) => onUpdatePreviewImage(index, file)}
|
||
onRemove={() => onRemovePreviewImage(index)}
|
||
onValidationError={onValidationError}
|
||
onProcessingChange={onPreviewProcessingChange}
|
||
/>
|
||
</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) {
|
||
if (files.length === 0) return null
|
||
|
||
return (
|
||
<div className="space-y-2 max-h-[65vh] overflow-y-auto">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex flex-wrap items-center gap-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||
{item.file.name}
|
||
</p>
|
||
<p className={fieldHintClass}>
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||
placeholder="SPU型号"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
onUpdatePreviewImage(index, file)
|
||
}
|
||
}}
|
||
className="hidden"
|
||
id={`3d-preview-${index}`}
|
||
/>
|
||
<label
|
||
htmlFor={`3d-preview-${index}`}
|
||
className={`${uploadAreaButtonClass} cursor-pointer border border-[#e7e8ea] bg-white`}
|
||
>
|
||
{item.previewImage ? '已选预览图' : '选择预览图'}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
删除
|
||
</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="space-y-2 max-h-[65vh] overflow-y-auto">
|
||
{files.map((item, index) => (
|
||
<div key={index} className="flex flex-wrap items-center gap-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||
{item.file.name}
|
||
</p>
|
||
<p className={fieldHintClass}>
|
||
{formatBytes(item.file.size)}
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||
placeholder="SPU型号"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDelete(index)}
|
||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
// ========== 买家秀批量上传UI ==========
|
||
|
||
interface BatchBuyerShowUIProps {
|
||
batches: BatchImageGroup[]
|
||
onUpdateProductModel: (batchIndex: number, value: string) => void
|
||
onImageSelect: (e: React.ChangeEvent<HTMLInputElement>, 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) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{batches.map((batch, batchIndex) => (
|
||
<div key={batchIndex} className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={batch.productModel}
|
||
onChange={(e) => onUpdateProductModel(batchIndex, e.target.value)}
|
||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||
placeholder="SPU型号 *"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => onImageSelect(e, batchIndex)}
|
||
className="hidden"
|
||
id={`buyer-show-images-${batchIndex}`}
|
||
/>
|
||
<label
|
||
htmlFor={`buyer-show-images-${batchIndex}`}
|
||
className={`${uploadAreaButtonClass} cursor-pointer border border-[#e7e8ea] bg-white`}
|
||
>
|
||
选择图片(至少8张)
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDeleteBatch(batchIndex)}
|
||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
删除批次
|
||
</button>
|
||
</div>
|
||
{batch.images.length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{batch.images.map((img, imgIndex) => (
|
||
<div key={imgIndex} className="relative group">
|
||
<div className={uploadAreaTileClass}>
|
||
<Image
|
||
src={URL.createObjectURL(img)}
|
||
alt={`图片 ${imgIndex + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemoveImage(batchIndex, imgIndex)}
|
||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
aria-label={`移除图片 ${imgIndex + 1}`}
|
||
>
|
||
×
|
||
</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={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
|
||
>
|
||
+ 添加新批次
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 小红书批量上传UI ==========
|
||
|
||
interface BatchXiaohongshuUIProps {
|
||
batches: BatchImageGroup[]
|
||
onUpdateProductModel: (batchIndex: number, value: string) => void
|
||
onImageSelect: (e: React.ChangeEvent<HTMLInputElement>, 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) {
|
||
return (
|
||
<div className="space-y-2">
|
||
{batches.map((batch, batchIndex) => (
|
||
<div key={batchIndex} className="rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||
<input
|
||
type="text"
|
||
value={batch.productModel}
|
||
onChange={(e) => onUpdateProductModel(batchIndex, e.target.value)}
|
||
className={`min-w-36 flex-1 ${fieldControlClass}`}
|
||
placeholder="SPU型号 *"
|
||
/>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => onImageSelect(e, batchIndex)}
|
||
className="hidden"
|
||
id={`xiaohongshu-images-${batchIndex}`}
|
||
/>
|
||
<label
|
||
htmlFor={`xiaohongshu-images-${batchIndex}`}
|
||
className={`${uploadAreaButtonClass} cursor-pointer border border-[#e7e8ea] bg-white`}
|
||
>
|
||
选择图片
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => onDeleteBatch(batchIndex)}
|
||
className="text-sm text-[#b42318] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
删除批次
|
||
</button>
|
||
</div>
|
||
{batch.images.length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{batch.images.map((img, imgIndex) => (
|
||
<div key={imgIndex} className="relative group">
|
||
<div className={uploadAreaTileClass}>
|
||
<Image
|
||
src={URL.createObjectURL(img)}
|
||
alt={`图片 ${imgIndex + 1}`}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemoveImage(batchIndex, imgIndex)}
|
||
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
aria-label={`移除图片 ${imgIndex + 1}`}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={onAddBatch}
|
||
className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}
|
||
>
|
||
+ 添加新批次
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|