gongxiangp2/components/upload/TaskPageUploader.tsx

648 lines
21 KiB
TypeScript
Raw Permalink 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 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>
);
}