Files
ipad/src/components/exam/ExamPrintPanel.tsx
2025-12-26 15:44:28 +08:00

470 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};