372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
'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>
|
||
)
|
||
}
|