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:
@@ -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<AftersalesPublicView | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<SignaturePadHandle>(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 (
|
||||
<PublicLayout>
|
||||
@@ -214,18 +221,29 @@ function AftersalesConfirmPage() {
|
||||
</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 && (
|
||||
<>
|
||||
<div className="aftersales-phone-verify">
|
||||
<p className="aftersales-phone-tip">请输入联系人手机号后 4 位确认身份</p>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="手机号后 4 位"
|
||||
maxLength={4}
|
||||
value={phoneLast4}
|
||||
onChange={(e) => setPhoneLast4(e.target.value.replace(/\D/g, ''))}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="aftersales-signature-section">
|
||||
<div className="aftersales-signature-header">
|
||||
<p className="aftersales-signature-tip">请在下方签名确认维修结果</p>
|
||||
<Button size="small" type="link" onClick={handleClearSignature}>
|
||||
清除签名
|
||||
</Button>
|
||||
</div>
|
||||
<div className="aftersales-signature-canvas-wrap">
|
||||
<SignaturePad ref={signatureRef} onChange={setSignatureData} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="aftersales-actions">
|
||||
<Button
|
||||
@@ -233,7 +251,7 @@ function AftersalesConfirmPage() {
|
||||
danger
|
||||
block
|
||||
loading={submitting}
|
||||
onClick={handleReject}
|
||||
onClick={openRejectDialog}
|
||||
>
|
||||
未授权
|
||||
</Button>
|
||||
@@ -252,7 +270,7 @@ function AftersalesConfirmPage() {
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="请说明退回原因(可选)"
|
||||
title="请说明退回原因"
|
||||
open={showRejectDialog}
|
||||
onCancel={() => setShowRejectDialog(false)}
|
||||
onOk={confirmReject}
|
||||
|
||||
@@ -322,6 +322,30 @@ function AftersalesDetailPage() {
|
||||
</Descriptions.Item>
|
||||
</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.Item
|
||||
name="companyAddress"
|
||||
|
||||
@@ -31,16 +31,58 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aftersales-phone-verify {
|
||||
.aftersales-signature-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
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;
|
||||
margin: 0 0 12px;
|
||||
margin: 0;
|
||||
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 {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
.public-query-container {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
|
||||
Reference in New Issue
Block a user