gongxiangp2/components/upload/CertificateReportUploader.tsx

461 lines
17 KiB
TypeScript
Raw Permalink 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 Select from '@/components/ui/Select'
import { useMemo, useRef, useState } from 'react'
import {
CERTIFICATE_REPORT_KIND,
CERTIFICATE_REPORT_MATERIAL_TYPE,
CERTIFICATE_REPORT_PLATFORM_OPTIONS,
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
detectFileKindFromName,
getDefaultCertificateExpiryDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
import { FileUp } from 'lucide-react'
import { dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass, uploadAreaButtonClass } from './upload-field-styles'
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 [reportKind, setReportKind] = useState<string>(CERTIFICATE_REPORT_KIND.FINISHED)
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const defaultExpiryDate = getDefaultCertificateExpiryDate(certificateTestedOn)?.toISOString().slice(0, 10) || ''
const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
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 () => {
const submittedProductModel = reportKind === CERTIFICATE_REPORT_KIND.RAW ? '' : productModel.trim()
if (reportKind === CERTIFICATE_REPORT_KIND.FINISHED && !reportTitle.trim()) {
setError('请填写报告名称 / 检测对象')
return
}
const metadata = validateCertificateReportMetadata({
reportKind,
productModel: submittedProductModel,
reportTitle,
certificateTestedOn,
certificateNumber,
certificateExpiresOn,
reportUsageRules,
platformName,
})
if ('error' in metadata) {
setError(metadata.error)
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: submittedProductModel,
reportTitle: reportTitle.trim(),
reportKind,
certificateTestedOn,
certificateNumber: certificateNumber.trim(),
certificateExpiresOn,
reportUsageRules: reportUsageRules.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-5">
{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>
)}
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-gray-700">报告类别 *</legend>
<div className="flex flex-wrap gap-4">
{[CERTIFICATE_REPORT_KIND.FINISHED, CERTIFICATE_REPORT_KIND.RAW].map((kind) => (
<label key={kind} className="flex min-h-11 items-center gap-2 text-sm text-gray-800">
<input
type="radio"
name="certificate-report-kind"
value={kind}
checked={reportKind === kind}
onChange={() => setReportKind(kind)}
disabled={uploading}
className="accent-[#1a1c1d]"
/>
{kind}
</label>
))}
</div>
</fieldset>
<div className={`grid grid-cols-1 gap-4 ${reportKind === CERTIFICATE_REPORT_KIND.FINISHED ? 'md:grid-cols-2' : ''}`}>
<div>
<label htmlFor="certificate-report-title" className={fieldLabelClass}>
报告名称 / 检测对象 *
</label>
<input
id="certificate-report-title"
type="text"
value={reportTitle}
onChange={(event) => setReportTitle(event.target.value)}
placeholder="例如:E0级颗粒板检测报告"
className={fieldControlClass}
required
/>
<p className="mt-1 text-xs text-gray-500">
上传后将作为素材名称显示
</p>
</div>
{reportKind === CERTIFICATE_REPORT_KIND.FINISHED && (
<div>
<label htmlFor="certificate-report-spu" className={fieldLabelClass}>SPU</label>
<input
id="certificate-report-spu"
type="text"
value={productModel}
onChange={(event) => setProductModel(event.target.value)}
placeholder="没有 SPU 可以留空"
className={fieldControlClass}
/>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label htmlFor="certificate-report-category" className={fieldLabelClass}>品类 *</label>
<Select
id="certificate-report-category"
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
className={fieldControlClass}
required
>
<option value="">请选择品类</option>
{productCategories.map((category) => (
<option key={category.id} value={category.name}>
{category.name}
</option>
))}
</Select>
</div>
<div>
<label htmlFor="certificate-report-platform" className={fieldLabelClass}>平台分类</label>
<Select
id="certificate-report-platform"
value={platformName}
onChange={(event) => setPlatformName(event.target.value)}
className={fieldControlClass}
>
<option value="">不选择</option>
{CERTIFICATE_REPORT_PLATFORM_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</Select>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label htmlFor="certificate-report-tested-on" className={fieldLabelClass}>证书检测日期 *</label>
<CertificateDatePicker id="certificate-report-tested-on" label="证书检测日期" value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={uploading} />
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
</div>
<div>
<label htmlFor="certificate-report-expires-on" className={fieldLabelClass}>证书到期时间(选填)</label>
<CertificateDatePicker id="certificate-report-expires-on" label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={uploading} suggestedDate={defaultExpiryDate} placeholder={defaultExpiryDate ? `默认 ${defaultExpiryDate}` : '留空自动计算'} />
<p className="mt-1 text-xs text-gray-500">留空按检测日期顺延一年,也可自行选择日期</p>
</div>
</div>
<div>
<label htmlFor="certificate-report-number" className={fieldLabelClass}>证书编号 *</label>
<input id="certificate-report-number" type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className={fieldControlClass} required />
</div>
<div>
<label htmlFor="certificate-report-rules" className={fieldLabelClass}>检测报告使用规则说明 *</label>
<textarea id="certificate-report-rules" value={reportUsageRules} onChange={(event) => setReportUsageRules(event.target.value)} rows={10} maxLength={10000} className={`${fieldControlClass} resize-y leading-relaxed`} required />
<p className="mt-1 text-xs text-gray-500">已预填完整说明,可按这份报告的实际情况修改</p>
</div>
<div>
<label className={fieldLabelClass}>报告文件 *</label>
<div className={`flex flex-wrap items-center gap-2 p-3 ${dropZoneIdleClass}`}>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,image/*"
onChange={(event) => handleFileSelect(event.target.files)}
className="hidden"
disabled={uploading}
/>
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading} className={`${uploadAreaButtonClass} border border-[#e7e8ea] bg-white`}>
<FileUp className="h-4 w-4" aria-hidden="true" />
选择 PDF / 图片
</button>
<p className={fieldHintClass}>
可混合多份 PDF 和图片 · 分享页按当前顺序展示
</p>
</div>
</div>
{files.length > 0 && (
<div className="rounded-[8px] border border-[#e7e8ea] bg-white">
<div className="flex items-center justify-between border-b border-[#e7e8ea] px-3 py-2">
<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-[#e7e8ea]">
{files.map((item, index) => (
<div key={`${item.file.name}-${index}`} className="flex items-center gap-3 px-3 py-2">
<div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-[8px] bg-[#f3f4f5]">
{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 border-t border-[#e7e8ea] pt-5">
<button type="button" onClick={onCancel} className={outlineButtonClass} disabled={uploading}>
取消
</button>
<button type="button" onClick={handleSubmit} className={inkButtonClass} disabled={uploading}>
{uploading ? '上传中...' : '创建分享素材'}
</button>
</div>
</div>
)
}