gongxiangp2/components/upload/ProductPackageUploader.tsx

791 lines
28 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, useEffect } from 'react';
import DetailImageSorter from './DetailImageSorter';
import UploadForm from './UploadForm';
import { addCategory, addScene } from './category-handlers';
interface ProductPackageUploaderProps {
onSuccess: () => void;
onCancel: () => void;
categories: Array<{ id: string; name: string }>;
scenes: Array<{ id: string; name: string }>;
platforms: Array<{ id: string; name: string }>;
productCategories: Array<{ id: string; name: string }>;
onRefreshCategories: () => Promise<void>;
onRefreshScenes: () => Promise<void>;
}
interface AssetFile {
file: File;
preview: string;
id: string;
sortOrder: number;
}
export default function ProductPackageUploader({
onSuccess,
onCancel,
categories,
scenes,
platforms,
productCategories,
onRefreshCategories,
onRefreshScenes,
}: ProductPackageUploaderProps) {
// 基本信息
const [taskNo, setTaskNo] = useState('');
const [productModel, setProductModel] = useState('');
const [categoryName, setCategoryName] = useState('');
const [sceneName, setSceneName] = useState('');
const [platformName, setPlatformName] = useState('');
const [productCategoryName, setProductCategoryName] = useState('');
// 资产文件
const [mainImages, setMainImages] = useState<AssetFile[]>([]);
const [skuImages, setSkuImages] = useState<AssetFile[]>([]);
const [detailImages, setDetailImages] = useState<AssetFile[]>([]);
const [psdFiles, setPsdFiles] = useState<AssetFile[]>([]);
// 拖拽状态
const [isMainDragging, setIsMainDragging] = useState(false);
const [isSkuDragging, setIsSkuDragging] = useState(false);
const [isDetailDragging, setIsDetailDragging] = useState(false);
const [isPsdDragging, setIsPsdDragging] = useState(false);
// 上传状态
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState('');
// 文件输入引用
const mainInputRef = useRef<HTMLInputElement>(null);
const skuInputRef = useRef<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const psdInputRef = useRef<HTMLInputElement>(null);
// 验证任务号格式
// 支持格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
// 示例:
// TASK202601030184 - 基础任务号
// TASK202601071437194808-01 - 一级子任务
// TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务)
const validateTaskNo = (value: string): boolean => {
const regex = /^TASK\d{12,18}(-\d{2})*$/;
return regex.test(value);
};
// 处理添加新店铺
const handleAddNewCategory = async (name: string) => {
await addCategory(
name,
async (newCategory) => {
await onRefreshCategories();
setCategoryName(newCategory.name);
},
(error) => setError(error)
);
};
// 处理添加新场景
const handleAddNewScene = async (name: string) => {
await addScene(
name,
async (newScene) => {
await onRefreshScenes();
setSceneName(newScene.name);
},
(error) => setError(error)
);
};
// 拖拽处理函数
const handleDragEnter = (e: React.DragEvent, setter: (value: boolean) => void) => {
e.preventDefault();
e.stopPropagation();
setter(true);
};
const handleDragLeave = (e: React.DragEvent, setter: (value: boolean) => void) => {
e.preventDefault();
e.stopPropagation();
setter(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (
e: React.DragEvent,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
dragSetter: (value: boolean) => void,
accept: 'image' | 'file' | 'psd'
) => {
e.preventDefault();
e.stopPropagation();
dragSetter(false);
const files = Array.from(e.dataTransfer.files);
if (accept === 'image') {
const imageFiles = files.filter(f => f.type && f.type.startsWith('image/'));
handleImageSelect(imageFiles, setter);
} else if (accept === 'psd') {
// PSD文件拖拽(支持多个文件)
const psdFiles: File[] = [];
Array.from(files).forEach((file) => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (['psd', 'zip', 'rar', '7z'].includes(ext || '')) {
psdFiles.push(file);
}
});
if (psdFiles.length > 0) {
setPsdFiles((prev) => {
const newFiles: AssetFile[] = psdFiles.map((file, index) => ({
file,
preview: '', // PSD文件不需要预览
id: Math.random().toString(36).substring(2, 11),
sortOrder: prev.length + index,
}));
return [...prev, ...newFiles];
});
setError('');
}
}
};
// 处理图片选择
const handleImageSelect = (
files: FileList | File[] | null,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
isDetail: boolean = false
) => {
if (!files) return;
setter((prev) => {
const startOrder = prev.length;
const newFiles: AssetFile[] = [];
const fileArray = Array.isArray(files) ? files : Array.from(files);
fileArray.forEach((file, index) => {
if (file && file.type && file.type.startsWith('image/')) {
newFiles.push({
file,
preview: URL.createObjectURL(file),
id: Math.random().toString(36).substring(2, 11),
sortOrder: startOrder + index,
});
}
});
return [...prev, ...newFiles];
});
};
// 删除图片
const handleRemoveImage = (
id: string,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>
) => {
setter((prev) => {
const removed = prev.find((item) => item.id === id);
if (removed) {
URL.revokeObjectURL(removed.preview);
}
return prev.filter((item) => item.id !== id);
});
};
// 处理 PSD 文件选择
const handlePsdSelect = (files: FileList | null) => {
if (!files || files.length === 0) return;
const validFiles: File[] = [];
const invalidFiles: string[] = [];
Array.from(files).forEach((file) => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (['psd', 'zip', 'rar', '7z'].includes(ext || '')) {
validFiles.push(file);
} else {
invalidFiles.push(file.name);
}
});
if (invalidFiles.length > 0) {
setError(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 .psd/.zip/.rar/.7z 文件`);
}
if (validFiles.length > 0) {
setPsdFiles((prev) => {
const newFiles: AssetFile[] = validFiles.map((file, index) => ({
file,
preview: '', // PSD文件不需要预览
id: Math.random().toString(36).substring(2, 11),
sortOrder: prev.length + index,
}));
return [...prev, ...newFiles];
});
if (invalidFiles.length === 0) {
setError('');
}
}
};
// 上传文件到 S3
const uploadToS3 = async (file: File, type: string): Promise<string> => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
const response = await fetch('/api/upload/s3', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('文件上传失败');
}
const data = await response.json();
return data.url;
};
// 提交上传
const handleSubmit = async () => {
// 验证必填字段(任务号改为可选,不提示)
if (!productModel || !categoryName) {
setError('请填写SPU型号和店铺');
return;
}
// 如果提供了任务号,验证格式
if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) {
setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)');
return;
}
// 主图改为非必选,只要至少有一张图片即可
if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) {
setError('请至少上传一张图片(主图、SKU图或详情图)');
return;
}
setUploading(true);
setUploadProgress(0);
setError('');
try {
// 1. 初始化上传
const initResponse = await fetch('/api/materials/upload/product-package/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskNo,
productModel,
categoryName,
sceneName,
platformName: platformName || undefined,
productCategoryName: productCategoryName || undefined,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json();
throw new Error(errorData.error || '初始化失败');
}
const { data: initData } = await initResponse.json();
const materialId = initData.materialId;
// 2. 上传所有文件(并行上传以提高速度)
const totalFiles =
mainImages.length + skuImages.length + detailImages.length + psdFiles.length;
let uploadedCount = 0;
// 进度更新函数(线程安全)
const updateProgress = () => {
uploadedCount++;
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100));
};
// 并行上传资产(单个资产类型内的文件并行上传)
const uploadAssets = async (
assets: AssetFile[],
assetType: string
): Promise<Array<{ fileName: string; fileSize: number; fileUrl: string; sortOrder: number }>> => {
if (assets.length === 0) return [];
// 并行上传所有文件
const uploadPromises = assets.map(async (asset, index) => {
try {
const url = await uploadToS3(asset.file, assetType);
updateProgress();
return {
fileName: asset.file.name,
fileSize: asset.file.size,
fileUrl: url,
sortOrder: index,
};
} catch (error) {
console.error(`[Upload] Failed to upload ${assetType} asset ${index}:`, error);
throw new Error(`上传${assetType}图片 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
}
});
// 等待所有文件上传完成
return await Promise.all(uploadPromises);
};
// 并行上传PSD文件
const uploadPsdFiles = async (): Promise<Array<{ fileName: string; fileSize: number; fileUrl: string }>> => {
if (psdFiles.length === 0) return [];
const uploadPromises = psdFiles.map(async (psdFileItem, index) => {
try {
const psdFile = psdFileItem.file;
const formData = new FormData();
formData.append('file', psdFile);
formData.append('materialId', materialId);
const psdResponse = await fetch('/api/upload/psd', {
method: 'POST',
body: formData,
});
if (!psdResponse.ok) {
const errorData = await psdResponse.json().catch(() => ({}));
throw new Error(errorData.error || `PSD 文件 ${psdFile.name} 上传失败`);
}
const psdData = await psdResponse.json();
updateProgress();
return {
fileName: psdFile.name,
fileSize: psdFile.size,
fileUrl: psdData.path,
};
} catch (error) {
console.error(`[Upload] Failed to upload PSD file ${index}:`, error);
throw new Error(`上传PSD文件 ${psdFileItem.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
}
});
return await Promise.all(uploadPromises);
};
// 并行上传所有类型的资产(主图、SKU图、详情图、PSD文件同时上传)
const [mainAssets, skuAssets, detailAssets, psdAssetsArray] = await Promise.all([
uploadAssets(mainImages, 'main'),
uploadAssets(skuImages, 'sku'),
uploadAssets(detailImages, 'detail'),
uploadPsdFiles(),
]);
// PSD资产格式:支持数组(complete接口支持数组格式)
const psdAssets = psdAssetsArray.length > 0 ? psdAssetsArray : undefined;
// 3. 完成上传(传递taskNo,上传完成后写入数据库)
const completeResponse = await fetch('/api/materials/upload/product-package/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
taskNo, // 传递taskNo,上传完成后写入数据库
assets: {
main: mainAssets,
sku: skuAssets,
detail: detailAssets,
psd: psdAssets, // 数组格式(complete接口支持)
},
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}));
const errorMessage = errorData.error || '完成上传失败';
console.error('[Upload] Complete failed:', errorMessage, errorData);
throw new Error(errorMessage);
}
onSuccess();
} catch (error: any) {
console.error('上传失败:', error);
const { parseApiError } = await import('@/lib/error-handler');
const { message } = parseApiError(error);
setError(message);
} finally {
setUploading(false);
}
};
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>
{/* 使用 UploadForm 组件显示 SPU、店铺、场景字段 */}
<UploadForm
materialType="任务-详情"
productModel={productModel}
categoryName={categoryName}
sceneName={sceneName}
platformName={platformName}
productCategoryName={productCategoryName}
renderer=""
categories={categories}
scenes={scenes}
platforms={platforms}
productCategories={productCategories}
isBatchMode={false}
onProductModelChange={setProductModel}
onCategoryChange={setCategoryName}
onSceneChange={setSceneName}
onPlatformChange={setPlatformName}
onProductCategoryChange={setProductCategoryName}
onRendererChange={() => {}}
onAddCategory={handleAddNewCategory}
onAddScene={handleAddNewScene}
/>
{/* 主图上传 */}
<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 ${
isMainDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')}
>
{/* 已上传的图片预览 */}
{mainImages.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{mainImages.map((img) => (
<div key={img.id} 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="" className="w-full h-full object-cover" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setMainImages)}
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>
))}
</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={mainInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files, setMainImages)}
className="hidden"
disabled={uploading}
/>
<button
onClick={() => mainInputRef.current?.click()}
className="btn-secondary"
disabled={uploading}
type="button"
>
{mainImages.length > 0 ? '继续添加图片' : '选择主图'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
</p>
</div>
</div>
</div>
</div>
{/* SKU图上传 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SKU图
</label>
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isSkuDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsSkuDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')}
>
{/* 已上传的图片预览 */}
{skuImages.length > 0 && (
<div className="grid grid-cols-4 gap-4 mb-4">
{skuImages.map((img) => (
<div key={img.id} 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="" className="w-full h-full object-cover" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setSkuImages)}
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>
))}
</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={skuInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files, setSkuImages)}
className="hidden"
disabled={uploading}
/>
<button
onClick={() => skuInputRef.current?.click()}
className="btn-secondary"
disabled={uploading}
type="button"
>
{skuImages.length > 0 ? '继续添加图片' : '选择SKU图'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
</p>
</div>
</div>
</div>
</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 ${
isDetailDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsDetailDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setDetailImages, setIsDetailDragging, 'image')}
>
<input
ref={detailInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => handleImageSelect(e.target.files, setDetailImages, true)}
className="hidden"
disabled={uploading}
/>
{/* 上传按钮 */}
<div className="flex items-center space-x-4 mb-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">
<button
onClick={() => detailInputRef.current?.click()}
className="btn-secondary"
disabled={uploading}
type="button"
>
{detailImages.length > 0 ? '继续添加图片' : '选择详情图'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
</p>
</div>
</div>
{/* 详情图排序组件 */}
{detailImages.length > 0 && (
<DetailImageSorter
images={detailImages}
onChange={setDetailImages}
onRemove={(id) => handleRemoveImage(id, setDetailImages)}
/>
)}
</div>
</div>
{/* PSD上传 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
PSD源文件(可选)
</label>
<div
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
isPsdDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
onDragEnter={(e) => handleDragEnter(e, setIsPsdDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsPsdDragging)}
onDragOver={handleDragOver}
onDrop={(e) => {
handleDrop(e, setDetailImages, setIsPsdDragging, 'psd');
}}
>
<input
ref={psdInputRef}
type="file"
accept=".psd,.zip,.rar,.7z"
multiple
onChange={(e) => handlePsdSelect(e.target.files)}
className="hidden"
disabled={uploading}
/>
{/* 上传按钮 */}
<div className="flex items-center space-x-4">
{psdFiles.length > 0 ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600 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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="absolute bottom-1 right-1 bg-blue-500 text-white text-xs rounded-full w-6 h-6 flex items-center justify-center">
{psdFiles.length}
</div>
</div>
) : (
<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">
<button
onClick={() => psdInputRef.current?.click()}
className="btn-secondary"
disabled={uploading}
type="button"
>
{psdFiles.length > 0 ? '继续添加PSD文件' : '选择PSD文件'}
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 .psd、.zip、.rar、.7z 格式,可一次选择多个或拖拽上传
</p>
{psdFiles.length > 0 && (
<div className="mt-2 space-y-2">
{psdFiles.map((psdFile) => (
<div key={psdFile.id} className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700 rounded-apple">
<span className="text-sm text-gray-700 dark:text-gray-300">{psdFile.file.name}</span>
<button
onClick={() => setPsdFiles((prev) => prev.filter((f) => f.id !== psdFile.id))}
className="text-red-500 dark:text-red-400 text-sm hover:text-red-600 dark:hover:text-red-300"
disabled={uploading}
type="button"
>
删除
</button>
</div>
))}
</div>
)}
</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>
<p className="text-sm text-center text-gray-700 dark:text-gray-300">{uploadProgress}%</p>
</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>
);
}