diff --git a/AGENTS.md b/AGENTS.md index 23948cb..2459c22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ src/ - `hardware`: 硬件故障 - `maintenance`: 售后维保 - Aftersales `companyName` is a customer-company text field only. Do not call company-management APIs or create managed companies from aftersales create/update flows. -- Use label text `问题描述反馈` for `issueDescription` in create/detail/public-confirm views. +- Use label text `现场情况说明` for `issueDescription` in create/detail/public-confirm views. - In admin detail page, use `工单分配` as the UI label for reassign action. - Signature display text should be `客户确认签名`. diff --git a/README.md b/README.md index f38515e..906b721 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ VITE_API_BASE_URL=/api - 技术员创建工单、填写处理结果、提交客户确认 - 工单里的企业名称是售后客户信息,不会进入企业管理列表 - 服务类型:软件故障 / 硬件故障 / 售后维保 - - 新建和详情字段使用“问题描述反馈” + - 新建和详情字段使用“现场情况说明” - 管理员可进行工单分配(重新分配技术员)或强制关闭工单 - 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回 - 用户资料管理 diff --git a/src/App.tsx b/src/App.tsx index cdda7f1..b708289 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,9 @@ import EmployeeSerialsPage from './pages/EmployeeSerials'; import AftersalesPage from './pages/Aftersales'; import AftersalesDetailPage from './pages/AftersalesDetail'; import AftersalesConfirmPage from './pages/AftersalesConfirm'; +import ProjectOrdersPage from './pages/ProjectOrders'; +import ProjectOrderDetailPage from './pages/ProjectOrderDetail'; +import ProjectOrderCompletePage from './pages/ProjectOrderComplete'; const PrivateRoute = () => { const user = authApi.getCurrentUser(); @@ -48,6 +51,7 @@ function App() { } /> } /> } /> + } /> }> }> @@ -57,6 +61,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 67e916d..b58ad19 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -23,7 +23,12 @@ function AdminLayout() { const isTechnician = user?.role === 'technician'; useEffect(() => { - if (isTechnician && !location.pathname.includes('/aftersales') && !location.pathname.includes('/profile')) { + if ( + isTechnician && + !location.pathname.includes('/aftersales') && + !location.pathname.includes('/project-orders') && + !location.pathname.includes('/profile') + ) { navigate('/admin/aftersales', { replace: true }); } }, [isTechnician, location.pathname, navigate]); @@ -47,6 +52,12 @@ function AdminLayout() { label: '员工管理', onClick: () => navigate('/admin/employee-serials'), }, + { + key: 'project-orders', + icon: , + label: '项目工单', + onClick: () => navigate('/admin/project-orders'), + }, { key: 'aftersales', icon: , @@ -55,6 +66,12 @@ function AdminLayout() { }, ]; const technicianMenuItems = [ + { + key: 'project-orders', + icon: , + label: '项目工单', + onClick: () => navigate('/admin/project-orders'), + }, { key: 'aftersales', icon: , @@ -107,6 +124,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('/project-orders')) return 'project-orders'; if (path.includes('/aftersales')) return 'aftersales'; if (path.includes('/profile')) return 'profile'; return 'dashboard'; @@ -117,6 +135,7 @@ function AdminLayout() { if (path.includes('/dashboard')) return '控制台'; if (path.includes('/manage')) return '企业管理'; if (path.includes('/employee-serials')) return '员工管理'; + if (path.includes('/project-orders')) return '项目工单'; if (path.includes('/aftersales')) return '售后工单'; if (path.includes('/profile')) return '用户资料'; return '控制台'; diff --git a/src/pages/Aftersales.tsx b/src/pages/Aftersales.tsx index 1ba43c1..5fda2d1 100644 --- a/src/pages/Aftersales.tsx +++ b/src/pages/Aftersales.tsx @@ -369,8 +369,8 @@ function AftersalesPage() { diff --git a/src/pages/AftersalesConfirm.tsx b/src/pages/AftersalesConfirm.tsx index 9146317..f204502 100644 --- a/src/pages/AftersalesConfirm.tsx +++ b/src/pages/AftersalesConfirm.tsx @@ -288,7 +288,7 @@ function AftersalesConfirmPage() { {SERVICE_TYPE_LABEL[order.serviceType] || order.serviceType}
- 问题描述反馈 + 现场情况说明 {order.issueDescription}
{order.resolutionNote && ( diff --git a/src/pages/AftersalesDetail.tsx b/src/pages/AftersalesDetail.tsx index 3aa3e02..ff3d613 100644 --- a/src/pages/AftersalesDetail.tsx +++ b/src/pages/AftersalesDetail.tsx @@ -483,8 +483,8 @@ function AftersalesDetailPage() { @@ -616,7 +616,7 @@ function AftersalesDetailPage() { {formatDateTime(order.confirmedAt)} - 问题描述反馈 + 现场情况说明 {order.issueDescription || '-'} diff --git a/src/pages/ProjectOrderComplete.tsx b/src/pages/ProjectOrderComplete.tsx new file mode 100644 index 0000000..95a10a6 --- /dev/null +++ b/src/pages/ProjectOrderComplete.tsx @@ -0,0 +1,356 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, Button, Spin, Result, message, Input } from 'antd'; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ClockCircleOutlined, + EditOutlined, + UploadOutlined, +} from '@ant-design/icons'; +import { projectOrdersApi } from '@/services/api'; +import type { ProjectOrderPublicView, ProjectType } from '@/types'; +import PublicLayout, { PublicLogo } from '@/components/PublicLayout'; +import SignatureOverlay from '@/components/SignatureOverlay'; +import './styles/PublicQuery.css'; +import './styles/AftersalesConfirm.css'; + +const PROJECT_TYPE_LABEL: Record = { + survey: '现场勘查', + implementation: '现场实施', + maintenance: '项目维保', + other: '其他', +}; + +const SITE_IMAGE_MAX_EDGE = 1600; +const SITE_IMAGE_QUALITY = 0.78; + +async function compressSiteImage(file: File): Promise { + if (!file.type.startsWith('image/') || file.type === 'image/gif') { + return file; + } + + const imageUrl = URL.createObjectURL(file); + try { + const img = await loadImage(imageUrl); + const scale = Math.min(1, SITE_IMAGE_MAX_EDGE / Math.max(img.width, img.height)); + const width = Math.max(1, Math.round(img.width * scale)); + const height = Math.max(1, Math.round(img.height * scale)); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return file; + + ctx.drawImage(img, 0, 0, width, height); + const blob = await canvasToBlob(canvas, 'image/jpeg', SITE_IMAGE_QUALITY); + if (!blob || blob.size >= file.size) { + return file; + } + + const name = file.name.replace(/\.[^.]+$/, '') || 'site-image'; + return new File([blob], `${name}.jpg`, { + type: 'image/jpeg', + lastModified: Date.now(), + }); + } catch { + return file; + } finally { + URL.revokeObjectURL(imageUrl); + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +function canvasToBlob( + canvas: HTMLCanvasElement, + type: string, + quality: number, +): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, type, quality)); +} + +function ProjectOrderCompletePage() { + const { serialNumber = '' } = useParams<{ serialNumber: string }>(); + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [uploadingImages, setUploadingImages] = useState(false); + const [completionNote, setCompletionNote] = useState(''); + const [engineerSignature, setEngineerSignature] = useState(''); + const [signatureOpen, setSignatureOpen] = useState(false); + + const loadOrder = async () => { + setLoading(true); + setError(null); + try { + const data = await projectOrdersApi.publicQuery(serialNumber); + setOrder(data); + setCompletionNote(data.completionNote || ''); + } 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 handleUploadSiteImages = async (files: FileList | null) => { + if (!files || files.length === 0) return; + setUploadingImages(true); + try { + const compressedFiles = await Promise.all(Array.from(files).map(compressSiteImage)); + const images = await projectOrdersApi.uploadSiteImages(serialNumber, compressedFiles); + setOrder((prev) => (prev ? { ...prev, siteImages: images } : prev)); + message.success('现场图片上传成功'); + } catch (err: any) { + message.error(err?.response?.data?.message || err.message || '上传现场图片失败'); + } finally { + setUploadingImages(false); + } + }; + + const handleComplete = async () => { + if (!order) return; + if (!order.siteImages || order.siteImages.length === 0) { + message.error('请至少上传 1 张现场图片'); + return; + } + if (!completionNote.trim()) { + message.error('请填写完成说明'); + return; + } + if (!engineerSignature) { + message.error('请先完成工程师签名'); + return; + } + + setSubmitting(true); + try { + const updated = await projectOrdersApi.complete(serialNumber, { + completionNote: completionNote.trim(), + engineerSignature, + }); + setOrder(updated); + message.success('项目工单已完成'); + } catch (err: any) { + message.error(err?.response?.data?.message || err.message || '提交完成失败'); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return ( + + +
+ +

正在加载工单...

+
+
+
+ ); + } + + if (error || !order) { + return ( + + + } + title="工单不存在" + subTitle={error || '请检查您扫描的二维码是否正确'} + /> + + + ); + } + + const isClosed = order.workOrderStatus === 'closed'; + + return ( + + +
+ +

项目工单完成

+

{order.serialNumber}

+
+ + {isClosed ? ( + } + title="工单已完成" + subTitle={ + order.completedAt + ? `完成时间:${new Date(order.completedAt).toLocaleString('zh-CN')}` + : undefined + } + /> + ) : ( + } + title="待现场完成" + subTitle="请上传现场图片、填写完成说明并完成工程师签名" + /> + )} + +
+
+ 公司名称 + {order.companyName} +
+
+ 公司位置 + {order.companyAddress} +
+
+ 现场联系人 + {order.contactName} +
+
+ 项目类型 + {PROJECT_TYPE_LABEL[order.projectType] || order.projectType} +
+
+ 现场情况说明 + {order.siteDescription} +
+ {order.completionNote && ( +
+ 完成说明 + {order.completionNote} +
+ )} + {order.technicianName && ( +
+ 工程师 + {order.technicianName} +
+ )} +
+ 创建日期 + {new Date(order.createdAt).toLocaleString('zh-CN')} +
+ {order.siteImages && order.siteImages.length > 0 && ( +
+ 现场图片 +
+ {order.siteImages.map((url) => ( + + 现场图片 + + ))} +
+
+ )} +
+ + {isClosed && order.engineerSignature && ( +
+
+
+

工程师签名

+ 工程师签名 +
+
+
+ )} + + {!isClosed && ( + <> +
+

现场图片

+ +
+ +
+

完成说明

+ setCompletionNote(e.target.value)} + placeholder="请描述现场勘查、实施过程和最终完成情况" + /> +
+ +
+

工程师签名

+
+ {engineerSignature ? ( + 工程师签名 + ) : ( + 未签名 + )} + +
+
+ + + + )} +
+ + setSignatureOpen(false)} + onConfirm={(dataUrl) => { + setEngineerSignature(dataUrl); + setSignatureOpen(false); + }} + /> +
+ ); +} + +export default ProjectOrderCompletePage; diff --git a/src/pages/ProjectOrderDetail.tsx b/src/pages/ProjectOrderDetail.tsx new file mode 100644 index 0000000..b58ee76 --- /dev/null +++ b/src/pages/ProjectOrderDetail.tsx @@ -0,0 +1,660 @@ +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; +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 { projectOrdersApi, authApi, usersApi } from '@/services/api'; +import logo from '@/assets/img/logo.png?url'; +import type { + ProjectOrder, + ProjectType, + ProjectOrderStatus, + UpdateProjectOrderRequest, + User, +} from '@/types'; +import './styles/AftersalesDetail.css'; + +const PROJECT_TYPE_LABEL: Record = { + survey: '现场勘查', + implementation: '现场实施', + maintenance: '项目维保', + other: '其他', +}; + +const WORK_ORDER_STATUS_LABEL: Record = { + created: '待处理', + pending_completion: '待完成确认', + closed: '已完成', +}; + +const WORK_ORDER_STATUS_COLOR: Record = { + created: 'default', + pending_completion: 'processing', + closed: 'success', +}; + +function statusStepIndex(status: ProjectOrderStatus): number { + switch (status) { + case 'created': + return 0; + case 'pending_completion': + return 1; + case 'closed': + return 2; + } +} + +function formatDateTime(value?: string) { + if (!value) return '-'; + return new Date(value).toLocaleString('zh-CN'); +} + +function getProjectOrderPublicUrl(serialNumber: string) { + return `${window.location.origin}/project-orders/${serialNumber}`; +} + +function ProjectOrderDetailPage() { + 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 [electronicFormVisible, setElectronicFormVisible] = useState(false); + const [electronicFormQrCodeDataUrl, setElectronicFormQrCodeDataUrl] = useState(''); + + const [reassignModalVisible, setReassignModalVisible] = useState(false); + const [reassignTechnicianId, setReassignTechnicianId] = useState(); + const [assignableUsers, setAssignableUsers] = useState([]); + + const loadOrder = async () => { + setLoading(true); + try { + const data = await projectOrdersApi.get(serialNumber); + setOrder(data); + form.setFieldsValue({ + companyAddress: data.companyAddress, + contactName: data.contactName, + contactPhone: data.contactPhone, + projectType: data.projectType, + siteDescription: data.siteDescription, + completionNote: data.completionNote, + }); + } 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: UpdateProjectOrderRequest) => { + if (!order) return; + setSaving(true); + try { + const updated = await projectOrdersApi.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 completionNote = form.getFieldValue('completionNote')?.trim(); + if (!completionNote) { + message.error('请先填写完成说明'); + return; + } + Modal.confirm({ + title: '确认提交', + content: '提交后工单将进入"待完成确认"状态,工程师扫码确认后才能关闭工单。', + okText: '提交', + cancelText: '取消', + onOk: async () => { + setSubmitting(true); + try { + const updated = await projectOrdersApi.submit(order.serialNumber, completionNote); + 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 projectOrdersApi.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 openElectronicForm = async () => { + if (!order) return; + try { + const qrCode = await QRCode.toDataURL(getProjectOrderPublicUrl(order.serialNumber), { + width: 132, + margin: 1, + }); + setElectronicFormQrCodeDataUrl(qrCode); + setElectronicFormVisible(true); + } catch (err: any) { + message.error(err?.message || '生成电子表单二维码失败'); + } + }; + + 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(` + + + + ${order?.serialNumber || '项目完成表单'} + + + ${formNode.outerHTML} + + `); + 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 projectOrdersApi.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 projectOrdersApi.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_completion'; + const canSubmit = + !isClosed && + !isPendingCustomer && + (isAdmin || order.technicianId === currentUser?.id); + const canEdit = !isClosed && (isAdmin || order.technicianId === currentUser?.id); + + const stepStatus = isClosed ? 'finish' : 'process'; + + return ( +
+ + + + {isAdmin && !isClosed && ( + <> + + + + )} + + } + > + + + + + + {order.companyName} + + {order.technician?.name || '-'} + + {order.creator?.name || '-'} + + {WORK_ORDER_STATUS_LABEL[order.workOrderStatus]} + + + + {order.siteImages && order.siteImages.length > 0 && ( +
+
现场图片
+
+ {order.siteImages.map((url) => ( + + 现场图片 + + ))} +
+
+ )} + + {order.engineerSignature && ( +
+
+ 工程师完成签名 + {order.completedAt && ( + 签署时间:{new Date(order.completedAt).toLocaleString('zh-CN')} + )} +
+
+
+
工程师签名
+ 工程师签名 +
+
+
+ )} + +
+ + + + + + + + + + + + setReassignTechnicianId(v)} + showSearch + optionFilterProp="label" + options={assignableUsers.map((u) => ({ + value: u.id, + label: `${u.name}(${u.username})${u.role === 'admin' ? ' · 管理员' : ' · 技术员'}`, + }))} + /> + + + +
+ ); +} + +export default ProjectOrderDetailPage; diff --git a/src/pages/ProjectOrders.tsx b/src/pages/ProjectOrders.tsx new file mode 100644 index 0000000..167cb98 --- /dev/null +++ b/src/pages/ProjectOrders.tsx @@ -0,0 +1,385 @@ +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 { projectOrdersApi, authApi } from '@/services/api'; +import type { + ProjectOrder, + ProjectOrderStatus, + ProjectType, + CreateProjectOrderRequest, +} from '@/types'; + +const WORK_ORDER_STATUS_LABEL: Record = { + created: '待处理', + pending_completion: '待完成确认', + closed: '已完成', +}; + +const WORK_ORDER_STATUS_COLOR: Record = { + created: 'default', + pending_completion: 'processing', + closed: 'success', +}; + +const PROJECT_TYPE_LABEL: Record = { + survey: '现场勘查', + implementation: '现场实施', + maintenance: '项目维保', + other: '其他', +}; + +function ProjectOrdersPage() { + 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 [projectType, setProjectType] = 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 projectOrdersApi.list({ + page, + limit, + search: search || undefined, + workOrderStatus, + projectType, + 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, projectType, mineOnly]); + + const handleCreate = async (values: CreateProjectOrderRequest) => { + setCreating(true); + try { + const order = await projectOrdersApi.create(values); + message.success(`工单创建成功:${order.serialNumber}`); + setCreateModalVisible(false); + createForm.resetFields(); + navigate(`/admin/project-orders/${order.serialNumber}`); + } catch (err: any) { + message.error(err?.response?.data?.message || err.message || '创建失败'); + } finally { + setCreating(false); + } + }; + + const handleDelete = (order: ProjectOrder) => { + Modal.confirm({ + title: '确认删除', + content: `确定要删除工单 ${order.serialNumber} 吗?此操作不可恢复!`, + okText: '确定', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await projectOrdersApi.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: 'projectType', + key: 'projectType', + width: 100, + render: (type: ProjectType) => PROJECT_TYPE_LABEL[type] || type, + }, + { + title: '工程师', + key: 'technician', + width: 120, + render: (_: any, record: ProjectOrder) => record.technician?.name || '-', + }, + { + title: '工单状态', + dataIndex: 'workOrderStatus', + key: 'workOrderStatus', + width: 130, + render: (status: ProjectOrderStatus) => ( + + + {WORK_ORDER_STATUS_LABEL[status]} + + + ), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 170, + render: (date: string) => new Date(date).toLocaleString('zh-CN'), + }, + { + title: '操作', + key: 'actions', + width: 180, + render: (_: any, record: ProjectOrder) => ( + + + {isAdmin && ( + + )} + + ), + }, + ]; + + return ( +
+ + + 项目工单 + + } + extra={ + + } + > + + { + setPage(1); + setSearch(v); + }} + onChange={(e) => { + if (!e.target.value) { + setPage(1); + setSearch(''); + } + }} + /> + { + setPage(1); + setProjectType(v); + }} + options={(Object.keys(PROJECT_TYPE_LABEL) as ProjectType[]).map((k) => ({ + value: k, + label: PROJECT_TYPE_LABEL[k], + }))} + /> + {isAdmin && ( + + )} + + + +
+ { + setPage(newPage); + setLimit(newLimit); + }} + showSizeChanger + showTotal={(t) => `共计 ${t} 条记录`} + /> +
+ + + { + setCreateModalVisible(false); + createForm.resetFields(); + }} + footer={null} + width={560} + > +
+ + + + + + + + + + + + + +