From d70dc3cf3ddc69f3c7fb23bf8fe906584be59703 Mon Sep 17 00:00:00 2001 From: xianyi Date: Mon, 15 Dec 2025 15:40:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E7=A6=BB=E4=BD=93=E6=A3=80=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/exam/ExamModal.tsx | 13 +- src/components/exam/ExamSignPanel.tsx | 349 ++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 10 deletions(-) create mode 100644 src/components/exam/ExamSignPanel.tsx diff --git a/src/components/exam/ExamModal.tsx b/src/components/exam/ExamModal.tsx index f939366..f749fe0 100644 --- a/src/components/exam/ExamModal.tsx +++ b/src/components/exam/ExamModal.tsx @@ -1,16 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import type { ExamClient, ExamModalTab } from '../../data/mockData'; -import type { - CustomerAppointmentInfo, - CustomerExamAddItem, - CustomerInfo, - PhysicalExamProgressItem, - OutputTongyishuFileInfo, -} from '../../api'; -import { getCustomerDetail, getPhysicalExamProgressDetail, signInMedicalExamCenter, getTongyishuPdf, submitTongyishuSign } from '../../api'; -import type { SignaturePadHandle } from '../ui'; -import { Button, Input, SignaturePad } from '../ui'; +import type { CustomerAppointmentInfo, CustomerExamAddItem, CustomerInfo, OutputTongyishuFileInfo, PhysicalExamProgressItem } from '../../api'; +import { getCustomerDetail, getPhysicalExamProgressDetail, getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api'; +import { Button, Input, SignaturePad, type SignaturePadHandle } from '../ui'; import { ExamDetailPanel } from './ExamDetailPanel'; interface ExamModalProps { diff --git a/src/components/exam/ExamSignPanel.tsx b/src/components/exam/ExamSignPanel.tsx new file mode 100644 index 0000000..5edb4de --- /dev/null +++ b/src/components/exam/ExamSignPanel.tsx @@ -0,0 +1,349 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { OutputTongyishuFileInfo } from '../../api'; +import { getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api'; +import type { SignaturePadHandle } from '../ui'; +import { Button, SignaturePad } from '../ui'; + +interface ExamSignPanelProps { + examId?: number; +} + +export const ExamSignPanel = ({ examId }: ExamSignPanelProps) => { + const [idCardFile, setIdCardFile] = useState(null); + const [previewImage, setPreviewImage] = useState(null); + const [showImagePreview, setShowImagePreview] = useState(false); + const [signLoading, setSignLoading] = useState(false); + const [message, setMessage] = useState(null); + const fileInputRef = useRef(null); + const [consentList, setConsentList] = useState([]); + const [consentLoading, setConsentLoading] = useState(false); + const [consentMessage, setConsentMessage] = useState(null); + const [previewPdf, setPreviewPdf] = useState(null); + const [showSignature, setShowSignature] = useState(false); + const signaturePadRef = useRef(null); + const [submitLoading, setSubmitLoading] = useState(false); + const [submitMessage, setSubmitMessage] = useState(null); + const [signedCombinationCodes, setSignedCombinationCodes] = useState([]); + + const SIGN_STORAGE_KEY = 'yh_signed_consents'; + + 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 => { + 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) => { + 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; + setMessage(ok ? '签到成功' : 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('签名提交成功'); + 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 || []; + setConsentList(list); + if (!list.length) { + setConsentMessage(res.Data?.message || '暂无知情同意书'); + } + }) + .catch((err) => { + console.error('获取知情同意书失败', err); + setConsentMessage('知情同意书加载失败,请稍后重试'); + }) + .finally(() => setConsentLoading(false)); + }, [examId]); + + return ( +
+
+
身份证拍照与签到
+
拍照身份证后点击签到按钮完成签到。
+
+ + + {previewImage && ( +
setShowImagePreview(true)} + > + 身份证预览 +
+ )} + +
+ {message && ( +
{message}
+ )} +
+ {showImagePreview && previewImage && ( +
setShowImagePreview(false)}> +
+ 身份证预览 + +
+
+ )} +
+
体检知情同意书
+
点击后弹出知情同意书全文及签名区域,签署完成后回到签到页面。
+
+ {consentLoading &&
加载中...
} + {!consentLoading && consentMessage &&
{consentMessage}
} + {!consentLoading && consentList.length > 0 && ( +
+ {consentList.map((item) => ( +
+
+ {item.pdf_name} + {item.combination_code !== undefined && signedCombinationCodes.includes(Number(item.combination_code)) && ( + 已签名 + )} +
+ +
+ ))} +
+ )} +
+
+ {previewPdf && ( +
+
+
{previewPdf.pdf_name}
+
+ + +
+
+
+