Replace phone verification with canvas signature on confirm page
Adds a canvas-based SignaturePad component used on the customer confirm page. Authorize now requires a non-empty signature; reject opens a required reason modal. The archived signature is shown to the customer after confirming and on the admin detail page. Also fixes the confirm page being clipped at the top when its content exceeds the viewport: the public layout used height:100vh + overflow:hidden which cropped the centered card. Switched to min-height:100vh so tall content can scroll naturally. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
export interface SignaturePadHandle {
|
||||
clear: () => void;
|
||||
isEmpty: () => boolean;
|
||||
getDataURL: () => string;
|
||||
}
|
||||
|
||||
interface SignaturePadProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
onChange?: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(
|
||||
{ width = 480, height = 180, onChange },
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawingRef = useRef(false);
|
||||
const emptyRef = useRef(true);
|
||||
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const getContext = () => canvasRef.current?.getContext('2d') ?? null;
|
||||
|
||||
const resetCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = getContext();
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#111827';
|
||||
emptyRef.current = true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
// 高 DPI 适配
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = width * ratio;
|
||||
canvas.height = height * ratio;
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.scale(ratio, ratio);
|
||||
resetCanvas();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [width, height]);
|
||||
|
||||
const pointFromEvent = (
|
||||
e: React.PointerEvent<HTMLCanvasElement>,
|
||||
): { x: number; y: number } | null => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const ctx = getContext();
|
||||
const pt = pointFromEvent(e);
|
||||
if (!ctx || !pt) return;
|
||||
drawingRef.current = true;
|
||||
lastPointRef.current = pt;
|
||||
canvasRef.current?.setPointerCapture(e.pointerId);
|
||||
// 画一个点(防止只点一下没移动看不到痕迹)
|
||||
ctx.beginPath();
|
||||
ctx.arc(pt.x, pt.y, 1.2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.fill();
|
||||
emptyRef.current = false;
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingRef.current) return;
|
||||
e.preventDefault();
|
||||
const ctx = getContext();
|
||||
const pt = pointFromEvent(e);
|
||||
const last = lastPointRef.current;
|
||||
if (!ctx || !pt || !last) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(last.x, last.y);
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
ctx.stroke();
|
||||
lastPointRef.current = pt;
|
||||
emptyRef.current = false;
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingRef.current) return;
|
||||
drawingRef.current = false;
|
||||
lastPointRef.current = null;
|
||||
canvasRef.current?.releasePointerCapture(e.pointerId);
|
||||
if (onChange && canvasRef.current) {
|
||||
onChange(canvasRef.current.toDataURL('image/png'));
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
resetCanvas();
|
||||
onChange?.('');
|
||||
},
|
||||
isEmpty: () => emptyRef.current,
|
||||
getDataURL: () => (canvasRef.current ? canvasRef.current.toDataURL('image/png') : ''),
|
||||
}));
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="signature-pad-canvas"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default SignaturePad;
|
||||
Reference in New Issue
Block a user