gongxiangp2/components/upload/TaskPageUploader.tsx
chenpei cd6e40d9d2 优化:网络传输性能和稳定性 - TCP缓冲区+并发控制优化
主要改动:
1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题
2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET
3. 动态并发控制 - 根据网络环境自动调整
4. Nginx超时增加到120s - 支持大分片上传
5. 上传进度修复 - 修复剩余时间计算
6. 新增任务-页面上传器和详情组件
7. 统一部署文档为deploy-remote-build.ps1

问题修复:
- 公网用户上传从200-900KB/s提升到预期3-5MB/s
- 内网用户上传更稳定,无连接重置错误
- UploadProgress组件剩余时间计算修正

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-27 09:33:09 +08:00

615 lines
22 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 } from 'react';
import Image from 'next/image';
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 [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('');
// 验证任务号格式
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;
const newFiles: AssetFile[] = [];
const currentList = previewImages;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const ext = file.name.toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].some(e => ext.endsWith(e))) {
newFiles.push({
file,
preview: URL.createObjectURL(file),
sortOrder: currentList.length + i,
});
}
}
setPreviewImages([...currentList, ...newFiles]);
setError('');
};
// 删除预览图
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;
}
// 验证至少有一张预览图
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,
productModel,
categoryName,
sceneName,
platformName: platformName || '',
productCategoryName: productCategoryName || '',
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json();
throw new Error(errorData.error || '初始化失败');
}
const { data: initData } = await initResponse.json();
const materialId = initData.materialId;
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,
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-6">
{/* 任务号字段 */}
<div>
<label htmlFor="taskNo" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
任务号 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
</label>
<input
id="taskNo"
type="text"
value={taskNo}
onChange={(e) => setTaskNo(e.target.value)}
placeholder="TASK + 12-18位数字,如 TASK202601030184 或 TASK202601071437194808-01"
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"
disabled={uploading}
/>
{taskNo && !validateTaskNo(taskNo) && (
<p className="text-red-500 text-sm mt-1">任务号格式错误</p>
)}
</div>
{/* SPU型号 */}
<div>
<label htmlFor="productModel" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
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="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"
disabled={uploading}
/>
</div>
{/* 使用店铺 */}
<div>
<label htmlFor="categoryName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
使用店铺 *
</label>
{showNewCategoryInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="flex-1 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="输入新店铺名称"
/>
<button
type="button"
onClick={handleAddNewCategory}
className="btn-primary"
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewCategoryInput(false);
setNewCategoryName('');
}}
className="btn-secondary"
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="categoryName"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
className="flex-1 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"
>
<option value="">选择使用店铺</option>
{categories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategoryInput(true)}
className="btn-secondary whitespace-nowrap"
>
+ 新建店铺
</button>
</div>
)}
</div>
{/* 场景类别 */}
<div>
<label htmlFor="sceneName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
场景类别 *
</label>
{showNewSceneInput ? (
<div className="flex space-x-2">
<input
type="text"
value={newSceneName}
onChange={(e) => setNewSceneName(e.target.value)}
className="flex-1 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="输入新场景类别"
/>
<button
type="button"
onClick={handleAddNewScene}
className="btn-primary"
>
添加
</button>
<button
type="button"
onClick={() => {
setShowNewSceneInput(false);
setNewSceneName('');
}}
className="btn-secondary"
>
取消
</button>
</div>
) : (
<div className="flex space-x-2">
<select
id="sceneName"
value={sceneName}
onChange={(e) => setSceneName(e.target.value)}
className="flex-1 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"
>
<option value="">选择场景类别</option>
{scenes.map((scene) => (
<option key={scene.id} value={scene.name}>
{scene.name}
</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewSceneInput(true)}
className="btn-secondary whitespace-nowrap"
>
+ 新建场景类别
</button>
</div>
)}
</div>
{/* 平台分类(可选) */}
{platforms.length > 0 && (
<div>
<label htmlFor="platformName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
平台分类(可选)
</label>
<select
id="platformName"
value={platformName}
onChange={(e) => setPlatformName(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"
>
<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="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
品类(可选)
</label>
<select
id="productCategoryName"
value={productCategoryName}
onChange={(e) => setProductCategoryName(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"
>
<option value="">不选择品类</option>
{productCategories.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>
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isPreviewDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
setIsPreviewDragging(true);
}}
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) {
setIsPreviewDragging(false);
}
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setIsPreviewDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter(f => f.type && f.type.startsWith('image/'));
if (imageFiles.length > 0) {
handlePreviewSelect(e.dataTransfer.files as FileList);
setError('');
}
}}
>
{/* 已上传的预览图 */}
{previewImages.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{previewImages.map((img, index) => (
<div key={index} className="relative group">
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<img src={img.preview} alt={`预览图 ${index + 1}`} className="w-full h-full object-cover" />
<div className="absolute top-1 left-1 bg-blue-500 text-white text-xs rounded px-1">
{index + 1}
</div>
</div>
<button
onClick={() => removePreviewImage(index)}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
disabled={uploading}
type="button"
>
×
</button>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 truncate">
{img.file.name} ({formatBytes(img.file.size)})
</div>
</div>
))}
</div>
)}
{/* 上传按钮 */}
<div className="flex items-center space-x-4">
<div className="w-32 h-32 rounded-apple bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div className="flex-1">
<input
ref={previewInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handlePreviewSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
<button
onClick={() => previewInputRef.current?.click()}
className="btn-secondary"
disabled={uploading}
type="button"
>
{previewImages.length > 0 ? '继续添加预览图' : '选择预览图'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
</p>
</div>
</div>
</div>
</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 space-x-3">
<button
onClick={onCancel}
className="btn-secondary"
disabled={uploading}
type="button"
>
取消
</button>
<button
onClick={handleSubmit}
className="btn-primary disabled:opacity-50"
disabled={uploading}
type="button"
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
);
}