Files
frontend/src/pages/AftersalesConfirm.tsx
T
Frudrax Cheng 1d944b0fd3 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>
2026-05-26 18:02:18 +08:00

294 lines
9.2 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Button, Spin, Result, message, Modal, Input } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
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';
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件',
hardware: '硬件',
other: '其他',
};
function AftersalesConfirmPage() {
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
const [order, setOrder] = useState<AftersalesPublicView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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);
setError(null);
try {
const data = await aftersalesApi.publicQuery(serialNumber);
setOrder(data);
} catch (err: any) {
setError(err?.response?.data?.message || err.message || '查询失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (serialNumber) {
loadOrder();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialNumber]);
const handleAuthorize = async () => {
if (signatureRef.current?.isEmpty() || !signatureData) {
message.error('请先签名');
return;
}
setSubmitting(true);
try {
const updated = await aftersalesApi.customerConfirm(serialNumber, {
action: 'authorize',
signature: signatureData,
});
setOrder(updated);
message.success('感谢您的确认,工单已完成');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
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, {
action: 'reject',
rejectReason: reason,
});
setOrder(updated);
setShowRejectDialog(false);
message.success('已通知技术员重新处理');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const handleClearSignature = () => {
signatureRef.current?.clear();
setSignatureData('');
};
if (loading) {
return (
<PublicLayout>
<Card className="query-card" bordered={false}>
<div className="loading-container">
<Spin size="large" />
<p>...</p>
</div>
</Card>
</PublicLayout>
);
}
if (error || !order) {
return (
<PublicLayout>
<Card className="query-card" bordered={false}>
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title="工单不存在"
subTitle={error || '请检查您扫描的二维码是否正确'}
/>
</Card>
</PublicLayout>
);
}
const isPending = order.workOrderStatus === 'pending_confirmation';
const isClosed = order.workOrderStatus === 'closed';
const isRejected = order.workOrderStatus === 'rejected';
const isCreated = order.workOrderStatus === 'created';
const renderStatusBanner = () => {
if (isClosed) {
return (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '64px' }} />}
title="工单已完成"
subTitle={
order.confirmedAt
? `确认时间:${new Date(order.confirmedAt).toLocaleString('zh-CN')}`
: undefined
}
/>
);
}
if (isRejected) {
return (
<Result
icon={<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '64px' }} />}
title="工单已退回"
subTitle="技术员将重新处理,请耐心等待"
/>
);
}
if (isCreated) {
return (
<Result
icon={<ClockCircleOutlined style={{ color: '#1677ff', fontSize: '64px' }} />}
title="工单处理中"
subTitle="技术员尚未提交客户确认,请稍后再来查看"
/>
);
}
return null;
};
return (
<PublicLayout>
<Card className="query-card aftersales-confirm-card" bordered={false}>
<div className="query-header">
<PublicLogo />
<h1 className="aftersales-title"></h1>
<p className="aftersales-serial">{order.serialNumber}</p>
</div>
{renderStatusBanner()}
<div className="result-details aftersales-details">
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.companyName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.companyAddress}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.contactName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{SERVICE_TYPE_LABEL[order.serviceType] || order.serviceType}</span>
</div>
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.issueDescription}</span>
</div>
{order.resolutionNote && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.resolutionNote}</span>
</div>
)}
{order.technicianName && (
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.technicianName}</span>
</div>
)}
<div className="detail-item">
<span className="label"></span>
<span className="value">{new Date(order.createdAt).toLocaleString('zh-CN')}</span>
</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-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
size="large"
danger
block
loading={submitting}
onClick={openRejectDialog}
>
</Button>
<Button
type="primary"
size="large"
block
loading={submitting}
onClick={handleAuthorize}
>
</Button>
</div>
</>
)}
</Card>
<Modal
title="请说明退回原因"
open={showRejectDialog}
onCancel={() => setShowRejectDialog(false)}
onOk={confirmReject}
okText="确认退回"
cancelText="取消"
confirmLoading={submitting}
>
<Input.TextArea
rows={4}
placeholder="请简要说明未授权的原因,方便技术员改进"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
maxLength={200}
/>
</Modal>
</PublicLayout>
);
}
export default AftersalesConfirmPage;