Files
frontend/src/pages/AftersalesConfirm.tsx
T
2026-06-02 10:14:13 +08:00

321 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Button, Spin, Result, message, Modal, Input } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
EditOutlined,
} from '@ant-design/icons';
import { aftersalesApi } from '@/services/api';
import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import SignatureOverlay from '@/components/SignatureOverlay';
import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css';
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件故障',
hardware: '硬件故障',
maintenance: '售后维保',
};
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 [showSignatureOverlay, setShowSignatureOverlay] = useState(false);
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 (!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 = () => {
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>
{signatureData && (
<Button size="small" type="link" onClick={handleClearSignature}>
</Button>
)}
</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 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>
<SignatureOverlay
open={showSignatureOverlay}
onCancel={() => setShowSignatureOverlay(false)}
onConfirm={(url) => {
setSignatureData(url);
setShowSignatureOverlay(false);
}}
/>
<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;