From 6fef5175568241fb12dfd3da43361bf740bf9bec Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Tue, 26 May 2026 10:51:25 +0800 Subject: [PATCH] Add aftersales work order frontend pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- AGENTS.md | 5 + README.md | 15 +- src/App.tsx | 14 +- src/components/AdminLayout.tsx | 9 + src/components/PublicLayout.tsx | 47 +++ src/pages/Aftersales.tsx | 391 +++++++++++++++++++++ src/pages/AftersalesConfirm.tsx | 275 +++++++++++++++ src/pages/AftersalesDetail.tsx | 452 +++++++++++++++++++++++++ src/pages/PublicQuery.tsx | 92 ++--- src/pages/styles/AftersalesConfirm.css | 54 +++ src/services/api.ts | 119 ++++++- src/types/index.ts | 83 +++++ 12 files changed, 1505 insertions(+), 51 deletions(-) create mode 100644 src/components/PublicLayout.tsx create mode 100644 src/pages/Aftersales.tsx create mode 100644 src/pages/AftersalesConfirm.tsx create mode 100644 src/pages/AftersalesDetail.tsx create mode 100644 src/pages/styles/AftersalesConfirm.css diff --git a/AGENTS.md b/AGENTS.md index 9e6ef7d..d5ffac8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ src/ - `serialApi` - Serial number management - `companyApi` - Company management - `dashboardApi` - Dashboard statistics + - `employeeSerialApi` - Employee serial management + - `aftersalesApi` - Aftersales work orders (admin + public) - Auth token automatically added via axios interceptor - All API calls return typed responses based on `src/types/index.ts` @@ -97,6 +99,9 @@ src/ - Admin pages wrapped with `` layout component - Use `useNavigate()` for programmatic navigation - Use `useLocation()` to get current path +- Public routes (no auth): `/login`, `/query`, `/aftersales/:serialNumber` +- `PublicQuery` auto-redirects scanned `zjbf-sh-*` serials to `/aftersales/:serialNumber` +- Shared public-page chrome (logo + 备案 footer) lives in `components/PublicLayout.tsx` ### State Management - Use React hooks (`useState`, `useEffect`) for local component state diff --git a/README.md b/README.md index faf88e8..1b52800 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,17 @@ frontend/ ├── src/ │ ├── components/ # 通用组件 -│ │ └── AdminLayout.tsx +│ │ ├── AdminLayout.tsx +│ │ └── PublicLayout.tsx # 公开页统一布局(Logo + 备案) │ ├── pages/ # 页面组件 │ │ ├── Login.tsx │ │ ├── PublicQuery.tsx │ │ ├── Dashboard.tsx -│ │ ├── Generate.tsx │ │ ├── Manage.tsx +│ │ ├── EmployeeSerials.tsx +│ │ ├── Aftersales.tsx # 售后工单列表(管理后台) +│ │ ├── AftersalesDetail.tsx # 售后工单详情(管理后台) +│ │ ├── AftersalesConfirm.tsx # 售后工单扫码确认(公开) │ │ └── Profile.tsx │ ├── services/ # API 服务层 │ │ └── api.ts @@ -86,6 +90,8 @@ VITE_API_BASE_URL=/api - 用户登录 - 公开查询序列号(支持二维码扫描) + - 扫描到 `zjbf-sh-*` 售后码时自动跳转到售后确认页 +- 售后工单确认页(扫码 → 手机号末四位校验 → 已授权/未授权) ### 管理后台 @@ -98,6 +104,11 @@ VITE_API_BASE_URL=/api - 查看序列号列表 - 吊销企业/序列号 - 查看序列号二维码 +- 员工管理(员工赋码生成与维护) +- 售后工单 + - 技术员创建工单、填写处理结果、提交客户确认 + - 管理员可重新分配技术员或强制关闭工单 + - 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回 - 用户资料管理 ## License diff --git a/src/App.tsx b/src/App.tsx index 906445d..9bbf5eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - - - - ); + return ; }; function App() { @@ -43,13 +42,16 @@ function App() { } /> } /> - + } /> + }> }> } /> } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 61e7949..5e8751b 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -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: , + 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 '控制台'; }; diff --git a/src/components/PublicLayout.tsx b/src/components/PublicLayout.tsx new file mode 100644 index 0000000..44de773 --- /dev/null +++ b/src/components/PublicLayout.tsx @@ -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 ( +
+ {children} + +
+

+ Copyright © 2026 浙江贝凡网络科技有限公司. All Rights Reserved. |{' '} + + 浙ICP备2025170226号-4 + +

+

+ + 备案图标 + 浙公网安备33011002018371号 + +

+
+
+ ); +} + +export const PublicLogo = () => ( +
+ Logo +
+); + +export default PublicLayout; diff --git a/src/pages/Aftersales.tsx b/src/pages/Aftersales.tsx new file mode 100644 index 0000000..5e9fda7 --- /dev/null +++ b/src/pages/Aftersales.tsx @@ -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 = { + created: '待处理', + pending_confirmation: '待客户确认', + closed: '已完成', + rejected: '已退回', +}; + +const WORK_ORDER_STATUS_COLOR: Record = { + created: 'default', + pending_confirmation: 'processing', + closed: 'success', + rejected: 'warning', +}; + +const SERVICE_TYPE_LABEL: Record = { + software: '软件', + hardware: '硬件', + other: '其他', +}; + +function AftersalesPage() { + const navigate = useNavigate(); + const currentUser = authApi.getCurrentUser(); + const isAdmin = currentUser?.role === 'admin'; + + const [orders, setOrders] = useState([]); + 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(); + const [serviceType, setServiceType] = useState(); + const [mineOnly, setMineOnly] = useState(false); + + const [createModalVisible, setCreateModalVisible] = useState(false); + const [creating, setCreating] = useState(false); + const [createForm] = Form.useForm(); + + 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) => ( + {sn} + ), + }, + { + 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) => ( + + + {WORK_ORDER_STATUS_LABEL[status]} + + {record.rejectCount > 0 && ( + + 退回 {record.rejectCount} 次 + + )} + + ), + }, + { + 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) => ( + + + {isAdmin && ( + + )} + + ), + }, + ]; + + return ( +
+ + + 售后工单 + + } + extra={ + + } + > + + { + setPage(1); + setSearch(v); + }} + onChange={(e) => { + if (!e.target.value) { + setPage(1); + setSearch(''); + } + }} + /> + { + setPage(1); + setServiceType(v); + }} + options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({ + value: k, + label: SERVICE_TYPE_LABEL[k], + }))} + /> + {isAdmin && ( + + )} + + + +
+ { + setPage(newPage); + setLimit(newLimit); + }} + showSizeChanger + showTotal={(t) => `共计 ${t} 条记录`} + /> +
+ + + { + setCreateModalVisible(false); + createForm.resetFields(); + }} + footer={null} + width={560} + > +
+ + + + + + + + + + + + + + setPhoneLast4(e.target.value.replace(/\D/g, ''))} + inputMode="numeric" + /> + +
+ + +
+ + )} + + + setShowRejectDialog(false)} + onOk={confirmReject} + okText="确认退回" + cancelText="取消" + confirmLoading={submitting} + > + setRejectReason(e.target.value)} + maxLength={200} + /> + + + ); +} + +export default AftersalesConfirmPage; diff --git a/src/pages/AftersalesDetail.tsx b/src/pages/AftersalesDetail.tsx new file mode 100644 index 0000000..f5f13dd --- /dev/null +++ b/src/pages/AftersalesDetail.tsx @@ -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 = { + software: '软件', + hardware: '硬件', + other: '其他', +}; + +const WORK_ORDER_STATUS_LABEL: Record = { + created: '待处理', + pending_confirmation: '待客户确认', + closed: '已完成', + rejected: '已退回', +}; + +const WORK_ORDER_STATUS_COLOR: Record = { + 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(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(); + + 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 ( +
+ +
+ ); + } + + 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 ( +
+ + + {isAdmin && !isClosed && ( + <> + + + + )} + + } + > + + + + + + {order.companyName} + + {order.technician?.name || '-'} + + {order.creator?.name || '-'} + + {order.authorizationStatus === 'authorized' + ? '已授权' + : order.authorizationStatus === 'unauthorized' + ? '未授权' + : '待确认'} + + + + + + + + + + + + + + + + + setReassignTechnicianId(e.target.value ? Number(e.target.value) : undefined) + } + /> + +

+ 后续会在用户管理页提供技术员列表选择,目前先输入 ID。 +

+ + +
+ ); +} + +export default AftersalesDetailPage; diff --git a/src/pages/PublicQuery.tsx b/src/pages/PublicQuery.tsx index 2936398..d374213 100644 --- a/src/pages/PublicQuery.tsx +++ b/src/pages/PublicQuery.tsx @@ -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(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 ( -
+ {!showResult ? (
-
- Logo -
+

授权查询

@@ -109,9 +128,7 @@ function PublicQueryPage() { ) : (
-
- Logo -
+
{loading ? (
@@ -123,20 +140,21 @@ function PublicQueryPage() { {(result as any).isActive === false || (result as any).status === 'inactive' ? ( } - 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}` } /> ) : ( } - title={serialType === 'employee' ? "员工身份有效" : "授权有效"} + title={serialType === 'employee' ? '员工身份有效' : '授权有效'} subTitle="您的序列号已验证通过" /> )} - +
序列号 @@ -161,14 +179,24 @@ function PublicQueryPage() { {serialType !== 'employee' && (result as Serial).validUntil && (
有效期至 - {new Date((result as Serial).validUntil).toLocaleString('zh-CN')} + + {new Date((result as Serial).validUntil).toLocaleString('zh-CN')} +
)}
授权状态 - - {(result as any).isActive === false || (result as any).status === 'inactive' ? '已吊销' : '有效'} + + {(result as any).isActive === false || (result as any).status === 'inactive' + ? '已吊销' + : '有效'}
@@ -184,33 +212,13 @@ function PublicQueryPage() {
)} - )} - -
-

- Copyright © 2026 浙江贝凡网络科技有限公司. All Rights Reserved. | - - 浙ICP备2025170226号-4 - -

-

- - 备案图标 - 浙公网安备33011002018371号 - -

-
-
+ ); } -export default PublicQueryPage; \ No newline at end of file +export default PublicQueryPage; diff --git a/src/pages/styles/AftersalesConfirm.css b/src/pages/styles/AftersalesConfirm.css new file mode 100644 index 0000000..ef37f65 --- /dev/null +++ b/src/pages/styles/AftersalesConfirm.css @@ -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; +} diff --git a/src/services/api.ts b/src/services/api.ts index 3b917b3..5987c9e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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 || '提交确认失败'); + }, }; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index b8872eb..56fd916 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; } \ No newline at end of file