合并同意书导检单

This commit is contained in:
xianyi
2025-12-30 17:46:30 +08:00
parent 1e2163f3ca
commit 59b5cc5b31
3 changed files with 531 additions and 10 deletions

View File

@@ -22,7 +22,7 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
{ key: 'detail', label: '详情' },
{ key: 'sign', label: '签到' },
{ key: 'addon', label: '加项' },
{ key: 'print', label: '打印导检单' },
// { key: 'print', label: '打印导检单' },
{ key: 'delivery', label: '报告寄送' },
];

View File

@@ -266,7 +266,7 @@ export const ExamSection = ({
}}
>
<span></span>
{signDone && <span></span>}
{(signDone && printDone) && <span></span>}
</button>
<button
type='button'
@@ -279,7 +279,7 @@ export const ExamSection = ({
<span></span>
{addonCount > 0 && <span className='opacity-80'>({addonCount})</span>}
</button>
<button
{/* <button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'
onClick={(e) => {
@@ -289,7 +289,7 @@ export const ExamSection = ({
>
<span>打印导检单</span>
{printDone && <span>✅</span>}
</button>
</button> */}
<button
type='button'
className='px-2 py-0.5 rounded-2xl border bg-white hover:bg-gray-100 flex items-center gap-1'

View File

@@ -3,11 +3,13 @@ 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 } from '../../api';
import { getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign, submitDaojiandanSign, editDaojiandanPrintStatus } from '../../api';
import {
setExamActionRecord,
setTongyishuPdfList,
getTongyishuPdfList,
setDaojiandanPdf,
getDaojiandanPdf as getDaojiandanPdfFromStorage,
type TongyishuPdfInfo,
} from '../../utils/examActions';
import type { SignaturePadHandle } from '../ui';
@@ -55,7 +57,16 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
const [pdfLoading, setPdfLoading] = useState(false);
const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const busy = signLoading || submitLoading || consentLoading || pdfLoading;
// 导检单相关状态
const [daojiandanUrl, setDaojiandanUrl] = useState<string | null>(null);
const [showDaojiandanSignature, setShowDaojiandanSignature] = useState(false);
const daojiandanSignaturePadRef = useRef<SignaturePadHandle | null>(null);
const [daojiandanSubmitLoading, setDaojiandanSubmitLoading] = useState(false);
const [daojiandanSubmitMessage, setDaojiandanSubmitMessage] = useState<string | null>(null);
const [showDaojiandanPreview, setShowDaojiandanPreview] = useState(false);
const busy = signLoading || submitLoading || consentLoading || pdfLoading || daojiandanSubmitLoading;
useEffect(() => {
onBusyChange?.(busy);
@@ -282,6 +293,16 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
.finally(() => setConsentLoading(false));
}, [examId]);
// 组件加载时检查导检单localStorage
useEffect(() => {
if (!examId) return;
const storedPdf = getDaojiandanPdfFromStorage(examId);
if (storedPdf && storedPdf.pdf_url) {
setDaojiandanUrl(storedPdf.pdf_url);
}
}, [examId]);
// 加载 PDF 数据
useEffect(() => {
if (!previewPdf?.pdf_url) {
@@ -538,6 +559,364 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
}
};
// 导检单签名提交
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 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(`
<!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>
`);
canvasImages.forEach((imgData) => {
printWindow.document.write(`<img src="${imgData}" />`);
});
printWindow.document.write(`
</body>
</html>
`);
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(`
<!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>
`);
allImages.forEach((imgData) => {
printWindow.document.write(`<img src="${imgData}" />`);
});
printWindow.document.write(`
</body>
</html>
`);
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 (
<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'>
@@ -582,12 +961,23 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
</div>
)}
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
<div className='flex items-center justify-between'>
<div className='font-medium'></div>
<div className='text-xs text-gray-500'></div>
{checkAllSigned() && (
<Button
className='py-1 px-3 bg-green-600 hover:bg-green-700 text-white text-xs'
onClick={handleBatchPrint}
disabled={busy}
>
</Button>
)}
</div>
{/* <div className='text-xs text-gray-500'>点击后弹出知情同意书全文及签名区域,签署完成后回到签到页面。</div> */}
<div className='flex flex-col gap-2'>
{consentLoading && <div className='text-xs text-gray-500'>...</div>}
{!consentLoading && consentMessage && <div className='text-xs text-amber-600'>{consentMessage}</div>}
{!consentLoading && consentList.length > 0 && (
{!consentLoading && (consentList.length > 0 || true) && (
<div className='space-y-2'>
{consentList.map((item) => (
<div
@@ -668,6 +1058,56 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
</div>
</div>
))}
<div 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'>
<span className='truncate'></span>
{daojiandanUrl && (
<img
src='/sign.png'
alt='已签名'
className='w-16 h-16 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none select-none'
loading='lazy'
/>
)}
</div>
<div className='flex items-center gap-2'>
{daojiandanUrl ? (
<>
<Button
className='py-1.5 px-3 bg-blue-600 hover:bg-blue-700 text-white'
onClick={() => {
if (busy) return;
handleDaojiandanDirectPrint();
}}
disabled={busy}
>
</Button>
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setShowDaojiandanPreview(true);
}}
disabled={busy}
>
</Button>
</>
) : (
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setShowDaojiandanSignature(true);
}}
disabled={busy}
>
</Button>
)}
</div>
</div>
</div>
)}
</div>
@@ -715,7 +1155,7 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
showSignature && (
<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='flex items-center justify之间'>
<div className='flex items-center justify-between'>
<div className='text-base font-semibold text-gray-900'></div>
<div className='flex items-center gap-2'>
<Button className='py-1 px-3' onClick={() => setShowSignature(false)} disabled={busy}>
@@ -762,6 +1202,87 @@ export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
</div>
)
}
{
showDaojiandanSignature && (
<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='flex items-center justify-between'>
<div className='text-base font-semibold text-gray-900'></div>
<div className='flex items-center gap-2'>
<Button className='py-1 px-3' onClick={() => setShowDaojiandanSignature(false)} disabled={busy}>
</Button>
</div>
</div>
<div className='ui7-signature-wrapper border rounded-xl overflow-hidden bg-gray-50'>
<SignaturePad
ref={daojiandanSignaturePadRef}
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={() => daojiandanSignaturePadRef.current?.clear()} disabled={daojiandanSubmitLoading}>
</Button>
</div>
</div>
{daojiandanSubmitMessage && (
<div className={`text-sm text-center ${daojiandanSubmitMessage.includes('成功') ? 'text-green-600' : 'text-amber-600'}`}>{daojiandanSubmitMessage}</div>
)}
<div className='flex items-center justify-end gap-3'>
<Button
className='py-2 px-6'
onClick={() => {
setShowDaojiandanSignature(false);
setDaojiandanSubmitMessage(null);
daojiandanSignaturePadRef.current?.clear();
}}
disabled={daojiandanSubmitLoading}
>
</Button>
<Button
className='py-2 px-6 bg-blue-600 text-white hover:bg-blue-700'
onClick={handleSubmitDaojiandanSign}
disabled={daojiandanSubmitLoading}
>
{daojiandanSubmitLoading ? '提交中...' : '提交签名'}
</Button>
</div>
</div>
</div>
)
}
{
showDaojiandanPreview && daojiandanUrl && (
<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='text-sm font-medium truncate pr-3'></div>
<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={handleDaojiandanDirectPrint}
disabled={busy}
>
</Button>
<Button className='py-1 px-3' onClick={() => !busy && setShowDaojiandanPreview(false)} disabled={busy}>
</Button>
</div>
</div>
<div className='flex-1 bg-gray-100 overflow-auto'>
<div className='flex justify-center p-4'>
<iframe
src={daojiandanUrl}
className='w-full h-full min-h-[600px] border rounded-lg bg-white'
title='导检单预览'
/>
</div>
</div>
</div>
)
}
</div >
);
};