gongxiangp2/components/upload/CertificateReportUploader.tsx
2026-05-07 16:37:22 +08:00

372 lines
13 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 { useMemo, useRef, useState } from 'react'
import { CERTIFICATE_REPORT_MATERIAL_TYPE, detectFileKindFromName } from '@/lib/certificate-report'
interface CategoryItem {
id: string
name: string
}
interface CertificateReportUploaderProps {
onSuccess: () => void
onCancel: () => void
productCategories: CategoryItem[]
}
interface ReportFileItem {
file: File
previewUrl?: string
sortOrder: number
fileKind: 'pdf' | 'image'
}
export default function CertificateReportUploader({
onSuccess,
onCancel,
productCategories,
}: CertificateReportUploaderProps) {
const [productModel, setProductModel] = useState('')
const [reportTitle, setReportTitle] = useState('')
const [productCategoryName, setProductCategoryName] = useState('')
const [platformName, setPlatformName] = useState('')
const [files, setFiles] = useState<ReportFileItem[]>([])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [processingStep, setProcessingStep] = useState('')
const [error, setError] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const totalSizeText = useMemo(() => {
const totalBytes = files.reduce((sum, item) => sum + item.file.size, 0)
if (totalBytes <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const unitIndex = Math.floor(Math.log(totalBytes) / Math.log(1024))
return `${(totalBytes / Math.pow(1024, unitIndex)).toFixed(2)} ${units[unitIndex]}`
}, [files])
const handleFileSelect = (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) {
return
}
const currentLength = files.length
const nextFiles: ReportFileItem[] = []
for (let index = 0; index < selectedFiles.length; index += 1) {
const file = selectedFiles[index]
const fileKind = detectFileKindFromName(file.name)
const isPdf = fileKind === 'pdf'
const isImage = file.type.startsWith('image/')
if (!isPdf && !isImage) {
continue
}
nextFiles.push({
file,
previewUrl: isImage ? URL.createObjectURL(file) : undefined,
sortOrder: currentLength + nextFiles.length,
fileKind,
})
}
if (nextFiles.length === 0) {
setError('请选择 PDF 或图片文件')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
return
}
setFiles((prev) => [...prev, ...nextFiles])
setError('')
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const removeFile = (index: number) => {
setFiles((prev) => {
const target = prev[index]
if (target?.previewUrl) {
URL.revokeObjectURL(target.previewUrl)
}
return prev
.filter((_, currentIndex) => currentIndex !== index)
.map((item, currentIndex) => ({
...item,
sortOrder: currentIndex,
}))
})
}
const handleSubmit = async () => {
if (!productModel.trim() && !reportTitle.trim()) {
setError('SPU 和 报告名称/检测对象 至少填写一个')
return
}
if (!productCategoryName.trim()) {
setError('请选择品类')
return
}
if (files.length === 0) {
setError('请至少上传一个 PDF 或图片文件')
return
}
setUploading(true)
setUploadProgress(0)
setProcessingStep('初始化上传...')
setError('')
try {
const initResponse = await fetch('/api/materials/upload/certificate-report/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productModel: productModel.trim(),
reportTitle: reportTitle.trim(),
productCategoryName: productCategoryName.trim(),
platformName: platformName.trim(),
}),
})
if (!initResponse.ok) {
const errorData = await initResponse.json().catch(() => ({}))
throw new Error(errorData.error || '初始化失败')
}
const { materialId } = await initResponse.json()
const { chunkUpload } = await import('@/lib/chunk-upload')
const uploadedFiles: Array<{
uploadId: string
fileName: string
fileSize: number
sortOrder: number
fileKind: 'pdf' | 'image'
}> = []
setProcessingStep('上传文件中...')
for (let index = 0; index < files.length; index += 1) {
const current = files[index]
const result = await chunkUpload(
current.file,
{
materialType: CERTIFICATE_REPORT_MATERIAL_TYPE,
fileName: current.file.name,
},
{
skipComplete: true,
onProgress: (progress) => {
const fileProgress = (index + progress / 100) / files.length
const totalProgress = 10 + fileProgress * 70
setUploadProgress(Math.round(totalProgress))
},
},
)
uploadedFiles.push({
uploadId: result.uploadId,
fileName: current.file.name,
fileSize: current.file.size,
sortOrder: current.sortOrder,
fileKind: current.fileKind,
})
}
setUploadProgress(82)
setProcessingStep('生成分享内容...')
const completeResponse = await fetch('/api/materials/upload/certificate-report/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId,
files: uploadedFiles,
}),
})
if (!completeResponse.ok) {
const errorData = await completeResponse.json().catch(() => ({}))
throw new Error(errorData.error || '完成上传失败')
}
setUploadProgress(100)
setProcessingStep('完成')
files.forEach((item) => {
if (item.previewUrl) {
URL.revokeObjectURL(item.previewUrl)
}
})
setTimeout(() => {
onSuccess()
}, 300)
} catch (submitError) {
console.error('[Certificate Report Upload]', submitError)
setError(submitError instanceof Error ? submitError.message : '上传失败,请稍后再试')
setProcessingStep('')
setUploading(false)
return
}
}
return (
<div className="space-y-6">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
表格里只需要放分享链接。对方点击后,会在分享页里看到这组检测报告的 PDF 和图片内容。
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
{error}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">SPU</label>
<input
type="text"
value={productModel}
onChange={(event) => setProductModel(event.target.value)}
placeholder="没有 SPU 可以留空"
className="input-apple"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
报告名称 / 检测对象
</label>
<input
type="text"
value={reportTitle}
onChange={(event) => setReportTitle(event.target.value)}
placeholder="例如:E0级颗粒板检测报告"
className="input-apple"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">SPU 和这个字段至少填写一个</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">品类 *</label>
<select
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
className="input-apple"
>
<option value="">请选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">平台分类</label>
<input
type="text"
value={platformName}
onChange={(event) => setPlatformName(event.target.value)}
placeholder="可选"
className="input-apple"
/>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
报告文件 *
</label>
<div className="rounded-apple border-2 border-dashed border-gray-300 p-6 text-center dark:border-gray-600">
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,image/*"
onChange={(event) => handleFileSelect(event.target.files)}
className="hidden"
/>
<button type="button" onClick={() => fileInputRef.current?.click()} className="btn-secondary">
选择 PDF / 图片
</button>
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
可以混合上传多份 PDF 和多张图片,分享时会按这里的顺序展示
</p>
</div>
</div>
{files.length > 0 && (
<div className="rounded-apple border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">文件列表</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{files.length} 个文件 · {totalSizeText}
</div>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{files.map((item, index) => (
<div key={`${item.file.name}-${index}`} className="flex items-center gap-4 px-4 py-3">
<div className="flex h-14 w-14 items-center justify-center overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-700">
{item.fileKind === 'image' && item.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.previewUrl} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-xs font-semibold text-red-500">PDF</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{item.file.name}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{item.fileKind === 'pdf' ? 'PDF 报告' : '图片附件'} · {(item.file.size / 1024 / 1024).toFixed(2)} MB
</div>
</div>
<button
type="button"
onClick={() => removeFile(index)}
className="rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
>
删除
</button>
</div>
))}
</div>
</div>
)}
{uploading && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-gray-700 dark:text-gray-200">{processingStep || '上传中...'}</span>
<span className="text-gray-500 dark:text-gray-400">{uploadProgress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-full bg-blue-600 transition-all" style={{ width: `${uploadProgress}%` }} />
</div>
</div>
)}
<div className="flex items-center justify-end gap-3">
<button type="button" onClick={onCancel} className="btn-secondary" disabled={uploading}>
取消
</button>
<button type="button" onClick={handleSubmit} className="btn-primary" disabled={uploading}>
{uploading ? '上传中...' : '创建分享素材'}
</button>
</div>
</div>
)
}