Require dual signatures for aftersales confirmation

This commit is contained in:
Frudrax Cheng
2026-06-02 10:38:40 +08:00
parent d216a25364
commit 30e3ac67d2
6 changed files with 205 additions and 74 deletions
+3 -2
View File
@@ -5,11 +5,12 @@ import './SignatureOverlay.css';
interface SignatureOverlayProps { interface SignatureOverlayProps {
open: boolean; open: boolean;
title?: string;
onCancel: () => void; onCancel: () => void;
onConfirm: (dataUrl: string) => void; onConfirm: (dataUrl: string) => void;
} }
function SignatureOverlay({ open, onCancel, onConfirm }: SignatureOverlayProps) { function SignatureOverlay({ open, title = '请在框内签名', onCancel, onConfirm }: SignatureOverlayProps) {
const padRef = useRef<SignaturePadHandle>(null); const padRef = useRef<SignaturePadHandle>(null);
const [, setData] = useState(''); const [, setData] = useState('');
@@ -44,7 +45,7 @@ function SignatureOverlay({ open, onCancel, onConfirm }: SignatureOverlayProps)
<Button type="text" onClick={onCancel}> <Button type="text" onClick={onCancel}>
</Button> </Button>
<span className="signature-overlay-title"></span> <span className="signature-overlay-title">{title}</span>
<Button type="text" onClick={handleClear}> <Button type="text" onClick={handleClear}>
</Button> </Button>
+121 -45
View File
@@ -29,8 +29,11 @@ function AftersalesConfirmPage() {
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 [customerSignatureData, setCustomerSignatureData] = useState('');
const [showSignatureOverlay, setShowSignatureOverlay] = useState(false); const [responsibleSignatureData, setResponsibleSignatureData] = useState('');
const [activeSignatureRole, setActiveSignatureRole] = useState<'customer' | 'responsible' | null>(
null,
);
const loadOrder = async () => { const loadOrder = async () => {
setLoading(true); setLoading(true);
@@ -53,15 +56,20 @@ function AftersalesConfirmPage() {
}, [serialNumber]); }, [serialNumber]);
const handleAuthorize = async () => { const handleAuthorize = async () => {
if (!signatureData) { if (!customerSignatureData) {
message.error('请先签名'); message.error('请先完成客户签名');
return;
}
if (!responsibleSignatureData) {
message.error('请先完成负责人签名');
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const updated = await aftersalesApi.customerConfirm(serialNumber, { const updated = await aftersalesApi.customerConfirm(serialNumber, {
action: 'authorize', action: 'authorize',
signature: signatureData, signature: customerSignatureData,
responsibleSignature: responsibleSignatureData,
}); });
setOrder(updated); setOrder(updated);
message.success('感谢您的确认,工单已完成'); message.success('感谢您的确认,工单已完成');
@@ -99,8 +107,16 @@ function AftersalesConfirmPage() {
} }
}; };
const handleClearSignature = () => { const handleClearSignature = (role: 'customer' | 'responsible') => {
setSignatureData(''); if (role === 'customer') {
setCustomerSignatureData('');
return;
}
setResponsibleSignatureData('');
};
const openSignatureOverlay = (role: 'customer' | 'responsible') => {
setActiveSignatureRole(role);
}; };
if (loading) { if (loading) {
@@ -220,48 +236,103 @@ function AftersalesConfirmPage() {
</div> </div>
</div> </div>
{isClosed && order.signature && ( {isClosed && (order.signature || order.responsibleSignature) && (
<div className="aftersales-signature-archived"> <div className="aftersales-signature-archived">
<p className="aftersales-signature-tip"></p> <div className="aftersales-signature-grid">
<img {order.signature && (
src={order.signature} <div className="aftersales-signature-archived-item">
alt="客户确认签名" <p className="aftersales-signature-tip"></p>
className="aftersales-signature-archived-img" <img
/> src={order.signature}
alt="客户签名"
className="aftersales-signature-archived-img"
/>
</div>
)}
{order.responsibleSignature && (
<div className="aftersales-signature-archived-item">
<p className="aftersales-signature-tip"></p>
<img
src={order.responsibleSignature}
alt="负责人签名"
className="aftersales-signature-archived-img"
/>
</div>
)}
</div>
</div> </div>
)} )}
{isPending && ( {isPending && (
<> <>
<div className="aftersales-signature-section"> <div className="aftersales-signature-section">
<div className="aftersales-signature-header"> <p className="aftersales-signature-section-title"></p>
<p className="aftersales-signature-tip"></p> <div className="aftersales-signature-grid">
{signatureData && ( <div className="aftersales-signature-item">
<Button size="small" type="link" onClick={handleClearSignature}> <div className="aftersales-signature-header">
<p className="aftersales-signature-tip"></p>
</Button> {customerSignatureData && (
)} <Button size="small" type="link" onClick={() => handleClearSignature('customer')}>
</Button>
)}
</div>
{customerSignatureData ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => openSignatureOverlay('customer')}
>
<img src={customerSignatureData} alt="客户签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => openSignatureOverlay('customer')}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div>
<div className="aftersales-signature-item">
<div className="aftersales-signature-header">
<p className="aftersales-signature-tip"></p>
{responsibleSignatureData && (
<Button
size="small"
type="link"
onClick={() => handleClearSignature('responsible')}
>
</Button>
)}
</div>
{responsibleSignatureData ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => openSignatureOverlay('responsible')}
>
<img src={responsibleSignatureData} alt="负责人签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => openSignatureOverlay('responsible')}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div>
</div> </div>
{signatureData ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => setShowSignatureOverlay(true)}
>
<img src={signatureData} alt="客户确认签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => setShowSignatureOverlay(true)}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div> </div>
<div className="aftersales-actions"> <div className="aftersales-actions">
<Button <Button
@@ -288,11 +359,16 @@ function AftersalesConfirmPage() {
</Card> </Card>
<SignatureOverlay <SignatureOverlay
open={showSignatureOverlay} open={activeSignatureRole !== null}
onCancel={() => setShowSignatureOverlay(false)} title={activeSignatureRole === 'responsible' ? '负责人签名' : '客户签名'}
onCancel={() => setActiveSignatureRole(null)}
onConfirm={(url) => { onConfirm={(url) => {
setSignatureData(url); if (activeSignatureRole === 'responsible') {
setShowSignatureOverlay(false); setResponsibleSignatureData(url);
} else {
setCustomerSignatureData(url);
}
setActiveSignatureRole(null);
}} }}
/> />
+26 -14
View File
@@ -241,11 +241,11 @@ function AftersalesDetailPage() {
.electronic-form-table td { min-height: 24px; } .electronic-form-table td { min-height: 24px; }
.electronic-form-table .electronic-form-code { color: #165dff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 16px; font-weight: 700; } .electronic-form-table .electronic-form-code { color: #165dff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 16px; font-weight: 700; }
.electronic-form-text { min-height: 72px; white-space: pre-wrap; } .electronic-form-text { min-height: 72px; white-space: pre-wrap; }
.electronic-form-signature { display: flex; justify-content: flex-end; margin-top: 18px; } .electronic-form-signatures { display: flex; justify-content: space-between; gap: 40px; margin-top: 22px; }
.electronic-form-signature-box { width: 240px; text-align: center; } .electronic-form-signature-box { flex: 1; max-width: 300px; text-align: center; }
.electronic-form-signature-title { margin: 0 0 8px; font-weight: 600; } .electronic-form-signature-title { margin: 0 0 8px; font-weight: 600; }
.electronic-form-signature-img { max-width: 220px; max-height: 90px; object-fit: contain; } .electronic-form-signature-stage { height: 96px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #1f2937; }
.electronic-form-signature-line { height: 64px; border-bottom: 1px solid #1f2937; } .electronic-form-signature-img { max-width: 240px; max-height: 90px; object-fit: contain; }
</style> </style>
</head> </head>
<body>${formNode.outerHTML}</body> <body>${formNode.outerHTML}</body>
@@ -611,18 +611,30 @@ function AftersalesDetailPage() {
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div className="electronic-form-signature"> <div className="electronic-form-signatures">
<div className="electronic-form-signature-box">
<p className="electronic-form-signature-title"></p>
<div className="electronic-form-signature-stage">
{order.responsibleSignature ? (
<img
src={order.responsibleSignature}
alt="负责人签名"
className="electronic-form-signature-img"
/>
) : null}
</div>
</div>
<div className="electronic-form-signature-box"> <div className="electronic-form-signature-box">
<p className="electronic-form-signature-title"></p> <p className="electronic-form-signature-title"></p>
{order.signature ? ( <div className="electronic-form-signature-stage">
<img {order.signature ? (
src={order.signature} <img
alt="客户签名" src={order.signature}
className="electronic-form-signature-img" alt="客户签名"
/> className="electronic-form-signature-img"
) : ( />
<div className="electronic-form-signature-line" /> ) : null}
)} </div>
</div> </div>
</div> </div>
</div> </div>
+32 -2
View File
@@ -37,6 +37,23 @@
border-top: 1px solid rgba(0, 0, 0, 0.06); border-top: 1px solid rgba(0, 0, 0, 0.06);
} }
.aftersales-signature-section-title {
margin: 0 0 14px;
color: #111827;
font-size: 15px;
font-weight: 600;
}
.aftersales-signature-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.aftersales-signature-item {
min-width: 0;
}
.aftersales-signature-header { .aftersales-signature-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -53,6 +70,7 @@
.aftersales-signature-trigger { .aftersales-signature-trigger {
width: 100%; width: 100%;
min-height: 132px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -88,6 +106,7 @@
.aftersales-signature-preview { .aftersales-signature-preview {
width: 100%; width: 100%;
min-height: 132px;
padding: 8px; padding: 8px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 10px; border-radius: 10px;
@@ -102,8 +121,9 @@
.aftersales-signature-preview img { .aftersales-signature-preview img {
max-width: 100%; max-width: 100%;
max-height: 160px; max-height: 120px;
display: block; display: block;
object-fit: contain;
} }
.aftersales-signature-preview-hint { .aftersales-signature-preview-hint {
@@ -115,12 +135,16 @@
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-signature-archived-item {
min-width: 0;
text-align: center; text-align: center;
} }
.aftersales-signature-archived-img { .aftersales-signature-archived-img {
max-width: 100%; max-width: 100%;
max-height: 220px; max-height: 160px;
margin-top: 8px; margin-top: 8px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
@@ -137,3 +161,9 @@
.aftersales-actions > button { .aftersales-actions > button {
flex: 1; flex: 1;
} }
@media (max-width: 520px) {
.aftersales-signature-grid {
grid-template-columns: 1fr;
}
}
+20 -11
View File
@@ -78,14 +78,16 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.electronic-form-signature { .electronic-form-signatures {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
margin-top: 18px; gap: 40px;
margin-top: 22px;
} }
.electronic-form-signature-box { .electronic-form-signature-box {
width: 240px; flex: 1;
max-width: 300px;
text-align: center; text-align: center;
} }
@@ -94,15 +96,18 @@
font-weight: 600; font-weight: 600;
} }
.electronic-form-signature-img { .electronic-form-signature-stage {
max-width: 220px; height: 96px;
max-height: 90px; display: flex;
object-fit: contain; align-items: center;
justify-content: center;
border-bottom: 1px solid #1f2937;
} }
.electronic-form-signature-line { .electronic-form-signature-img {
height: 64px; max-width: 240px;
border-bottom: 1px solid #1f2937; max-height: 90px;
object-fit: contain;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -131,4 +136,8 @@
.electronic-form-table td { .electronic-form-table td {
padding: 8px; padding: 8px;
} }
.electronic-form-signatures {
gap: 16px;
}
} }
+3
View File
@@ -197,6 +197,7 @@ export interface AftersalesOrder {
confirmedAt?: string; confirmedAt?: string;
rejectCount: number; rejectCount: number;
signature?: string; signature?: string;
responsibleSignature?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
technician?: User; technician?: User;
@@ -217,6 +218,7 @@ export interface AftersalesPublicView {
createdAt: string; createdAt: string;
confirmedAt?: string; confirmedAt?: string;
signature?: string; signature?: string;
responsibleSignature?: string;
} }
export interface CreateAftersalesRequest { export interface CreateAftersalesRequest {
@@ -257,5 +259,6 @@ export interface AftersalesListResponse {
export interface CustomerConfirmRequest { export interface CustomerConfirmRequest {
action: 'authorize' | 'reject'; action: 'authorize' | 'reject';
signature?: string; signature?: string;
responsibleSignature?: string;
rejectReason?: string; rejectReason?: string;
} }