gongxiangp2/components/upload/BatchFileList.tsx

429 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.

'use client'
import Image from 'next/image'
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 { 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-gray-700 dark:text-gray-300">
批量上传模式({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 rounded-apple border border-gray-200 p-3 dark:border-gray-700 sm:max-h-[65vh] sm:overflow-y-auto sm:p-4">
{batchFiles.map((item, index) => (
<div
key={item.id}
className={`rounded-apple overflow-hidden border ${
isVideoMaterialType(materialType) && !isPlausibleSpuProductModel(item.productModel)
? 'border-red-300 dark:border-red-700'
: 'border-gray-200 dark:border-gray-700'
}`}
>
{/* 文件行(可点击展开/收起) */}
<div
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
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="text-xs text-gray-500 dark:text-gray-400">
{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="p-4 bg-white dark:bg-gray-900 space-y-4 border-t border-gray-200 dark:border-gray-700">
{/* SPU型号 */}
<div>
<label htmlFor={`batch-product-model-${item.id}`} className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图片 *
</label>
{item.previewImageUrl ? (
<div className="relative w-20 h-20 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={item.previewImageUrl}
alt="Preview"
fill
unoptimized
className="object-cover"
/>
<button
type="button"
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onRemovePreviewImage(index)
}}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs disabled:cursor-not-allowed disabled:opacity-50"
>
×
</button>
</div>
) : (
<div
aria-disabled={disabled}
className={`border-2 border-dashed rounded-apple h-20 flex items-center justify-center transition-colors ${
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
} ${
batchPreviewDragging[item.id]
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
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)
}}
onClick={(e) => {
e.stopPropagation()
if (disabled) return
document.getElementById(`batch-preview-${item.id}`)?.click()
}}
>
<input
type="file"
accept="image/*"
onChange={(e) => handlePreviewFileSelect(e, index)}
className="hidden"
id={`batch-preview-${item.id}`}
onClick={(e) => e.stopPropagation()}
disabled={disabled}
/>
<span className="text-sm text-gray-500 dark:text-gray-400">拖拽或点击选择图片</span>
</div>
)}
</div>
)}
{/* 使用店铺和场景类别(如果不是自动通用类型) */}
{requiresCategoryAndScene(materialType) && (() => {
// 检查是否是自动识别的店铺
const spuResult = parseSPU(item.productModel)
const isShopAutoFilled = spuResult.shopMatched && spuResult.shopName === item.categoryName
return (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
{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="w-full px-4 py-2 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 disabled:cursor-not-allowed disabled:opacity-60"
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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
<select
value={item.sceneName}
onChange={(e) => onUpdateScene(index, e.target.value)}
disabled={disabled}
className="w-full px-4 py-2 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 disabled:cursor-not-allowed disabled:opacity-60"
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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
value={item.platformName}
onChange={(e) => onUpdatePlatform(index, e.target.value)}
disabled={disabled}
className="w-full px-4 py-2 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 disabled:cursor-not-allowed disabled:opacity-60"
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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类{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="w-full px-4 py-2 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 disabled:cursor-not-allowed disabled:opacity-60"
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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
渲染器 *
</label>
<select
value={item.renderer || renderer}
onChange={(e) => onUpdateRenderer(index, e.target.value)}
disabled={disabled}
className="w-full px-4 py-2 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 disabled:cursor-not-allowed disabled:opacity-60"
onClick={(e) => e.stopPropagation()}
>
<option value="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)
}