gongxiangp2/components/upload/BatchFileList.tsx
chenpei 752b7bf999 fix: 修复批量上传和视频扩展名问题
主要修复:

- 修复批量上传闭包陷阱,防止文件丢失

- 添加 fileName 参数传递原始文件名,保留正确扩展名

- 添加取消检查回调,改进批量上传取消功能

其他改进:

- 优化搜索功能和管理后台

- 添加 FRP 故障诊断文档

- 更新环境验证和网络检测
2026-02-03 11:12:50 +08:00

353 lines
16 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'
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
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
}
export default function BatchFileList({
batchFiles,
materialType,
categories,
scenes,
platforms,
productCategories,
renderer,
batchPreviewDragging,
onToggleExpand,
onDelete,
onUpdateProductModel,
onUpdatePreviewImage,
onRemovePreviewImage,
onUpdateCategory,
onUpdateScene,
onUpdatePlatform,
onUpdateProductCategory,
onUpdateRenderer,
onExitBatchMode,
onPreviewDragEnter,
onPreviewDragLeave,
}: BatchFileListProps) {
const handlePreviewDrop = (e: React.DragEvent, itemId: string, index: number) => {
e.preventDefault()
e.stopPropagation()
const files = Array.from(e.dataTransfer.files)
const imageFile = files.find(f => f.type.startsWith('image/'))
if (imageFile) {
onUpdatePreviewImage(index, imageFile)
}
}
const handlePreviewFileSelect = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
const file = e.target.files?.[0]
if (file) {
onUpdatePreviewImage(index, file)
}
}
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
className={`border-2 border-dashed rounded-apple h-20 flex items-center justify-center transition-colors 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) => {
e.preventDefault()
e.stopPropagation()
onPreviewDragEnter(item.id)
}}
onDragLeave={(e) => {
e.preventDefault()
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = e.clientX
const y = e.clientY
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
onPreviewDragLeave(item.id)
}
}}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onDrop={(e) => {
handlePreviewDrop(e, item.id, index)
onPreviewDragLeave(item.id)
}}
onClick={(e) => {
e.stopPropagation()
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()}
/>
<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>
)
}