gongxiangp2/components/upload/SpecialBatchUploadUI.tsx

496 lines
18 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 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>
)
}