Files
ipad/src/components/exam/ExamSignPanel.tsx
2026-01-06 16:42:56 +08:00

2277 lines
80 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,
type AddItemBillPdfInfo,
} 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 [daojiandanPdfData, setDaojiandanPdfData] = useState<ArrayBuffer | null>(null);
const [daojiandanPdfLoading, setDaojiandanPdfLoading] = useState(false);
const [daojiandanPdfReady, setDaojiandanPdfReady] = useState(false);
const [daojiandanPdfBlobUrl, setDaojiandanPdfBlobUrl] = useState<string | null>(null);
const daojiandanCanvasContainerRef = useRef<HTMLDivElement>(null);
// 加项单相关状态(支持多个加项单)
const [addItemBillList, setAddItemBillList] = useState<AddItemBillPdfInfo[]>([]);
const [currentAddItemBill, setCurrentAddItemBill] = useState<AddItemBillPdfInfo | null>(null);
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 [addItemBillPdfData, setAddItemBillPdfData] = useState<ArrayBuffer | null>(null);
const [addItemBillPdfLoading, setAddItemBillPdfLoading] = useState(false);
const [addItemBillPdfReady, setAddItemBillPdfReady] = useState(false);
const [addItemBillPdfBlobUrl, setAddItemBillPdfBlobUrl] = useState<string | null>(null);
const addItemBillCanvasContainerRef = useRef<HTMLDivElement>(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,
is_signed: true,
}));
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);
// 先检查 localStorage 中是否有已签名的知情同意书
const storedList = getTongyishuPdfList(examId);
const allSigned = storedList && storedList.length > 0 && storedList.every((pdf) => pdf.is_signed === true);
// 如果所有知情同意书都已签名,直接使用 localStorage 中的数据,不请求接口
if (allSigned && storedList) {
const mergedList = storedList.map((pdf) => ({
pdf_name: pdf.pdf_name,
pdf_url: pdf.pdf_url,
combination_code: pdf.combination_code ?? null,
}));
setConsentList(mergedList);
setConsentLoading(false);
return;
}
// 如果有未签名的,请求接口获取最新列表
getTongyishuPdf({ exam_id: examId })
.then((res) => {
const list = res.Data?.list_pdf_url || [];
// 合并接口返回的数据和本地已保存的已签名 PDF
let mergedList = list;
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 && pdf.is_signed === true,
);
// 如果本地有已签名的版本,优先使用
if (matched && matched.pdf_url && matched.is_signed === true) {
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 storedAddItemBillList = getAddItemBillPdfFromStorage(examId);
if (storedAddItemBillList && storedAddItemBillList.length > 0) {
setAddItemBillList(storedAddItemBillList);
// 默认选中第一个未签名的加项单,如果都已签名则选中第一个
const unsigned = storedAddItemBillList.find((item) => item.is_signed !== true);
setCurrentAddItemBill(unsigned || storedAddItemBillList[0]);
} else {
// 没有加项单:不再主动调用接口获取,因为新接口需要 CombinationCode仅支付时知道
setAddItemBillList([]);
setCurrentAddItemBill(null);
}
}, [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 = 3;
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.style.maxWidth = '100%';
canvas.style.height = 'auto';
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]);
// 加载导检单 PDF 数据
useEffect(() => {
if (!showDaojiandanPreview || !daojiandanUrl) {
setDaojiandanPdfData(null);
setDaojiandanPdfBlobUrl(null);
setDaojiandanPdfReady(false);
return;
}
let objectUrl: string | null = null;
setDaojiandanPdfReady(false);
setDaojiandanPdfLoading(true);
setDaojiandanPdfData(null);
fetch(daojiandanUrl)
.then((resp) => {
if (!resp.ok) throw new Error('获取PDF文件失败');
return resp.blob();
})
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setDaojiandanPdfBlobUrl(objectUrl);
return blob.arrayBuffer();
})
.then((arrayBuffer) => {
setDaojiandanPdfData(arrayBuffer);
setDaojiandanPdfLoading(false);
})
.catch((err) => {
console.error('导检单PDF 拉取失败', err);
setDaojiandanPdfLoading(false);
});
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [showDaojiandanPreview, daojiandanUrl]);
// 渲染导检单 PDF
useEffect(() => {
if (!daojiandanPdfData || !daojiandanCanvasContainerRef.current) return;
setDaojiandanPdfReady(false);
const renderAllPages = async () => {
try {
const pdf = await pdfjsLib.getDocument({ data: daojiandanPdfData }).promise;
if (!daojiandanCanvasContainerRef.current) return;
// 清空容器
daojiandanCanvasContainerRef.current.innerHTML = '';
const scale = 3.0;
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.style.maxWidth = '100%';
canvas.style.height = 'auto';
canvas.className = 'mx-auto border rounded-lg shadow-sm';
daojiandanCanvasContainerRef.current.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
} as any;
await page.render(renderContext).promise;
}
setDaojiandanPdfReady(true);
} catch (err) {
console.error('导检单PDF 渲染失败', err);
setDaojiandanPdfLoading(false);
}
};
renderAllPages();
}, [daojiandanPdfData]);
// 加载加项单 PDF 数据(当前选中的加项单)
useEffect(() => {
if (!showAddItemBillPreview || !currentAddItemBill?.pdf_url) {
setAddItemBillPdfData(null);
setAddItemBillPdfBlobUrl(null);
setAddItemBillPdfReady(false);
return;
}
let objectUrl: string | null = null;
setAddItemBillPdfReady(false);
setAddItemBillPdfLoading(true);
setAddItemBillPdfData(null);
fetch(currentAddItemBill.pdf_url)
.then((resp) => {
if (!resp.ok) throw new Error('获取PDF文件失败');
return resp.blob();
})
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setAddItemBillPdfBlobUrl(objectUrl);
return blob.arrayBuffer();
})
.then((arrayBuffer) => {
setAddItemBillPdfData(arrayBuffer);
setAddItemBillPdfLoading(false);
})
.catch((err) => {
console.error('加项单PDF 拉取失败', err);
setAddItemBillPdfLoading(false);
});
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [showAddItemBillPreview, currentAddItemBill?.pdf_url]);
// 渲染加项单 PDF
useEffect(() => {
if (!addItemBillPdfData || !addItemBillCanvasContainerRef.current) return;
setAddItemBillPdfReady(false);
const renderAllPages = async () => {
try {
const pdf = await pdfjsLib.getDocument({ data: addItemBillPdfData }).promise;
if (!addItemBillCanvasContainerRef.current) return;
// 清空容器
addItemBillCanvasContainerRef.current.innerHTML = '';
const scale = 3.0;
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.style.maxWidth = '100%';
canvas.style.height = 'auto';
canvas.className = 'mx-auto border rounded-lg shadow-sm';
addItemBillCanvasContainerRef.current.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
} as any;
await page.render(renderContext).promise;
}
setAddItemBillPdfReady(true);
} catch (err) {
console.error('加项单PDF 渲染失败', err);
setAddItemBillPdfLoading(false);
}
};
renderAllPages();
}, [addItemBillPdfData]);
// 导检单预览:使用 pdfjs 渲染到 canvas
useEffect(() => {
if (!showDaojiandanPreview || !daojiandanUrl || !daojiandanCanvasContainerRef.current) return;
const container = daojiandanCanvasContainerRef.current;
let cancelled = false;
const renderDaojiandan = async () => {
try {
setDaojiandanPdfLoading(true);
container.innerHTML = '';
const resp = await fetch(daojiandanUrl);
if (!resp.ok) throw new Error('获取PDF文件失败');
const blob = await resp.blob();
const arrayBuffer = await blob.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const scale = 3.0;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
if (cancelled) return;
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.style.maxWidth = '100%';
canvas.style.height = 'auto';
canvas.className = 'mx-auto border rounded-lg shadow-sm';
container.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
} as any;
await page.render(renderContext).promise;
}
} catch (err) {
console.error('导检单PDF 渲染失败', err);
} finally {
if (!cancelled) {
setDaojiandanPdfLoading(false);
}
}
};
renderDaojiandan();
return () => {
cancelled = true;
container.innerHTML = '';
};
}, [showDaojiandanPreview, daojiandanUrl]);
// 加项单预览:使用 pdfjs 渲染到 canvas依赖加载好的 addItemBillPdfData
useEffect(() => {
if (!showAddItemBillPreview || !addItemBillPdfData || !addItemBillCanvasContainerRef.current) return;
const container = addItemBillCanvasContainerRef.current;
container.innerHTML = '';
const renderAddItemBill = async () => {
try {
const pdf = await pdfjsLib.getDocument({ data: addItemBillPdfData }).promise;
const scale = 3.0;
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.style.maxWidth = '100%';
canvas.style.height = 'auto';
canvas.className = 'mx-auto border rounded-lg shadow-sm';
container.appendChild(canvas);
const renderContext = {
canvasContext: context,
viewport: viewport,
} as any;
await page.render(renderContext).promise;
}
} catch (err) {
console.error('加项单PDF 渲染失败', err);
}
};
renderAddItemBill();
return () => {
container.innerHTML = '';
};
}, [showAddItemBillPreview, addItemBillPdfData]);
const handlePrint = () => {
if (!pdfBlobUrl || !pdfReady || !canvasContainerRef.current || !previewPdf) return;
// 创建打印窗口
const printWindow = window.open('', '_blank');
if (!printWindow) return;
// 获取所有的 canvas
const canvases = canvasContainerRef.current.querySelectorAll('canvas');
// 构建打印页面
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>${previewPdf.pdf_name || '知情同意书打印'}</title>
<style>
@media print {
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
display: none;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.print-button {
background-color: #2563eb;
color: white;
}
.print-button:hover {
background-color: #1d4ed8;
}
.close-button {
background-color: transparent;
color: #374151;
}
.close-button:hover {
background-color: #e5e7eb;
}
.button-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="button-container">
<button class="close-button" onclick="window.close()">关闭</button>
<button class="print-button" onclick="window.print()">打印</button>
</div>
`);
// 将每个 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.0;
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;
}
.print-button, .close-button {
display: none;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.print-button {
background-color: #2563eb;
color: white;
}
.print-button:hover {
background-color: #1d4ed8;
}
.close-button {
background-color: transparent;
color: #374151;
}
.close-button:hover {
background-color: #e5e7eb;
}
.button-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="button-container">
<button class="close-button" onclick="window.close()">关闭</button>
<button class="print-button" onclick="window.print()">打印</button>
</div>
`);
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;
}
if (!currentAddItemBill) {
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,
pdf_sort: currentAddItemBill.pdf_sort,
combinationCode: currentAddItemBill.combinationCode,
sign_file: blob,
});
if (res.Status === 200 && res.Data?.pdf_url) {
setAddItemBillSubmitMessage('签名提交成功');
const pdfUrlValue = res.Data.pdf_url;
const pdfNameValue = res.Data.pdf_name || '加项单';
// 更新当前加项单为已签名,并更新本地列表与 localStorage
setAddItemBillList((prev) => {
const next = prev.map((item) => {
if (item.pdf_sort === currentAddItemBill.pdf_sort) {
const updated: AddItemBillPdfInfo = {
...item,
pdf_name: pdfNameValue,
pdf_url: pdfUrlValue,
payment_status: res.Data?.payment_status ?? item.payment_status ?? null,
payment_status_name: res.Data?.payment_status_name ?? item.payment_status_name ?? null,
is_signed: true,
};
// 同步写入 localStorage
setAddItemBillPdf(examId, updated);
return updated;
}
return item;
});
return next;
});
setCurrentAddItemBill((prev) =>
prev && prev.pdf_sort === currentAddItemBill.pdf_sort
? {
...prev,
pdf_name: pdfNameValue,
pdf_url: pdfUrlValue,
payment_status: res.Data?.payment_status ?? prev.payment_status ?? null,
payment_status_name: res.Data?.payment_status_name ?? prev.payment_status_name ?? null,
is_signed: true,
}
: prev
);
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 (target: AddItemBillPdfInfo | null) => {
if (!target?.pdf_url) return;
try {
const response = await fetch(target.pdf_url);
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 = 3.0;
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>${target.pdf_name || '加项单打印'}</title>
<style>
@media print {
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
display: none;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.print-button {
background-color: #2563eb;
color: white;
}
.print-button:hover {
background-color: #1d4ed8;
}
.close-button {
background-color: transparent;
color: #374151;
}
.close-button:hover {
background-color: #e5e7eb;
}
.button-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="button-container">
<button class="close-button" onclick="window.close()">关闭</button>
<button class="print-button" onclick="window.print()">打印</button>
</div>
`);
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 = 3.0;
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;
}
.print-button, .close-button {
display: none;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.print-button {
background-color: #2563eb;
color: white;
}
.print-button:hover {
background-color: #1d4ed8;
}
.close-button {
background-color: transparent;
color: #374151;
}
.close-button:hover {
background-color: #e5e7eb;
}
.button-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="button-container">
<button class="close-button" onclick="window.close()">关闭</button>
<button class="print-button" onclick="window.print()">打印</button>
</div>
`);
canvasImages.forEach((imgData) => {
printWindow.document.write(`<img src="${imgData}" />`);
});
printWindow.document.write(`
</body>
</html>
`);
// 在关闭文档前设置 onload 事件
printWindow.onload = () => {
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 1000);
};
printWindow.document.close();
// 更新导检单打印状态(不阻塞打印流程)
if (examId) {
editDaojiandanPrintStatus({ exam_id: examId }).catch((err) => {
console.error('更新导检单打印状态失败', err);
});
}
} 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 = 3.0;
// 处理所有已签名的知情同意书
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 (addItemBillList.length > 0) {
try {
for (const bill of addItemBillList) {
if (!bill.pdf_url || bill.is_signed !== true) continue;
const response = await fetch(bill.pdf_url);
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;
}
.print-button, .close-button {
display: none;
}
img {
max-width: 100%;
page-break-after: always;
page-break-inside: avoid;
}
img:last-child {
page-break-after: auto;
}
}
body {
margin: 0;
padding: 0;
}
.print-button, .close-button {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.print-button {
background-color: #2563eb;
color: white;
}
.print-button:hover {
background-color: #1d4ed8;
}
.close-button {
background-color: transparent;
color: #374151;
}
.close-button:hover {
background-color: #e5e7eb;
}
.button-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="button-container">
<button class="close-button" onclick="window.close()">关闭</button>
<button class="print-button" onclick="window.print()">打印</button>
</div>
`);
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>
{/* 加项单列表(可能有多个) */}
{addItemBillList.length > 0 &&
addItemBillList.map((bill) => {
const isSigned = bill.is_signed === true;
const isCurrent = currentAddItemBill && currentAddItemBill.pdf_sort === bill.pdf_sort;
const displayName =
bill.pdf_name && bill.pdf_name.length > 12 ? bill.pdf_name.slice(0, 12) + '...' : bill.pdf_name || '加项单';
return (
<div
key={bill.pdf_sort}
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'>
{displayName}
{typeof bill.pdf_sort === 'number' ? `#${bill.pdf_sort}` : ''}
</span>
{isSigned && (
<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'>
{isSigned && bill.pdf_url && (
<Button
className='py-1.5 px-3 bg-blue-600 hover:bg-blue-700 text-white'
onClick={() => {
if (busy) return;
handleAddItemBillDirectPrint(bill);
}}
disabled={busy}
>
</Button>
)}
{bill.pdf_url && (
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setCurrentAddItemBill(bill);
setShowAddItemBillPreview(true);
}}
disabled={busy}
>
</Button>
)}
{!isSigned && (
<Button
className='py-1.5 px-3'
onClick={() => {
if (busy) return;
setCurrentAddItemBill(bill);
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'>
{daojiandanPdfLoading ? (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF...</div>
</div>
) : daojiandanPdfData ? (
<div className='flex justify-center p-4'>
<div ref={daojiandanCanvasContainerRef} className='w-full' />
</div>
) : (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF加载失败</div>
</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 && currentAddItemBill && currentAddItemBill.pdf_url && (
<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'>{currentAddItemBill.pdf_name || '加项单'}</div>
<div className='flex items-center gap-2'>
{currentAddItemBill.is_signed && (
<Button
className='py-1 px-3 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
onClick={() => handleAddItemBillDirectPrint(currentAddItemBill)}
disabled={busy}
>
</Button>
)}
{!currentAddItemBill.is_signed && (
<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'>
{addItemBillPdfLoading ? (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF...</div>
</div>
) : addItemBillPdfData ? (
<div className='flex justify-center p-4'>
<div ref={addItemBillCanvasContainerRef} className='w-full' />
</div>
) : (
<div className='flex items-center justify-center h-full text-white'>
<div>PDF加载失败</div>
</div>
)}
</div>
</div>
)
}
</div >
);
};