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:
Frudrax Cheng
2026-05-26 10:51:25 +08:00
parent 11f3eda668
commit 6fef517556
12 changed files with 1505 additions and 51 deletions
+452
View File
@@ -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;