gongxiangp2/components/upload/VideoScreenshotUploader.tsx

578 lines
19 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 { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import CompactImageFileGrid from './CompactImageFileGrid';
import { dropZoneActiveClass, dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass } from './upload-field-styles';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
interface AssetFile {
file: File;
preview: string;
sortOrder: number;
}
interface CategoryItem {
id: string;
name: string;
}
interface VideoScreenshotUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: CategoryItem[];
scenes: CategoryItem[];
platforms: CategoryItem[];
productCategories: CategoryItem[];
onRefreshCategories: () => void;
onRefreshScenes: () => void;
}
export default function VideoScreenshotUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: VideoScreenshotUploaderProps) {
const [taskNo, setTaskNo] = useState('');
const [dingtalkApprovalNo, setDingtalkApprovalNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 视频截图列表(后端会自动压缩为zip并生成预览图)
const [screenshots, setScreenshots] = useState<AssetFile[]>([]);
const [isScreenshotDragging, setIsScreenshotDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [processingStep, setProcessingStep] = useState('');
const [error, setError] = useState('');
// 文件输入引用
const screenshotInputRef = useRef<HTMLInputElement>(null);
// 新建店铺/场景输入状态
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [showNewSceneInput, setShowNewSceneInput] = useState(false);
const [newSceneName, setNewSceneName] = useState('');
// SPU 自动识别状态
const [shopAutoFilled, setShopAutoFilled] = useState(false);
const [categoryAutoFilled, setCategoryAutoFilled] = useState(false);
const lastParsedSPU = useRef<string>('');
// SPU 变化时自动解析并填充店铺和品类
useEffect(() => {
if (productModel === lastParsedSPU.current) return;
lastParsedSPU.current = productModel;
if (!productModel) {
setShopAutoFilled(false);
setCategoryAutoFilled(false);
return;
}
const result = parseSPU(productModel);
// 自动填充店铺
if (result.shopMatched && result.shopName) {
const shopExists = categories.some(c => c.name === result.shopName);
if (shopExists && categoryName !== result.shopName) {
setCategoryName(result.shopName);
setShopAutoFilled(true);
} else {
setShopAutoFilled(false);
}
} else {
setShopAutoFilled(false);
}
// 自动填充品类
if (result.categoryMatched && result.categoryName) {
const catExists = productCategories.some(c => c.name === result.categoryName);
if (catExists && productCategoryName !== result.categoryName) {
setProductCategoryName(result.categoryName);
setCategoryAutoFilled(true);
} else {
setCategoryAutoFilled(false);
}
} else {
setCategoryAutoFilled(false);
}
}, [productModel, categories, productCategories, categoryName, productCategoryName]);
// 验证任务号格式
const validateTaskNo = (taskNo: string): boolean => {
if (!taskNo) return true;
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(taskNo);
};
// 处理添加新店铺
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) return;
onRefreshCategories();
setCategoryName(newCategoryName.trim());
setNewCategoryName('');
setShowNewCategoryInput(false);
};
// 处理添加新场景
const handleAddNewScene = async () => {
if (!newSceneName.trim()) return;
onRefreshScenes();
setSceneName(newSceneName.trim());
setNewSceneName('');
setShowNewSceneInput(false);
};
// 处理截图文件选择(可多选)
const handleScreenshotSelect = (files: FileList | null) => {
if (!files) return;
if (uploading) {
setError('文件正在上传,请等待完成后再添加图片');
return;
}
const currentList = screenshots;
const { acceptedFiles, errors } = validateImageFiles(files, {
maxBytes: 100 * 1024 * 1024,
});
const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({
file,
preview: URL.createObjectURL(file),
sortOrder: currentList.length + index,
}));
if (newFiles.length > 0) {
setScreenshots([...currentList, ...newFiles]);
}
setError(errors.join(';'));
};
// 删除截图
const removeScreenshot = (index: number) => {
const newList = screenshots.filter((_, i) => i !== index);
URL.revokeObjectURL(screenshots[index].preview);
setScreenshots(newList.map((item, i) => ({ ...item, sortOrder: i })));
};
// 拖拽处理
const handleDragOver = (e: React.DragEvent) => {
prepareFileDrag(e, uploading);
if (uploading) return;
setIsScreenshotDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (hasDragLeftDropZone(e)) {
setIsScreenshotDragging(false);
}
};
const handleDrop = (e: React.DragEvent) => {
prepareFileDrag(e, uploading);
setIsScreenshotDragging(false);
if (uploading) {
setError('文件正在上传,请等待完成后再添加图片');
return;
}
handleScreenshotSelect(e.dataTransfer.files);
};
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 表单验证
if (taskNo && !validateTaskNo(taskNo)) {
setError('任务号格式错误');
return;
}
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
if (dingtalkApprovalNoError) {
setError(dingtalkApprovalNoError);
return;
}
if (!productModel.trim()) {
setError('请输入产品型号(SPU)');
return;
}
if (!categoryName.trim()) {
setError('请选择使用店铺');
return;
}
if (!sceneName.trim()) {
setError('请选择场景类别');
return;
}
if (screenshots.length === 0) {
setError('请至少上传1张视频截图');
return;
}
setUploading(true);
setUploadProgress(0);
setProcessingStep('');
setError('');
try {
// 步骤1: 初始化素材记录(10%)
setProcessingStep('初始化上传...');
const initResponse = await fetch('/api/materials/upload/video-screenshot/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
productModel,
categoryName,
sceneName,
platformName,
productCategoryName,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}));
throw new Error(errorData.error || '初始化失败');
}
const { materialId } = await initResponse.json();
setUploadProgress(10);
// 步骤2: 上传截图(10% -> 80%)
setProcessingStep('上传截图中...');
const { chunkUpload } = await import('@/lib/chunk-upload');
const screenshotAssets: Array<{ uploadId: string; fileName: string; fileSize: number }> = [];
for (let i = 0; i < screenshots.length; i++) {
const asset = screenshots[i];
try {
const result = await chunkUpload(asset.file, {
materialType: '视频截图',
fileName: asset.file.name,
}, {
// chunkSize使用默认配置:内网20MB,公网10MB
skipComplete: true,
onProgress: (progress) => {
const fileProgress = (i + progress / 100) / screenshots.length;
const totalProgress = 10 + fileProgress * 70;
setUploadProgress(Math.round(totalProgress));
},
});
screenshotAssets.push({
uploadId: result.uploadId,
fileName: asset.file.name,
fileSize: asset.file.size,
});
} catch (error) {
console.error(`[Upload] Failed to upload screenshot ${i}:`, error);
throw new Error(`上传截图 ${asset.file.name} 失败`);
}
}
setUploadProgress(80);
// 步骤3: 完成上传(80% -> 95%)
// 后端会:1. 打包截图为zip 2. 为每张截图生成预览图
setProcessingStep('打包文件中...');
const completeResponse = await fetch('/api/materials/upload/video-screenshot/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
taskNo: taskNo || undefined,
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
assets: {
preview: [], // 不传预览图,后端自动生成
main: screenshotAssets.map((m, index) => ({
uploadId: m.uploadId,
fileName: m.fileName,
fileSize: m.fileSize,
sortOrder: index,
})),
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
throw new Error(errorData.error || '完成上传失败');
}
setUploadProgress(95);
setProcessingStep('生成预览图中...');
// 短暂延迟以显示处理完成状态
await new Promise(resolve => setTimeout(resolve, 300));
setUploadProgress(100);
setProcessingStep('完成!');
// 清理预览URL
screenshots.forEach(img => URL.revokeObjectURL(img.preview));
setTimeout(() => {
setProcessingStep('');
onSuccess();
}, 500);
} catch (err: unknown) {
console.error('[Video Screenshot Upload Error]', err);
setError(err instanceof Error ? err.message : '上传失败,请重试');
setProcessingStep('');
setUploading(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-5">
<TaskReferenceFields
idPrefix="video-screenshot"
taskNo={taskNo}
onTaskNoChange={setTaskNo}
dingtalkApprovalNo={dingtalkApprovalNo}
onDingtalkApprovalNoChange={setDingtalkApprovalNo}
disabled={uploading}
/>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className={fieldLabelClass}>
SPU型号 *
</label>
<input
id="productModel"
type="text"
value={productModel}
onChange={(e) => {
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase();
setProductModel(value);
}}
placeholder="例如:XY-2024-001"
className={fieldControlClass}
disabled={uploading}
/>
</div>
{/* 品类分类 */}
{productCategories.length > 0 && (
<div>
<label htmlFor="productCategoryName" className={fieldLabelClass}>
品类(可选)
{categoryAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => {
setProductCategoryName(e.target.value);
setCategoryAutoFilled(false);
}}
className={fieldControlClass}
disabled={uploading}
>
<option value="">不选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
</div>
)}
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className={fieldLabelClass}>
使用店铺 *
{shopAutoFilled && (
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
✓ 自动识别
</span>
)}
</label>
{showNewCategoryInput ? (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className={`flex-1 ${fieldControlClass}`}
placeholder="输入新店铺名称"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewCategory} className={inkButtonClass} disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewCategoryInput(false); setNewCategoryName(''); }} className={outlineButtonClass} disabled={uploading}>取消</button>
</div>
) : (
<div className="flex gap-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => {
setCategoryName(e.target.value);
setShopAutoFilled(false);
}}
className={`flex-1 ${fieldControlClass}`}
disabled={uploading}
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>{category.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewCategoryInput(true)} className={`${outlineButtonClass} whitespace-nowrap`} disabled={uploading}>+ 新建店铺</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className={fieldLabelClass}>
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex gap-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className={`flex-1 ${fieldControlClass}`}
placeholder="输入新场景类别"
disabled={uploading}
/>
<button type="button" onClick={handleAddNewScene} className={inkButtonClass} disabled={uploading}>添加</button>
<button type="button" onClick={() => { setShowNewSceneInput(false); setNewSceneName(''); }} className={outlineButtonClass} disabled={uploading}>取消</button>
</div>
) : (
<div className="flex gap-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className={`flex-1 ${fieldControlClass}`}
disabled={uploading}
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>{scene.name}</option>
))}
</select>
<button type="button" onClick={() => setShowNewSceneInput(true)} className={`${outlineButtonClass} whitespace-nowrap`} disabled={uploading}>+ 新建场景类别</button>
</div>
)}
</div>
{/* 平台分类 */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className={fieldLabelClass}>
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(e.target.value)}
className={fieldControlClass}
disabled={uploading}
>
<option value="">不选择平台</option>
{platforms.map((platform) => (
<option key={platform.id} value={platform.name}>{platform.name}</option>
))}
</select>
</div>
)}
{/* 视频截图上传区 */}
<div>
<label className={fieldLabelClass}>视频截图 *</label>
<div
aria-disabled={uploading}
className={`p-3 ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${isScreenshotDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={screenshotInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleScreenshotSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<CompactImageFileGrid files={screenshots} label="截图" disabled={uploading} onAdd={() => screenshotInputRef.current?.click()} onRemove={removeScreenshot} />
</div>
<p className={`mt-1.5 ${fieldHintClass}`}>至少 1 张 · 可多选或拖入 · 自动生成预览图和压缩包</p>
</div>
{/* 错误提示 */}
{error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-blue-500 h-2 rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-700 dark:text-gray-300">{uploadProgress}%</span>
{processingStep && (
<span className="text-gray-500 dark:text-gray-400">{processingStep}</span>
)}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end gap-3 border-t border-[#e7e8ea] pt-5">
<button onClick={onCancel} className={outlineButtonClass} disabled={uploading} type="button">取消</button>
<button onClick={handleSubmit} className={inkButtonClass} disabled={uploading} type="button">
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}