diff --git a/Dockerfile b/Dockerfile index 2ed1ff3..674d2fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -100,7 +100,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \ test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices/migration.sql" && \ test -f "/app/prisma/migrations/20260915_add_brand_knowledge_soft_delete/migration.sql" && \ test -f "/app/prisma/migrations/20260918_add_performance_conversion_sync/migration.sql" && \ - test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" + test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \ + test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 @@ -234,6 +235,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_bra COPY --from=builder --chown=root:root /app/prisma/migrations/20260915_add_brand_knowledge_soft_delete ./prisma/migrations/20260915_add_brand_knowledge_soft_delete COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_performance_conversion_sync ./prisma/migrations/20260918_add_performance_conversion_sync COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata +COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index f02b086..f410fff 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -167,7 +167,7 @@ export async function GET( productModel: true, reportTitle: true, reportKind: true, - certificateExpiresOn: true, + certificateTestedOn: true, certificateNumber: true, certificateUsageInstructions: true, reportUsageRules: true, @@ -249,7 +249,7 @@ export async function GET( productModel: true, reportTitle: true, reportKind: true, - certificateExpiresOn: true, + certificateTestedOn: true, certificateNumber: true, certificateUsageInstructions: true, reportUsageRules: true, @@ -385,7 +385,7 @@ export async function GET( productModel: materialWithUser.productModel, reportTitle: materialWithUser.reportTitle || null, reportKind: materialWithUser.reportKind || null, - certificateExpiresOn: materialWithUser.certificateExpiresOn || null, + certificateTestedOn: materialWithUser.certificateTestedOn || null, certificateNumber: materialWithUser.certificateNumber || null, certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null, reportUsageRules: materialWithUser.reportUsageRules || null, @@ -479,7 +479,7 @@ export async function GET( productModel: materialWithUser.productModel, reportTitle: materialWithUser.reportTitle || null, reportKind: materialWithUser.reportKind || null, - certificateExpiresOn: materialWithUser.certificateExpiresOn || null, + certificateTestedOn: materialWithUser.certificateTestedOn || null, certificateNumber: materialWithUser.certificateNumber || null, certificateUsageInstructions: materialWithUser.certificateUsageInstructions || null, reportUsageRules: materialWithUser.reportUsageRules || null, @@ -613,7 +613,7 @@ export async function PUT( ? { reportTitle: formData.get('reportTitle') as string | null } : {}), ...(formData.has('reportKind') ? { reportKind: formData.get('reportKind') as string | null } : {}), - ...(formData.has('certificateExpiresOn') ? { certificateExpiresOn: formData.get('certificateExpiresOn') 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('reportUsageRules') ? { reportUsageRules: formData.get('reportUsageRules') as string | null } : {}), diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index ca8745e..8f86dd0 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -126,7 +126,7 @@ export async function GET(request: NextRequest) { productModel: true, reportTitle: true, reportKind: true, - certificateExpiresOn: true, + certificateTestedOn: true, certificateNumber: true, certificateUsageInstructions: true, reportUsageRules: true, @@ -362,7 +362,7 @@ export async function GET(request: NextRequest) { videoSourceUrl: canAccessSource ? material.videoSourceUrl : null, reportTitle: (material as any).reportTitle || null, reportKind: (material as any).reportKind || null, - certificateExpiresOn: (material as any).certificateExpiresOn || null, + certificateTestedOn: (material as any).certificateTestedOn || null, certificateNumber: (material as any).certificateNumber || null, certificateUsageInstructions: (material as any).certificateUsageInstructions || null, reportUsageRules: (material as any).reportUsageRules || null, diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index e54d616..87d80af 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -46,7 +46,7 @@ export async function PUT(request: NextRequest) { ? { reportTitle: formData.get('reportTitle') as string | null } : {}), ...(formData.has('reportKind') ? { reportKind: formData.get('reportKind') as string | null } : {}), - ...(formData.has('certificateExpiresOn') ? { certificateExpiresOn: formData.get('certificateExpiresOn') 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('reportUsageRules') ? { reportUsageRules: formData.get('reportUsageRules') as string | null } : {}), diff --git a/app/api/materials/upload/certificate-report/init/route.test.ts b/app/api/materials/upload/certificate-report/init/route.test.ts index 343e6b1..e2f47af 100644 --- a/app/api/materials/upload/certificate-report/init/route.test.ts +++ b/app/api/materials/upload/certificate-report/init/route.test.ts @@ -20,7 +20,7 @@ const input = { reportKind: '原材料', productCategoryName: '板材', platformName: '品牌', - certificateExpiresOn: '2027-09-23', + certificateTestedOn: '2027-09-23', certificateNumber: 'CMA-2026-001', certificateUsageInstructions: '使用前核对送检主体', reportUsageRules: '完整说明\n第二行', @@ -48,7 +48,7 @@ describe('检测报告初始化', () => { expect(prisma.material.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportKind: '原材料', - certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'), + certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'), certificateNumber: 'CMA-2026-001', certificateUsageInstructions: '使用前核对送检主体', reportUsageRules: '完整说明\n第二行', diff --git a/app/api/materials/upload/certificate-report/init/route.ts b/app/api/materials/upload/certificate-report/init/route.ts index d845611..f5b5c36 100644 --- a/app/api/materials/upload/certificate-report/init/route.ts +++ b/app/api/materials/upload/certificate-report/init/route.ts @@ -21,7 +21,7 @@ export async function POST(request: NextRequest) { reportKind: body.reportKind, productModel, reportTitle, - certificateExpiresOn: body.certificateExpiresOn, + certificateTestedOn: body.certificateTestedOn, certificateNumber: body.certificateNumber, certificateUsageInstructions: body.certificateUsageInstructions, reportUsageRules: body.reportUsageRules, diff --git a/app/edit/[id]/page.tsx b/app/edit/[id]/page.tsx index 3cae8fc..2b60632 100644 --- a/app/edit/[id]/page.tsx +++ b/app/edit/[id]/page.tsx @@ -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 CertificateExpiryDatePicker from '@/components/upload/CertificateExpiryDatePicker' +import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker' import { canManuallyReplacePreview, getSelectableMaterialTypeOptions, @@ -36,7 +36,7 @@ interface Material { productModel: string reportTitle?: string | null reportKind?: string | null - certificateExpiresOn?: string | Date | null + certificateTestedOn?: string | Date | null certificateNumber?: string | null certificateUsageInstructions?: string | null reportUsageRules?: string | null @@ -109,7 +109,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) const [productModel, setProductModel] = useState('') const [reportTitle, setReportTitle] = useState('') const [reportKind, setReportKind] = useState('') - const [certificateExpiresOn, setCertificateExpiresOn] = useState('') + const [certificateTestedOn, setCertificateTestedOn] = useState('') const [certificateNumber, setCertificateNumber] = useState('') const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('') const [reportUsageRules, setReportUsageRules] = useState('') @@ -161,7 +161,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) productModel, reportTitle, reportKind, - certificateExpiresOn, + certificateTestedOn, certificateNumber, certificateUsageInstructions, reportUsageRules, @@ -189,7 +189,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) renderer, reportTitle, reportKind, - certificateExpiresOn, + certificateTestedOn, certificateNumber, certificateUsageInstructions, reportUsageRules, @@ -304,7 +304,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) setProductModel(data.productModel || '') setReportTitle(data.reportTitle || '') setReportKind(data.reportKind || '') - setCertificateExpiresOn(data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '') + setCertificateTestedOn(data.certificateTestedOn ? String(data.certificateTestedOn).slice(0, 10) : '') setCertificateNumber(data.certificateNumber || '') setCertificateUsageInstructions(data.certificateUsageInstructions || '') setReportUsageRules(data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : '')) @@ -347,7 +347,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) productModel: data.productModel || '', reportTitle: data.reportTitle || '', reportKind: data.reportKind || '', - certificateExpiresOn: data.certificateExpiresOn ? String(data.certificateExpiresOn).slice(0, 10) : '', + certificateTestedOn: data.certificateTestedOn ? String(data.certificateTestedOn).slice(0, 10) : '', certificateNumber: data.certificateNumber || '', certificateUsageInstructions: data.certificateUsageInstructions || '', reportUsageRules: data.reportUsageRules || (isCertificateReportMaterialType(data.materialType) ? DEFAULT_CERTIFICATE_REPORT_USAGE_RULES : ''), @@ -524,7 +524,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) reportKind, productModel, reportTitle, - certificateExpiresOn, + certificateTestedOn, certificateNumber, certificateUsageInstructions, reportUsageRules, @@ -590,7 +590,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } }) formData.append('reportTitle', reportTitle) if (isCertificateReport) { formData.append('reportKind', reportKind) - formData.append('certificateExpiresOn', certificateExpiresOn) + formData.append('certificateTestedOn', certificateTestedOn) formData.append('certificateNumber', certificateNumber) formData.append('certificateUsageInstructions', certificateUsageInstructions) formData.append('reportUsageRules', reportUsageRules) @@ -1064,9 +1064,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } })

证书信息

- - -

按证书标注日期选择,不自动推算有效期

+ + +

请按报告标注的检测日期选择

diff --git a/app/share/[id]/page.test.tsx b/app/share/[id]/page.test.tsx index 5b1f8aa..0bc2152 100644 --- a/app/share/[id]/page.test.tsx +++ b/app/share/[id]/page.test.tsx @@ -248,7 +248,8 @@ describe('certificate report share page', () => { productModel: '', reportTitle: '板材检测报告', reportKind: '原材料', - certificateExpiresOn: '2027-09-23T00:00:00.000Z', + certificateExpiresOn: '2028-09-23T00:00:00.000Z', + certificateTestedOn: '2027-09-23T00:00:00.000Z', certificateNumber: 'CMA-2026-001', certificateUsageInstructions: '使用前核对主体', reportUsageRules: '第一段\n第二段', @@ -269,6 +270,7 @@ describe('certificate report share page', () => { render() 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.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent) .toBe('第一段\n第二段') diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 930053d..3b1ffdf 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -39,7 +39,7 @@ interface Material { productModel: string reportTitle?: string | null reportKind?: string | null - certificateExpiresOn?: string | null + certificateTestedOn?: string | null certificateNumber?: string | null certificateUsageInstructions?: string | null reportUsageRules?: string | null @@ -2082,8 +2082,8 @@ export default function SharePage() {

{material.platformName || '未填写'}

-

证书到期日

-

{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '待补充'}

+

证书检测日期

+

{material.certificateTestedOn ? String(material.certificateTestedOn).slice(0, 10) : '待补充'}

证书编号

diff --git a/components/upload/CertificateReportUploader.tsx b/components/upload/CertificateReportUploader.tsx index 75f31b0..a5970b4 100644 --- a/components/upload/CertificateReportUploader.tsx +++ b/components/upload/CertificateReportUploader.tsx @@ -9,7 +9,7 @@ import { detectFileKindFromName, validateCertificateReportMetadata, } from '@/lib/certificate-report' -import CertificateExpiryDatePicker from '@/components/upload/CertificateExpiryDatePicker' +import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker' interface CategoryItem { id: string @@ -37,7 +37,7 @@ export default function CertificateReportUploader({ const [productModel, setProductModel] = useState('') const [reportTitle, setReportTitle] = useState('') const [reportKind, setReportKind] = useState(CERTIFICATE_REPORT_KIND.FINISHED) - const [certificateExpiresOn, setCertificateExpiresOn] = useState('') + const [certificateTestedOn, setCertificateTestedOn] = useState('') const [certificateNumber, setCertificateNumber] = useState('') const [certificateUsageInstructions, setCertificateUsageInstructions] = useState('') const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES) @@ -122,7 +122,7 @@ export default function CertificateReportUploader({ reportKind, productModel: submittedProductModel, reportTitle, - certificateExpiresOn, + certificateTestedOn, certificateNumber, certificateUsageInstructions, reportUsageRules, @@ -156,7 +156,7 @@ export default function CertificateReportUploader({ productModel: submittedProductModel, reportTitle: reportTitle.trim(), reportKind, - certificateExpiresOn, + certificateTestedOn, certificateNumber: certificateNumber.trim(), certificateUsageInstructions: certificateUsageInstructions.trim(), reportUsageRules: reportUsageRules.trim(), @@ -343,9 +343,9 @@ export default function CertificateReportUploader({
- - -

请按证书标注日期选择,不自动推算有效期

+ + +

请按报告标注的检测日期选择

diff --git a/components/upload/CertificateExpiryDatePicker.test.tsx b/components/upload/CertificateTestDatePicker.test.tsx similarity index 52% rename from components/upload/CertificateExpiryDatePicker.test.tsx rename to components/upload/CertificateTestDatePicker.test.tsx index e051ea3..43381f6 100644 --- a/components/upload/CertificateExpiryDatePicker.test.tsx +++ b/components/upload/CertificateTestDatePicker.test.tsx @@ -1,58 +1,58 @@ import { fireEvent, render, screen } from '@testing-library/react' -import CertificateExpiryDatePicker from '@/components/upload/CertificateExpiryDatePicker' +import CertificateTestDatePicker from '@/components/upload/CertificateTestDatePicker' -describe('CertificateExpiryDatePicker', () => { +describe('CertificateTestDatePicker', () => { test('日历图标可反复展开和收起,Escape 也会关闭', () => { - render() + render() - const trigger = screen.getByRole('button', { name: /证书到期日/ }) + const trigger = screen.getByRole('button', { name: /证书检测日期/ }) const icon = trigger.querySelector('svg') expect(icon).not.toBeNull() fireEvent.click(icon!) - expect(screen.getByRole('dialog', { name: '选择证书到期日' })).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: '选择证书检测日期' })).toBeInTheDocument() expect(trigger).toHaveAttribute('aria-expanded', 'true') fireEvent.click(icon!) - expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument() expect(trigger).toHaveAttribute('aria-expanded', 'false') fireEvent.click(icon!) fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument() expect(trigger).toHaveFocus() }) test('按日历选择日期时返回统一格式,并关闭日历', () => { const onChange = jest.fn() - render() + render() - fireEvent.click(screen.getByRole('button', { name: /证书到期日/ })) + fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ })) expect(screen.getByText('2026年9月')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '下个月' })) expect(screen.getByText('2026年10月')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '2026年10月5日' })) expect(onChange).toHaveBeenCalledWith('2026-10-05') - expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument() }) test('点击日历外部会关闭,不改动已选择的日期', () => { const onChange = jest.fn() - render() + render() - fireEvent.click(screen.getByRole('button', { name: /证书到期日/ })) + fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ })) fireEvent.pointerDown(document.body) - expect(screen.queryByRole('dialog', { name: '选择证书到期日' })).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: '选择证书检测日期' })).not.toBeInTheDocument() expect(onChange).not.toHaveBeenCalled() }) - test('可以直接切换年份和月份选择较远的到期日', () => { + test('可以直接切换年份和月份选择较远的检测日期', () => { const onChange = jest.fn() - render() + render() - fireEvent.click(screen.getByRole('button', { name: /证书到期日/ })) + fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ })) fireEvent.click(screen.getByRole('button', { name: '选择年份和月份' })) fireEvent.click(screen.getByRole('button', { name: '下一年' })) fireEvent.click(screen.getByRole('button', { name: '12月' })) diff --git a/components/upload/CertificateExpiryDatePicker.tsx b/components/upload/CertificateTestDatePicker.tsx similarity index 96% rename from components/upload/CertificateExpiryDatePicker.tsx rename to components/upload/CertificateTestDatePicker.tsx index b1396f1..186e572 100644 --- a/components/upload/CertificateExpiryDatePicker.tsx +++ b/components/upload/CertificateTestDatePicker.tsx @@ -12,14 +12,14 @@ function parseDate(value: string): Date | null { return isValid(date) && format(date, 'yyyy-MM-dd') === value ? date : null } -interface CertificateExpiryDatePickerProps { +interface CertificateTestDatePickerProps { id: string value: string onChange: (value: string) => void disabled?: boolean } -export default function CertificateExpiryDatePicker({ id, value, onChange, disabled = false }: CertificateExpiryDatePickerProps) { +export default function CertificateTestDatePicker({ id, value, onChange, disabled = false }: CertificateTestDatePickerProps) { const containerRef = useRef(null) const triggerRef = useRef(null) const calendarRef = useRef(null) @@ -91,7 +91,7 @@ export default function CertificateExpiryDatePicker({ id, value, onChange, disab id={id} type="button" disabled={disabled} - aria-label={`证书到期日,${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`} + aria-label={`证书检测日期,${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`} aria-haspopup="dialog" aria-expanded={isOpen} aria-controls={`${id}-calendar`} @@ -102,7 +102,7 @@ export default function CertificateExpiryDatePicker({ id, value, onChange, disab className="input-apple flex min-h-[50px] items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60" > - {selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择到期日期'} + {selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择检测日期'}