优化体检知情同意书列表打印

This commit is contained in:
xianyi
2025-12-29 15:27:51 +08:00
parent 35172e4a4c
commit 243a6edc2e

View File

@@ -1,4 +1,6 @@
import { useEffect, useRef, useState } from 'react'; 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 type { OutputTongyishuFileInfo } from '../../api';
import { getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api'; import { getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api';
@@ -11,6 +13,22 @@ import {
import type { SignaturePadHandle } from '../ui'; import type { SignaturePadHandle } from '../ui';
import { Button, SignaturePad } from '../ui'; import { Button, SignaturePad } from '../ui';
// Polyfill for Promise.withResolvers
if (typeof (Promise as any).withResolvers === 'undefined') {
(Promise as any).withResolvers = function <T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
// 配置 PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
interface ExamSignPanelProps { interface ExamSignPanelProps {
examId?: number; examId?: number;
onBusyChange?: (busy: boolean) => void; onBusyChange?: (busy: boolean) => void;
@@ -32,7 +50,12 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
const [submitMessage, setSubmitMessage] = useState<string | null>(null); const [submitMessage, setSubmitMessage] = useState<string | null>(null);
const [signedCombinationCodes, setSignedCombinationCodes] = useState<number[]>([]); const [signedCombinationCodes, setSignedCombinationCodes] = useState<number[]>([]);
const busy = signLoading || submitLoading || consentLoading; const [pdfData, setPdfData] = useState<ArrayBuffer | null>(null);
const [pdfReady, setPdfReady] = useState(false);
const [pdfLoading, setPdfLoading] = useState(false);
const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const busy = signLoading || submitLoading || consentLoading || pdfLoading;
useEffect(() => { useEffect(() => {
onBusyChange?.(busy); onBusyChange?.(busy);
@@ -259,6 +282,262 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
.finally(() => setConsentLoading(false)); .finally(() => setConsentLoading(false));
}, [examId]); }, [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(`
<!DOCTYPE html>
<html>
<head>
<title>知情同意书打印</title>
<style>
@media print {
body {
margin: 0;
padding: 0;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
`);
// 将每个 canvas 转换为图片并添加到打印窗口
canvases.forEach((canvas) => {
const imgData = (canvas as HTMLCanvasElement).toDataURL('image/png');
printWindow.document.write(`<img src="${imgData}" />`);
});
printWindow.document.write(`
</body>
</html>
`);
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(`
<!DOCTYPE html>
<html>
<head>
<title>${pdfItem.pdf_name || '知情同意书打印'}</title>
<style>
@media print {
body {
margin: 0;
padding: 0;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
`);
canvasImages.forEach((imgData) => {
printWindow.document.write(`<img src="${imgData}" />`);
});
printWindow.document.write(`
</body>
</html>
`);
printWindow.document.close();
// 等待图片加载完成后执行打印
printWindow.onload = () => {
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 1000);
};
} catch (err) {
console.error('打印失败', err);
alert('打印失败,请稍后重试');
}
};
return ( return (
<div className='grid grid-cols-2 gap-4 text-sm'> <div className='grid grid-cols-2 gap-4 text-sm'>
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'> <div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
@@ -313,10 +592,10 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
{consentList.map((item) => ( {consentList.map((item) => (
<div <div
key={item.pdf_url || item.pdf_name} key={item.pdf_url || item.pdf_name}
className='flex items-center justify-between gap-3 p-3 rounded-xl border bg-white shadow-sm' className='flex items-center justify-between gap-3 p-2 rounded-xl border bg-white shadow-sm'
> >
<div className='text-sm text-gray-800 truncate flex items-center gap-2 relative pr-20'> <div className='text-sm text-gray-800 truncate flex items-center gap-2 relative pr-20'>
<span className='truncate'>{item.pdf_name}</span> <span className='truncate'>{item.pdf_name.length > 12 ? item.pdf_name.slice(0, 12) + "..." : item.pdf_name}</span>
{item.combination_code !== undefined && signedCombinationCodes.includes(Number(item.combination_code)) && ( {item.combination_code !== undefined && signedCombinationCodes.includes(Number(item.combination_code)) && (
<img <img
src='/sign.png' src='/sign.png'
@@ -326,6 +605,36 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
/> />
)} )}
</div> </div>
<div className='flex items-center gap-2'>
{item.combination_code !== undefined && signedCombinationCodes.includes(Number(item.combination_code)) && (
<Button
className='py-1.5 px-3 bg-blue-600 hover:bg-blue-700 text-white'
onClick={() => {
if (busy) return;
let target = item;
// 如果本地已保存签名后的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) {
target = {
...item,
pdf_url: matched.pdf_url,
};
}
}
}
handleDirectPrint(target);
}}
disabled={busy}
>
</Button>
)}
<Button <Button
className='py-1.5 px-3' className='py-1.5 px-3'
onClick={() => { onClick={() => {
@@ -357,30 +666,53 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
</div> </div>
</div> </div>
{previewPdf && ( {
previewPdf && (
<div className='fixed inset-0 z-[60] bg-black/75 flex flex-col'> <div className='fixed inset-0 z-[60] bg-black/75 flex flex-col'>
<div className='flex items-center justify-between p-4 text-white bg-gray-900/80'> <div className='flex items-center justify-between p-4 text-white bg-gray-900/80'>
<div className='text-sm font-medium truncate pr-3'>{previewPdf.pdf_name}</div> <div className='text-sm font-medium truncate pr-3'>{previewPdf.pdf_name}</div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button
className='py-1 px-3 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
onClick={handlePrint}
disabled={pdfLoading || !pdfReady || !pdfBlobUrl}
>
</Button>
<Button className='py-1 px-3' onClick={() => !busy && setShowSignature(true)} disabled={busy}> <Button className='py-1 px-3' onClick={() => !busy && setShowSignature(true)} disabled={busy}>
</Button> </Button>
<Button className='py-1 px-3' onClick={() => !busy && setPreviewPdf(null)} disabled={busy}> <Button className='py-1 px-3' onClick={() => !busy && setPreviewPdf(null)} disabled={busy}>
</Button> </Button>
</div> </div>
</div> </div>
<div className='flex-1 bg-gray-100'> <div className='flex-1 bg-gray-100 overflow-auto'>
<iframe src={previewPdf.pdf_url} title={previewPdf.pdf_name} className='w-full h-full' /> {pdfLoading ? (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF...</div>
</div> </div>
) : pdfData ? (
<div className='flex justify-center p-4'>
<div ref={canvasContainerRef} className='w-full' />
</div>
) : (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF加载失败</div>
</div> </div>
)} )}
{showSignature && ( </div>
</div>
)
}
{
showSignature && (
<div className='fixed inset-0 z-[70] bg-black/80 flex items-center justify-center px-6'> <div className='fixed inset-0 z-[70] bg-black/80 flex items-center justify-center px-6'>
<div className='bg-white rounded-2xl w-full max-w-3xl shadow-2xl p-4 flex flex-col gap-4'> <div className='bg-white rounded-2xl w-full max-w-3xl shadow-2xl p-4 flex flex-col gap-4'>
<div className='flex items-center justify之间'> <div className='flex items-center justify之间'>
@@ -428,7 +760,8 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
</div> </div>
</div> </div>
</div> </div>
)} )
}
</div > </div >
); );
}; };