Files
ipad/src/components/exam/ExamSignPanel.tsx
2026-01-05 17:16:32 +08:00

1689 lines
60 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 type { OutputTongyishuFileInfo } from '../../api';
import { getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign, submitDaojiandanSign, editDaojiandanPrintStatus, getDaojiandanPdf as getDaojiandanPdfApi, getAddItemBillPdf as getAddItemBillPdfApi, submitAddItemBillSign } from '../../api';
import {
setExamActionRecord,
setTongyishuPdfList,
getTongyishuPdfList,
setDaojiandanPdf,
getDaojiandanPdf as getDaojiandanPdfFromStorage,
setAddItemBillPdf,
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 <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 {
examId?: number;
onBusyChange?: (busy: boolean) => void;
}
export const ExamSignPanel = ({ examId, onBusyChange }: ExamSignPanelProps) => {
const [idCardFile, setIdCardFile] = useState<File | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [showImagePreview, setShowImagePreview] = useState(false);
const [signLoading, setSignLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [consentList, setConsentList] = useState<OutputTongyishuFileInfo[]>([]);
const [consentLoading, setConsentLoading] = useState(false);
const [consentMessage, setConsentMessage] = useState<string | null>(null);
const [previewPdf, setPreviewPdf] = useState<OutputTongyishuFileInfo | null>(null);
const [showSignature, setShowSignature] = useState(false);
const signaturePadRef = useRef<SignaturePadHandle | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
const [signedCombinationCodes, setSignedCombinationCodes] = useState<number[]>([]);
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 [daojiandanUrl, setDaojiandanUrl] = useState<string | null>(null);
const [isDaojiandanSigned, setIsDaojiandanSigned] = useState(false); // 导检单是否已签名
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 [addItemBillUrl, setAddItemBillUrl] = useState<string | null>(null);
const [addItemBillName, setAddItemBillName] = useState<string>('加项单');
const [isAddItemBillSigned, setIsAddItemBillSigned] = useState(false); // 加项单是否已签名
const [showAddItemBillPreview, setShowAddItemBillPreview] = useState(false);
const [showAddItemBillSignature, setShowAddItemBillSignature] = useState(false);
const addItemBillSignaturePadRef = useRef<SignaturePadHandle | null>(null);
const [addItemBillSubmitLoading, setAddItemBillSubmitLoading] = useState(false);
const [addItemBillSubmitMessage, setAddItemBillSubmitMessage] = useState<string | null>(null);
const busy = signLoading || submitLoading || consentLoading || pdfLoading || daojiandanSubmitLoading || addItemBillSubmitLoading;
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<File> => {
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<HTMLInputElement>) => {
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;
// 先检查 localStorage 中的导检单
const storedPdf = getDaojiandanPdfFromStorage(examId);
if (storedPdf && storedPdf.pdf_url) {
setDaojiandanUrl(storedPdf.pdf_url);
// 使用 localStorage 中保存的 is_signed 字段判断是否已签名
setIsDaojiandanSigned(storedPdf.is_signed === true);
} else {
// 如果 localStorage 中没有导检单,调用接口获取未签名的导检单用于查看和签名
const fetchDaojiandan = async () => {
try {
const res = await getDaojiandanPdfApi({ exam_id: examId });
if (res.Status === 200 && res.Data?.pdf_url) {
const pdfUrlValue = res.Data.pdf_url;
// 不保存到 localStorage只用于显示和签名
setDaojiandanUrl(pdfUrlValue);
setIsDaojiandanSigned(false); // 接口获取的是未签名的
}
} catch (err) {
console.error('获取导检单失败', err);
}
};
fetchDaojiandan();
}
// 检查加项单PDF逻辑和导检单一样
const storedAddItemBill = getAddItemBillPdfFromStorage(examId);
if (storedAddItemBill && storedAddItemBill.pdf_url) {
setAddItemBillUrl(storedAddItemBill.pdf_url);
setAddItemBillName(storedAddItemBill.pdf_name || '加项单');
// 使用 localStorage 中保存的 is_signed 字段判断是否已签名
setIsAddItemBillSigned(storedAddItemBill.is_signed === true);
} else {
// 如果 localStorage 中没有加项单,调用接口获取未签名的加项单用于查看和签名
const fetchAddItemBill = async () => {
try {
const res = await getAddItemBillPdfApi({ exam_id: examId });
if (res.Status === 200 && res.Data?.pdf_url) {
const pdfUrlValue = res.Data.pdf_url;
const pdfNameValue = res.Data.pdf_name || '加项单';
// 不保存到 localStorage只用于显示和签名
setAddItemBillUrl(pdfUrlValue);
setAddItemBillName(pdfNameValue);
setIsAddItemBillSigned(false); // 接口获取的是未签名的
}
} catch (err) {
console.error('获取加项单失败', err);
}
};
fetchAddItemBill();
}
}, [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('打印失败,请稍后重试');
}
};
// 导检单签名提交
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);
setIsDaojiandanSigned(true); // 签名成功后标记为已签名
// 保存导检单PDF信息到localStorage标记为已签名
setDaojiandanPdf(examId, {
pdf_name: pdfNameValue,
pdf_url: pdfUrlValue,
is_signed: true,
});
// 记录打印导检单是否签名操作
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 handleSubmitAddItemBillSign = async () => {
if (!examId) {
setAddItemBillSubmitMessage('缺少必要信息,无法提交签名');
return;
}
const dataUrl = addItemBillSignaturePadRef.current?.toDataURL('image/png');
if (!dataUrl) {
setAddItemBillSubmitMessage('请先完成签名');
return;
}
setAddItemBillSubmitLoading(true);
setAddItemBillSubmitMessage(null);
try {
const blob = await fetch(dataUrl).then((r) => r.blob());
const res = await submitAddItemBillSign({
exam_id: examId,
sign_file: blob,
});
if (res.Status === 200 && res.Data?.pdf_url) {
setAddItemBillSubmitMessage('签名提交成功');
const pdfUrlValue = res.Data.pdf_url;
const pdfNameValue = res.Data.pdf_name || '加项单';
setAddItemBillUrl(pdfUrlValue);
setAddItemBillName(pdfNameValue);
setIsAddItemBillSigned(true); // 签名成功后标记为已签名
// 保存加项单PDF信息到localStorage标记为已签名
setAddItemBillPdf(examId, {
pdf_name: pdfNameValue,
pdf_url: pdfUrlValue,
is_signed: true,
});
setTimeout(() => {
setShowAddItemBillSignature(false);
setAddItemBillSubmitMessage(null);
addItemBillSignaturePadRef.current?.clear();
setShowAddItemBillPreview(true);
}, 2000);
} else {
setAddItemBillSubmitMessage(res.Message || '签名提交失败');
}
} catch (err) {
console.error('提交签名失败', err);
setAddItemBillSubmitMessage('签名提交失败,请稍后重试');
} finally {
setAddItemBillSubmitLoading(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(`
<!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();
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(`
<!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));
});
// 检查导检单是否已签名(只有 localStorage 中的或签名后的才是已签名的)
const daojiandanSigned = isDaojiandanSigned;
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'>
<div className='font-medium'></div>
<div className='text-xs text-gray-500'></div>
<div className='flex items-center gap-3'>
<Button className='py-1.5 px-3' onClick={handlePickFile} disabled={busy}>
{idCardFile ? '重新拍照' : '拍照'}
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
capture='environment'
className='hidden'
onChange={handleFileChange}
/>
{previewImage && (
<div
className='w-16 h-16 rounded-lg border-2 border-gray-300 overflow-hidden cursor-pointer hover:border-blue-500 transition-colors'
onClick={() => setShowImagePreview(true)}
>
<img src={previewImage} alt='身份证预览' className='w-full h-full object-cover' />
</div>
)}
<Button className='py-1.5 px-4 flex-1' onClick={handleSign} disabled={busy || !idCardFile}>
{signLoading ? '签到中...' : '签到'}
</Button>
</div>
{message && (
<div className={`text-xs ${message.includes('成功') ? 'text-green-600' : 'text-amber-600'}`}>{message}</div>
)}
</div>
{showImagePreview && previewImage && (
<div className='fixed inset-0 z-[80] bg-black/90 flex items-center justify-center p-6' onClick={() => setShowImagePreview(false)}>
<div className='relative max-w-full max-h-full'>
<img src={previewImage} alt='身份证预览' className='max-w-full max-h-[90vh] object-contain' />
<Button className='absolute top-4 right-4 bg-white/90 hover:bg-white' onClick={() => setShowImagePreview(false)}>
</Button>
</div>
</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>
{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 || true) && (
<div className='space-y-2'>
{consentList.map((item) => (
<div
key={item.pdf_url || item.pdf_name}
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'>{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)) && (
<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'>
{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
className='py-1.5 px-3'
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,
};
}
}
}
setPreviewPdf(target);
}}
disabled={busy}
>
</Button>
</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>
{isDaojiandanSigned && (
<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'>
{isDaojiandanSigned ? (
// 已签名:显示打印和查看按钮
<>
<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>
</>
) : daojiandanUrl ? (
// 未签名但有导检单:显示查看和签名按钮
<>
<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>
</>
) : (
// 没有导检单:只显示签名按钮
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setShowDaojiandanSignature(true);
}}
disabled={busy}
>
</Button>
)}
</div>
</div>
{/* 加项单 */}
{addItemBillUrl && (
<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'>{addItemBillName.length > 12 ? addItemBillName.slice(0, 12) + "..." : addItemBillName}</span>
{isAddItemBillSigned && (
<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'>
{isAddItemBillSigned ? (
// 已签名:显示打印和查看按钮
<>
<Button
className='py-1.5 px-3 bg-blue-600 hover:bg-blue-700 text-white'
onClick={() => {
if (busy) return;
handleAddItemBillDirectPrint();
}}
disabled={busy}
>
</Button>
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setShowAddItemBillPreview(true);
}}
disabled={busy}
>
</Button>
</>
) : (
// 未签名:显示查看和签名按钮
<>
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setShowAddItemBillPreview(true);
}}
disabled={busy}
>
</Button>
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setShowAddItemBillSignature(true);
}}
disabled={busy}
>
</Button>
</>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
{
previewPdf && (
<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'>{previewPdf.pdf_name}</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={handlePrint}
disabled={pdfLoading || !pdfReady || !pdfBlobUrl}
>
</Button>
<Button className='py-1 px-3' onClick={() => !busy && setShowSignature(true)} disabled={busy}>
</Button>
<Button className='py-1 px-3' onClick={() => !busy && setPreviewPdf(null)} disabled={busy}>
</Button>
</div>
</div>
<div className='flex-1 bg-gray-100 overflow-auto'>
{pdfLoading ? (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF...</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>
</div>
)
}
{
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-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}>
</Button>
</div>
</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'
onClick={() => {
setShowSignature(false);
setSubmitMessage(null);
signaturePadRef.current?.clear();
}}
disabled={submitLoading}
>
</Button>
<Button
className='py-2 px-6 bg-blue-600 text-white hover:bg-blue-700'
onClick={handleSubmitSign}
disabled={submitLoading}
>
{submitLoading ? '提交中...' : '提交签名'}
</Button>
</div>
</div>
</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>
)
}
{
showAddItemBillSignature && (
<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={() => setShowAddItemBillSignature(false)} disabled={busy}>
</Button>
</div>
</div>
<div className='ui7-signature-wrapper border rounded-xl overflow-hidden bg-gray-50'>
<SignaturePad
ref={addItemBillSignaturePadRef}
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={() => addItemBillSignaturePadRef.current?.clear()} disabled={addItemBillSubmitLoading}>
</Button>
</div>
</div>
{addItemBillSubmitMessage && (
<div className={`text-sm text-center ${addItemBillSubmitMessage.includes('成功') ? 'text-green-600' : 'text-amber-600'}`}>{addItemBillSubmitMessage}</div>
)}
<div className='flex items-center justify-end gap-3'>
<Button
className='py-2 px-6'
onClick={() => {
setShowAddItemBillSignature(false);
setAddItemBillSubmitMessage(null);
addItemBillSignaturePadRef.current?.clear();
}}
disabled={addItemBillSubmitLoading}
>
</Button>
<Button
className='py-2 px-6 bg-blue-600 text-white hover:bg-blue-700'
onClick={handleSubmitAddItemBillSign}
disabled={addItemBillSubmitLoading}
>
{addItemBillSubmitLoading ? '提交中...' : '提交签名'}
</Button>
</div>
</div>
</div>
)
}
{
showAddItemBillPreview && addItemBillUrl && (
<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'>{addItemBillName}</div>
<div className='flex items-center gap-2'>
{isAddItemBillSigned && (
<Button
className='py-1 px-3 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
onClick={handleAddItemBillDirectPrint}
disabled={busy}
>
</Button>
)}
{!isAddItemBillSigned && (
<Button
className='py-1 px-3 bg-blue-600 hover:bg-blue-700 text-white'
onClick={() => {
if (busy) return;
setShowAddItemBillPreview(false);
setShowAddItemBillSignature(true);
}}
disabled={busy}
>
</Button>
)}
<Button className='py-1 px-3' onClick={() => !busy && setShowAddItemBillPreview(false)} disabled={busy}>
</Button>
</div>
</div>
<div className='flex-1 bg-gray-100 overflow-auto'>
<div className='flex justify-center p-4'>
<iframe
src={addItemBillUrl}
className='w-full h-full min-h-[600px] border rounded-lg bg-white'
title='加项单预览'
/>
</div>
</div>
</div>
)
}
</div >
);
};