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')} + + )} +
+ 客户签名 +
+ )} +