461 lines
17 KiB
TypeScript
461 lines
17 KiB
TypeScript
'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>
|
||
)
|
||
}
|