From ab5acbc452c43761189c3584fc4f7998f5414646 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Wed, 27 May 2026 09:11:05 +0800 Subject: [PATCH] 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) --- package.json | 3 +- pnpm-lock.yaml | 8 ++ src/components/SignatureOverlay.css | 75 ++++++++++++ src/components/SignatureOverlay.tsx | 74 ++++++++++++ src/components/SignaturePad.tsx | 154 ++++++++++--------------- src/pages/AftersalesConfirm.tsx | 53 ++++++--- src/pages/styles/AftersalesConfirm.css | 63 ++++++++-- 7 files changed, 310 insertions(+), 120 deletions(-) create mode 100644 src/components/SignatureOverlay.css create mode 100644 src/components/SignatureOverlay.tsx diff --git a/package.json b/package.json index 12b365e..e79f9d0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b5f5d2..2746308 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/components/SignatureOverlay.css b/src/components/SignatureOverlay.css new file mode 100644 index 0000000..44ca178 --- /dev/null +++ b/src/components/SignatureOverlay.css @@ -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; +} diff --git a/src/components/SignatureOverlay.tsx b/src/components/SignatureOverlay.tsx new file mode 100644 index 0000000..73cfcd3 --- /dev/null +++ b/src/components/SignatureOverlay.tsx @@ -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(null); + const [data, setData] = useState(''); + + useEffect(() => { + if (!open) return; + + const orientation = (screen as Screen & { + orientation?: { lock?: (o: string) => Promise }; + }).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 ( +
+
+
+ + 请在框内签名 + +
+
+ +
+
+ +
+
+
+ ); +} + +export default SignatureOverlay; diff --git a/src/components/SignaturePad.tsx b/src/components/SignaturePad.tsx index 3100a56..7e2c1e0 100644 --- a/src/components/SignaturePad.tsx +++ b/src/components/SignaturePad.tsx @@ -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(function SignaturePad( - { width = 480, height = 180, onChange }, + { onChange, className }, ref, ) { + const wrapRef = useRef(null); const canvasRef = useRef(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(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 ctx = canvas.getContext('2d'); - if (ctx) ctx.scale(ratio, ratio); - resetCanvas(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [width, height]); + const wrap = wrapRef.current; + if (!canvas || !wrap) return; - const pointFromEvent = ( - e: React.PointerEvent, - ): { 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 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'); + ctx?.scale(ratio, ratio); + pad.clear(); + if (data.length) pad.fromData(data); }; - }; - const handlePointerDown = (e: React.PointerEvent) => { - 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) => { - 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) => { - 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 ( - +
+ +
); }); diff --git a/src/pages/AftersalesConfirm.tsx b/src/pages/AftersalesConfirm.tsx index 7f1f4b6..c35f282 100644 --- a/src/pages/AftersalesConfirm.tsx +++ b/src/pages/AftersalesConfirm.tsx @@ -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(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() { <>
-

请在下方签名确认维修结果

- -
-
- +

请签名确认维修结果

+ {signatureData && ( + + )}
+ {signatureData ? ( + + ) : ( + + )}