添加canvas签名

This commit is contained in:
xianyi
2025-12-12 16:40:44 +08:00
parent ad696d2fcb
commit 2e62ce4a1d
3 changed files with 246 additions and 4 deletions

View File

@@ -5,10 +5,12 @@ import type {
CustomerAppointmentInfo, CustomerAppointmentInfo,
CustomerExamAddItem, CustomerExamAddItem,
CustomerInfo, CustomerInfo,
OutputTongyishuFileInfo,
PhysicalExamProgressItem, PhysicalExamProgressItem,
} from '../../api'; } from '../../api';
import { getCustomerDetail, getPhysicalExamProgressDetail, signInMedicalExamCenter, getTongyishuPdf } from '../../api'; import { getCustomerDetail, getPhysicalExamProgressDetail, signInMedicalExamCenter, getTongyishuPdf } from '../../api';
import { Button, Input } from '../ui'; import type { SignaturePadHandle } from '../ui';
import { Button, Input, SignaturePad } from '../ui';
interface ExamModalProps { interface ExamModalProps {
client: ExamClient; client: ExamClient;
@@ -164,7 +166,7 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
loading={detailLoading} loading={detailLoading}
/> />
)} )}
{tab === 'sign' && <ExamSignPanel />} {tab === 'sign' && <ExamSignPanel examId={Number(client.id)} />}
{tab === 'addon' && <ExamAddonPanel client={client} />} {tab === 'addon' && <ExamAddonPanel client={client} />}
{tab === 'print' && <ExamPrintPanel client={client} />} {tab === 'print' && <ExamPrintPanel client={client} />}
{tab === 'delivery' && <ExamDeliveryPanel client={client} />} {tab === 'delivery' && <ExamDeliveryPanel client={client} />}
@@ -175,12 +177,18 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
); );
}; };
const ExamSignPanel = () => { const ExamSignPanel = ({ examId }: { examId?: number }) => {
const [idNo, setIdNo] = useState(''); const [idNo, setIdNo] = useState('');
const [ocrLoading, setOcrLoading] = useState(false); const [ocrLoading, setOcrLoading] = useState(false);
const [signLoading, setSignLoading] = useState(false); const [signLoading, setSignLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | 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 handlePickFile = () => { const handlePickFile = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
@@ -230,6 +238,29 @@ const ExamSignPanel = () => {
} }
}; };
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 ( return (
<div className='grid grid-cols-2 gap-4 text-sm'> <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='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
@@ -268,8 +299,82 @@ const ExamSignPanel = () => {
<div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'> <div className='p-4 rounded-2xl border bg-gray-50/60 flex flex-col gap-3'>
<div className='font-medium'></div> <div className='font-medium'></div>
<div className='text-xs text-gray-500'></div> <div className='text-xs text-gray-500'></div>
<Button className='py-1.5 px-3'></Button> <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'>{item.pdf_name}</div>
<Button className='py-1.5 px-3' onClick={() => setPreviewPdf(item)}>
</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'
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-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)}>
</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>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,136 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { cls } from '../../utils/cls';
export interface SignaturePadHandle {
clear: () => void;
toDataURL: (type?: string, quality?: number) => string | undefined;
}
interface SignaturePadProps {
className?: string;
lineWidth?: number;
strokeStyle?: string;
onDrawEnd?: (dataUrl: string) => void;
}
export const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(
({ className = '', lineWidth = 5, strokeStyle = '#111827', onDrawEnd }, ref) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const isDrawingRef = useRef(false);
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
const scaleRef = useRef({ scaleX: 1, scaleY: 1 });
const resizeCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.strokeStyle = strokeStyle;
scaleRef.current = { scaleX: dpr, scaleY: dpr };
}, [lineWidth, strokeStyle]);
useEffect(() => {
resizeCanvas();
const handleResize = () => resizeCanvas();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [resizeCanvas]);
const getPoint = useCallback((e: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
// 使用 CSS 像素坐标,避免与 ctx.scale 的 DPR 缩放重复,防止漂移
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}, []);
const startDrawing = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
isDrawingRef.current = true;
lastPointRef.current = getPoint(e);
canvas.setPointerCapture(e.pointerId);
},
[getPoint],
);
const draw = useCallback(
(e: ReactPointerEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const current = getPoint(e);
const last = lastPointRef.current || current;
ctx.beginPath();
ctx.moveTo(last.x, last.y);
ctx.lineTo(current.x, current.y);
ctx.stroke();
lastPointRef.current = current;
},
[getPoint],
);
const stopDrawing = useCallback(
(e?: ReactPointerEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
isDrawingRef.current = false;
lastPointRef.current = null;
if (e?.pointerId && canvasRef.current) {
canvasRef.current.releasePointerCapture(e.pointerId);
}
const dataUrl = canvasRef.current?.toDataURL();
if (dataUrl && onDrawEnd) {
onDrawEnd(dataUrl);
}
},
[onDrawEnd],
);
const clear = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}, []);
useImperativeHandle(
ref,
() => ({
clear,
toDataURL: (type?: string, quality?: number) => canvasRef.current?.toDataURL(type, quality),
}),
[clear],
);
return (
<canvas
ref={canvasRef}
className={cls('w-full h-72 bg-white touch-none', className)}
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerLeave={stopDrawing}
onPointerCancel={stopDrawing}
/>
);
},
);
SignaturePad.displayName = 'SignaturePad';

View File

@@ -4,5 +4,6 @@ export * from './Card';
export * from './InfoCard'; export * from './InfoCard';
export * from './Input'; export * from './Input';
export * from './Select'; export * from './Select';
export * from './SignaturePad';