添加canvas签名
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
136
src/components/ui/SignaturePad.tsx
Normal file
136
src/components/ui/SignaturePad.tsx
Normal 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';
|
||||||
|
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user