gongxiangp2/components/upload/BatchFileList.tsx

430 lines
19 KiB
TypeScript
Raw Permalink 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.

'use client'
import Select from '@/components/ui/Select'
import Image from 'next/image'
import { Plus } from 'lucide-react'
import type { BatchFileItem } from './upload-types'
import { PREVIEW_OPTIONAL_TYPES } from './upload-constants'
import { requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer } from '@/lib/material-validation'
import { formatBytes } from './upload-utils'
import { parseSPU } from '@/lib/spu-parser'
import { isVideoMaterialType } from '@/lib/video-material-types'
import VideoPreviewImageUpload from './VideoPreviewImageUpload'
import { dropZoneActiveClass, dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, uploadAreaEmptyButtonClass } from './upload-field-styles'
import { getSpuProductModelValidationError, isPlausibleSpuProductModel } from '@/lib/product-model'
import {
hasDragLeftDropZone,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils'
interface BatchFileListProps {
batchFiles: BatchFileItem[]
materialType: string
categories: Array<{ id: string; name: string }>
scenes: Array<{ id: string; name: string }>
platforms: Array<{ id: string; name: string }>
productCategories: Array<{ id: string; name: string }>
renderer: string
disabled?: boolean
batchPreviewDragging: Record<string, boolean>
onToggleExpand: (index: number) => void
onDelete: (index: number) => void
onUpdateProductModel: (index: number, value: string) => void
onUpdatePreviewImage: (index: number, file: File) => void | Promise<void>
onRemovePreviewImage: (index: number) => void
onUpdateCategory: (index: number, value: string) => void
onUpdateScene: (index: number, value: string) => void
onUpdatePlatform: (index: number, value: string) => void
onUpdateProductCategory: (index: number, value: string) => void
onUpdateRenderer: (index: number, value: string) => void
onExitBatchMode: () => void
onPreviewDragEnter: (itemId: string) => void
onPreviewDragLeave: (itemId: string) => void
onValidationError: (message: string) => void
onPreviewProcessingChange?: (processing: boolean) => void
}
export default function BatchFileList({
batchFiles,
materialType,
categories,
scenes,
platforms,
productCategories,
renderer,
disabled = false,
batchPreviewDragging,
onToggleExpand,
onDelete,
onUpdateProductModel,
onUpdatePreviewImage,
onRemovePreviewImage,
onUpdateCategory,
onUpdateScene,
onUpdatePlatform,
onUpdateProductCategory,
onUpdateRenderer,
onExitBatchMode,
onPreviewDragEnter,
onPreviewDragLeave,
onValidationError,
onPreviewProcessingChange,
}: BatchFileListProps) {
const handlePreviewDrop = (e: React.DragEvent, index: number) => {
prepareFileDrag(e, disabled)
if (disabled) {
onValidationError('文件正在上传,请等待完成后再更换预览图')
return
}
const { acceptedFiles, errors } = validateImageFiles(e.dataTransfer.files, { maxCount: 1 })
onValidationError(errors.join(';'))
if (acceptedFiles[0]) {
onUpdatePreviewImage(index, acceptedFiles[0])
}
}
const handlePreviewFileSelect = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
const file = e.target.files?.[0]
if (disabled) {
onValidationError('文件正在上传,请等待完成后再更换预览图')
} else if (file) {
const { acceptedFiles, errors } = validateImageFiles([file], { maxCount: 1 })
onValidationError(errors.join(';'))
if (acceptedFiles[0]) {
onUpdatePreviewImage(index, acceptedFiles[0])
}
}
e.currentTarget.value = ''
}
return (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-[#1a1c1d]">
批量上传模式({batchFiles.length} 个文件)
</p>
<button
type="button"
onClick={onExitBatchMode}
disabled={disabled}
className="text-sm text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
>
退出批量模式
</button>
</div>
<div className="space-y-2 overflow-visible sm:max-h-[65vh] sm:overflow-y-auto">
{batchFiles.map((item, index) => (
<div
key={item.id}
className={`rounded-[8px] overflow-hidden border ${
isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)
? 'border-red-300 dark:border-red-700'
: 'border-[#e7e8ea]'
}`}
>
{/* 文件行(可点击展开/收起) */}
<div
className="flex items-center justify-between bg-[#fafafa] p-3 cursor-pointer transition-colors hover:bg-[#f3f4f5]"
onClick={() => onToggleExpand(index)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${item.expanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<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={fieldHintClass}>
{formatBytes(item.file.size)}{' '}
{item.productModel
? `· SPU: ${item.productModel}`
: isVideoMaterialType(materialType) && '· 未识别到 SPU,请填写'}
</p>
</div>
</div>
<button
type="button"
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onDelete(index)
}}
className="ml-2 text-sm text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:text-red-300"
>
删除
</button>
</div>
{/* 展开的配置区域 */}
{item.expanded && (
<div className="space-y-4 border-t border-[#e7e8ea] bg-white p-3">
{/* SPU型号 */}
<div>
<label htmlFor={`batch-product-model-${item.id}`} className={fieldLabelClass}>
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
</label>
<input
id={`batch-product-model-${item.id}`}
type="text"
value={item.productModel}
onChange={(e) => onUpdateProductModel(index, e.target.value)}
disabled={disabled}
maxLength={100}
aria-invalid={isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)}
aria-describedby={
isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)
? `batch-product-model-hint-${item.id}`
: undefined
}
className={`w-full px-4 py-2 rounded-apple border dark:bg-gray-700 dark:text-white outline-none disabled:cursor-not-allowed disabled:opacity-60 ${
isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)
? 'border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-100 dark:border-red-600 dark:focus:ring-red-900'
: 'border-gray-300 focus:border-gray-900 focus:ring-2 focus:ring-gray-200 dark:border-gray-600 dark:focus:border-gray-400 dark:focus:ring-gray-600'
}`}
placeholder="例如:DF-A3DZ0008"
onClick={(e) => e.stopPropagation()}
/>
{isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel) && (
<p
id={`batch-product-model-hint-${item.id}`}
className="mt-2 text-sm text-red-600 dark:text-red-400"
>
{item.productModel
? getSpuProductModelValidationError(item.productModel)
: '文件名不是完整、合规的 SPU,请填写真实型号;系统不会从文件名中截取或改写 SPU。'}
</p>
)}
</div>
{/* 视频预览图选填;其它需要预览图的素材保持必填。 */}
{isVideoMaterialType(materialType) ? (
<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}
/>
) : !PREVIEW_OPTIONAL_TYPES.includes(materialType) && materialType !== '详情模板' && (
<div>
<label className={fieldLabelClass}>
预览图片 *
</label>
{item.previewImageUrl ? (
<div className="relative h-20 w-20 overflow-hidden rounded-[8px] border border-[#e7e8ea]">
<Image
src={item.previewImageUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
<button
type="button"
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onRemovePreviewImage(index)
}}
aria-label={`移除文件 ${index + 1} 的预览图`}
className="absolute right-1 top-1 flex h-7 w-7 items-center justify-center rounded-[6px] bg-[#1a1c1d]/80 text-white text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
>
×
</button>
</div>
) : (
<div
aria-disabled={disabled}
className={`p-3 ${disabled ? 'cursor-not-allowed opacity-60' : ''} ${batchPreviewDragging[item.id] ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragEnter={(e) => {
prepareFileDrag(e, disabled)
if (disabled) return
onPreviewDragEnter(item.id)
}}
onDragLeave={(e) => {
e.preventDefault()
e.stopPropagation()
if (hasDragLeftDropZone(e)) {
onPreviewDragLeave(item.id)
}
}}
onDragOver={(e) => prepareFileDrag(e, disabled)}
onDrop={(e) => {
handlePreviewDrop(e, index)
onPreviewDragLeave(item.id)
}}
>
<input
type="file"
accept="image/*"
onChange={(e) => handlePreviewFileSelect(e, index)}
className="hidden"
id={`batch-preview-${item.id}`}
onClick={(e) => e.stopPropagation()}
disabled={disabled}
/>
<button
type="button"
disabled={disabled}
onClick={() => document.getElementById(`batch-preview-${item.id}`)?.click()}
className={uploadAreaEmptyButtonClass}
>
<Plus className="h-5 w-5" aria-hidden="true" />
添加预览图片
</button>
</div>
)}
</div>
)}
{/* 使用店铺和场景类别(如果不是自动通用类型) */}
{requiresCategoryAndScene(materialType) && (() => {
// 检查是否是自动识别的店铺
const spuResult = parseSPU(item.productModel)
const isShopAutoFilled = spuResult.shopMatched && spuResult.shopName === item.categoryName
return (
<>
<div>
<label className={fieldLabelClass}>
使用店铺 *
{isShopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<Select
value={item.categoryName}
onChange={(e) => onUpdateCategory(index, e.target.value)}
disabled={disabled}
className={fieldControlClass}
onClick={(e) => e.stopPropagation()}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</Select>
</div>
<div>
<label className={fieldLabelClass}>
场景类别 *
</label>
<Select
value={item.sceneName}
onChange={(e) => onUpdateScene(index, e.target.value)}
disabled={disabled}
className={fieldControlClass}
onClick={(e) => e.stopPropagation()}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</Select>
</div>
</>
)})()}
{/* 平台分类(可选,仅在后台配置了平台时显示,标准模型不显示) */}
{platforms.length > 0 && materialType !== '标准模型' && (
<div>
<label className={fieldLabelClass}>
平台分类(可选)
</label>
<Select
value={item.platformName}
onChange={(e) => onUpdatePlatform(index, e.target.value)}
disabled={disabled}
className={fieldControlClass}
onClick={(e) => e.stopPropagation()}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>
{platform.name}
</option>
))}
</Select>
</div>
)}
{/* 品类分类(标准模型必填,其他类型可选) */}
{productCategories.length > 0 && (() => {
// 检查是否是自动识别的品类
const spuResult = parseSPU(item.productModel)
const isCategoryAutoFilled = spuResult.categoryMatched && spuResult.categoryName === item.productCategoryName
return (
<div>
<label className={fieldLabelClass}>
品类{requiresProductCategoryName(materialType) ? ' *' : '(可选)'}
{isCategoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<Select
value={item.productCategoryName}
onChange={(e) => onUpdateProductCategory(index, e.target.value)}
disabled={disabled}
className={fieldControlClass}
onClick={(e) => e.stopPropagation()}
>
<option value="">{requiresProductCategoryName(materialType) ? '请选择品类' : '不选择品类'}</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</Select>
</div>
)})()}
{/* 渲染器(仅3D场景显示) */}
{materialType === '3D场景' && (
<div>
<label className={fieldLabelClass}>
渲染器 *
</label>
<Select
value={item.renderer || renderer}
onChange={(e) => onUpdateRenderer(index, e.target.value)}
disabled={disabled}
className={fieldControlClass}
onClick={(e) => e.stopPropagation()}
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</Select>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)
}