Add aftersales work order frontend pages
- Public scan-to-confirm page (/aftersales/:sn) with phone last-4 verification - Admin list + detail pages with state machine, QR generation, reassign, force-close - PublicLayout extracted from PublicQuery so both pages share logo + 备案 chrome - PublicQuery auto-redirects scanned zjbf-sh-* serials to the aftersales page - AdminLayout: new 售后工单 menu entry Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Input, Button, Spin, Result, message, Modal } 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 './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 [phoneLast4, setPhoneLast4] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [showRejectDialog, setShowRejectDialog] = 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 validatePhoneInput = () => {
|
||||
if (!/^\d{4}$/.test(phoneLast4)) {
|
||||
message.error('请输入 4 位手机号');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAuthorize = async () => {
|
||||
if (!validatePhoneInput()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await aftersalesApi.customerConfirm(serialNumber, {
|
||||
phoneLast4,
|
||||
action: 'authorize',
|
||||
});
|
||||
setOrder(updated);
|
||||
message.success('感谢您的确认,工单已完成');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!validatePhoneInput()) return;
|
||||
setShowRejectDialog(true);
|
||||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await aftersalesApi.customerConfirm(serialNumber, {
|
||||
phoneLast4,
|
||||
action: 'reject',
|
||||
rejectReason: rejectReason.trim() || undefined,
|
||||
});
|
||||
setOrder(updated);
|
||||
setShowRejectDialog(false);
|
||||
message.success('已通知技术员重新处理');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{isPending && (
|
||||
<>
|
||||
<div className="aftersales-phone-verify">
|
||||
<p className="aftersales-phone-tip">请输入联系人手机号后 4 位确认身份</p>
|
||||
<Input
|
||||
size="large"
|
||||
placeholder="手机号后 4 位"
|
||||
maxLength={4}
|
||||
value={phoneLast4}
|
||||
onChange={(e) => setPhoneLast4(e.target.value.replace(/\D/g, ''))}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="aftersales-actions">
|
||||
<Button
|
||||
size="large"
|
||||
danger
|
||||
block
|
||||
loading={submitting}
|
||||
onClick={handleReject}
|
||||
>
|
||||
未授权
|
||||
</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;
|
||||
Reference in New Issue
Block a user