371 lines
17 KiB
TypeScript
371 lines
17 KiB
TypeScript
'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 {
|
||
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
|
||
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
|
||
}
|
||
|
||
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,
|
||
}: 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}
|
||
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||
>
|
||
退出批量模式
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2 max-h-[65vh] overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-apple p-4">
|
||
{batchFiles.map((item, index) => (
|
||
<div key={item.id} className="border border-gray-200 dark:border-gray-700 rounded-apple overflow-hidden">
|
||
{/* 文件行(可点击展开/收起) */}
|
||
<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}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onDelete(index)
|
||
}}
|
||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm ml-2"
|
||
>
|
||
删除
|
||
</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 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{materialType === '材质' ? '材质名称 *' : 'SPU型号 *'}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={item.productModel}
|
||
onChange={(e) => onUpdateProductModel(index, e.target.value)}
|
||
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"
|
||
placeholder="例如:XY-2024-001"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
|
||
{/* 预览图片(如果需要) */}
|
||
{!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"
|
||
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"
|
||
>
|
||
×
|
||
</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)}
|
||
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"
|
||
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)}
|
||
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"
|
||
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)}
|
||
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"
|
||
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)}
|
||
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"
|
||
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)}
|
||
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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<option value="CR">CR</option>
|
||
<option value="VR">VR</option>
|
||
<option value="其它">其它</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|