470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import * as pdfjsLib from 'pdfjs-dist';
|
||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||
|
||
import { getDaojiandanPdf as getDaojiandanPdfApi, submitDaojiandanSign, editDaojiandanPrintStatus } from '../../api';
|
||
import type { ExamClient } from '../../data/mockData';
|
||
import { setExamActionRecord, setDaojiandanPdf, getDaojiandanPdf } 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 <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;
|
||
|
||
export const ExamPrintPanel = ({ client }: { client: ExamClient }) => {
|
||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [pdfReady, setPdfReady] = useState(false);
|
||
const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
|
||
const [signatureSubmitted, setSignatureSubmitted] = useState(false);
|
||
const [submitLoading, setSubmitLoading] = useState(false);
|
||
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
|
||
const [showPreview, setShowPreview] = useState(false);
|
||
const [fetchLoading, setFetchLoading] = useState(false);
|
||
const [pdfData, setPdfData] = useState<ArrayBuffer | null>(null);
|
||
const signaturePadRef = useRef<SignaturePadHandle | null>(null);
|
||
const printRef = useRef<HTMLDivElement>(null);
|
||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const handleSubmitSign = async () => {
|
||
const examId = Number(client.id);
|
||
if (!examId) {
|
||
setSubmitMessage('无效的体检ID');
|
||
return;
|
||
}
|
||
|
||
const dataUrl = signaturePadRef.current?.toDataURL('image/png');
|
||
if (!dataUrl) {
|
||
setSubmitMessage('请先完成签名');
|
||
return;
|
||
}
|
||
|
||
setSubmitLoading(true);
|
||
setSubmitMessage(null);
|
||
try {
|
||
// 将 base64 转换为 Blob
|
||
const blob = await fetch(dataUrl).then(r => r.blob());
|
||
|
||
// 提交签名生成导检单PDF
|
||
const res = await submitDaojiandanSign({
|
||
exam_id: examId,
|
||
sign_file: blob,
|
||
});
|
||
|
||
if (res.Status === 200 && res.Data?.pdf_url) {
|
||
setSubmitMessage('签名提交成功,正在加载导检单...');
|
||
setSignatureSubmitted(true);
|
||
const pdfUrlValue = res.Data.pdf_url;
|
||
const pdfNameValue = res.Data.pdf_name || '导检单';
|
||
setPdfUrl(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);
|
||
// 不阻塞主流程,仅记录错误
|
||
}
|
||
} else {
|
||
setSubmitMessage(res.Message || '签名提交失败');
|
||
}
|
||
} catch (err) {
|
||
console.error('提交签名失败', err);
|
||
setSubmitMessage('签名提交失败,请稍后重试');
|
||
} finally {
|
||
setSubmitLoading(false);
|
||
}
|
||
};
|
||
|
||
// 组件加载时检查localStorage,如果有则直接展示
|
||
useEffect(() => {
|
||
const examId = Number(client.id);
|
||
if (!examId) return;
|
||
|
||
const storedPdf = getDaojiandanPdf(examId);
|
||
if (storedPdf && storedPdf.pdf_url) {
|
||
setPdfUrl(storedPdf.pdf_url);
|
||
setShowPreview(true);
|
||
}
|
||
}, [client.id]);
|
||
|
||
// 获取导检单PDF(优先使用localStorage,没有则调用接口)
|
||
const handleFetchPdf = async () => {
|
||
const examId = Number(client.id);
|
||
if (!examId) {
|
||
setError('无效的体检ID');
|
||
return;
|
||
}
|
||
|
||
setFetchLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 先尝试从localStorage获取
|
||
const storedPdf = getDaojiandanPdf(examId);
|
||
if (storedPdf && storedPdf.pdf_url) {
|
||
setPdfUrl(storedPdf.pdf_url);
|
||
setShowPreview(true);
|
||
setFetchLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 如果没有存储的,则调用接口获取
|
||
const res = await getDaojiandanPdfApi({ exam_id: examId });
|
||
if (res.Status === 200 && res.Data?.pdf_url) {
|
||
const pdfUrlValue = res.Data.pdf_url;
|
||
const pdfNameValue = res.Data.pdf_name || '导检单';
|
||
setPdfUrl(pdfUrlValue);
|
||
// 保存到localStorage
|
||
setDaojiandanPdf(examId, {
|
||
pdf_name: pdfNameValue,
|
||
pdf_url: pdfUrlValue,
|
||
});
|
||
setShowPreview(true);
|
||
} else {
|
||
setError(res.Message || '获取导检单失败');
|
||
}
|
||
} catch (err) {
|
||
console.error('获取导检单失败', err);
|
||
setError('获取导检单失败,请稍后重试');
|
||
} finally {
|
||
setFetchLoading(false);
|
||
}
|
||
};
|
||
|
||
// 第一步:加载 PDF 数据
|
||
useEffect(() => {
|
||
if (!pdfUrl) return;
|
||
|
||
let objectUrl: string | null = null;
|
||
setPdfReady(false);
|
||
setLoading(true);
|
||
setPdfData(null);
|
||
|
||
fetch(pdfUrl)
|
||
.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);
|
||
setLoading(false);
|
||
})
|
||
.catch((err) => {
|
||
console.error('PDF 拉取失败', err);
|
||
setError('PDF 加载失败,请稍后重试');
|
||
setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
if (objectUrl) {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
};
|
||
}, [pdfUrl]);
|
||
|
||
// 第二步:渲染 PDF
|
||
useEffect(() => {
|
||
if (!pdfData || !canvasContainerRef.current) return;
|
||
|
||
console.log('开始渲染 PDF');
|
||
setPdfReady(false);
|
||
|
||
const renderAllPages = async () => {
|
||
try {
|
||
const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||
console.log('PDF 加载成功,共 ' + pdf.numPages + ' 页');
|
||
|
||
if (!canvasContainerRef.current) {
|
||
console.log('canvasContainerRef 仍为 null');
|
||
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) {
|
||
console.log('无法获取 canvas 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;
|
||
console.log('渲染完成第 ' + pageNum + ' 页');
|
||
}
|
||
|
||
setPdfReady(true);
|
||
console.log('所有页面渲染完成');
|
||
} catch (err) {
|
||
console.error('PDF 渲染失败', err);
|
||
setError('PDF 渲染失败,请稍后重试: ' + err);
|
||
}
|
||
};
|
||
|
||
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);
|
||
};
|
||
};
|
||
|
||
return (
|
||
<div className='flex justify-center'>
|
||
<div className='w-full max-w-[95%] bg-white rounded-2xl border shadow-sm px-6 py-4 text-xs text-gray-800'>
|
||
<div className='flex items-center justify-between border-b pb-3 mb-3'>
|
||
<div>
|
||
<div className='text-sm font-semibold'>圆和医疗体检中心 · 导检单预览</div>
|
||
<div className='text-[11px] text-gray-500 mt-1'>
|
||
{signatureSubmitted || showPreview
|
||
? '此为预览页面,实际打印效果以院内打印机为准。'
|
||
: '请先完成签名,签名后将生成导检单PDF'}
|
||
</div>
|
||
</div>
|
||
<div className='flex items-center gap-3'>
|
||
<div className='text-right text-[11px] text-gray-500'>
|
||
<div>体检号:{client.id}</div>
|
||
<div>日期:{new Date().toLocaleDateString('zh-CN')}</div>
|
||
</div>
|
||
{!showPreview && !signatureSubmitted && (
|
||
<Button
|
||
className='px-4 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||
onClick={handleFetchPdf}
|
||
disabled={fetchLoading}
|
||
>
|
||
{fetchLoading ? '加载中...' : '查看'}
|
||
</Button>
|
||
)}
|
||
{showPreview && !signatureSubmitted && (
|
||
<>
|
||
<Button
|
||
className='px-4 py-1.5 text-xs bg-gray-600 hover:bg-gray-700 text-white'
|
||
onClick={() => {
|
||
setShowPreview(false);
|
||
setPdfUrl(null);
|
||
setPdfBlobUrl(null);
|
||
setPdfReady(false);
|
||
setError(null);
|
||
}}
|
||
>
|
||
签名
|
||
</Button>
|
||
<Button
|
||
className='px-4 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||
onClick={handlePrint}
|
||
disabled={loading || !pdfReady || !pdfUrl}
|
||
>
|
||
打印
|
||
</Button>
|
||
</>
|
||
)}
|
||
{signatureSubmitted && (
|
||
<Button
|
||
className='px-4 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||
onClick={handlePrint}
|
||
disabled={loading || !pdfReady || !pdfUrl}
|
||
>
|
||
打印
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{!signatureSubmitted && !showPreview ? (
|
||
<div className='flex flex-col gap-4'>
|
||
<div className='text-sm font-medium text-gray-900'>请在下方区域签名</div>
|
||
<div className='ui7-signature-wrapper border rounded-xl overflow-hidden bg-gray-50'>
|
||
<SignaturePad
|
||
ref={signaturePadRef}
|
||
className='ui7-signature-canvas w-full h-72 bg-white touch-none'
|
||
/>
|
||
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t'>
|
||
<div className='text-xs text-gray-500'>请在上方区域签名</div>
|
||
<Button
|
||
className='ui7-clear-button py-1 px-3'
|
||
onClick={() => signaturePadRef.current?.clear()}
|
||
disabled={submitLoading}
|
||
>
|
||
清除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{submitMessage && (
|
||
<div
|
||
className={`text-sm text-center ${submitMessage.includes('成功') ? 'text-green-600' : 'text-amber-600'
|
||
}`}
|
||
>
|
||
{submitMessage}
|
||
</div>
|
||
)}
|
||
<div className='flex items-center justify-end gap-3'>
|
||
<Button
|
||
className='py-2 px-6 bg-blue-600 text-white hover:bg-blue-700'
|
||
onClick={handleSubmitSign}
|
||
disabled={submitLoading}
|
||
>
|
||
{submitLoading ? '提交中...' : '提交签名'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : fetchLoading ? (
|
||
<div className='flex items-center justify-center py-12 text-gray-500'>
|
||
<div>正在加载导检单...</div>
|
||
</div>
|
||
) : loading ? (
|
||
<div className='flex items-center justify-center py-12 text-gray-500'>
|
||
<div>正在加载PDF...</div>
|
||
</div>
|
||
) : error ? (
|
||
<div className='flex flex-col items-center justify-center py-12 text-gray-500'>
|
||
<div className='mb-2'>{error}</div>
|
||
{signatureSubmitted ? (
|
||
<Button
|
||
className='px-4 py-1.5 text-xs'
|
||
onClick={() => {
|
||
setError(null);
|
||
setPdfUrl(null);
|
||
setSignatureSubmitted(false);
|
||
setShowPreview(false);
|
||
signaturePadRef.current?.clear();
|
||
}}
|
||
>
|
||
重新签名
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
className='px-4 py-1.5 text-xs'
|
||
onClick={() => {
|
||
setError(null);
|
||
setShowPreview(false);
|
||
}}
|
||
>
|
||
关闭
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : pdfUrl ? (
|
||
<div className='w-full'>
|
||
<div ref={printRef} className='print-content'>
|
||
<div className='flex justify-center border rounded-lg p-4 bg-gray-50 overflow-auto max-h-[600px]'>
|
||
<div ref={canvasContainerRef} className='w-full' />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
|