分离体检签名面板

This commit is contained in:
xianyi
2025-12-15 15:40:48 +08:00
parent 4741a437a4
commit d70dc3cf3d
2 changed files with 352 additions and 10 deletions

View File

@@ -1,16 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { ExamClient, ExamModalTab } from '../../data/mockData'; import type { ExamClient, ExamModalTab } from '../../data/mockData';
import type { import type { CustomerAppointmentInfo, CustomerExamAddItem, CustomerInfo, OutputTongyishuFileInfo, PhysicalExamProgressItem } from '../../api';
CustomerAppointmentInfo, import { getCustomerDetail, getPhysicalExamProgressDetail, getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api';
CustomerExamAddItem, import { Button, Input, SignaturePad, type SignaturePadHandle } from '../ui';
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 { ExamDetailPanel } from './ExamDetailPanel'; import { ExamDetailPanel } from './ExamDetailPanel';
interface ExamModalProps { interface ExamModalProps {

View File

@@ -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<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 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<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;
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 (
<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={signLoading}>
{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={signLoading || !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='font-medium'></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 && (
<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-3 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}</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>
<Button className='py-1.5 px-3' onClick={() => setPreviewPdf(item)}>
</Button>
</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' onClick={() => setShowSignature(true)}>
</Button>
<Button className='py-1 px-3' onClick={() => setPreviewPdf(null)}>
</Button>
</div>
</div>
<div className='flex-1 bg-gray-100'>
<iframe src={previewPdf.pdf_url} title={previewPdf.pdf_name} className='w-full h-full' />
</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之间'>
<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)}>
</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()}>
</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>
)}
</div>
);
};