Open signature in a landscape overlay and fix mobile touch

The inline signature pad on the confirm page was unusable on phones:
the canvas had a fixed 480px internal width but shrank via max-width
on small screens, so pointer coordinates landed in only the top-left
fraction of the drawing buffer. Edge-of-screen strokes also collided
with iOS Safari back-swipe.

Tapping the new signature trigger now opens a full-screen overlay
that rotates to landscape (via 100dvh/100dvw + CSS rotate on portrait
phones, plus an opportunistic screen.orientation.lock) so customers
get the widest possible signing area, away from the system edge.

Also swap the hand-rolled drawing logic for signature_pad, with a
ResizeObserver-driven resize that preserves strokes via
toData/fromData and scales the canvas to devicePixelRatio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frudrax Cheng
2026-05-27 09:11:05 +08:00
parent 1d944b0fd3
commit ab5acbc452
7 changed files with 310 additions and 120 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
"react-router-dom": "^7.13.1",
"signature_pad": "^5.1.3"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
+8
View File
@@ -29,6 +29,9 @@ importers:
react-router-dom:
specifier: ^7.13.1
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
signature_pad:
specifier: ^5.1.3
version: 5.1.3
devDependencies:
'@types/qrcode':
specifier: ^1.5.6
@@ -1146,6 +1149,9 @@ packages:
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
signature_pad@5.1.3:
resolution: {integrity: sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2368,6 +2374,8 @@ snapshots:
set-cookie-parser@2.7.2: {}
signature_pad@5.1.3: {}
source-map-js@1.2.1: {}
string-convert@0.2.1: {}
+75
View File
@@ -0,0 +1,75 @@
.signature-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: #f8fafc;
overscroll-behavior: contain;
touch-action: none;
}
.signature-overlay-stage {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 12px 16px;
gap: 10px;
box-sizing: border-box;
}
/* Phones in portrait: rotate the whole stage to use landscape space */
@media (orientation: portrait) and (max-width: 820px) {
.signature-overlay-stage {
inset: auto;
top: 50%;
left: 50%;
width: 100dvh;
height: 100dvw;
transform: translate(-50%, -50%) rotate(90deg);
transform-origin: center center;
}
}
.signature-overlay-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.signature-overlay-title {
font-size: 15px;
font-weight: 500;
color: #1f2937;
}
.signature-overlay-pad {
flex: 1 1 auto;
min-height: 0;
border: 1px dashed #94a3b8;
border-radius: 12px;
background: #ffffff;
overflow: hidden;
/* keep room from screen edges so iOS back-swipe doesn't intercept */
margin: 0 4px;
}
.signature-pad-wrap {
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.signature-pad-canvas {
display: block;
width: 100%;
height: 100%;
background: #ffffff;
touch-action: none;
cursor: crosshair;
}
.signature-overlay-actions {
flex-shrink: 0;
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from 'antd';
import SignaturePad, { type SignaturePadHandle } from './SignaturePad';
import './SignatureOverlay.css';
interface SignatureOverlayProps {
open: boolean;
onCancel: () => void;
onConfirm: (dataUrl: string) => void;
}
function SignatureOverlay({ open, onCancel, onConfirm }: SignatureOverlayProps) {
const padRef = useRef<SignaturePadHandle>(null);
const [data, setData] = useState('');
useEffect(() => {
if (!open) return;
const orientation = (screen as Screen & {
orientation?: { lock?: (o: string) => Promise<void> };
}).orientation;
if (orientation?.lock) {
orientation.lock('landscape').catch(() => {
// ignore; iOS Safari etc. don't permit lock outside fullscreen
});
}
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow;
};
}, [open]);
if (!open) return null;
const handleClear = () => {
padRef.current?.clear();
setData('');
};
const handleConfirm = () => {
if (padRef.current?.isEmpty()) return;
const url = padRef.current?.getDataURL() || data;
if (!url) return;
onConfirm(url);
};
return (
<div className="signature-overlay" role="dialog" aria-modal="true">
<div className="signature-overlay-stage">
<div className="signature-overlay-bar">
<Button type="text" onClick={onCancel}>
</Button>
<span className="signature-overlay-title"></span>
<Button type="text" onClick={handleClear}>
</Button>
</div>
<div className="signature-overlay-pad">
<SignaturePad ref={padRef} onChange={setData} />
</div>
<div className="signature-overlay-actions">
<Button size="large" type="primary" block onClick={handleConfirm}>
</Button>
</div>
</div>
</div>
);
}
export default SignatureOverlay;
+57 -93
View File
@@ -1,4 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import SignaturePadLib, { type PointGroup } from 'signature_pad';
export interface SignaturePadHandle {
clear: () => void;
@@ -7,123 +8,86 @@ export interface SignaturePadHandle {
}
interface SignaturePadProps {
width?: number;
height?: number;
onChange?: (dataUrl: string) => void;
className?: string;
}
const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(
{ width = 480, height = 180, onChange },
{ onChange, className },
ref,
) {
const wrapRef = useRef<HTMLDivElement>(null);
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;
};
const padRef = useRef<SignaturePadLib | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
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 wrap = wrapRef.current;
if (!canvas || !wrap) return;
const pad = new SignaturePadLib(canvas, {
penColor: '#111827',
backgroundColor: '#ffffff',
minWidth: 0.8,
maxWidth: 2.6,
});
padRef.current = pad;
const resize = () => {
const rect = wrap.getBoundingClientRect();
const cssW = Math.max(1, Math.floor(rect.width));
const cssH = Math.max(1, Math.floor(rect.height));
const ratio = Math.max(1, window.devicePixelRatio || 1);
const data: PointGroup[] = pad.toData();
canvas.width = cssW * ratio;
canvas.height = cssH * ratio;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}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,
};
ctx?.scale(ratio, ratio);
pad.clear();
if (data.length) pad.fromData(data);
};
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;
};
resize();
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 handleEnd = () => {
onChangeRef.current?.(pad.isEmpty() ? '' : pad.toDataURL('image/png'));
};
pad.addEventListener('endStroke', handleEnd);
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'));
}
const ro = new ResizeObserver(resize);
ro.observe(wrap);
window.addEventListener('orientationchange', resize);
return () => {
pad.removeEventListener('endStroke', handleEnd);
pad.off();
ro.disconnect();
window.removeEventListener('orientationchange', resize);
};
}, []);
useImperativeHandle(ref, () => ({
clear: () => {
resetCanvas();
onChange?.('');
padRef.current?.clear();
onChangeRef.current?.('');
},
isEmpty: () => padRef.current?.isEmpty() ?? true,
getDataURL: () => {
const pad = padRef.current;
if (!pad || pad.isEmpty()) return '';
return pad.toDataURL('image/png');
},
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' }}
/>
<div ref={wrapRef} className={`signature-pad-wrap ${className ?? ''}`}>
<canvas ref={canvasRef} className="signature-pad-canvas" style={{ touchAction: 'none' }} />
</div>
);
});
+37 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Button, Spin, Result, message, Modal, Input } from 'antd';
import {
@@ -6,11 +6,12 @@ import {
CloseCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
EditOutlined,
} from '@ant-design/icons';
import { aftersalesApi } from '@/services/api';
import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import SignaturePad, { type SignaturePadHandle } from '@/components/SignaturePad';
import SignatureOverlay from '@/components/SignatureOverlay';
import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css';
@@ -29,8 +30,7 @@ function AftersalesConfirmPage() {
const [rejectReason, setRejectReason] = useState('');
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [signatureData, setSignatureData] = useState('');
const signatureRef = useRef<SignaturePadHandle>(null);
const [showSignatureOverlay, setShowSignatureOverlay] = useState(false);
const loadOrder = async () => {
setLoading(true);
@@ -53,7 +53,7 @@ function AftersalesConfirmPage() {
}, [serialNumber]);
const handleAuthorize = async () => {
if (signatureRef.current?.isEmpty() || !signatureData) {
if (!signatureData) {
message.error('请先签名');
return;
}
@@ -100,7 +100,6 @@ function AftersalesConfirmPage() {
};
const handleClearSignature = () => {
signatureRef.current?.clear();
setSignatureData('');
};
@@ -236,14 +235,33 @@ function AftersalesConfirmPage() {
<>
<div className="aftersales-signature-section">
<div className="aftersales-signature-header">
<p className="aftersales-signature-tip"></p>
<p className="aftersales-signature-tip"></p>
{signatureData && (
<Button size="small" type="link" onClick={handleClearSignature}>
</Button>
)}
</div>
<div className="aftersales-signature-canvas-wrap">
<SignaturePad ref={signatureRef} onChange={setSignatureData} />
</div>
{signatureData ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => setShowSignatureOverlay(true)}
>
<img src={signatureData} alt="客户签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => setShowSignatureOverlay(true)}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div>
<div className="aftersales-actions">
<Button
@@ -269,6 +287,15 @@ function AftersalesConfirmPage() {
)}
</Card>
<SignatureOverlay
open={showSignatureOverlay}
onCancel={() => setShowSignatureOverlay(false)}
onConfirm={(url) => {
setSignatureData(url);
setShowSignatureOverlay(false);
}}
/>
<Modal
title="请说明退回原因"
open={showRejectDialog}
+52 -11
View File
@@ -51,23 +51,64 @@
font-weight: 500;
}
.aftersales-signature-canvas-wrap {
border: 1px dashed #94a3b8;
.aftersales-signature-trigger {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 22px 16px;
border: 1.5px dashed #94a3b8;
border-radius: 12px;
background: #f8fafc;
color: #1f2937;
font-size: 16px;
font-weight: 500;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s, border-color 0.15s;
}
.aftersales-signature-trigger:active {
background: #eef2f7;
border-color: #1677ff;
}
.aftersales-signature-trigger .anticon {
font-size: 22px;
color: #1677ff;
}
.aftersales-signature-trigger small {
color: #6b7280;
font-size: 12px;
font-weight: 400;
}
.aftersales-signature-preview {
width: 100%;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #ffffff;
display: flex;
justify-content: center;
padding: 8px;
touch-action: none;
flex-direction: column;
align-items: center;
gap: 6px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.signature-pad-canvas {
display: block;
.aftersales-signature-preview img {
max-width: 100%;
touch-action: none;
background: #ffffff;
border-radius: 6px;
cursor: crosshair;
max-height: 160px;
display: block;
}
.aftersales-signature-preview-hint {
font-size: 12px;
color: #6b7280;
}
.aftersales-signature-archived {