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,452 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
Modal,
|
||||
Spin,
|
||||
Steps,
|
||||
Descriptions,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
QrcodeOutlined,
|
||||
SaveOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { aftersalesApi, authApi } from '@/services/api';
|
||||
import type {
|
||||
AftersalesOrder,
|
||||
AftersalesServiceType,
|
||||
AftersalesWorkOrderStatus,
|
||||
UpdateAftersalesRequest,
|
||||
} from '@/types';
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
|
||||
software: '软件',
|
||||
hardware: '硬件',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const WORK_ORDER_STATUS_LABEL: Record<AftersalesWorkOrderStatus, string> = {
|
||||
created: '待处理',
|
||||
pending_confirmation: '待客户确认',
|
||||
closed: '已完成',
|
||||
rejected: '已退回',
|
||||
};
|
||||
|
||||
const WORK_ORDER_STATUS_COLOR: Record<AftersalesWorkOrderStatus, string> = {
|
||||
created: 'default',
|
||||
pending_confirmation: 'processing',
|
||||
closed: 'success',
|
||||
rejected: 'warning',
|
||||
};
|
||||
|
||||
function statusStepIndex(status: AftersalesWorkOrderStatus): number {
|
||||
switch (status) {
|
||||
case 'created':
|
||||
return 0;
|
||||
case 'pending_confirmation':
|
||||
return 1;
|
||||
case 'closed':
|
||||
case 'rejected':
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
function AftersalesDetailPage() {
|
||||
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
|
||||
const navigate = useNavigate();
|
||||
const currentUser = authApi.getCurrentUser();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
const [order, setOrder] = useState<AftersalesOrder | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [qrModalVisible, setQrModalVisible] = useState(false);
|
||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
||||
const [qrUrl, setQrUrl] = useState('');
|
||||
|
||||
const [reassignModalVisible, setReassignModalVisible] = useState(false);
|
||||
const [reassignTechnicianId, setReassignTechnicianId] = useState<number | undefined>();
|
||||
|
||||
const loadOrder = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await aftersalesApi.get(serialNumber);
|
||||
setOrder(data);
|
||||
form.setFieldsValue({
|
||||
companyAddress: data.companyAddress,
|
||||
contactName: data.contactName,
|
||||
contactPhone: data.contactPhone,
|
||||
serviceType: data.serviceType,
|
||||
issueDescription: data.issueDescription,
|
||||
resolutionNote: data.resolutionNote,
|
||||
});
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '加载工单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (serialNumber) loadOrder();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serialNumber]);
|
||||
|
||||
const handleSave = async (values: UpdateAftersalesRequest) => {
|
||||
if (!order) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await aftersalesApi.update(order.serialNumber, values);
|
||||
setOrder(updated);
|
||||
message.success('保存成功');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForConfirmation = async () => {
|
||||
if (!order) return;
|
||||
const resolutionNote = form.getFieldValue('resolutionNote')?.trim();
|
||||
if (!resolutionNote) {
|
||||
message.error('请先填写处理结果');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认提交',
|
||||
content: '提交后工单将进入"待客户确认"状态,客户扫码确认后才能关闭工单。',
|
||||
okText: '提交',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await aftersalesApi.submit(order.serialNumber, resolutionNote);
|
||||
setOrder(updated);
|
||||
message.success('已提交客户确认');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateQrCode = async () => {
|
||||
if (!order) return;
|
||||
try {
|
||||
const result = await aftersalesApi.generateQrCode(order.serialNumber);
|
||||
const data = result.qrCodeData.startsWith('data:')
|
||||
? result.qrCodeData
|
||||
: `data:image/png;base64,${result.qrCodeData}`;
|
||||
setQrCodeDataUrl(data);
|
||||
setQrUrl(result.queryUrl);
|
||||
setQrModalVisible(true);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '生成二维码失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadQR = () => {
|
||||
if (!order || !qrCodeDataUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.download = `${order.serialNumber}.png`;
|
||||
link.href = qrCodeDataUrl;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleForceClose = () => {
|
||||
if (!order) return;
|
||||
Modal.confirm({
|
||||
title: '强制关闭工单',
|
||||
content: '强制关闭后工单状态将变为"已完成",且不可恢复。',
|
||||
okText: '强制关闭',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const updated = await aftersalesApi.forceClose(order.serialNumber);
|
||||
setOrder(updated);
|
||||
message.success('工单已强制关闭');
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '强制关闭失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReassign = async () => {
|
||||
if (!order || !reassignTechnicianId) {
|
||||
message.error('请输入新的技术员 ID');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await aftersalesApi.reassign(order.serialNumber, reassignTechnicianId);
|
||||
setOrder(updated);
|
||||
message.success('重新分配成功');
|
||||
setReassignModalVisible(false);
|
||||
setReassignTechnicianId(undefined);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || err.message || '重新分配失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isClosed = order.workOrderStatus === 'closed';
|
||||
const isPendingCustomer = order.workOrderStatus === 'pending_confirmation';
|
||||
const canSubmit =
|
||||
!isClosed &&
|
||||
!isPendingCustomer &&
|
||||
(isAdmin || order.technicianId === currentUser?.id);
|
||||
const canEdit = !isClosed && (isAdmin || order.technicianId === currentUser?.id);
|
||||
|
||||
const stepStatus =
|
||||
order.workOrderStatus === 'rejected'
|
||||
? 'error'
|
||||
: isClosed
|
||||
? 'finish'
|
||||
: 'process';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/admin/aftersales')}
|
||||
/>
|
||||
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>
|
||||
{order.serialNumber}
|
||||
</span>
|
||||
<Tag color={WORK_ORDER_STATUS_COLOR[order.workOrderStatus]}>
|
||||
{WORK_ORDER_STATUS_LABEL[order.workOrderStatus]}
|
||||
</Tag>
|
||||
{order.rejectCount > 0 && <Tag color="orange">退回 {order.rejectCount} 次</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<QrcodeOutlined />} onClick={handleGenerateQrCode}>
|
||||
生成二维码
|
||||
</Button>
|
||||
{isAdmin && !isClosed && (
|
||||
<>
|
||||
<Button icon={<UserSwitchOutlined />} onClick={() => setReassignModalVisible(true)}>
|
||||
重新分配
|
||||
</Button>
|
||||
<Button danger icon={<StopOutlined />} onClick={handleForceClose}>
|
||||
强制关闭
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Steps
|
||||
current={statusStepIndex(order.workOrderStatus)}
|
||||
status={stepStatus}
|
||||
items={[
|
||||
{ title: '工单创建', description: new Date(order.createdAt).toLocaleString('zh-CN') },
|
||||
{
|
||||
title: '待客户确认',
|
||||
description: order.scannedAt
|
||||
? `客户扫码 ${new Date(order.scannedAt).toLocaleString('zh-CN')}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
title: order.workOrderStatus === 'rejected' ? '客户退回' : '工单完成',
|
||||
description: order.confirmedAt
|
||||
? new Date(order.confirmedAt).toLocaleString('zh-CN')
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="客户公司">{order.companyName}</Descriptions.Item>
|
||||
<Descriptions.Item label="负责技术员">
|
||||
{order.technician?.name || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建人">{order.creator?.name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="授权状态">
|
||||
{order.authorizationStatus === 'authorized'
|
||||
? '已授权'
|
||||
: order.authorizationStatus === 'unauthorized'
|
||||
? '未授权'
|
||||
: '待确认'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={handleSave} disabled={!canEdit}>
|
||||
<Form.Item
|
||||
name="companyAddress"
|
||||
label="公司位置"
|
||||
rules={[{ required: true, message: '请输入公司位置' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space style={{ display: 'flex' }} size={16}>
|
||||
<Form.Item
|
||||
name="contactName"
|
||||
label="联系人"
|
||||
rules={[{ required: true, message: '请输入联系人' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="contactPhone"
|
||||
label="联系电话"
|
||||
rules={[
|
||||
{ required: true, message: '请输入联系电话' },
|
||||
{ pattern: /^\d{11}$/, message: '请输入 11 位手机号' },
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="serviceType"
|
||||
label="服务类型"
|
||||
rules={[{ required: true, message: '请选择服务类型' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({
|
||||
value: k,
|
||||
label: SERVICE_TYPE_LABEL[k],
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item
|
||||
name="issueDescription"
|
||||
label="问题反馈"
|
||||
rules={[{ required: true, message: '请填写问题反馈' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="resolutionNote"
|
||||
label="处理结果"
|
||||
tooltip="维修完成后填写,提交客户确认后客户可在公开页看到此内容"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请描述维修过程和最终处理结果" />
|
||||
</Form.Item>
|
||||
|
||||
{canEdit && (
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmitForConfirmation}
|
||||
>
|
||||
提交客户确认
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="售后工单二维码"
|
||||
open={qrModalVisible}
|
||||
onCancel={() => setQrModalVisible(false)}
|
||||
footer={[
|
||||
<Button key="download" onClick={handleDownloadQR}>
|
||||
下载二维码
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setQrModalVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{qrCodeDataUrl && (
|
||||
<>
|
||||
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: 220, height: 220 }} />
|
||||
<p
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#165DFF',
|
||||
}}
|
||||
>
|
||||
{order.serialNumber}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#888', wordBreak: 'break-all' }}>{qrUrl}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="重新分配技术员"
|
||||
open={reassignModalVisible}
|
||||
onCancel={() => setReassignModalVisible(false)}
|
||||
onOk={handleReassign}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="新技术员 ID" required>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="请输入技术员的用户 ID"
|
||||
value={reassignTechnicianId}
|
||||
onChange={(e) =>
|
||||
setReassignTechnicianId(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<p style={{ color: '#888', fontSize: 12 }}>
|
||||
后续会在用户管理页提供技术员列表选择,目前先输入 ID。
|
||||
</p>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AftersalesDetailPage;
|
||||
Reference in New Issue
Block a user