578 lines
19 KiB
TypeScript
578 lines
19 KiB
TypeScript
'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>
|
||
);
|
||
}
|