import { useEffect, useRef, useState } from 'react'; import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; import type { OutputTongyishuFileInfo } from '../../api'; import { getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign, submitDaojiandanSign, editDaojiandanPrintStatus } from '../../api'; import { setExamActionRecord, setTongyishuPdfList, getTongyishuPdfList, setDaojiandanPdf, getDaojiandanPdf as getDaojiandanPdfFromStorage, getAddItemBillPdf as getAddItemBillPdfFromStorage, type TongyishuPdfInfo, } from '../../utils/examActions'; import type { SignaturePadHandle } from '../ui'; import { Button, SignaturePad } from '../ui'; // Polyfill for Promise.withResolvers if (typeof (Promise as any).withResolvers === 'undefined') { (Promise as any).withResolvers = function () { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: any) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; }; } // 配置 PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; interface ExamSignPanelProps { examId?: number; onBusyChange?: (busy: boolean) => void; } export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => { const [idCardFile, setIdCardFile] = useState(null); const [previewImage, setPreviewImage] = useState(null); const [showImagePreview, setShowImagePreview] = useState(false); const [signLoading, setSignLoading] = useState(false); const [message, setMessage] = useState(null); const fileInputRef = useRef(null); const [consentList, setConsentList] = useState([]); const [consentLoading, setConsentLoading] = useState(false); const [consentMessage, setConsentMessage] = useState(null); const [previewPdf, setPreviewPdf] = useState(null); const [showSignature, setShowSignature] = useState(false); const signaturePadRef = useRef(null); const [submitLoading, setSubmitLoading] = useState(false); const [submitMessage, setSubmitMessage] = useState(null); const [signedCombinationCodes, setSignedCombinationCodes] = useState([]); const [pdfData, setPdfData] = useState(null); const [pdfReady, setPdfReady] = useState(false); const [pdfLoading, setPdfLoading] = useState(false); const [pdfBlobUrl, setPdfBlobUrl] = useState(null); const canvasContainerRef = useRef(null); // 导检单相关状态 const [daojiandanUrl, setDaojiandanUrl] = useState(null); const [showDaojiandanSignature, setShowDaojiandanSignature] = useState(false); const daojiandanSignaturePadRef = useRef(null); const [daojiandanSubmitLoading, setDaojiandanSubmitLoading] = useState(false); const [daojiandanSubmitMessage, setDaojiandanSubmitMessage] = useState(null); const [showDaojiandanPreview, setShowDaojiandanPreview] = useState(false); // 加项单相关状态 const [addItemBillUrl, setAddItemBillUrl] = useState(null); const [addItemBillName, setAddItemBillName] = useState('加项单'); const [showAddItemBillPreview, setShowAddItemBillPreview] = useState(false); const busy = signLoading || submitLoading || consentLoading || pdfLoading || daojiandanSubmitLoading; useEffect(() => { onBusyChange?.(busy); return () => onBusyChange?.(false); }, [busy, onBusyChange]); const SIGN_STORAGE_KEY = `yh_signed_consents_${new Date().toISOString().slice(0, 10)}`; const handlePickFile = () => { fileInputRef.current?.click(); }; useEffect(() => { if (typeof window === 'undefined') return; const raw = localStorage.getItem(SIGN_STORAGE_KEY); if (raw) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { setSignedCombinationCodes(parsed.filter((x) => typeof x === 'number')); } } catch (err) { console.warn('签名记录解析失败', err); } } }, []); const convertToJpg = async (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext('2d'); if (!ctx) { reject(new Error('无法创建画布上下文')); return; } ctx.drawImage(img, 0, 0); canvas.toBlob( (blob) => { if (!blob) { reject(new Error('图片转换失败')); return; } const jpgFile = new File([blob], 'id_card.jpg', { type: 'image/jpeg' }); resolve(jpgFile); }, 'image/jpeg', 0.92 ); }; img.onerror = () => reject(new Error('图片加载失败')); img.src = event.target?.result as string; }; reader.onerror = () => reject(new Error('文件读取失败')); reader.readAsDataURL(file); }); }; const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setMessage(null); try { const jpgFile = await convertToJpg(file); setIdCardFile(jpgFile); const reader = new FileReader(); reader.onload = (event) => { setPreviewImage(event.target?.result as string); }; reader.readAsDataURL(jpgFile); } catch (err) { console.error('图片转换失败', err); setMessage('图片处理失败,请重试'); } e.target.value = ''; }; const handleSign = async () => { if (!idCardFile) { setMessage('请先上传身份证照片'); return; } setSignLoading(true); setMessage(null); try { const res = await signInMedicalExamCenter({ id_no_pic: idCardFile }); const ok = res.Status === 200 && res.Data?.is_success === 0; if (ok) { setMessage('签到成功'); // 记录身份证拍照与签到操作 if (examId) { setExamActionRecord(examId, 'idCardSignIn', true); } } else { setMessage(res.Message || '签到失败'); } } catch (err) { console.error(err); setMessage('签到请求失败,请稍后重试'); } finally { setSignLoading(false); } }; const handleSubmitSign = async () => { if (!examId || !previewPdf?.combination_code) { setSubmitMessage('缺少必要信息,无法提交签名'); return; } const dataUrl = signaturePadRef.current?.toDataURL('image/png'); if (!dataUrl) { setSubmitMessage('请先完成签名'); return; } setSubmitLoading(true); setSubmitMessage(null); try { const blob = await fetch(dataUrl).then((r) => r.blob()); const res = await submitTongyishuSign({ exam_id: examId, combination_code: previewPdf.combination_code, sign_file: blob, }); if (res.Status === 200) { setSubmitMessage('签名提交成功'); // 记录体检知情同意书的签字操作 if (examId) { setExamActionRecord(examId, 'consentSign', true); } // 存储返回的PDF列表 if (res.Data?.list_pdf_url && Array.isArray(res.Data.list_pdf_url) && examId) { const pdfList: TongyishuPdfInfo[] = res.Data.list_pdf_url.map((item) => ({ pdf_name: item.pdf_name || '', pdf_url: item.pdf_url || '', combination_code: item.combination_code ?? null, })); setTongyishuPdfList(examId, pdfList); } setSignedCombinationCodes((prev) => { const code = Number(previewPdf.combination_code); if (!Number.isFinite(code)) return prev || []; const next = Array.from(new Set([...(prev || []), code])); if (typeof window !== 'undefined') { localStorage.setItem(SIGN_STORAGE_KEY, JSON.stringify(next)); } return next; }); setTimeout(() => { setShowSignature(false); setPreviewPdf(null); setSubmitMessage(null); signaturePadRef.current?.clear(); }, 2000); } else { setSubmitMessage(res.Message || '签名提交失败'); } } catch (err) { console.error('提交签名失败', err); setSubmitMessage('签名提交失败,请稍后重试'); } finally { setSubmitLoading(false); } }; useEffect(() => { if (!examId) { setConsentList([]); setConsentMessage('缺少体检ID,无法获取知情同意书'); return; } setConsentLoading(true); setConsentMessage(null); getTongyishuPdf({ exam_id: examId }) .then((res) => { const list = res.Data?.list_pdf_url || []; // 先拉取接口返回的全部 list_pdf_url,再用本地已保存的已签名 PDF 覆盖 let mergedList = list; const storedList = getTongyishuPdfList(examId); if (storedList && storedList.length > 0) { mergedList = list.map((item) => { if (item.combination_code === undefined || item.combination_code === null) return item; const code = Number(item.combination_code); if (!Number.isFinite(code)) return item; const matched = storedList.find( (pdf) => pdf.combination_code !== null && Number(pdf.combination_code) === code, ); if (matched && matched.pdf_url) { return { ...item, pdf_url: matched.pdf_url, pdf_name: matched.pdf_name || item.pdf_name, }; } return item; }); } setConsentList(mergedList); if (!list.length) { setConsentMessage(res.Data?.message || '暂无知情同意书'); } }) .catch((err) => { console.error('获取知情同意书失败', err); setConsentMessage('知情同意书加载失败,请稍后重试'); }) .finally(() => setConsentLoading(false)); }, [examId]); // 组件加载时检查导检单localStorage useEffect(() => { if (!examId) return; const storedPdf = getDaojiandanPdfFromStorage(examId); if (storedPdf && storedPdf.pdf_url) { setDaojiandanUrl(storedPdf.pdf_url); } // 检查加项单PDF const storedAddItemBill = getAddItemBillPdfFromStorage(examId); if (storedAddItemBill && storedAddItemBill.pdf_url) { setAddItemBillUrl(storedAddItemBill.pdf_url); setAddItemBillName(storedAddItemBill.pdf_name || '加项单'); } }, [examId]); // 加载 PDF 数据 useEffect(() => { if (!previewPdf?.pdf_url) { setPdfData(null); setPdfBlobUrl(null); setPdfReady(false); return; } let objectUrl: string | null = null; setPdfReady(false); setPdfLoading(true); setPdfData(null); fetch(previewPdf.pdf_url) .then((resp) => { if (!resp.ok) throw new Error('获取PDF文件失败'); return resp.blob(); }) .then((blob) => { objectUrl = URL.createObjectURL(blob); setPdfBlobUrl(objectUrl); return blob.arrayBuffer(); }) .then((arrayBuffer) => { setPdfData(arrayBuffer); setPdfLoading(false); }) .catch((err) => { console.error('PDF 拉取失败', err); setPdfLoading(false); }); return () => { if (objectUrl) { URL.revokeObjectURL(objectUrl); } }; }, [previewPdf?.pdf_url]); // 渲染 PDF useEffect(() => { if (!pdfData || !canvasContainerRef.current) return; setPdfReady(false); const renderAllPages = async () => { try { const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise; if (!canvasContainerRef.current) return; // 清空容器 canvasContainerRef.current.innerHTML = ''; const scale = 1.2; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) continue; canvas.height = viewport.height; canvas.width = viewport.width; canvas.style.display = 'block'; canvas.style.marginBottom = '10px'; canvas.className = 'mx-auto border rounded-lg shadow-sm'; canvasContainerRef.current.appendChild(canvas); const renderContext = { canvasContext: context, viewport: viewport, } as any; await page.render(renderContext).promise; } setPdfReady(true); } catch (err) { console.error('PDF 渲染失败', err); setPdfLoading(false); } }; renderAllPages(); }, [pdfData]); const handlePrint = () => { if (!pdfBlobUrl || !pdfReady || !canvasContainerRef.current) return; // 创建打印窗口 const printWindow = window.open('', '_blank'); if (!printWindow) return; // 获取所有的 canvas const canvases = canvasContainerRef.current.querySelectorAll('canvas'); // 构建打印页面 printWindow.document.write(` 知情同意书打印 `); // 将每个 canvas 转换为图片并添加到打印窗口 canvases.forEach((canvas) => { const imgData = (canvas as HTMLCanvasElement).toDataURL('image/png'); printWindow.document.write(``); }); printWindow.document.write(` `); printWindow.document.close(); // 等待图片加载完成后执行打印 printWindow.onload = () => { printWindow.focus(); setTimeout(() => { printWindow.print(); }, 1000); }; }; const handleDirectPrint = async (pdfItem: OutputTongyishuFileInfo) => { if (!pdfItem.pdf_url) return; try { // 加载PDF const response = await fetch(pdfItem.pdf_url); if (!response.ok) throw new Error('获取PDF文件失败'); const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); // 渲染PDF到临时canvas const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; const scale = 1.2; const canvasImages: string[] = []; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) continue; canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport: viewport, } as any; await page.render(renderContext).promise; canvasImages.push(canvas.toDataURL('image/png')); } // 创建打印窗口 const printWindow = window.open('', '_blank'); if (!printWindow) return; printWindow.document.write(` ${pdfItem.pdf_name || '知情同意书打印'} `); canvasImages.forEach((imgData) => { printWindow.document.write(``); }); printWindow.document.write(` `); printWindow.document.close(); // 等待图片加载完成后执行打印 printWindow.onload = () => { printWindow.focus(); setTimeout(() => { printWindow.print(); }, 1000); }; } catch (err) { console.error('打印失败', err); alert('打印失败,请稍后重试'); } }; // 导检单签名提交 const handleSubmitDaojiandanSign = async () => { if (!examId) { setDaojiandanSubmitMessage('缺少必要信息,无法提交签名'); return; } const dataUrl = daojiandanSignaturePadRef.current?.toDataURL('image/png'); if (!dataUrl) { setDaojiandanSubmitMessage('请先完成签名'); return; } setDaojiandanSubmitLoading(true); setDaojiandanSubmitMessage(null); try { const blob = await fetch(dataUrl).then((r) => r.blob()); const res = await submitDaojiandanSign({ exam_id: examId, sign_file: blob, }); if (res.Status === 200 && res.Data?.pdf_url) { setDaojiandanSubmitMessage('签名提交成功'); const pdfUrlValue = res.Data.pdf_url; const pdfNameValue = res.Data.pdf_name || '导检单'; setDaojiandanUrl(pdfUrlValue); // 保存导检单PDF信息到localStorage setDaojiandanPdf(examId, { pdf_name: pdfNameValue, pdf_url: pdfUrlValue, }); // 记录打印导检单是否签名操作 setExamActionRecord(examId, 'printSign', true); // 更新导检单打印状态 try { await editDaojiandanPrintStatus({ exam_id: examId }); } catch (err) { console.error('更新导检单打印状态失败', err); } setTimeout(() => { setShowDaojiandanSignature(false); setDaojiandanSubmitMessage(null); daojiandanSignaturePadRef.current?.clear(); setShowDaojiandanPreview(true); }, 2000); } else { setDaojiandanSubmitMessage(res.Message || '签名提交失败'); } } catch (err) { console.error('提交签名失败', err); setDaojiandanSubmitMessage('签名提交失败,请稍后重试'); } finally { setDaojiandanSubmitLoading(false); } }; // 加项单直接打印 const handleAddItemBillDirectPrint = async () => { if (!addItemBillUrl) return; try { const response = await fetch(addItemBillUrl); if (!response.ok) throw new Error('获取PDF文件失败'); const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; const scale = 1.2; const canvasImages: string[] = []; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) continue; canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport: viewport, } as any; await page.render(renderContext).promise; canvasImages.push(canvas.toDataURL('image/png')); } const printWindow = window.open('', '_blank'); if (!printWindow) return; printWindow.document.write(` 加项单打印 `); canvasImages.forEach((imgData) => { printWindow.document.write(``); }); printWindow.document.write(` `); printWindow.document.close(); printWindow.onload = () => { printWindow.focus(); setTimeout(() => { printWindow.print(); }, 1000); }; } catch (err) { console.error('打印失败', err); alert('打印失败,请稍后重试'); } }; // 导检单直接打印 const handleDaojiandanDirectPrint = async () => { if (!daojiandanUrl) return; try { const response = await fetch(daojiandanUrl); if (!response.ok) throw new Error('获取PDF文件失败'); const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; const scale = 1.2; const canvasImages: string[] = []; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) continue; canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport: viewport, } as any; await page.render(renderContext).promise; canvasImages.push(canvas.toDataURL('image/png')); } const printWindow = window.open('', '_blank'); if (!printWindow) return; printWindow.document.write(` 导检单打印 `); canvasImages.forEach((imgData) => { printWindow.document.write(``); }); printWindow.document.write(` `); printWindow.document.close(); // 更新导检单打印状态 if (examId) { try { await editDaojiandanPrintStatus({ exam_id: examId }); } catch (err) { console.error('更新导检单打印状态失败', err); // 不阻塞打印流程,仅记录错误 } } printWindow.onload = () => { printWindow.focus(); setTimeout(() => { printWindow.print(); }, 1000); }; } catch (err) { console.error('打印失败', err); alert('打印失败,请稍后重试'); } }; // 检查所有文档是否都已签名 const checkAllSigned = () => { // 检查所有知情同意书是否都已签名 const allConsentsSigned = consentList.every((item) => { if (item.combination_code === undefined || item.combination_code === null) return true; return signedCombinationCodes.includes(Number(item.combination_code)); }); // 检查导检单是否已签名 const daojiandanSigned = !!daojiandanUrl; return allConsentsSigned && consentList.length > 0 && daojiandanSigned; }; // 一键打印所有文档 const handleBatchPrint = async () => { if (busy) return; try { const allImages: string[] = []; const scale = 1.2; // 处理所有已签名的知情同意书 for (const item of consentList) { if (item.combination_code === undefined || item.combination_code === null) continue; if (!signedCombinationCodes.includes(Number(item.combination_code))) continue; let targetUrl = item.pdf_url; // 如果本地已保存签名后的PDF列表,则优先使用签名后的PDF地址 if (examId) { const storedList = getTongyishuPdfList(examId); if (storedList && item.combination_code !== undefined && item.combination_code !== null) { const code = Number(item.combination_code); const matched = storedList.find( (pdf) => pdf.combination_code !== null && Number(pdf.combination_code) === code, ); if (matched && matched.pdf_url) { targetUrl = matched.pdf_url; } } } if (!targetUrl) continue; try { const response = await fetch(targetUrl); if (!response.ok) continue; const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) continue; canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport: viewport, } as any; await page.render(renderContext).promise; allImages.push(canvas.toDataURL('image/png')); } } catch (err) { console.error(`处理知情同意书 ${item.pdf_name} 失败`, err); } } // 处理导检单 if (daojiandanUrl) { try { const response = await fetch(daojiandanUrl); if (response.ok) { const blob = await response.blob(); const arrayBuffer = await blob.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) continue; canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport: viewport, } as any; await page.render(renderContext).promise; allImages.push(canvas.toDataURL('image/png')); } } } catch (err) { console.error('处理导检单失败', err); } } if (allImages.length === 0) { alert('没有可打印的内容'); return; } // 创建打印窗口 const printWindow = window.open('', '_blank'); if (!printWindow) { alert('无法打开打印窗口,请检查浏览器弹窗设置'); return; } printWindow.document.write(` 一键打印 `); allImages.forEach((imgData) => { printWindow.document.write(``); }); printWindow.document.write(` `); printWindow.document.close(); // 如果包含导检单,更新导检单打印状态 if (examId && daojiandanUrl) { try { await editDaojiandanPrintStatus({ exam_id: examId }); } catch (err) { console.error('更新导检单打印状态失败', err); // 不阻塞打印流程,仅记录错误 } } // 等待图片加载完成后执行打印 printWindow.onload = () => { printWindow.focus(); setTimeout(() => { printWindow.print(); }, 1000); }; } catch (err) { console.error('一键打印失败', err); alert('一键打印失败,请稍后重试'); } }; return (
身份证拍照与签到
拍照身份证后点击签到按钮完成签到。
{previewImage && (
setShowImagePreview(true)} > 身份证预览
)}
{message && (
{message}
)}
{showImagePreview && previewImage && (
setShowImagePreview(false)}>
身份证预览
)}
体检知情同意书
{checkAllSigned() && ( )}
{/*
点击后弹出知情同意书全文及签名区域,签署完成后回到签到页面。
*/}
{consentLoading &&
加载中...
} {!consentLoading && consentMessage &&
{consentMessage}
} {!consentLoading && (consentList.length > 0 || true) && (
{consentList.map((item) => (
{item.pdf_name.length > 12 ? item.pdf_name.slice(0, 12) + "..." : item.pdf_name} {item.combination_code !== undefined && signedCombinationCodes.includes(Number(item.combination_code)) && ( 已签名 )}
{item.combination_code !== undefined && signedCombinationCodes.includes(Number(item.combination_code)) && ( )}
))}
导检单 {daojiandanUrl && ( 已签名 )}
{daojiandanUrl ? ( <> ) : ( )}
{/* 加项单 */} {addItemBillUrl && (
{addItemBillName.length > 12 ? addItemBillName.slice(0, 12) + "..." : addItemBillName} 已生成
)}
)}
{ previewPdf && (
{previewPdf.pdf_name}
{pdfLoading ? (
正在加载PDF...
) : pdfData ? (
) : (
PDF加载失败
)}
) } { showSignature && (
签署知情同意书
请在上方区域签名完成签到
{submitMessage && (
{submitMessage}
)}
) } { showDaojiandanSignature && (
签署导检单
请在上方区域签名
{daojiandanSubmitMessage && (
{daojiandanSubmitMessage}
)}
) } { showDaojiandanPreview && daojiandanUrl && (
导检单