gongxiangp2/components/UploadModal.tsx
chenpei e69f15337d feat: Add automatic PSD preview generation
- Add psd-processor library to auto-generate previews from PSD files
- Update upload API to detect PSD files and generate previews automatically
- Make preview image optional for PSD file uploads
- Update UploadModal UI to show PSD files don't require manual preview upload
- Install ag-psd package for PSD processing
2025-10-26 15:49:33 +08:00

792 lines
29 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 Image from 'next/image'
interface UploadModalProps {
onClose: () => void
onSuccess: () => void
}
// 内网服务器地址
const INTERNAL_SERVER = 'http://192.168.58.20:4320'
export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string>('')
const [zipFile, setZipFile] = useState<File | null>(null)
const [productModel, setProductModel] = useState('')
// 一级分类
const [categoryName, setCategoryName] = useState('')
const [categories, setCategories] = useState<Array<{ id: string; name: string }>>([])
const [showNewCategoryInput, setShowNewCategoryInput] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
// 二级分类
const [sceneName, setSceneName] = useState('')
const [scenes, setScenes] = useState<Array<{ id: string; name: string }>>([])
const [showNewSceneInput, setShowNewSceneInput] = useState(false)
const [newSceneName, setNewSceneName] = useState('')
// 素材类型
const [materialType, setMaterialType] = useState('3DMAX')
// 渲染器(仅3DMAX使用)
const [renderer, setRenderer] = useState('CR')
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadSpeed, setUploadSpeed] = useState(0)
const [error, setError] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const MAX_RETRY = 2
const previewInputRef = useRef<HTMLInputElement>(null)
const zipInputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const xhrRef = useRef<XMLHttpRequest | null>(null)
// 加载分类和场景列表
useEffect(() => {
fetchCategories()
fetchScenes()
}, [])
const fetchCategories = async () => {
try {
const response = await fetch('/api/categories')
if (response.ok) {
const data = await response.json()
setCategories(data)
}
} catch (error) {
console.error('Error fetching categories:', error)
}
}
const fetchScenes = async () => {
try {
const response = await fetch('/api/scenes')
if (response.ok) {
const data = await response.json()
setScenes(data)
}
} catch (error) {
console.error('Error fetching scenes:', error)
}
}
const handlePreviewChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (!file.type.startsWith('image/')) {
setError('请选择图片文件')
return
}
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setError('')
}
}
// 根据文件扩展名自动识别素材类型
const detectMaterialType = (filename: string): string | null => {
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase()
const typeMap: { [key: string]: string | null } = {
'.max': '3DMAX',
'.psd': 'PSD',
'.ai': 'AI',
'.cad': 'CAD',
'.dwg': 'CAD',
'.c4d': 'C4D',
'.zip': null, // 压缩包需要用户手动选择
'.rar': null,
'.7z': null,
}
return typeMap[ext] ?? '其他'
}
const handleZipChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const validExtensions = ['.zip', '.rar', '.7z', '.max', '.psd', '.ai', '.cad', '.dwg', '.c4d']
const fileExtension = file.name.substring(file.name.lastIndexOf('.')).toLowerCase()
if (!validExtensions.includes(fileExtension)) {
setError('请选择有效的文件格式(压缩包:.zip/.rar/.7z 或素材文件:.max/.psd/.ai/.cad/.dwg/.c4d)')
return
}
setZipFile(file)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
setError('')
}
}
// 拖拽处理 - 修复闪屏问题
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
// 只有当鼠标真正离开容器时才设置为false,避免经过子元素时闪屏
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) {
setIsDragging(false)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
if (files.length === 0) return
// 处理图片文件
const imageFiles = files.filter(f => f.type.startsWith('image/'))
if (imageFiles.length > 0 && !previewImage) {
const imgFile = imageFiles[0]
setPreviewImage(imgFile)
setPreviewUrl(URL.createObjectURL(imgFile))
}
// 处理素材文件(包括压缩包和直接文件)
const materialFiles = files.filter(f => {
const ext = f.name.substring(f.name.lastIndexOf('.')).toLowerCase()
return ['.zip', '.rar', '.7z', '.max', '.psd', '.ai', '.cad', '.dwg', '.c4d'].includes(ext)
})
if (materialFiles.length > 0 && !zipFile) {
const file = materialFiles[0]
setZipFile(file)
// 自动识别并设置素材类型
const detectedType = detectMaterialType(file.name)
if (detectedType) {
setMaterialType(detectedType)
}
}
setError('')
}
const handleAddNewCategory = async () => {
if (!newCategoryName.trim()) {
setError('请输入分类名称')
return
}
try {
const response = await fetch('/api/categories', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newCategoryName.trim() }),
})
if (response.ok) {
const newCategory = await response.json()
await fetchCategories()
setCategoryName(newCategory.name)
setNewCategoryName('')
setShowNewCategoryInput(false)
setError('')
} else {
const data = await response.json()
setError(data.error || '添加分类失败')
}
} catch (error) {
console.error('Error adding category:', error)
setError('添加分类失败')
}
}
const handleAddNewScene = async () => {
if (!newSceneName.trim()) {
setError('请输入场景名称')
return
}
try {
const response = await fetch('/api/scenes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newSceneName.trim() }),
})
if (response.ok) {
const newScene = await response.json()
await fetchScenes()
setSceneName(newScene.name)
setNewSceneName('')
setShowNewSceneInput(false)
setError('')
} else {
const data = await response.json()
setError(data.error || '添加场景失败')
}
} catch (error) {
console.error('Error adding scene:', error)
setError('添加场景失败')
}
}
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]
}
const formatSpeed = (bytesPerSecond: number) => {
return formatBytes(bytesPerSecond) + '/s'
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
// Check if file is PSD
const isPsdFile = zipFile && zipFile.name.toLowerCase().endsWith('.psd')
// Validation - preview is optional for PSD files
if (!zipFile || !productModel || !categoryName || !sceneName) {
setError('请填写所有必填字段')
return
}
// For non-PSD files, preview is required
if (!isPsdFile && !previewImage) {
setError('请上传预览图,或者上传PSD文件以自动生成预览图')
return
}
// 重置重试计数
setRetryCount(0)
setUploading(true)
setUploadProgress(0)
setUploadSpeed(0)
try {
const formData = new FormData()
// Only append preview image if it exists
if (previewImage) {
formData.append('previewImage', previewImage)
}
formData.append('zipFile', zipFile)
formData.append('productModel', productModel)
formData.append('categoryName', categoryName)
formData.append('sceneName', sceneName)
formData.append('materialType', materialType)
formData.append('renderer', renderer)
const xhr = new XMLHttpRequest()
xhrRef.current = xhr // 保存引用以便取消
// 禁用超时限制,适用于大文件上传
xhr.timeout = 0
let startTime = Date.now()
let startLoaded = 0
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
setUploadProgress(percentComplete)
// 计算上传速度
const currentTime = Date.now()
const timeElapsed = (currentTime - startTime) / 1000 // 秒
if (timeElapsed > 0) {
const speed = (e.loaded - startLoaded) / timeElapsed
setUploadSpeed(speed)
startTime = currentTime
startLoaded = e.loaded
}
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 201) {
setRetryCount(0)
onSuccess()
onClose()
} else {
try {
const data = JSON.parse(xhr.responseText)
let errorMsg = data.error || '上传失败'
// 提供更详细的错误信息
if (xhr.status === 413) {
errorMsg = '文件过大,请联系管理员增加上传限制'
} else if (xhr.status === 408 || xhr.status === 504) {
errorMsg = '上传超时,建议:\n1. 压缩文件后再上传\n2. 使用内网地址(更快)\n3. 分批上传大文件'
} else if (xhr.status >= 500) {
errorMsg = `服务器错误 (${xhr.status}):${errorMsg}`
}
setError(errorMsg)
} catch {
setError(`上传失败 (状态码: ${xhr.status})`)
}
}
setUploading(false)
setRetryCount(0)
})
xhr.addEventListener('error', (e) => {
console.error('Upload XHR error:', e)
console.error('XHR status:', xhr.status)
console.error('XHR readyState:', xhr.readyState)
console.error('Upload progress:', uploadProgress)
// 检测是否是连接重置错误
const isConnectionReset = xhr.status === 0
let errorMsg = '上传失败,请稍后重试'
// 如果上传进度已达到100%,可能实际已成功
if (uploadProgress === 100 && isConnectionReset) {
errorMsg = '⚠️ 上传可能已完成!\n\n由于网络原因,无法确认最终状态。\n\n请执行以下操作:\n1. 刷新页面查看文件是否已上传\n2. 如果文件已存在,说明上传成功\n3. 如果未看到文件,请重新上传\n\n提示:使用内网地址上传更稳定'
// 3秒后自动关闭弹窗并刷新
setTimeout(() => {
onClose()
window.location.reload()
}, 5000)
} else if (isConnectionReset) {
errorMsg = '网络连接中断,可能原因:\n• 文件过大导致超时\n• 网络不稳定\n• 服务器响应慢\n\n建议:\n1. 检查文件大小(压缩包建议<1GB)\n2. 使用内网地址上传(更快更稳定)\n3. 如果是大文件,尝试压缩后再上传\n4. 刷新页面检查文件是否已上传成功'
}
setError(errorMsg)
setUploading(false)
})
xhr.addEventListener('abort', () => {
setError('上传已取消')
setUploading(false)
setRetryCount(0)
xhrRef.current = null
})
// 始终使用相对路径上传,避免跨域Cookie问题
// 用户应通过Header的提示切换到内网地址以获得更快速度
const uploadUrl = '/api/materials/upload'
console.log(`🚀 上传地址: ${uploadUrl}`)
xhr.open('POST', uploadUrl)
xhr.send(formData)
} catch (error) {
console.error('Upload error:', error)
setError('上传失败,请稍后再试')
setUploading(false)
}
}
const handleCancelUpload = () => {
if (xhrRef.current && uploading) {
xhrRef.current.abort()
} else {
onClose()
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div
className="bg-white dark:bg-gray-800 rounded-apple max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-apple-lg"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* 拖拽覆盖层 */}
{isDragging && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-90 flex items-center justify-center z-50 rounded-apple">
<div className="text-center text-white">
<svg className="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-2xl font-bold">拖放文件到这里</p>
<p className="text-sm mt-2">支持图片和压缩包文件</p>
</div>
</div>
)}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">上传素材</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
×
</button>
</div>
<div className="space-y-1 mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
💡 提示:可以直接拖拽图片和压缩包文件到窗口
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
提示:如在内网环境,请通过顶部提示切换到内网地址以获得更快速度
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 预览图片 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
预览图片 {zipFile?.name.toLowerCase().endsWith('.psd') ? '(可选)' : '*'}
</label>
<div className="flex items-center space-x-4">
{previewUrl ? (
<div className="relative w-32 h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<Image
src={previewUrl}
alt="Preview"
fill
className="object-cover"
/>
</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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
<div className="flex-1">
<input
ref={previewInputRef}
type="file"
onChange={handlePreviewChange}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
className="btn-secondary"
>
选择图片
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持 JPG、PNG、WebP 等格式
{zipFile?.name.toLowerCase().endsWith('.psd') && (
<span className="block text-green-600 dark:text-green-400 mt-1">
💡 PSD文件可自动生成预览图,无需手动上传
</span>
)}
</p>
</div>
</div>
</div>
{/* SPU */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SPU型号 *
</label>
<input
type="text"
value={productModel}
onChange={(e) => {
// 只允许英文、数字和常见符号(- _ .),过滤中文,自动转大写
const value = e.target.value.replace(/[^a-zA-Z0-9\-_.]/g, '').toUpperCase()
setProductModel(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"
placeholder="例如:XY-2024-001"
/>
</div>
{/* 使用店铺 */}
<div>
<label 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
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 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
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>
{/* 素材类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
素材类型 *
</label>
<select
value={materialType}
onChange={(e) => setMaterialType(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="3DMAX">3DMAX</option>
<option value="PSD">PSD</option>
<option value="AI">AI</option>
<option value="CAD">CAD</option>
<option value="C4D">C4D</option>
<option value="其他">其他</option>
</select>
</div>
{/* 渲染器(仅3DMAX显示) */}
{materialType === '3DMAX' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
渲染器 *
</label>
<select
value={renderer}
onChange={(e) => setRenderer(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="CR">CR</option>
<option value="VR">VR</option>
<option value="其它">其它</option>
</select>
</div>
)}
{/* Material File */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
素材文件 *
</label>
<div
ref={dropZoneRef}
className={`border-2 border-dashed rounded-apple p-6 text-center transition-colors ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'
}`}
>
{zipFile ? (
<div className="space-y-2">
<p className="text-gray-700 dark:text-gray-300 font-medium">{zipFile.name}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{formatBytes(zipFile.size)}
</p>
{detectMaterialType(zipFile.name) && (
<p className="text-sm text-green-600 dark:text-green-400">
已自动识别类型: {detectMaterialType(zipFile.name)}
</p>
)}
<button
type="button"
onClick={() => setZipFile(null)}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
移除
</button>
</div>
) : (
<div>
<input
ref={zipInputRef}
type="file"
onChange={handleZipChange}
accept=".zip,.rar,.7z,.max,.psd,.ai,.cad,.dwg,.c4d"
className="hidden"
/>
<button
type="button"
onClick={() => zipInputRef.current?.click()}
className="btn-secondary"
>
选择素材文件
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
支持格式:
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
压缩包(.zip / .rar / .7z)或素材文件(.max / .psd / .ai / .cad / .dwg / .c4d)
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
💡 非压缩包文件会自动识别类型
</p>
</div>
)}
</div>
</div>
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 text-right">
速度: {formatSpeed(uploadSpeed)}
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-4 py-3 rounded-apple">
{error}
</div>
)}
{/* Buttons */}
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={handleCancelUpload}
className="btn-secondary"
>
{uploading ? '取消上传' : '取消'}
</button>
<button
type="submit"
className="btn-primary"
disabled={uploading}
>
{uploading ? `上传中 ${uploadProgress}%` : '上传'}
</button>
</div>
</form>
</div>
</div>
)
}