Files
frontend/src/pages/AftersalesDetail.tsx
T
2026-05-29 09:53:46 +08:00

641 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;