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