添加canvas签名
This commit is contained in:
@@ -5,10 +5,12 @@ import type {
|
||||
CustomerAppointmentInfo,
|
||||
CustomerExamAddItem,
|
||||
CustomerInfo,
|
||||
OutputTongyishuFileInfo,
|
||||
PhysicalExamProgressItem,
|
||||
} 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 {
|
||||
client: ExamClient;
|
||||
@@ -164,7 +166,7 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
|
||||
loading={detailLoading}
|
||||
/>
|
||||
)}
|
||||
{tab === 'sign' && <ExamSignPanel />}
|
||||
{tab === 'sign' && <ExamSignPanel examId={Number(client.id)} />}
|
||||
{tab === 'addon' && <ExamAddonPanel client={client} />}
|
||||
{tab === 'print' && <ExamPrintPanel 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 [ocrLoading, setOcrLoading] = 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 handlePickFile = () => {
|
||||
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 (
|
||||
<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'>
|
||||
@@ -268,8 +299,82 @@ const ExamSignPanel = () => {
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
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 './Input';
|
||||
export * from './Select';
|
||||
export * from './SignaturePad';
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user