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
+1 -1
View File
@@ -92,7 +92,7 @@ VITE_API_BASE_URL=/api
- 用户登录 - 用户登录
- 公开查询序列号(支持二维码扫描) - 公开查询序列号(支持二维码扫描)
- 扫描到 `zjbf-sh-*` 售后码时自动跳转到售后确认页 - 扫描到 `zjbf-sh-*` 售后码时自动跳转到售后确认页
- 售后工单确认页(扫码 → 手机号末四位校验 → 已授权/未授权) - 售后工单确认页(扫码 → 签名画板 → 已授权;或填写退回原因 → 未授权)
### 管理后台 ### 管理后台
+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;
+47 -29
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; 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 { import {
CheckCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, CloseCircleOutlined,
@@ -10,6 +10,7 @@ import {
import { aftersalesApi } from '@/services/api'; import { aftersalesApi } from '@/services/api';
import type { AftersalesPublicView, AftersalesServiceType } from '@/types'; import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout'; import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import SignaturePad, { type SignaturePadHandle } from '@/components/SignaturePad';
import './styles/PublicQuery.css'; import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css'; import './styles/AftersalesConfirm.css';
@@ -24,10 +25,12 @@ function AftersalesConfirmPage() {
const [order, setOrder] = useState<AftersalesPublicView | null>(null); const [order, setOrder] = useState<AftersalesPublicView | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [phoneLast4, setPhoneLast4] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [rejectReason, setRejectReason] = useState(''); const [rejectReason, setRejectReason] = useState('');
const [showRejectDialog, setShowRejectDialog] = useState(false); const [showRejectDialog, setShowRejectDialog] = useState(false);
const [signatureData, setSignatureData] = useState('');
const signatureRef = useRef<SignaturePadHandle>(null);
const loadOrder = async () => { const loadOrder = async () => {
setLoading(true); setLoading(true);
@@ -49,21 +52,16 @@ function AftersalesConfirmPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialNumber]); }, [serialNumber]);
const validatePhoneInput = () => {
if (!/^\d{4}$/.test(phoneLast4)) {
message.error('请输入 4 位手机号');
return false;
}
return true;
};
const handleAuthorize = async () => { const handleAuthorize = async () => {
if (!validatePhoneInput()) return; if (signatureRef.current?.isEmpty() || !signatureData) {
message.error('请先签名');
return;
}
setSubmitting(true); setSubmitting(true);
try { try {
const updated = await aftersalesApi.customerConfirm(serialNumber, { const updated = await aftersalesApi.customerConfirm(serialNumber, {
phoneLast4,
action: 'authorize', action: 'authorize',
signature: signatureData,
}); });
setOrder(updated); setOrder(updated);
message.success('感谢您的确认,工单已完成'); message.success('感谢您的确认,工单已完成');
@@ -74,18 +72,22 @@ function AftersalesConfirmPage() {
} }
}; };
const handleReject = async () => { const openRejectDialog = () => {
if (!validatePhoneInput()) return; setRejectReason('');
setShowRejectDialog(true); setShowRejectDialog(true);
}; };
const confirmReject = async () => { const confirmReject = async () => {
const reason = rejectReason.trim();
if (!reason) {
message.error('请填写退回原因');
return;
}
setSubmitting(true); setSubmitting(true);
try { try {
const updated = await aftersalesApi.customerConfirm(serialNumber, { const updated = await aftersalesApi.customerConfirm(serialNumber, {
phoneLast4,
action: 'reject', action: 'reject',
rejectReason: rejectReason.trim() || undefined, rejectReason: reason,
}); });
setOrder(updated); setOrder(updated);
setShowRejectDialog(false); setShowRejectDialog(false);
@@ -97,6 +99,11 @@ function AftersalesConfirmPage() {
} }
}; };
const handleClearSignature = () => {
signatureRef.current?.clear();
setSignatureData('');
};
if (loading) { if (loading) {
return ( return (
<PublicLayout> <PublicLayout>
@@ -214,18 +221,29 @@ function AftersalesConfirmPage() {
</div> </div>
</div> </div>
{isClosed && order.signature && (
<div className="aftersales-signature-archived">
<p className="aftersales-signature-tip"></p>
<img
src={order.signature}
alt="客户签名"
className="aftersales-signature-archived-img"
/>
</div>
)}
{isPending && ( {isPending && (
<> <>
<div className="aftersales-phone-verify"> <div className="aftersales-signature-section">
<p className="aftersales-phone-tip"> 4 </p> <div className="aftersales-signature-header">
<Input <p className="aftersales-signature-tip"></p>
size="large" <Button size="small" type="link" onClick={handleClearSignature}>
placeholder="手机号后 4 位"
maxLength={4} </Button>
value={phoneLast4} </div>
onChange={(e) => setPhoneLast4(e.target.value.replace(/\D/g, ''))} <div className="aftersales-signature-canvas-wrap">
inputMode="numeric" <SignaturePad ref={signatureRef} onChange={setSignatureData} />
/> </div>
</div> </div>
<div className="aftersales-actions"> <div className="aftersales-actions">
<Button <Button
@@ -233,7 +251,7 @@ function AftersalesConfirmPage() {
danger danger
block block
loading={submitting} loading={submitting}
onClick={handleReject} onClick={openRejectDialog}
> >
</Button> </Button>
@@ -252,7 +270,7 @@ function AftersalesConfirmPage() {
</Card> </Card>
<Modal <Modal
title="请说明退回原因(可选)" title="请说明退回原因"
open={showRejectDialog} open={showRejectDialog}
onCancel={() => setShowRejectDialog(false)} onCancel={() => setShowRejectDialog(false)}
onOk={confirmReject} onOk={confirmReject}
+24
View File
@@ -322,6 +322,30 @@ function AftersalesDetailPage() {
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{order.authorizationStatus === 'authorized' && order.signature && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 13, color: '#595959', marginBottom: 8 }}>
{order.confirmedAt && (
<span style={{ marginLeft: 12, color: '#8c8c8c', fontSize: 12 }}>
{new Date(order.confirmedAt).toLocaleString('zh-CN')}
</span>
)}
</div>
<img
src={order.signature}
alt="客户签名"
style={{
maxWidth: 480,
width: '100%',
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fff',
}}
/>
</div>
)}
<Form form={form} layout="vertical" onFinish={handleSave} disabled={!canEdit}> <Form form={form} layout="vertical" onFinish={handleSave} disabled={!canEdit}>
<Form.Item <Form.Item
name="companyAddress" name="companyAddress"
+45 -3
View File
@@ -31,16 +31,58 @@
width: 100%; width: 100%;
} }
.aftersales-phone-verify { .aftersales-signature-section {
margin-top: 24px; margin-top: 24px;
padding-top: 24px; padding-top: 24px;
border-top: 1px solid rgba(0, 0, 0, 0.06); border-top: 1px solid rgba(0, 0, 0, 0.06);
} }
.aftersales-phone-tip { .aftersales-signature-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.aftersales-signature-tip {
color: #4B5563; color: #4B5563;
margin: 0 0 12px; margin: 0;
font-size: 14px; font-size: 14px;
font-weight: 500;
}
.aftersales-signature-canvas-wrap {
border: 1px dashed #94a3b8;
border-radius: 10px;
background: #ffffff;
display: flex;
justify-content: center;
padding: 8px;
touch-action: none;
}
.signature-pad-canvas {
display: block;
max-width: 100%;
touch-action: none;
background: #ffffff;
border-radius: 6px;
cursor: crosshair;
}
.aftersales-signature-archived {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
text-align: center;
}
.aftersales-signature-archived-img {
max-width: 100%;
margin-top: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
} }
.aftersales-actions { .aftersales-actions {
+2 -3
View File
@@ -1,12 +1,11 @@
.public-query-container { .public-query-container {
height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 20px; padding: 40px 20px;
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%); background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
overflow: hidden;
} }
.query-card { .query-card {
+3 -1
View File
@@ -183,6 +183,7 @@ export interface AftersalesOrder {
scannedAt?: string; scannedAt?: string;
confirmedAt?: string; confirmedAt?: string;
rejectCount: number; rejectCount: number;
signature?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
technician?: User; technician?: User;
@@ -202,6 +203,7 @@ export interface AftersalesPublicView {
technicianName: string; technicianName: string;
createdAt: string; createdAt: string;
confirmedAt?: string; confirmedAt?: string;
signature?: string;
} }
export interface CreateAftersalesRequest { export interface CreateAftersalesRequest {
@@ -240,7 +242,7 @@ export interface AftersalesListResponse {
} }
export interface CustomerConfirmRequest { export interface CustomerConfirmRequest {
phoneLast4: string;
action: 'authorize' | 'reject'; action: 'authorize' | 'reject';
signature?: string;
rejectReason?: string; rejectReason?: string;
} }