feat: simplify report instructions and restore certificate expiry date

This commit is contained in:
WuYongJie 2026-09-27 15:36:29 +08:00
parent 41ecfb6d36
commit cb510075fc
18 changed files with 183 additions and 86 deletions

View File

@ -169,7 +169,7 @@ export async function GET(
reportKind: true,
certificateTestedOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
certificateExpiresOn: true,
reportUsageRules: true,
liveDate: true,
liveHost: true,
@ -251,7 +251,7 @@ export async function GET(
reportKind: true,
certificateTestedOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
certificateExpiresOn: true,
reportUsageRules: true,
liveDate: true,
liveHost: true,
@ -387,7 +387,7 @@ export async function GET(
reportKind: materialWithUser.reportKind || null,
certificateTestedOn: materialWithUser.certificateTestedOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
reportUsageRules: materialWithUser.reportUsageRules || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
@ -481,7 +481,7 @@ export async function GET(
reportKind: materialWithUser.reportKind || null,
certificateTestedOn: materialWithUser.certificateTestedOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
reportUsageRules: materialWithUser.reportUsageRules || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,
@ -617,7 +617,7 @@ export async function PUT(
...(formData.has('reportKind') ? { reportKind: formData.get('reportKind') as string | null } : {}),
...(formData.has('certificateTestedOn') ? { certificateTestedOn: formData.get('certificateTestedOn') as string | null } : {}),
...(formData.has('certificateNumber') ? { certificateNumber: formData.get('certificateNumber') as string | null } : {}),
...(formData.has('certificateUsageInstructions') ? { certificateUsageInstructions: formData.get('certificateUsageInstructions') as string | null } : {}),
...(formData.has('certificateExpiresOn') ? { certificateExpiresOn: formData.get('certificateExpiresOn') as string | null } : {}),
...(formData.has('reportUsageRules') ? { reportUsageRules: formData.get('reportUsageRules') as string | null } : {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,

View File

@ -128,7 +128,7 @@ export async function GET(request: NextRequest) {
reportKind: true,
certificateTestedOn: true,
certificateNumber: true,
certificateUsageInstructions: true,
certificateExpiresOn: true,
reportUsageRules: true,
liveDate: true,
liveHost: true,
@ -364,7 +364,7 @@ export async function GET(request: NextRequest) {
reportKind: (material as any).reportKind || null,
certificateTestedOn: (material as any).certificateTestedOn || null,
certificateNumber: (material as any).certificateNumber || null,
certificateUsageInstructions: (material as any).certificateUsageInstructions || null,
certificateExpiresOn: (material as any).certificateExpiresOn || null,
reportUsageRules: (material as any).reportUsageRules || null,
isAiGenerated: Boolean((material as any).isAiGenerated),
taskDetailSourceType: (material as any).taskDetailSourceType || null,

View File

@ -48,7 +48,7 @@ export async function PUT(request: NextRequest) {
...(formData.has('reportKind') ? { reportKind: formData.get('reportKind') as string | null } : {}),
...(formData.has('certificateTestedOn') ? { certificateTestedOn: formData.get('certificateTestedOn') as string | null } : {}),
...(formData.has('certificateNumber') ? { certificateNumber: formData.get('certificateNumber') as string | null } : {}),
...(formData.has('certificateUsageInstructions') ? { certificateUsageInstructions: formData.get('certificateUsageInstructions') as string | null } : {}),
...(formData.has('certificateExpiresOn') ? { certificateExpiresOn: formData.get('certificateExpiresOn') as string | null } : {}),
...(formData.has('reportUsageRules') ? { reportUsageRules: formData.get('reportUsageRules') as string | null } : {}),
categoryName: formData.get('categoryName') as string | null,
sceneName: formData.get('sceneName') as string | null,

View File

@ -22,7 +22,7 @@ const input = {
platformName: '品牌',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
certificateExpiresOn: '2028-09-23',
reportUsageRules: '完整说明\n第二行',
}
@ -50,7 +50,7 @@ describe('检测报告初始化', () => {
reportKind: '原材料',
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
certificateExpiresOn: new Date('2028-09-23T00:00:00.000Z'),
reportUsageRules: '完整说明\n第二行',
platformName: '品牌',
}),
@ -62,4 +62,10 @@ describe('检测报告初始化', () => {
expect(response.status).toBe(400)
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('到期时间早于检测日期时不写入素材', async () => {
const response = await POST(request({ ...input, certificateExpiresOn: '2027-09-22' }))
expect(response.status).toBe(400)
expect(prisma.material.create).not.toHaveBeenCalled()
})
})

View File

@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
reportTitle,
certificateTestedOn: body.certificateTestedOn,
certificateNumber: body.certificateNumber,
certificateUsageInstructions: body.certificateUsageInstructions,
certificateExpiresOn: body.certificateExpiresOn,
reportUsageRules: body.reportUsageRules,
platformName,
})

View File

@ -7,7 +7,7 @@ import Header from '@/components/Header'
import SearchableUserSelect from '@/components/SearchableUserSelect'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
import TaskReferenceFields from '@/components/upload/TaskReferenceFields'
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
import {
canManuallyReplacePreview,
getSelectableMaterialTypeOptions,
@ -38,7 +38,7 @@ interface Material {
reportKind?: string | null
certificateTestedOn?: string | Date | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
certificateExpiresOn?: string | null
reportUsageRules?: string | null
liveDate?: Date | string | null
liveHost?: string | null
@ -111,7 +111,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [reportKind, setReportKind] = useState('')
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const [reportUsageRules, setReportUsageRules] = useState('')
const [liveDate, setLiveDate] = useState('')
const [liveHost, setLiveHost] = useState('')
@ -163,7 +163,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
reportKind,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
certificateExpiresOn,
reportUsageRules,
categoryName,
sceneName,
@ -191,7 +191,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
reportKind,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
certificateExpiresOn,
reportUsageRules,
sceneName,
taskDetailRequesterUserId,
@ -306,7 +306,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
setReportKind(data.reportKind || '')
setCertificateTestedOn(data.certificateTestedOn ? String(data.certificateTestedOn).slice(0, 10) : '')
setCertificateNumber(data.certificateNumber || '')
setCertificateUsageInstructions(data.certificateUsageInstructions || '')
setCertificateExpiresOn(data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '')
setReportUsageRules(data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''))
setLiveDate(data.liveDate ? String(data.liveDate).slice(0, 10) : '')
setLiveHost(data.liveHost || '')
@ -349,7 +349,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
reportKind: data.reportKind || '',
certificateTestedOn: data.certificateTestedOn ? String(data.certificateTestedOn).slice(0, 10) : '',
certificateNumber: data.certificateNumber || '',
certificateUsageInstructions: data.certificateUsageInstructions || '',
certificateExpiresOn: data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '',
reportUsageRules: data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''),
categoryName: data.categoryName || '',
sceneName: data.sceneName || '',
@ -526,7 +526,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
reportTitle,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
certificateExpiresOn,
reportUsageRules,
platformName,
}, material?.materialType === materialType ? material?.platformName || '' : '')
@ -592,7 +592,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
formData.append('reportKind', reportKind)
formData.append('certificateTestedOn', certificateTestedOn)
formData.append('certificateNumber', certificateNumber)
formData.append('certificateUsageInstructions', certificateUsageInstructions)
formData.append('certificateExpiresOn', certificateExpiresOn)
formData.append('reportUsageRules', reportUsageRules)
}
if (materialType === '直播切片') {
@ -1065,18 +1065,18 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor={`edit-material-${params.id}-certificate-tested-on`} className="mb-2 block text-sm font-medium text-gray-700">证书检测日期 *</label>
<CertificateTestDatePicker id={`edit-material-${params.id}-certificate-tested-on`} value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={submitting} />
<CertificateDatePicker id={`edit-material-${params.id}-certificate-tested-on`} label="证书检测日期" value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={submitting} />
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-certificate-expires-on`} className="mb-2 block text-sm font-medium text-gray-700">证书到期时间 *</label>
<CertificateDatePicker id={`edit-material-${params.id}-certificate-expires-on`} label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={submitting} />
</div>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-certificate-number`} className="mb-2 block text-sm font-medium text-gray-700">证书编号 *</label>
<input id={`edit-material-${params.id}-certificate-number`} type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className="input-apple" required />
</div>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-certificate-instructions`} className="mb-2 block text-sm font-medium text-gray-700">证书使用说明 *</label>
<textarea id={`edit-material-${params.id}-certificate-instructions`} value={certificateUsageInstructions} onChange={(event) => setCertificateUsageInstructions(event.target.value)} rows={3} maxLength={5000} className="input-apple resize-y" required />
</div>
<div>
<label htmlFor={`edit-material-${params.id}-report-rules`} className="mb-2 block text-sm font-medium text-gray-700">检测报告使用规则说明 *</label>
<textarea id={`edit-material-${params.id}-report-rules`} value={reportUsageRules} onChange={(event) => setReportUsageRules(event.target.value)} rows={10} maxLength={10000} className="input-apple resize-y leading-relaxed" required />

View File

@ -270,8 +270,9 @@ describe('certificate report share page', () => {
render(<SharePage />)
expect(await screen.findByText('CMA-2026-001')).toBeInTheDocument()
expect(screen.getByText('2027-09-23')).toBeInTheDocument()
expect(screen.queryByText('2028-09-23')).not.toBeInTheDocument()
expect(screen.getByText('使用前核对主体')).toBeInTheDocument()
expect(screen.getByText('2028-09-23')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: '证书使用说明' })).not.toBeInTheDocument()
expect(screen.queryByText('使用前核对主体')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent)
.toBe('第一段\n第二段')
})

View File

@ -41,7 +41,7 @@ interface Material {
reportKind?: string | null
certificateTestedOn?: string | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
certificateExpiresOn?: string | null
reportUsageRules?: string | null
liveDate?: string | Date | null
liveHost?: string | null
@ -2085,6 +2085,10 @@ export default function SharePage() {
<p className="mb-1 text-[12px] text-[#86898e]">证书检测日期</p>
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateTestedOn ? String(material.certificateTestedOn).slice(0, 10) : '待补充'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书到期时间</p>
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '待补充'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书编号</p>
<p className="break-all text-[15px] font-medium text-[#1a1c1d]">{material.certificateNumber || '待补充'}</p>
@ -2244,16 +2248,10 @@ export default function SharePage() {
</div>
{isCertificateReportMaterialType(material.materialType) && (
<section className="mt-6 rounded-[12px] border border-[#e7e8ea] bg-white px-5 py-6 sm:px-7" aria-label="检测报告使用说明">
<div className="max-w-[75ch] space-y-6">
<div>
<h2 className="text-[17px] font-semibold text-[#1a1c1d]">证书使用说明</h2>
<p className="mt-3 whitespace-pre-wrap break-words text-[14px] leading-7 text-[#3d4246]">{material.certificateUsageInstructions || '待补充'}</p>
</div>
<div className="border-t border-[#e7e8ea] pt-6">
<div className="max-w-[75ch]">
<h2 className="text-[17px] font-semibold text-[#1a1c1d]">检测报告使用规则说明</h2>
<p className="mt-3 whitespace-pre-wrap break-words text-[14px] leading-7 text-[#3d4246]">{material.reportUsageRules || '待补充'}</p>
</div>
</div>
</section>
)}
</>

View File

@ -1,9 +1,27 @@
import { fireEvent, render, screen } from '@testing-library/react'
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
describe('CertificateDatePicker', () => {
test('检测日期和到期时间分别选择,日历使用独立的名称和状态', () => {
const onTested = jest.fn()
const onExpires = jest.fn()
render(<>
<CertificateDatePicker id="tested-on" label="证书检测日期" value="2026-09-23" onChange={onTested} />
<CertificateDatePicker id="expires-on" label="证书到期时间" value="2027-09-23" onChange={onExpires} />
</>)
const expires = screen.getByRole('button', { name: /证书到期时间/ })
fireEvent.click(expires)
expect(screen.getByRole('dialog', { name: '选择证书到期时间' })).toBeInTheDocument()
fireEvent.click(expires)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
fireEvent.click(expires)
fireEvent.click(screen.getByRole('button', { name: '2027年9月24日' }))
expect(onExpires).toHaveBeenCalledWith('2027-09-24')
expect(onTested).not.toHaveBeenCalled()
})
describe('CertificateTestDatePicker', () => {
test('日历图标可反复展开和收起,Escape 也会关闭', () => {
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={jest.fn()} />)
render(<CertificateDatePicker id="tested-on" label="证书检测日期" value="2026-09-23" onChange={jest.fn()} />)
const trigger = screen.getByRole('button', { name: /证书检测日期/ })
const icon = trigger.querySelector('svg')
@ -25,7 +43,7 @@ describe('CertificateTestDatePicker', () => {
test('按日历选择日期时返回统一格式,并关闭日历', () => {
const onChange = jest.fn()
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={onChange} />)
render(<CertificateDatePicker id="tested-on" label="证书检测日期" value="2026-09-23" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
expect(screen.getByText('2026年9月')).toBeInTheDocument()
@ -39,7 +57,7 @@ describe('CertificateTestDatePicker', () => {
test('点击日历外部会关闭,不改动已选择的日期', () => {
const onChange = jest.fn()
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={onChange} />)
render(<CertificateDatePicker id="tested-on" label="证书检测日期" value="2026-09-23" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.pointerDown(document.body)
@ -50,7 +68,7 @@ describe('CertificateTestDatePicker', () => {
test('可以直接切换年份和月份选择较远的检测日期', () => {
const onChange = jest.fn()
render(<CertificateTestDatePicker id="tested-on" value="2026-09-23" onChange={onChange} />)
render(<CertificateDatePicker id="tested-on" label="证书检测日期" value="2026-09-23" onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.click(screen.getByRole('button', { name: '选择年份和月份' }))

View File

@ -12,14 +12,15 @@ function parseDate(value: string): Date | null {
return isValid(date) && format(date, 'yyyy-MM-dd') === value ? date : null
}
interface CertificateTestDatePickerProps {
interface CertificateDatePickerProps {
id: string
label: string
value: string
onChange: (value: string) => void
disabled?: boolean
}
export default function CertificateTestDatePicker({ id, value, onChange, disabled = false }: CertificateTestDatePickerProps) {
export default function CertificateDatePicker({ id, label, value, onChange, disabled = false }: CertificateDatePickerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const calendarRef = useRef<HTMLDivElement>(null)
@ -91,7 +92,7 @@ export default function CertificateTestDatePicker({ id, value, onChange, disable
id={id}
type="button"
disabled={disabled}
aria-label={`证书检测日期,${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`}
aria-label={`${label},${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={`${id}-calendar`}
@ -102,7 +103,7 @@ export default function CertificateTestDatePicker({ id, value, onChange, disable
className="input-apple flex min-h-[50px] items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
<span className={selectedDate ? 'text-[#1a1c1d]' : 'text-gray-500'}>
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择检测日期'}
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择日期'}
</span>
<CalendarDays className="h-[18px] w-[18px] shrink-0 text-[#5c6470]" aria-hidden="true" />
</button>
@ -112,7 +113,7 @@ export default function CertificateTestDatePicker({ id, value, onChange, disable
ref={calendarRef}
id={`${id}-calendar`}
role="dialog"
aria-label="选择证书检测日期"
aria-label={`选择${label}`}
className="absolute left-1/2 top-full z-40 mt-2 w-[320px] max-w-[calc(100vw-3rem)] -translate-x-1/2 rounded-[12px] border border-[#e7e8ea] bg-white p-3 text-[#1a1c1d] shadow-[0_12px_28px_rgba(26,28,29,0.10)]"
>
<div className="mb-3 flex items-center justify-between gap-2">

View File

@ -0,0 +1,44 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CertificateReportUploader from './CertificateReportUploader'
import { chunkUpload } from '@/lib/chunk-upload'
import { DEFAULT_CERTIFICATE_REPORT_USAGE_RULES } from '@/lib/certificate-report'
jest.mock('@/lib/chunk-upload', () => ({ chunkUpload: jest.fn() }))
test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提交检测日期和到期时间', async (kind) => {
const originalFetch = global.fetch
jest.useFakeTimers().setSystemTime(new Date('2026-09-27T12:00:00'))
const { container } = render(<CertificateReportUploader onSuccess={jest.fn()} onCancel={jest.fn()} productCategories={[{ id: '1', name: '板材' }]} />)
try {
expect(screen.getByLabelText('成品')).toBeChecked()
fireEvent.click(screen.getByLabelText(kind))
fireEvent.change(screen.getByLabelText(/报告名称 \/ 检测对象/), { target: { value: '板材检测报告' } })
fireEvent.change(screen.getByLabelText('品类 *'), { target: { value: '板材' } })
fireEvent.change(screen.getByLabelText('证书编号 *'), { target: { value: 'CMA-001' } })
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.click(screen.getByRole('button', { name: '2026年9月23日' }))
fireEvent.click(screen.getByRole('button', { name: /证书到期时间/ }))
fireEvent.click(screen.getByRole('button', { name: '选择年份和月份' }))
fireEvent.click(screen.getByRole('button', { name: '下一年' }))
fireEvent.click(screen.getByRole('button', { name: '9月' }))
fireEvent.click(screen.getByRole('button', { name: '2027年9月23日' }))
expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument()
expect(screen.getAllByRole('textbox').filter((input) => input.tagName === 'TEXTAREA')).toHaveLength(1)
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
const pdf = new File(['%PDF-1.4'], '报告.pdf', { type: 'application/pdf' })
fireEvent.change(container.querySelector('input[type="file"]')!, { target: { files: [pdf] } })
jest.useRealTimers()
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ materialId: 'report-1' }) })
;(chunkUpload as jest.Mock).mockResolvedValue({ uploadId: 'file-1' })
fireEvent.click(screen.getByRole('button', { name: '创建分享素材' }))
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2))
const [url, init] = (global.fetch as jest.Mock).mock.calls[0]
expect(url).toBe('/api/materials/upload/certificate-report/init')
const payload = JSON.parse(init.body)
expect(payload).toMatchObject({ reportKind: kind, certificateTestedOn: '2026-09-23', certificateExpiresOn: '2027-09-23', reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES })
expect(payload).not.toHaveProperty('certificateUsageInstructions')
} finally {
jest.useRealTimers()
global.fetch = originalFetch
}
})

View File

@ -9,7 +9,7 @@ import {
detectFileKindFromName,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
import { FileUp } from 'lucide-react'
import { dropZoneIdleClass, fieldControlClass, fieldHintClass, fieldLabelClass, inkButtonClass, outlineButtonClass, uploadAreaButtonClass } from './upload-field-styles'
@ -41,7 +41,7 @@ export default function CertificateReportUploader({
const [reportKind, setReportKind] = useState<string>(CERTIFICATE_REPORT_KIND.FINISHED)
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
const [productCategoryName, setProductCategoryName] = useState('')
const [platformName, setPlatformName] = useState('')
@ -126,7 +126,7 @@ export default function CertificateReportUploader({
reportTitle,
certificateTestedOn,
certificateNumber,
certificateUsageInstructions,
certificateExpiresOn,
reportUsageRules,
platformName,
})
@ -160,7 +160,7 @@ export default function CertificateReportUploader({
reportKind,
certificateTestedOn,
certificateNumber: certificateNumber.trim(),
certificateUsageInstructions: certificateUsageInstructions.trim(),
certificateExpiresOn,
reportUsageRules: reportUsageRules.trim(),
productCategoryName: productCategoryName.trim(),
platformName: platformName.trim(),
@ -313,8 +313,9 @@ export default function CertificateReportUploader({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className={fieldLabelClass}>品类 *</label>
<label htmlFor="certificate-report-category" className={fieldLabelClass}>品类 *</label>
<select
id="certificate-report-category"
value={productCategoryName}
onChange={(event) => setProductCategoryName(event.target.value)}
className={fieldControlClass}
@ -346,18 +347,18 @@ export default function CertificateReportUploader({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label htmlFor="certificate-report-tested-on" className={fieldLabelClass}>证书检测日期 *</label>
<CertificateTestDatePicker id="certificate-report-tested-on" value={certificateTestedOn} onChange={setCertificateTestedOn} disabled={uploading} />
<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-number" className={fieldLabelClass}>证书编号 *</label>
<input id="certificate-report-number" type="text" value={certificateNumber} onChange={(event) => setCertificateNumber(event.target.value)} maxLength={120} className={fieldControlClass} required />
<label htmlFor="certificate-report-expires-on" className={fieldLabelClass}>证书到期时间 *</label>
<CertificateDatePicker id="certificate-report-expires-on" label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={uploading} />
</div>
</div>
<div>
<label htmlFor="certificate-report-instructions" className={fieldLabelClass}>证书使用说明 *</label>
<textarea id="certificate-report-instructions" value={certificateUsageInstructions} onChange={(event) => setCertificateUsageInstructions(event.target.value)} rows={3} maxLength={5000} className={`${fieldControlClass} resize-y`} required />
<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>

View File

@ -2,7 +2,7 @@ import {
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
getCertificateReportDisplayTitle,
isPublicCertificateReportPdf,
parseCertificateTestDate,
parseCertificateDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
@ -12,7 +12,7 @@ const metadata = {
reportTitle: '',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
certificateExpiresOn: '2028-09-23',
reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
platformName: '大货',
}
@ -47,14 +47,25 @@ describe('检测报告字段规则', () => {
})
test('检测日期必须是真实的 YYYY-MM-DD 日期', () => {
expect(parseCertificateTestDate('2027-02-28')?.toISOString().slice(0, 10)).toBe('2027-02-28')
expect(parseCertificateTestDate('2027-02-30')).toBeNull()
expect(parseCertificateTestDate('2027/02/28')).toBeNull()
expect(parseCertificateDate('2027-02-28')?.toISOString().slice(0, 10)).toBe('2027-02-28')
expect(parseCertificateDate('2027-02-30')).toBeNull()
expect(parseCertificateDate('2027/02/28')).toBeNull()
expect(validateCertificateReportMetadata({ ...metadata, certificateTestedOn: '2027-02-30' })).toEqual({
error: '请选择有效的证书检测日期',
})
})
test('到期时间独立必填,不自动计算,并拒绝早于检测日期的日期', () => {
for (const certificateExpiresOn of ['', undefined, '2028-02-30', '2028/09/23']) {
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn })).toEqual({ error: '请选择有效的证书到期时间' })
}
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-09-22' }))
.toEqual({ error: '证书到期时间不能早于检测日期' })
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-09-23' }))
.toHaveProperty('value.certificateExpiresOn', new Date('2027-09-23T00:00:00.000Z'))
expect(validateCertificateReportMetadata(metadata)).not.toHaveProperty('value.certificateUsageInstructions')
})
test('平台分类只接受报告专用选项,整段预置说明保持为一个文本值', () => {
expect(validateCertificateReportMetadata({ ...metadata, platformName: '品牌' })).toHaveProperty('value')
expect(validateCertificateReportMetadata({ ...metadata, platformName: '其它' })).toEqual({

View File

@ -1,6 +1,6 @@
/** @jest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useSession } from 'next-auth/react'
import EditMaterialPage from '@/app/edit/[id]/page'
@ -203,12 +203,22 @@ describe('EditMaterialPage', () => {
const kindSelect = await screen.findByLabelText('报告类别 *')
expect(screen.getByLabelText('SPU')).toHaveValue('SPU-001')
expect(screen.getByLabelText('证书检测日期 *')).toHaveTextContent('2027年9月23日')
expect(screen.queryByText('2028年9月23日')).not.toBeInTheDocument()
expect(screen.getByLabelText('证书到期时间 *')).toHaveTextContent('2028年9月23日')
expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument()
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue('第一段\n第二段')
expect(screen.getByLabelText('平台分类')).toHaveValue('品牌')
await user.selectOptions(kindSelect, '原材料')
expect(screen.queryByLabelText('SPU')).not.toBeInTheDocument()
expect(screen.getByLabelText('报告名称 / 检测对象 *')).toHaveValue('板材检测报告')
await user.click(screen.getByLabelText('证书到期时间 *'))
await user.click(screen.getByRole('button', { name: '2028年9月24日' }))
fireEvent.submit(screen.getByLabelText('报告名称 / 检测对象 *').closest('form')!)
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true))
const [, init] = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(init.body.get('certificateTestedOn')).toBe('2027-09-23')
expect(init.body.get('certificateExpiresOn')).toBe('2028-09-24')
expect(init.body.get('reportUsageRules')).toBe('第一段\n第二段')
expect(init.body.has('certificateUsageInstructions')).toBe(false)
})
})

View File

@ -63,7 +63,7 @@ describe('material-update shared rules', () => {
reportKind: '成品',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
certificateExpiresOn: '2028-09-23',
reportUsageRules: '完整的报告使用规则说明',
}
const baseMaterial = {
@ -250,7 +250,7 @@ describe('material-update shared rules', () => {
reportKind: '成品',
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
certificateExpiresOn: new Date('2028-09-23T00:00:00.000Z'),
reportUsageRules: '完整的报告使用规则说明',
categoryName: '通用',
sceneName: '通用',
@ -271,7 +271,7 @@ describe('material-update shared rules', () => {
reportKind: '成品',
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
certificateExpiresOn: new Date('2028-09-23T00:00:00.000Z'),
reportUsageRules: '完整的报告使用规则说明',
renderer: '',
}

View File

@ -128,7 +128,12 @@ describe('materials share security', () => {
test.each([null, { user: { id: 'admin-1' } }])('公开检测报告返回 PDF 和图片,保留源文件隔离 (%j)', async (session) => {
;(getServerSession as jest.Mock).mockResolvedValue(session)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'SYSTEM_ADMIN' })
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({ materialType: '证书-检测报告' }))
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '证书-检测报告',
certificateTestedOn: new Date('2026-09-23'),
certificateExpiresOn: new Date('2027-09-23'),
reportUsageRules: '唯一的完整规则说明',
}))
const pdfUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/pdf/0-report.pdf'
const imageUrl = 'https://s3.jiansun.vip/gongxiangpan/public-display/certificate-report/material-1/image/1-report.jpg'
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([
@ -144,6 +149,11 @@ describe('materials share security', () => {
expect(body.assets[1]).toMatchObject({ fileUrl: imageUrl, fileKind: 'image' })
expect(body.assets[2].fileUrl).toBe('')
expect(body.assets[3].fileUrl).toBe('')
expect(body.certificateTestedOn).toBe('2026-09-23T00:00:00.000Z')
expect(body.certificateExpiresOn).toBe('2027-09-23T00:00:00.000Z')
expect(body.reportUsageRules).toBe('唯一的完整规则说明')
expect(body).not.toHaveProperty('certificateUsageInstructions')
expect((prisma.material.findFirst as jest.Mock).mock.calls[0][0].select).not.toHaveProperty('certificateUsageInstructions')
})
test('未满15天的视频分享链接保留有效,但不返回任何视频内容', async () => {

View File

@ -65,7 +65,7 @@ export function normalizeReportTitle(value: string | null | undefined): string |
return normalized || null
}
export function parseCertificateTestDate(value: unknown): Date | null {
export function parseCertificateDate(value: unknown): Date | null {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const parsed = new Date(`${value}T00:00:00.000Z`)
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value
@ -78,8 +78,8 @@ export function validateCertificateReportMetadata(input: {
productModel: unknown
reportTitle: unknown
certificateTestedOn: unknown
certificateExpiresOn: unknown
certificateNumber: unknown
certificateUsageInstructions: unknown
reportUsageRules: unknown
platformName: unknown
}, legacyPlatformName = ''):
@ -87,8 +87,8 @@ export function validateCertificateReportMetadata(input: {
| { value: {
reportKind: CertificateReportKind
certificateTestedOn: Date
certificateExpiresOn: Date
certificateNumber: string
certificateUsageInstructions: string
reportUsageRules: string
} } {
const reportKind = input.reportKind
@ -103,18 +103,15 @@ export function validateCertificateReportMetadata(input: {
if (reportKind === CERTIFICATE_REPORT_KIND.FINISHED && !productModel && !reportTitle) {
return { error: 'SPU 和 报告名称/检测对象 至少填写一个' }
}
const certificateTestedOn = parseCertificateTestDate(input.certificateTestedOn)
const certificateTestedOn = parseCertificateDate(input.certificateTestedOn)
if (!certificateTestedOn) return { error: '请选择有效的证书检测日期' }
const certificateExpiresOn = parseCertificateDate(input.certificateExpiresOn)
if (!certificateExpiresOn) return { error: '请选择有效的证书到期时间' }
if (certificateExpiresOn < certificateTestedOn) return { error: '证书到期时间不能早于检测日期' }
const certificateNumber = typeof input.certificateNumber === 'string' ? input.certificateNumber.trim() : ''
if (!certificateNumber || certificateNumber.length > 120) {
return { error: '请填写证书编号(不超过120字)' }
}
const certificateUsageInstructions = typeof input.certificateUsageInstructions === 'string'
? input.certificateUsageInstructions.trim()
: ''
if (!certificateUsageInstructions || certificateUsageInstructions.length > 5000) {
return { error: '请填写证书使用说明(不超过5000字)' }
}
const reportUsageRules = typeof input.reportUsageRules === 'string' ? input.reportUsageRules.trim() : ''
if (!reportUsageRules || reportUsageRules.length > 10000) {
return { error: '请填写检测报告使用规则说明(不超过10000字)' }
@ -129,8 +126,8 @@ export function validateCertificateReportMetadata(input: {
value: {
reportKind,
certificateTestedOn,
certificateExpiresOn,
certificateNumber,
certificateUsageInstructions,
reportUsageRules,
},
}

View File

@ -43,7 +43,7 @@ export interface MaterialUpdateInput {
reportKind?: string | null
certificateTestedOn?: string | null
certificateNumber?: string | null
certificateUsageInstructions?: string | null
certificateExpiresOn?: string | null
reportUsageRules?: string | null
categoryName?: string | null
sceneName?: string | null
@ -196,7 +196,7 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
const hasReportKindField = Object.prototype.hasOwnProperty.call(input, 'reportKind')
const hasCertificateTestedOnField = Object.prototype.hasOwnProperty.call(input, 'certificateTestedOn')
const hasCertificateNumberField = Object.prototype.hasOwnProperty.call(input, 'certificateNumber')
const hasCertificateUsageInstructionsField = Object.prototype.hasOwnProperty.call(input, 'certificateUsageInstructions')
const hasCertificateExpiresOnField = Object.prototype.hasOwnProperty.call(input, 'certificateExpiresOn')
const hasReportUsageRulesField = Object.prototype.hasOwnProperty.call(input, 'reportUsageRules')
const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo')
const hasDingtalkApprovalNoField = Object.prototype.hasOwnProperty.call(input, 'dingtalkApprovalNo')
@ -237,9 +237,9 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
? input.certificateTestedOn
: material.certificateTestedOn?.toISOString().slice(0, 10),
certificateNumber: hasCertificateNumberField ? input.certificateNumber : material.certificateNumber,
certificateUsageInstructions: hasCertificateUsageInstructionsField
? input.certificateUsageInstructions
: material.certificateUsageInstructions,
certificateExpiresOn: hasCertificateExpiresOnField
? input.certificateExpiresOn
: material.certificateExpiresOn?.toISOString().slice(0, 10),
reportUsageRules: hasReportUsageRulesField ? input.reportUsageRules : material.reportUsageRules,
platformName,
}, isCertificateReportMaterialType(material.materialType) ? material.platformName : '')
@ -389,7 +389,7 @@ async function updateMaterialWithSharedRulesLocked(options: UpdateMaterialOption
reportKind: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportKind : null,
certificateTestedOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateTestedOn : null,
certificateNumber: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateNumber : null,
certificateUsageInstructions: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateUsageInstructions : null,
certificateExpiresOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateExpiresOn : null,
reportUsageRules: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportUsageRules : null,
categoryName: shouldUseGeneralCategoryScene ? '通用' : (categoryName ?? material.categoryName),
sceneName: shouldUseGeneralCategoryScene ? '通用' : (sceneName ?? material.sceneName),