648 lines
21 KiB
TypeScript
648 lines
21 KiB
TypeScript
'use client'
|
||
|
||
import Select from '@/components/ui/Select'
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
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';
|
||
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy';
|
||
import CompactImageFileGrid from './CompactImageFileGrid';
|
||
import { dropZoneActiveClass, dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass } from './upload-field-styles';
|
||
|
||
interface AssetFile {
|
||
file: File;
|
||
preview: string;
|
||
sortOrder: number;
|
||
}
|
||
|
||
interface CategoryItem {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
interface TaskPageUploaderProps {
|
||
onSuccess: () => void;
|
||
onCancel: () => void;
|
||
categories: CategoryItem[];
|
||
scenes: CategoryItem[];
|
||
platforms: CategoryItem[];
|
||
productCategories: CategoryItem[];
|
||
onRefreshCategories: () => void;
|
||
onRefreshScenes: () => void;
|
||
}
|
||
|
||
export default function TaskPageUploader({
|
||
onSuccess,
|
||
onCancel,
|
||
categories,
|
||
scenes,
|
||
platforms,
|
||
productCategories,
|
||
onRefreshCategories,
|
||
onRefreshScenes,
|
||
}: TaskPageUploaderProps) {
|
||
|
||
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 [previewImages, setPreviewImages] = useState<AssetFile[]>([]);
|
||
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
|
||
|
||
// 上传状态
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState(0);
|
||
const [processingStep, setProcessingStep] = useState('');
|
||
const [error, setError] = useState('');
|
||
|
||
// 文件输入引用
|
||
const previewInputRef = 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 handlePreviewSelect = (files: FileList | null) => {
|
||
if (!files) return;
|
||
if (uploading) {
|
||
setError('文件正在上传,请等待完成后再添加预览图');
|
||
return;
|
||
}
|
||
|
||
const currentList = previewImages;
|
||
const validation = validateImageFiles(files, {
|
||
maxBytes: TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
|
||
maxCount: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup,
|
||
existingCount: currentList.length,
|
||
});
|
||
const currentBytes = currentList.reduce((sum, item) => sum + item.file.size, 0);
|
||
let nextTotalBytes = currentBytes;
|
||
const rejectedByTotal: string[] = [];
|
||
const acceptedFiles = validation.acceptedFiles.filter((file) => {
|
||
if (nextTotalBytes + file.size > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
|
||
rejectedByTotal.push(file.name);
|
||
return false;
|
||
}
|
||
nextTotalBytes += file.size;
|
||
return true;
|
||
});
|
||
const errors = [...validation.errors];
|
||
if (rejectedByTotal.length > 0) {
|
||
errors.push(`本次图片总大小最多 1GB,已忽略:${rejectedByTotal.join('、')}`);
|
||
}
|
||
const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({
|
||
file,
|
||
preview: URL.createObjectURL(file),
|
||
sortOrder: currentList.length + index,
|
||
}));
|
||
|
||
setPreviewImages([...currentList, ...newFiles]);
|
||
setError(errors.join(';'));
|
||
};
|
||
|
||
// 删除预览图
|
||
const removePreviewImage = (index: number) => {
|
||
const newImages = previewImages.filter((_, i) => i !== index);
|
||
|
||
// 释放被删除图片的URL
|
||
URL.revokeObjectURL(previewImages[index].preview);
|
||
|
||
setPreviewImages(newImages);
|
||
};
|
||
|
||
// 提交上传
|
||
const handleSubmit = async () => {
|
||
// 验证必填字段
|
||
if (!productModel || !categoryName || !sceneName) {
|
||
setError('请填写所有必填字段(SPU型号、店铺、场景)');
|
||
return;
|
||
}
|
||
|
||
// 验证任务号格式(可选,仅验证格式)
|
||
if (taskNo && !validateTaskNo(taskNo)) {
|
||
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
|
||
return;
|
||
}
|
||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo);
|
||
if (dingtalkApprovalNoError) {
|
||
setError(dingtalkApprovalNoError);
|
||
return;
|
||
}
|
||
|
||
// 验证至少有一张预览图
|
||
if (previewImages.length === 0) {
|
||
setError('请至少上传一张预览图');
|
||
return;
|
||
}
|
||
|
||
setUploading(true);
|
||
setUploadProgress(0);
|
||
setProcessingStep('');
|
||
setError('');
|
||
|
||
try {
|
||
// 步骤1: 初始化上传(0% -> 10%)
|
||
setProcessingStep('初始化上传...');
|
||
const initResponse = await fetch('/api/materials/upload/task-page/init', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
taskNo,
|
||
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
platformName: platformName || '',
|
||
productCategoryName: productCategoryName || '',
|
||
}),
|
||
});
|
||
|
||
if (!initResponse.ok) {
|
||
const errorData = await initResponse.json();
|
||
throw new Error(errorData.error || '初始化失败');
|
||
}
|
||
|
||
const initData = await initResponse.json();
|
||
const materialId = typeof initData?.materialId === 'string' ? initData.materialId : '';
|
||
if (!materialId) {
|
||
throw new Error('初始化失败:服务器未返回素材编号');
|
||
}
|
||
|
||
setUploadProgress(10);
|
||
|
||
// 步骤2: 上传预览图(10% -> 80%)
|
||
setProcessingStep('上传预览图中...');
|
||
const { chunkUpload } = await import('@/lib/chunk-upload');
|
||
const previewAssets: Array<{ uploadId: string; fileName: string; fileSize: number }> = [];
|
||
|
||
for (let i = 0; i < previewImages.length; i++) {
|
||
const asset = previewImages[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) / previewImages.length;
|
||
const totalProgress = 10 + fileProgress * 70;
|
||
setUploadProgress(Math.round(totalProgress));
|
||
},
|
||
});
|
||
|
||
previewAssets.push({
|
||
uploadId: result.uploadId,
|
||
fileName: asset.file.name,
|
||
fileSize: asset.file.size,
|
||
});
|
||
} catch (error) {
|
||
console.error(`[Upload] Failed to upload preview image ${i}:`, error);
|
||
throw new Error(`上传预览图 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}
|
||
|
||
setUploadProgress(80);
|
||
|
||
// 步骤3: 完成上传(80% -> 95%)
|
||
// 后端会:1. 打包预览图为zip 2. 为每张预览图生成压缩预览图
|
||
setProcessingStep('打包文件中...');
|
||
const completeResponse = await fetch('/api/materials/upload/task-page/complete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
materialId,
|
||
taskNo: taskNo || undefined,
|
||
dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined,
|
||
assets: {
|
||
main: previewAssets.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
|
||
previewImages.forEach(img => URL.revokeObjectURL(img.preview));
|
||
|
||
setTimeout(() => {
|
||
setProcessingStep('');
|
||
onSuccess();
|
||
}, 500);
|
||
|
||
} catch (err: unknown) {
|
||
console.error('[Task Page 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="task-page"
|
||
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>
|
||
|
||
{/* 使用店铺 */}
|
||
<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="输入新店铺名称"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddNewCategory}
|
||
className={inkButtonClass}
|
||
>
|
||
添加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowNewCategoryInput(false);
|
||
setNewCategoryName('');
|
||
}}
|
||
className={outlineButtonClass}
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<Select
|
||
id="categoryName"
|
||
value={categoryName}
|
||
onChange={(e) => {
|
||
setCategoryName(e.target.value);
|
||
setShopAutoFilled(false);
|
||
}}
|
||
className={`flex-1 ${fieldControlClass}`}
|
||
>
|
||
<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`}
|
||
>
|
||
+ 新建店铺
|
||
</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="输入新场景类别"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddNewScene}
|
||
className={inkButtonClass}
|
||
>
|
||
添加
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowNewSceneInput(false);
|
||
setNewSceneName('');
|
||
}}
|
||
className={outlineButtonClass}
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<Select
|
||
id="sceneName"
|
||
value={sceneName}
|
||
onChange={(e) => setSceneName(e.target.value)}
|
||
className={`flex-1 ${fieldControlClass}`}
|
||
>
|
||
<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`}
|
||
>
|
||
+ 新建场景类别
|
||
</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}
|
||
>
|
||
<option value="">不选择平台</option>
|
||
{platforms.map((platform) => (
|
||
<option key={platform.id} value={platform.name}>
|
||
{platform.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</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}
|
||
>
|
||
<option value="">不选择品类</option>
|
||
{productCategories.map((category) => (
|
||
<option key={category.id} value={category.name}>
|
||
{category.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* 预览图上传 */}
|
||
<div>
|
||
<label className={fieldLabelClass}>预览图 *</label>
|
||
<div
|
||
aria-disabled={uploading}
|
||
className={`p-3 ${
|
||
uploading ? 'cursor-not-allowed opacity-60' : ''
|
||
} ${isPreviewDragging ? dropZoneActiveClass : dropZoneIdleClass}`}
|
||
onDragEnter={(e) => {
|
||
prepareFileDrag(e, uploading);
|
||
if (uploading) return;
|
||
setIsPreviewDragging(true);
|
||
}}
|
||
onDragLeave={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (hasDragLeftDropZone(e)) {
|
||
setIsPreviewDragging(false);
|
||
}
|
||
}}
|
||
onDragOver={(e) => prepareFileDrag(e, uploading)}
|
||
onDrop={(e) => {
|
||
prepareFileDrag(e, uploading);
|
||
setIsPreviewDragging(false);
|
||
if (uploading) {
|
||
setError('文件正在上传,请等待完成后再添加预览图');
|
||
return;
|
||
}
|
||
handlePreviewSelect(e.dataTransfer.files);
|
||
}}
|
||
>
|
||
<input
|
||
ref={previewInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handlePreviewSelect(e.target.files)}
|
||
className="hidden"
|
||
disabled={uploading}
|
||
/>
|
||
<CompactImageFileGrid files={previewImages} label="预览图" disabled={uploading} onAdd={() => previewInputRef.current?.click()} onRemove={removePreviewImage} />
|
||
</div>
|
||
<p className={`mt-1.5 ${fieldHintClass}`}>JPG、PNG、WebP · 可多选或拖入</p>
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
|
||
{error}
|
||
</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"
|
||
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>
|
||
);
|
||
}
|