From 1d944b0fd32762c1f1d04c63c4af9f4a0af18ea7 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Tue, 26 May 2026 18:02:18 +0800 Subject: [PATCH] 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 --- README.md | 2 +- src/components/SignaturePad.tsx | 130 +++++++++++++++++++++++++ src/pages/AftersalesConfirm.tsx | 76 +++++++++------ src/pages/AftersalesDetail.tsx | 24 +++++ src/pages/styles/AftersalesConfirm.css | 48 ++++++++- src/pages/styles/PublicQuery.css | 5 +- src/types/index.ts | 4 +- 7 files changed, 252 insertions(+), 37 deletions(-) create mode 100644 src/components/SignaturePad.tsx diff --git a/README.md b/README.md index 2ce20c4..f29b1ea 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ VITE_API_BASE_URL=/api - 用户登录 - 公开查询序列号(支持二维码扫描) - 扫描到 `zjbf-sh-*` 售后码时自动跳转到售后确认页 -- 售后工单确认页(扫码 → 手机号末四位校验 → 已授权/未授权) +- 售后工单确认页(扫码 → 签名画板 → 已授权;或填写退回原因 → 未授权) ### 管理后台 diff --git a/src/components/SignaturePad.tsx b/src/components/SignaturePad.tsx new file mode 100644 index 0000000..3100a56 --- /dev/null +++ b/src/components/SignaturePad.tsx @@ -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(function SignaturePad( + { width = 480, height = 180, onChange }, + ref, +) { + 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; + }; + + 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, + ): { 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) => { + 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) => { + 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) => { + 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 ( + + ); +}); + +export default SignaturePad; diff --git a/src/pages/AftersalesConfirm.tsx b/src/pages/AftersalesConfirm.tsx index 98a7172..7f1f4b6 100644 --- a/src/pages/AftersalesConfirm.tsx +++ b/src/pages/AftersalesConfirm.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Card, Input, Button, Spin, Result, message, Modal } from 'antd'; +import { Card, Button, Spin, Result, message, Modal, Input } from 'antd'; import { CheckCircleOutlined, CloseCircleOutlined, @@ -10,6 +10,7 @@ import { 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 './styles/PublicQuery.css'; import './styles/AftersalesConfirm.css'; @@ -24,10 +25,12 @@ function AftersalesConfirmPage() { const [order, setOrder] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [phoneLast4, setPhoneLast4] = useState(''); const [submitting, setSubmitting] = useState(false); const [rejectReason, setRejectReason] = useState(''); const [showRejectDialog, setShowRejectDialog] = useState(false); + const [signatureData, setSignatureData] = useState(''); + + const signatureRef = useRef(null); const loadOrder = async () => { setLoading(true); @@ -49,21 +52,16 @@ function AftersalesConfirmPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [serialNumber]); - const validatePhoneInput = () => { - if (!/^\d{4}$/.test(phoneLast4)) { - message.error('请输入 4 位手机号'); - return false; - } - return true; - }; - const handleAuthorize = async () => { - if (!validatePhoneInput()) return; + if (signatureRef.current?.isEmpty() || !signatureData) { + message.error('请先签名'); + return; + } setSubmitting(true); try { const updated = await aftersalesApi.customerConfirm(serialNumber, { - phoneLast4, action: 'authorize', + signature: signatureData, }); setOrder(updated); message.success('感谢您的确认,工单已完成'); @@ -74,18 +72,22 @@ function AftersalesConfirmPage() { } }; - const handleReject = async () => { - if (!validatePhoneInput()) return; + const openRejectDialog = () => { + setRejectReason(''); setShowRejectDialog(true); }; const confirmReject = async () => { + const reason = rejectReason.trim(); + if (!reason) { + message.error('请填写退回原因'); + return; + } setSubmitting(true); try { const updated = await aftersalesApi.customerConfirm(serialNumber, { - phoneLast4, action: 'reject', - rejectReason: rejectReason.trim() || undefined, + rejectReason: reason, }); setOrder(updated); setShowRejectDialog(false); @@ -97,6 +99,11 @@ function AftersalesConfirmPage() { } }; + const handleClearSignature = () => { + signatureRef.current?.clear(); + setSignatureData(''); + }; + if (loading) { return ( @@ -214,18 +221,29 @@ function AftersalesConfirmPage() { + {isClosed && order.signature && ( +
+

客户签名

+ 客户签名 +
+ )} + {isPending && ( <> -
-

请输入联系人手机号后 4 位确认身份

- setPhoneLast4(e.target.value.replace(/\D/g, ''))} - inputMode="numeric" - /> +
+
+

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

+ +
+
+ +
@@ -252,7 +270,7 @@ function AftersalesConfirmPage() { setShowRejectDialog(false)} onOk={confirmReject} diff --git a/src/pages/AftersalesDetail.tsx b/src/pages/AftersalesDetail.tsx index 9061740..736b5f8 100644 --- a/src/pages/AftersalesDetail.tsx +++ b/src/pages/AftersalesDetail.tsx @@ -322,6 +322,30 @@ function AftersalesDetailPage() { + {order.authorizationStatus === 'authorized' && order.signature && ( +
+
+ 客户签名 + {order.confirmedAt && ( + + 签署时间:{new Date(order.confirmedAt).toLocaleString('zh-CN')} + + )} +
+ 客户签名 +
+ )} +