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
+8 -6
View File
@@ -7,6 +7,9 @@ import DashboardPage from './pages/Dashboard';
import ManagePage from './pages/Manage';
import ProfilePage from './pages/Profile';
import EmployeeSerialsPage from './pages/EmployeeSerials';
import AftersalesPage from './pages/Aftersales';
import AftersalesDetailPage from './pages/AftersalesDetail';
import AftersalesConfirmPage from './pages/AftersalesConfirm';
const PrivateRoute = () => {
const user = authApi.getCurrentUser();
@@ -25,11 +28,7 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
};
const AdminRoutes = () => {
return (
<AdminLayout>
<Outlet />
</AdminLayout>
);
return <AdminLayout />;
};
function App() {
@@ -43,13 +42,16 @@ function App() {
</PublicRoute>
} />
<Route path="/query" element={<PublicQueryPage />} />
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
<Route element={<PrivateRoute />}>
<Route element={<AdminRoutes />}>
<Route path="/admin" element={<Navigate to="dashboard" replace />} />
<Route path="/admin/dashboard" element={<DashboardPage />} />
<Route path="/admin/manage" element={<ManagePage />} />
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
<Route path="/admin/aftersales" element={<AftersalesPage />} />
<Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} />
<Route path="/admin/profile" element={<ProfilePage />} />
</Route>
</Route>
+9
View File
@@ -7,6 +7,7 @@ import {
LogoutOutlined,
ExclamationCircleOutlined,
IdcardOutlined,
ToolOutlined,
} from '@ant-design/icons';
import { authApi } from '@/services/api';
import './styles/AdminLayout.css';
@@ -38,6 +39,12 @@ function AdminLayout() {
label: '员工管理',
onClick: () => navigate('/admin/employee-serials'),
},
{
key: 'aftersales',
icon: <ToolOutlined />,
label: '售后工单',
onClick: () => navigate('/admin/aftersales'),
},
];
const handleLogout = () => {
@@ -83,6 +90,7 @@ function AdminLayout() {
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/manage')) return 'manage';
if (path.includes('/employee-serials')) return 'employee-serials';
if (path.includes('/aftersales')) return 'aftersales';
if (path.includes('/profile')) return 'profile';
return 'dashboard';
};
@@ -92,6 +100,7 @@ function AdminLayout() {
if (path.includes('/dashboard')) return '控制台';
if (path.includes('/manage')) return '企业管理';
if (path.includes('/employee-serials')) return '员工管理';
if (path.includes('/aftersales')) return '售后工单';
if (path.includes('/profile')) return '用户资料';
return '控制台';
};
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import logo from '@/assets/img/logo.png?url';
import beian from '@/assets/img/beian.png?url';
import '@/pages/styles/PublicQuery.css';
interface PublicLayoutProps {
children: ReactNode;
}
function PublicLayout({ children }: PublicLayoutProps) {
return (
<div className="public-query-container">
{children}
<div className="copyright">
<p>
Copyright © 2026 . All Rights Reserved. |{' '}
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
ICP备2025170226号-4
</a>
</p>
<p>
<a
href="https://beian.mps.gov.cn/#/query/webSearch?code=33011002018371"
target="_blank"
rel="noopener noreferrer"
>
<img
src={beian}
alt="备案图标"
style={{ height: '20px', marginRight: '5px', verticalAlign: 'middle' }}
/>
33011002018371
</a>
</p>
</div>
</div>
);
}
export const PublicLogo = () => (
<div className="query-logo">
<img src={logo} alt="Logo" style={{ height: '24px' }} />
</div>
);
export default PublicLayout;
+391
View File
@@ -0,0 +1,391 @@
import { useEffect, useState } from 'react';
import {
Card,
Table,
Input,
Button,
Space,
message,
Modal,
Tag,
Form,
Select,
Pagination,
} from 'antd';
import {
ToolOutlined,
PlusOutlined,
DeleteOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { aftersalesApi, authApi } from '@/services/api';
import type {
AftersalesOrder,
AftersalesWorkOrderStatus,
AftersalesServiceType,
CreateAftersalesRequest,
} from '@/types';
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 SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件',
hardware: '硬件',
other: '其他',
};
function AftersalesPage() {
const navigate = useNavigate();
const currentUser = authApi.getCurrentUser();
const isAdmin = currentUser?.role === 'admin';
const [orders, setOrders] = useState<AftersalesOrder[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(10);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [workOrderStatus, setWorkOrderStatus] = useState<AftersalesWorkOrderStatus | undefined>();
const [serviceType, setServiceType] = useState<AftersalesServiceType | undefined>();
const [mineOnly, setMineOnly] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateAftersalesRequest>();
const loadOrders = async () => {
setLoading(true);
try {
const result = await aftersalesApi.list({
page,
limit,
search: search || undefined,
workOrderStatus,
serviceType,
mine: isAdmin ? mineOnly : undefined,
});
setOrders(result.data || []);
setTotal(result.pagination?.total || 0);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '加载工单列表失败');
setOrders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, limit, search, workOrderStatus, serviceType, mineOnly]);
const handleCreate = async (values: CreateAftersalesRequest) => {
setCreating(true);
try {
const order = await aftersalesApi.create(values);
message.success(`工单创建成功:${order.serialNumber}`);
setCreateModalVisible(false);
createForm.resetFields();
navigate(`/admin/aftersales/${order.serialNumber}`);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '创建失败');
} finally {
setCreating(false);
}
};
const handleDelete = (order: AftersalesOrder) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除工单 ${order.serialNumber} 吗?此操作不可恢复!`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await aftersalesApi.delete(order.serialNumber);
message.success('删除成功');
loadOrders();
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '删除失败');
}
},
});
};
const columns = [
{
title: '工单号',
dataIndex: 'serialNumber',
key: 'serialNumber',
width: 180,
render: (sn: string) => (
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>{sn}</span>
),
},
{
title: '客户公司',
dataIndex: 'companyName',
key: 'companyName',
},
{
title: '联系人',
dataIndex: 'contactName',
key: 'contactName',
width: 100,
},
{
title: '服务类型',
dataIndex: 'serviceType',
key: 'serviceType',
width: 100,
render: (type: AftersalesServiceType) => SERVICE_TYPE_LABEL[type] || type,
},
{
title: '负责技术员',
key: 'technician',
width: 120,
render: (_: any, record: AftersalesOrder) => record.technician?.name || '-',
},
{
title: '工单状态',
dataIndex: 'workOrderStatus',
key: 'workOrderStatus',
width: 130,
render: (status: AftersalesWorkOrderStatus, record: AftersalesOrder) => (
<Space size={4}>
<Tag color={WORK_ORDER_STATUS_COLOR[status]}>
{WORK_ORDER_STATUS_LABEL[status]}
</Tag>
{record.rejectCount > 0 && (
<Tag color="orange" style={{ marginLeft: 0 }}>
退 {record.rejectCount}
</Tag>
)}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_: any, record: AftersalesOrder) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/admin/aftersales/${record.serialNumber}`)}
>
</Button>
{isAdmin && (
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Card
title={
<Space>
<ToolOutlined />
<span></span>
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)}
>
</Button>
}
>
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
<Input.Search
placeholder="搜索工单号/公司/联系人"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setSearch(v);
}}
onChange={(e) => {
if (!e.target.value) {
setPage(1);
setSearch('');
}
}}
/>
<Select
placeholder="工单状态"
allowClear
style={{ width: 160 }}
value={workOrderStatus}
onChange={(v) => {
setPage(1);
setWorkOrderStatus(v);
}}
options={(Object.keys(WORK_ORDER_STATUS_LABEL) as AftersalesWorkOrderStatus[]).map(
(k) => ({ value: k, label: WORK_ORDER_STATUS_LABEL[k] })
)}
/>
<Select
placeholder="服务类型"
allowClear
style={{ width: 130 }}
value={serviceType}
onChange={(v) => {
setPage(1);
setServiceType(v);
}}
options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({
value: k,
label: SERVICE_TYPE_LABEL[k],
}))}
/>
{isAdmin && (
<Button
type={mineOnly ? 'primary' : 'default'}
onClick={() => {
setPage(1);
setMineOnly(!mineOnly);
}}
>
{mineOnly ? '查看全部' : '我负责的'}
</Button>
)}
</Space>
<Table
columns={columns}
dataSource={orders}
rowKey="serialNumber"
loading={loading}
pagination={false}
/>
<div style={{ marginTop: 16 }}>
<Pagination
current={page}
pageSize={limit}
total={total}
onChange={(newPage, newLimit) => {
setPage(newPage);
setLimit(newLimit);
}}
showSizeChanger
showTotal={(t) => `共计 ${t} 条记录`}
/>
</div>
</Card>
<Modal
title="新建售后工单"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false);
createForm.resetFields();
}}
footer={null}
width={560}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item
name="companyName"
label="客户公司"
rules={[{ required: true, message: '请输入客户公司' }]}
>
<Input placeholder="例如:浙江北凡科技" />
</Form.Item>
<Form.Item
name="companyAddress"
label="公司位置"
rules={[{ required: true, message: '请输入公司位置' }]}
>
<Input placeholder="例如:杭州市西湖区文三路 100 号" />
</Form.Item>
<Form.Item
name="contactName"
label="联系人"
rules={[{ required: true, message: '请输入联系人' }]}
>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话"
rules={[
{ required: true, message: '请输入联系电话' },
{ pattern: /^\d{11}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="11 位手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="serviceType"
label="服务类型"
rules={[{ required: true, message: '请选择服务类型' }]}
>
<Select
placeholder="请选择"
options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({
value: k,
label: SERVICE_TYPE_LABEL[k],
}))}
/>
</Form.Item>
<Form.Item
name="issueDescription"
label="问题反馈"
rules={[{ required: true, message: '请填写问题反馈' }]}
>
<Input.TextArea rows={4} placeholder="请描述客户反馈的问题" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setCreateModalVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default AftersalesPage;
+275
View File
@@ -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;
+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;
+50 -42
View File
@@ -1,11 +1,17 @@
import { useState, useEffect } from 'react';
import { Input, Button, Card, message, Spin, Result, Tag } from 'antd';
import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import {
QrcodeOutlined,
SearchOutlined,
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { employeeSerialApi } from '@/services/api';
import type { Serial } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import './styles/PublicQuery.css';
import logo from '@/assets/img/logo.png?url';
import beian from '@/assets/img/beian.png?url';
interface EmployeeSerialResult {
serialNumber: string;
@@ -16,7 +22,10 @@ interface EmployeeSerialResult {
createdAt: string;
}
const AFTERSALES_PREFIX = 'zjbf-sh-';
function PublicQueryPage() {
const navigate = useNavigate();
const [serialNumber, setSerialNumber] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<Serial | EmployeeSerialResult | null>(null);
@@ -24,7 +33,14 @@ function PublicQueryPage() {
const [showResult, setShowResult] = useState(false);
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
const isAftersalesSerial = (sn: string) => sn.toLowerCase().startsWith(AFTERSALES_PREFIX);
const performQuery = async (serialToQuery: string) => {
if (isAftersalesSerial(serialToQuery)) {
navigate(`/aftersales/${serialToQuery.toLowerCase()}`, { replace: true });
return;
}
setLoading(true);
setError(null);
setResult(null);
@@ -50,10 +66,15 @@ function PublicQueryPage() {
const urlParams = new URLSearchParams(window.location.search);
const serialFromUrl = urlParams.get('serial');
if (serialFromUrl) {
if (isAftersalesSerial(serialFromUrl)) {
navigate(`/aftersales/${serialFromUrl.toLowerCase()}`, { replace: true });
return;
}
setSerialNumber(serialFromUrl);
setShowResult(true);
performQuery(serialFromUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleQuery = async () => {
@@ -73,13 +94,11 @@ function PublicQueryPage() {
};
return (
<div className="public-query-container">
<PublicLayout>
{!showResult ? (
<Card className="query-card" bordered={false}>
<div className="query-header">
<div className="query-logo">
<img src={logo} alt="Logo" style={{ height: '24px' }} />
</div>
<PublicLogo />
<h1 className="query-title">
<QrcodeOutlined />
</h1>
@@ -109,9 +128,7 @@ function PublicQueryPage() {
) : (
<Card className={`result-card show`} bordered={false}>
<div className="result-header">
<div className="query-logo">
<img src={logo} alt="Logo" style={{ height: '24px' }} />
</div>
<PublicLogo />
</div>
{loading ? (
<div className="loading-container">
@@ -123,20 +140,21 @@ function PublicQueryPage() {
{(result as any).isActive === false || (result as any).status === 'inactive' ? (
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title={serialType === 'employee' ? "员工身份已吊销" : "授权已吊销"}
subTitle={serialType === 'employee'
? `序列号验证通过,但已被吊销。企业:${(result as EmployeeSerialResult).companyName}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
title={serialType === 'employee' ? '员工身份已吊销' : '授权已吊销'}
subTitle={
serialType === 'employee'
? `序列号验证通过,但已被吊销。企业:${(result as EmployeeSerialResult).companyName}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
}
/>
) : (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
title={serialType === 'employee' ? "员工身份有效" : "授权有效"}
title={serialType === 'employee' ? '员工身份有效' : '授权有效'}
subTitle="您的序列号已验证通过"
/>
)}
<div className="result-details">
<div className="detail-item">
<span className="label"></span>
@@ -161,14 +179,24 @@ function PublicQueryPage() {
{serialType !== 'employee' && (result as Serial).validUntil && (
<div className="detail-item">
<span className="label"></span>
<span className="value">{new Date((result as Serial).validUntil).toLocaleString('zh-CN')}</span>
<span className="value">
{new Date((result as Serial).validUntil).toLocaleString('zh-CN')}
</span>
</div>
)}
<div className="detail-item">
<span className="label"></span>
<span className="value status">
<Tag color={(result as any).isActive === false || (result as any).status === 'inactive' ? 'red' : 'green'}>
{(result as any).isActive === false || (result as any).status === 'inactive' ? '已吊销' : '有效'}
<Tag
color={
(result as any).isActive === false || (result as any).status === 'inactive'
? 'red'
: 'green'
}
>
{(result as any).isActive === false || (result as any).status === 'inactive'
? '已吊销'
: '有效'}
</Tag>
</span>
</div>
@@ -184,33 +212,13 @@ function PublicQueryPage() {
</div>
)}
<Button
size="large"
block
icon={<ArrowLeftOutlined />}
onClick={handleReset}
>
<Button size="large" block icon={<ArrowLeftOutlined />} onClick={handleReset}>
</Button>
</Card>
)}
<div className="copyright">
<p>
Copyright © 2026 . All Rights Reserved. |
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
ICP备2025170226号-4
</a>
</p>
<p>
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=33011002018371" target="_blank" rel="noopener noreferrer">
<img src={beian} alt="备案图标" style={{ height: '20px', marginRight: '5px', verticalAlign: 'middle' }} />
33011002018371
</a>
</p>
</div>
</div>
</PublicLayout>
);
}
export default PublicQueryPage;
export default PublicQueryPage;
+54
View File
@@ -0,0 +1,54 @@
.aftersales-confirm-card {
max-width: 560px;
}
.aftersales-title {
font-size: clamp(1.4rem, 4vw, 1.75rem);
font-weight: bold;
color: #165DFF;
text-align: center;
margin: 12px 0 4px;
}
.aftersales-serial {
text-align: center;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #4B5563;
margin: 0 0 16px;
letter-spacing: 1px;
}
.aftersales-details .detail-item-block {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.aftersales-details .value-block {
white-space: pre-wrap;
word-break: break-word;
text-align: left;
width: 100%;
}
.aftersales-phone-verify {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.aftersales-phone-tip {
color: #4B5563;
margin: 0 0 12px;
font-size: 14px;
}
.aftersales-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.aftersales-actions > button {
flex: 1;
}
+118 -1
View File
@@ -1,5 +1,16 @@
import axios from 'axios';
import type { ApiResponse, AuthResponse, User, EmployeeSerial, EmployeeSerialResponse } from '@/types';
import type {
User,
EmployeeSerial,
EmployeeSerialResponse,
AftersalesOrder,
AftersalesPublicView,
AftersalesListFilter,
AftersalesListResponse,
CreateAftersalesRequest,
UpdateAftersalesRequest,
CustomerConfirmRequest,
} from '@/types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
@@ -316,4 +327,110 @@ export const employeeSerialApi = {
}
throw new Error(response.data.error || '删除员工序列号失败');
},
};
export const aftersalesApi = {
create: async (data: CreateAftersalesRequest) => {
const response = await apiClient.post('/aftersales', data);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '创建工单失败');
},
list: async (filter?: AftersalesListFilter) => {
const params = new URLSearchParams();
if (filter?.page && filter.page > 1) params.append('page', String(filter.page));
if (filter?.limit && filter.limit !== 20) params.append('limit', String(filter.limit));
if (filter?.search) params.append('search', filter.search);
if (filter?.workOrderStatus) params.append('workOrderStatus', filter.workOrderStatus);
if (filter?.serviceType) params.append('serviceType', filter.serviceType);
if (filter?.technicianId) params.append('technicianId', String(filter.technicianId));
if (filter?.mine) params.append('mine', 'true');
const url = params.toString() ? `/aftersales?${params.toString()}` : '/aftersales';
const response = await apiClient.get(url);
if (response.data) {
return response.data as AftersalesListResponse;
}
throw new Error('获取售后工单列表失败');
},
get: async (serialNumber: string) => {
const response = await apiClient.get(`/aftersales/${encodeURIComponent(serialNumber)}`);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '查询工单失败');
},
update: async (serialNumber: string, data: UpdateAftersalesRequest) => {
const response = await apiClient.patch(`/aftersales/${encodeURIComponent(serialNumber)}`, data);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '更新工单失败');
},
submit: async (serialNumber: string, resolutionNote: string) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/submit`, {
resolutionNote,
});
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '提交客户确认失败');
},
generateQrCode: async (serialNumber: string, baseUrl?: string) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/qrcode`, {
baseUrl,
});
if (response.data.qrCodeData) {
return response.data as { qrCodeData: string; queryUrl: string };
}
throw new Error(response.data.error || '生成二维码失败');
},
reassign: async (serialNumber: string, technicianId: number) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/reassign`, {
technicianId,
});
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '重新分配失败');
},
forceClose: async (serialNumber: string) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/force-close`);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '强制关闭失败');
},
delete: async (serialNumber: string) => {
const response = await apiClient.delete(`/aftersales/${encodeURIComponent(serialNumber)}`);
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '删除工单失败');
},
publicQuery: async (serialNumber: string) => {
const response = await apiClient.get(`/aftersales/${encodeURIComponent(serialNumber)}/query`);
if (response.data.order) {
return response.data.order as AftersalesPublicView;
}
throw new Error(response.data.error || '查询工单失败');
},
customerConfirm: async (serialNumber: string, data: CustomerConfirmRequest) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/confirm`, data);
if (response.data.order) {
return response.data.order as AftersalesPublicView;
}
throw new Error(response.data.error || '提交确认失败');
},
};
+83
View File
@@ -117,4 +117,87 @@ export interface EmployeeSerialPagination {
export interface EmployeeSerialResponse {
data: EmployeeSerial[];
pagination: EmployeeSerialPagination;
}
export type AftersalesServiceType = 'software' | 'hardware' | 'other';
export type AftersalesWorkOrderStatus = 'created' | 'pending_confirmation' | 'closed' | 'rejected';
export type AftersalesAuthorizationStatus = 'pending' | 'authorized' | 'unauthorized';
export interface AftersalesOrder {
id: number;
serialNumber: string;
companyName: string;
companyAddress: string;
contactName: string;
contactPhone: string;
serviceType: AftersalesServiceType;
issueDescription: string;
resolutionNote: string;
workOrderStatus: AftersalesWorkOrderStatus;
authorizationStatus: AftersalesAuthorizationStatus;
technicianId?: number;
createdBy?: number;
scannedAt?: string;
confirmedAt?: string;
rejectCount: number;
createdAt: string;
updatedAt: string;
technician?: User;
creator?: User;
}
export interface AftersalesPublicView {
serialNumber: string;
companyName: string;
companyAddress: string;
contactName: string;
serviceType: AftersalesServiceType;
issueDescription: string;
resolutionNote: string;
workOrderStatus: AftersalesWorkOrderStatus;
authorizationStatus: AftersalesAuthorizationStatus;
technicianName: string;
createdAt: string;
confirmedAt?: string;
}
export interface CreateAftersalesRequest {
companyName: string;
companyAddress: string;
contactName: string;
contactPhone: string;
serviceType: AftersalesServiceType;
issueDescription: string;
technicianId?: number;
}
export interface UpdateAftersalesRequest {
companyAddress?: string;
contactName?: string;
contactPhone?: string;
serviceType?: AftersalesServiceType;
issueDescription?: string;
resolutionNote?: string;
technicianId?: number;
}
export interface AftersalesListFilter {
page?: number;
limit?: number;
search?: string;
workOrderStatus?: AftersalesWorkOrderStatus;
serviceType?: AftersalesServiceType;
technicianId?: number;
mine?: boolean;
}
export interface AftersalesListResponse {
data: AftersalesOrder[];
pagination: EmployeeSerialPagination;
}
export interface CustomerConfirmRequest {
phoneLast4: string;
action: 'authorize' | 'reject';
rejectReason?: string;
}