641 lines
21 KiB
TypeScript
641 lines
21 KiB
TypeScript
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,
|
||
FileTextOutlined,
|
||
PrinterOutlined,
|
||
QrcodeOutlined,
|
||
SaveOutlined,
|
||
SendOutlined,
|
||
StopOutlined,
|
||
UserSwitchOutlined,
|
||
} from '@ant-design/icons';
|
||
import { aftersalesApi, authApi, usersApi } from '@/services/api';
|
||
import logo from '@/assets/img/logo.png?url';
|
||
import type {
|
||
AftersalesOrder,
|
||
AftersalesServiceType,
|
||
AftersalesWorkOrderStatus,
|
||
UpdateAftersalesRequest,
|
||
User,
|
||
} from '@/types';
|
||
import './styles/AftersalesDetail.css';
|
||
|
||
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
|
||
software: '软件故障',
|
||
hardware: '硬件故障',
|
||
maintenance: '售后维保',
|
||
};
|
||
|
||
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',
|
||
};
|
||
|
||
const AUTHORIZATION_STATUS_LABEL = {
|
||
pending: '待确认',
|
||
authorized: '已授权',
|
||
unauthorized: '未授权',
|
||
} as const;
|
||
|
||
function statusStepIndex(status: AftersalesWorkOrderStatus): number {
|
||
switch (status) {
|
||
case 'created':
|
||
return 0;
|
||
case 'pending_confirmation':
|
||
return 1;
|
||
case 'closed':
|
||
case 'rejected':
|
||
return 2;
|
||
}
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return '-';
|
||
return new Date(value).toLocaleString('zh-CN');
|
||
}
|
||
|
||
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 [electronicFormVisible, setElectronicFormVisible] = useState(false);
|
||
|
||
const [reassignModalVisible, setReassignModalVisible] = useState(false);
|
||
const [reassignTechnicianId, setReassignTechnicianId] = useState<number | undefined>();
|
||
const [assignableUsers, setAssignableUsers] = useState<User[]>([]);
|
||
|
||
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 openReassign = async () => {
|
||
setReassignModalVisible(true);
|
||
if (assignableUsers.length === 0) {
|
||
try {
|
||
const users = await usersApi.assignable();
|
||
setAssignableUsers(users);
|
||
} catch (err: any) {
|
||
message.error(err?.response?.data?.message || err.message || '加载技术员列表失败');
|
||
}
|
||
}
|
||
};
|
||
|
||
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 handlePrintElectronicForm = () => {
|
||
const formNode = document.querySelector('.aftersales-electronic-form');
|
||
if (!formNode) return;
|
||
|
||
const printWindow = window.open('', '_blank', 'width=960,height=720');
|
||
if (!printWindow) {
|
||
message.error('无法打开打印窗口,请检查浏览器弹窗设置');
|
||
return;
|
||
}
|
||
|
||
printWindow.document.write(`
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<title>${order?.serialNumber || '售后电子表单'}</title>
|
||
<style>
|
||
body { margin: 24px; color: #111827; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||
.aftersales-electronic-form { max-width: 1080px; margin: 0 auto; }
|
||
.electronic-form-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||
.electronic-form-logo { height: 34px; object-fit: contain; }
|
||
.electronic-form-title { flex: 1; text-align: center; font-size: 20px; font-weight: 700; }
|
||
.electronic-form-serial { border: 2px solid #111827; padding: 8px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 16px; font-weight: 700; }
|
||
.electronic-form-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
|
||
.electronic-form-table th, .electronic-form-table td { border: 1px solid #1f2937; padding: 9px 10px; vertical-align: top; word-break: break-word; }
|
||
.electronic-form-table th { width: 120px; background: #f3f4f6; text-align: left; font-weight: 600; }
|
||
.electronic-form-table td { min-height: 24px; }
|
||
.electronic-form-table .electronic-form-code { color: #165dff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 16px; font-weight: 700; }
|
||
.electronic-form-text { min-height: 72px; white-space: pre-wrap; }
|
||
</style>
|
||
</head>
|
||
<body>${formNode.outerHTML}</body>
|
||
</html>
|
||
`);
|
||
printWindow.document.close();
|
||
printWindow.focus();
|
||
setTimeout(() => printWindow.print(), 300);
|
||
};
|
||
|
||
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={<FileTextOutlined />} onClick={() => setElectronicFormVisible(true)}>
|
||
电子表单
|
||
</Button>
|
||
<Button icon={<QrcodeOutlined />} onClick={handleGenerateQrCode}>
|
||
生成二维码
|
||
</Button>
|
||
{isAdmin && !isClosed && (
|
||
<>
|
||
<Button icon={<UserSwitchOutlined />} onClick={openReassign}>
|
||
工单分配
|
||
</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>
|
||
|
||
{order.authorizationStatus === 'authorized' && order.signature && (
|
||
<div style={{ marginBottom: 24 }}>
|
||
<div style={{ fontSize: 13, color: '#595959', marginBottom: 8 }}>
|
||
客户确认签名
|
||
{order.confirmedAt && (
|
||
<span style={{ marginLeft: 12, color: '#8c8c8c', fontSize: 12 }}>
|
||
签署时间:{new Date(order.confirmedAt).toLocaleString('zh-CN')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<img
|
||
src={order.signature}
|
||
alt="客户确认签名"
|
||
style={{
|
||
maxWidth: 480,
|
||
maxHeight: 220,
|
||
width: '100%',
|
||
height: 'auto',
|
||
objectFit: 'contain',
|
||
border: '1px solid #f0f0f0',
|
||
borderRadius: 6,
|
||
background: '#fff',
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<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={electronicFormVisible}
|
||
onCancel={() => setElectronicFormVisible(false)}
|
||
width={960}
|
||
footer={[
|
||
<Button key="print" icon={<PrinterOutlined />} onClick={handlePrintElectronicForm}>
|
||
打印表单
|
||
</Button>,
|
||
<Button key="close" type="primary" onClick={() => setElectronicFormVisible(false)}>
|
||
关闭
|
||
</Button>,
|
||
]}
|
||
>
|
||
<div className="aftersales-electronic-form">
|
||
<div className="electronic-form-header">
|
||
<img src={logo} alt="浙江贝凡" className="electronic-form-logo" />
|
||
<div className="electronic-form-title">浙江贝凡售后服务电子表单</div>
|
||
<div className="electronic-form-serial">售后码:{order.serialNumber}</div>
|
||
</div>
|
||
<table className="electronic-form-table">
|
||
<tbody>
|
||
<tr>
|
||
<th>售后码</th>
|
||
<td className="electronic-form-code">{order.serialNumber}</td>
|
||
<th>工单状态</th>
|
||
<td>{WORK_ORDER_STATUS_LABEL[order.workOrderStatus]}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>授权状态</th>
|
||
<td>{AUTHORIZATION_STATUS_LABEL[order.authorizationStatus]}</td>
|
||
<th>服务类型</th>
|
||
<td>{SERVICE_TYPE_LABEL[order.serviceType]}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>客户公司</th>
|
||
<td>{order.companyName}</td>
|
||
<th>公司位置</th>
|
||
<td>{order.companyAddress}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>联系人</th>
|
||
<td>{order.contactName}</td>
|
||
<th>联系电话</th>
|
||
<td>{order.contactPhone}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>负责技术员</th>
|
||
<td>{order.technician?.name || '-'}</td>
|
||
<th>创建人</th>
|
||
<td>{order.creator?.name || '-'}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>创建时间</th>
|
||
<td>{formatDateTime(order.createdAt)}</td>
|
||
<th>更新时间</th>
|
||
<td>{formatDateTime(order.updatedAt)}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>客户扫码时间</th>
|
||
<td>{formatDateTime(order.scannedAt)}</td>
|
||
<th>客户确认时间</th>
|
||
<td>{formatDateTime(order.confirmedAt)}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>退回次数</th>
|
||
<td>{order.rejectCount}</td>
|
||
<th>表单生成时间</th>
|
||
<td>{formatDateTime(new Date().toISOString())}</td>
|
||
</tr>
|
||
<tr>
|
||
<th>问题描述反馈</th>
|
||
<td colSpan={3} className="electronic-form-text">
|
||
{order.issueDescription || '-'}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<th>处理结果</th>
|
||
<td colSpan={3} className="electronic-form-text">
|
||
{order.resolutionNote || '-'}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="工单分配"
|
||
open={reassignModalVisible}
|
||
onCancel={() => {
|
||
setReassignModalVisible(false);
|
||
setReassignTechnicianId(undefined);
|
||
}}
|
||
onOk={handleReassign}
|
||
okText="确认"
|
||
cancelText="取消"
|
||
>
|
||
<Form layout="vertical">
|
||
<Form.Item label="选择技术员" required>
|
||
<Select
|
||
placeholder="请选择技术员或管理员"
|
||
value={reassignTechnicianId}
|
||
onChange={(v) => setReassignTechnicianId(v)}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={assignableUsers.map((u) => ({
|
||
value: u.id,
|
||
label: `${u.name}(${u.username})${u.role === 'admin' ? ' · 管理员' : ' · 技术员'}`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default AftersalesDetailPage;
|