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:
Frudrax Cheng
2026-05-26 18:02:18 +08:00
parent f61004ba12
commit 1d944b0fd3
7 changed files with 252 additions and 37 deletions
+130
View File
@@ -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;