1d944b0fd3
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>
294 lines
9.2 KiB
TypeScript
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;
|