Files
yuanhe-checkin-electron/src/pages/UI7/UI7.tsx
2025-11-28 17:31:23 +08:00

599 lines
18 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 React, { useState, useEffect, useRef, useCallback } from "react";
import { Document, Page } from "react-pdf";
import "./UI7.css";
import "../../assets/css/basic.css";
import { useNavigate } from "react-router-dom";
import BackButton from "../../components/BackButton";
import ConfirmButton from "../../components/ConfirmButton";
import DecorLine from "../../components/DecorLine";
import WaitButton from "../../components/WaitButton";
import {
getTongyishuPdf,
signIn,
submitDaojiandanSign,
submitTongyishuSign,
} from "../../api/hisApi";
// 独立的 PDF 渲染组件,使用 React.memo 避免不必要的重新渲染
const PdfRenderer = React.memo<{
pdfFile: Uint8Array | null;
loading: boolean;
error: string;
pageCount: number;
onPageCountUpdate: (numPages: number) => void;
loadMessage: string;
documentKey: string;
}>(
({
pdfFile,
loading,
error,
pageCount,
onPageCountUpdate,
loadMessage,
documentKey,
}) => {
const pagesToRender = Math.max(pageCount, 1);
return (
<>
{loading && (
<div style={{ padding: "20px", textAlign: "center", fontSize: "32px" }}>
<span style={{ fontSize: "32px" }}>{loadMessage}</span>
</div>
)}
{error && (
<div style={{ padding: "20px", color: "red", textAlign: "center" }}>
{error}
</div>
)}
{!loading && !error && !pdfFile && (
<div style={{ padding: "20px", textAlign: "center", fontSize: "28px" }}>
PDF
</div>
)}
{!loading && !error && pdfFile && (
<div style={{ marginBottom: "20px" }}>
<Document
key={documentKey}
file={{ data: pdfFile }}
loading=""
onLoadSuccess={({ numPages }) => onPageCountUpdate(numPages)}
>
{Array.from({ length: pagesToRender }, (_, pageIndex) => (
<Page
key={`pdf-page-${pageIndex + 1}`}
pageNumber={pageIndex + 1}
renderTextLayer={false}
renderAnnotationLayer={false}
width={900}
/>
))}
</Document>
</div>
)}
</>
);
}
);
PdfRenderer.displayName = "PdfRenderer";
type PdfMeta = {
pdf_url: string;
pdf_name: string;
combination_code: number | null;
};
const UI7: React.FC = () => {
const navigate = useNavigate();
const hasFetchedRef = useRef(false);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dprRef = useRef<number>(1);
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const [countdown, setCountdown] = useState(5);
const [showWaitButton, setShowWaitButton] = useState(true);
const [pdfFiles, setPdfFiles] = useState<Uint8Array[]>([]);
const [pdfInfoList, setPdfInfoList] = useState<PdfMeta[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [loadMessage, setLoadMessage] = useState("PDF加载中...");
const [isDrawing, setIsDrawing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [currentStep, setCurrentStep] = useState(0);
const [currentPdfPageCount, setCurrentPdfPageCount] = useState(0);
const totalRequiredSignatures = pdfInfoList.length + 1;
const isInstructionStep = currentStep === 0;
const currentPdfIndex = isInstructionStep ? -1 : currentStep - 1;
const currentPdfFile =
currentPdfIndex >= 0 && currentPdfIndex < pdfFiles.length
? pdfFiles[currentPdfIndex]
: null;
const currentPdfMeta =
currentPdfIndex >= 0 && currentPdfIndex < pdfInfoList.length
? pdfInfoList[currentPdfIndex]
: null;
const currentPdfName = currentPdfMeta?.pdf_name || "";
// 辅助函数:从 URL 获取 PDF 的 Uint8Array
const fetchPdfBytes = async (url: string) => {
try {
window.electronAPI?.log("info", `[UI7] 通过 fetch 获取 PDF: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`网络请求失败: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
} catch (error) {
throw new Error(`获取 PDF 失败: ${(error as Error).message}`);
}
};
// 从多份 PDF 中获取数据
useEffect(() => {
if (hasFetchedRef.current) {
window.electronAPI?.log("info", "[UI7] 已获取过 PDF跳过重复请求");
return;
}
hasFetchedRef.current = true;
const fetchPdfs = async () => {
try {
const res = await getTongyishuPdf(
Number(localStorage.getItem("selectedExamId"))
);
if (res.Status !== 200) {
alert(`获取PDF数据失败: ${res.Message}`);
setLoadMessage(res.Message || "获取PDF数据失败");
return;
}
const rawList = res.Data?.list_pdf_url || [];
const normalizedList = rawList
.map((item, idx): PdfMeta => {
if (typeof item === "string") {
return {
pdf_url: item,
pdf_name: `知情同意书${idx + 1}`,
combination_code: null,
};
}
if (item && typeof item === "object") {
const codeValue =
item.combination_code != null
? Number(item.combination_code)
: null;
return {
pdf_url: item.pdf_url || "",
pdf_name: item.pdf_name || `知情同意书${idx + 1}`,
combination_code:
codeValue != null && !Number.isNaN(codeValue)
? codeValue
: null,
};
}
return {
pdf_url: "",
pdf_name: `知情同意书${idx + 1}`,
combination_code: null,
};
})
.filter(
(item): item is PdfMeta =>
typeof item.pdf_url === "string" && item.pdf_url.length > 0
);
if (!normalizedList.length) {
throw new Error("未获取到任何 PDF 链接");
}
setLoading(true);
setError("");
setPdfFiles([]);
setPdfInfoList([]);
window.electronAPI?.log("info", "[UI7] 开始获取 PDF 文件");
const files: Uint8Array[] = [];
for (let idx = 0; idx < normalizedList.length; idx++) {
const { pdf_url: url } = normalizedList[idx];
window.electronAPI?.log(
"info",
`[UI7] 下载第 ${idx + 1} 份 PDF: ${url}`
);
const pdfBytes = await fetchPdfBytes(url);
files.push(pdfBytes);
}
setPdfFiles(files);
setPdfInfoList(normalizedList);
setLoading(false);
setError("");
} catch (err) {
const errorMsg = `PDF获取失败: ${err}`;
console.error(errorMsg);
window.electronAPI?.log("error", `[UI7] ${errorMsg}`);
setError("PDF 获取失败,请检查文件");
setLoading(false);
}
};
fetchPdfs();
}, []);
useEffect(() => {
localStorage.removeItem("consentSignatureList");
}, []);
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else {
setShowWaitButton(false);
}
}, [countdown]);
useEffect(() => {
setCurrentPdfPageCount(0);
}, [currentStep, currentPdfFile]);
const resetCountdown = useCallback(() => {
setCountdown(5);
setShowWaitButton(true);
}, []);
useEffect(() => {
const initCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", {
willReadFrequently: true,
alpha: true,
});
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const scale = 3;
dprRef.current = scale;
canvas.width = rect.width * scale;
canvas.height = rect.height * scale;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
ctx.strokeStyle = "#000000";
ctx.lineWidth = 10;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
};
const timer = setTimeout(initCanvas, 150);
window.addEventListener("resize", initCanvas);
return () => {
clearTimeout(timer);
window.removeEventListener("resize", initCanvas);
};
}, []);
const getCoordinates = useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
},
[]
);
const startDrawing = useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
if (event.cancelable) {
event.preventDefault();
}
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { x, y } = getCoordinates(event);
lastPointRef.current = { x, y };
ctx.beginPath();
ctx.moveTo(x, y);
setIsDrawing(true);
canvas.setPointerCapture(event.pointerId);
},
[getCoordinates]
);
const draw = useCallback(
(event: React.PointerEvent<HTMLCanvasElement>) => {
if (event.cancelable) {
event.preventDefault();
}
if (!isDrawing || !lastPointRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { x, y } = getCoordinates(event);
const lastPoint = lastPointRef.current;
const midX = (lastPoint.x + x) / 2;
const midY = (lastPoint.y + y) / 2;
ctx.quadraticCurveTo(lastPoint.x, lastPoint.y, midX, midY);
ctx.stroke();
lastPointRef.current = { x, y };
},
[getCoordinates, isDrawing]
);
const stopDrawing = useCallback(
(event?: React.PointerEvent<HTMLCanvasElement>) => {
setIsDrawing(false);
lastPointRef.current = null;
if (event && canvasRef.current?.hasPointerCapture(event.pointerId)) {
canvasRef.current.releasePointerCapture(event.pointerId);
}
},
[]
);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.restore();
ctx.strokeStyle = "#000000";
ctx.lineWidth = 10;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}, []);
const isCanvasBlank = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return true;
const ctx = canvas.getContext("2d");
if (!ctx) return true;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixelBuffer = new Uint32Array(imageData.data.buffer);
return !pixelBuffer.some((color) => color !== 0);
}, []);
const canvasToBlob = useCallback((canvas: HTMLCanvasElement) => {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("签名生成失败,请重试"));
}
},
"image/png",
1
);
});
}, []);
const handleBack = useCallback(() => {
navigate(-1);
}, [navigate]);
const handleConfirm = useCallback(() => {
const execute = async () => {
if (isSubmitting) {
return;
}
const canvas = canvasRef.current;
if (!canvas) {
alert("画布初始化失败,请刷新页面");
return;
}
if (isCanvasBlank()) {
alert("请先完成签名后再继续");
return;
}
const examIdStr = localStorage.getItem("selectedExamId");
const examId = Number(examIdStr);
if (!examIdStr || Number.isNaN(examId)) {
alert("未找到体检ID请返回重试");
return;
}
setIsSubmitting(true);
try {
const blob = await canvasToBlob(canvas);
// 本地缓存成列表,方便 UI8 或调试使用
try {
const dataUrl = canvas.toDataURL("image/png");
const storedRaw = localStorage.getItem("consentSignatureList");
const storedList: string[] = storedRaw ? JSON.parse(storedRaw) : [];
storedList[currentStep] = dataUrl;
localStorage.setItem("consentSignatureList", JSON.stringify(storedList));
} catch (cacheErr) {
window.electronAPI?.log(
"warn",
`[UI7] 签名缓存失败: ${(cacheErr as Error).message}`
);
}
if (currentStep === 0) {
window.electronAPI?.log(
"info",
`[UI7] 提交导检单签名(第 1 次exam_id=${examId}`
);
const daojiandanRes = await submitDaojiandanSign(examId, blob);
if (daojiandanRes.Status !== 200) {
throw new Error(daojiandanRes.Message || "提交导检单签名失败");
}
localStorage.setItem("consentSignature", daojiandanRes.Data.pdf_url || "");
} else {
const meta = pdfInfoList[currentStep - 1];
if (!meta || meta.combination_code == null) {
throw new Error("当前知情同意书缺少组合代码,无法提交签名");
}
window.electronAPI?.log(
"info",
`[UI7] 提交知情同意书签名exam_id=${examId}, combination_code=${meta.combination_code}`
);
const tongyishuRes = await submitTongyishuSign(
examId,
meta.combination_code,
blob
);
if (tongyishuRes.Status !== 200) {
throw new Error(tongyishuRes.Message || "提交知情同意书签名失败");
}
}
if (currentStep >= pdfInfoList.length) {
await sign();
navigate("/UI8");
return;
}
clearCanvas();
setCurrentStep((prev) => prev + 1);
resetCountdown();
window.electronAPI?.log(
"info",
`[UI7] 完成第 ${currentStep + 1} 次签名,进入下一份 PDF`
);
} catch (err) {
const msg = (err as Error).message || "签名提交失败,请稍后重试";
window.electronAPI?.log("error", `[UI7] ${msg}`);
alert(msg);
} finally {
setIsSubmitting(false);
}
};
execute();
}, [
canvasToBlob,
clearCanvas,
currentStep,
isCanvasBlank,
isSubmitting,
navigate,
pdfInfoList,
resetCountdown,
totalRequiredSignatures,
]);
const handlePageCountUpdate = useCallback((numPages: number) => {
setCurrentPdfPageCount(numPages);
}, []);
const sign = async () => {
const physical_exam_id = localStorage.getItem("selectedExamId");
const package_code = localStorage.getItem("package_code");
if (!physical_exam_id || !package_code) {
alert("体检ID或套餐代码不存在");
return;
}
const res = await signIn(Number(physical_exam_id), Number(package_code));
if (res.Status === 200) {
if (res.Data.is_success === 0) {
return;
} else {
console.log(res.Data);
alert(res.Message);
}
} else {
alert(res.Message);
}
};
return (
<div className="basic-root">
<div className="basic-white-block">
<div className="basic-content">
<span className="basic-title">
{!isInstructionStep && currentPdfName ? `${currentPdfName}` : "检测项目知情同意书确认"}
</span>
<DecorLine />
{isInstructionStep && (
<div
style={{
padding: "40px",
fontSize: "28px",
lineHeight: "48px",
color: "#333",
}}
>
<br />
<br />
<br />
{/* <p style={{ textAlign: "center", fontSize: "36px" }}>
请仔细阅读后签字确认
</p> */}
</div>
)}
{!isInstructionStep && (
<div className="ui7-text-wrapper">
<div className="ui7-text-content">
<PdfRenderer
pdfFile={currentPdfFile}
loading={loading}
error={error}
pageCount={currentPdfPageCount}
onPageCountUpdate={handlePageCountUpdate}
loadMessage={loadMessage}
documentKey={`pdf-step-${currentStep}`}
/>
</div>
</div>
)}
<span className="ui7-text_4">
</span>
<div className="ui7-signature-wrapper">
<canvas
ref={canvasRef}
className="ui7-signature-canvas"
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerLeave={stopDrawing}
onPointerCancel={stopDrawing}
/>
<button className="ui7-clear-button" onClick={clearCanvas}>
</button>
</div>
<div className="basic-confirm-section">
<BackButton text="返回" onClick={handleBack} />
{showWaitButton ? (
<WaitButton text={`等待(${countdown})S`} />
) : (
<ConfirmButton
text={isSubmitting ? "提交中..." : "确定"}
onClick={handleConfirm}
/>
)}
</div>
</div>
</div>
</div>
);
};
export default UI7;