主要修复: - 修复批量上传闭包陷阱,防止文件丢失 - 添加 fileName 参数传递原始文件名,保留正确扩展名 - 添加取消检查回调,改进批量上传取消功能 其他改进: - 优化搜索功能和管理后台 - 添加 FRP 故障诊断文档 - 更新环境验证和网络检测
667 lines
23 KiB
TypeScript
667 lines
23 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import Image from 'next/image';
|
||
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser';
|
||
|
||
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('');
|
||
|
||
// 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;
|
||
|
||
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">
|
||
使用店铺 *
|
||
{shopAutoFilled && (
|
||
<span className="ml-2 text-xs text-green-600 dark:text-green-400 font-normal">
|
||
✓ 自动识别
|
||
</span>
|
||
)}
|
||
</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);
|
||
setShopAutoFilled(false);
|
||
}}
|
||
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">
|
||
品类(可选)
|
||
{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="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('');
|
||
}
|
||
}}
|
||
>
|
||
<input
|
||
ref={previewInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
onChange={(e) => handlePreviewSelect(e.target.files)}
|
||
className="hidden"
|
||
disabled={uploading}
|
||
/>
|
||
<div className="grid grid-cols-4 gap-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>
|
||
))}
|
||
<button
|
||
type="button"
|
||
onClick={() => previewInputRef.current?.click()}
|
||
disabled={uploading}
|
||
className="group w-full h-32 rounded-apple bg-gray-100 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-gray-400 dark:hover:border-gray-500 transition-colors disabled:opacity-50"
|
||
>
|
||
<svg className="w-12 h-12 text-gray-400 group-hover:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div className="mt-3 space-y-1">
|
||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
|
||
</p>
|
||
</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>
|
||
);
|
||
}
|