feat: add project work order UI

This commit is contained in:
Frudrax Cheng
2026-06-04 10:26:05 +08:00
parent eafe55bef9
commit d8d305c051
13 changed files with 1653 additions and 9 deletions
+1 -1
View File
@@ -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 `客户确认签名`.
+1 -1
View File
@@ -117,7 +117,7 @@ VITE_API_BASE_URL=/api
- 技术员创建工单、填写处理结果、提交客户确认
- 工单里的企业名称是售后客户信息,不会进入企业管理列表
- 服务类型:软件故障 / 硬件故障 / 售后维保
- 新建和详情字段使用“问题描述反馈
- 新建和详情字段使用“现场情况说明
- 管理员可进行工单分配(重新分配技术员)或强制关闭工单
- 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回
- 用户资料管理
+6
View File
@@ -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() {
} />
<Route path="/query" element={<PublicQueryPage />} />
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
<Route path="/project-orders/:serialNumber" element={<ProjectOrderCompletePage />} />
<Route element={<PrivateRoute />}>
<Route element={<AdminRoutes />}>
@@ -57,6 +61,8 @@ function App() {
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
<Route path="/admin/aftersales" element={<AftersalesPage />} />
<Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} />
<Route path="/admin/project-orders" element={<ProjectOrdersPage />} />
<Route path="/admin/project-orders/:serialNumber" element={<ProjectOrderDetailPage />} />
<Route path="/admin/profile" element={<ProfilePage />} />
</Route>
</Route>
+20 -1
View File
@@ -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: <ToolOutlined />,
label: '项目工单',
onClick: () => navigate('/admin/project-orders'),
},
{
key: 'aftersales',
icon: <ToolOutlined />,
@@ -55,6 +66,12 @@ function AdminLayout() {
},
];
const technicianMenuItems = [
{
key: 'project-orders',
icon: <ToolOutlined />,
label: '项目工单',
onClick: () => navigate('/admin/project-orders'),
},
{
key: 'aftersales',
icon: <ToolOutlined />,
@@ -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 '控制台';
+2 -2
View File
@@ -369,8 +369,8 @@ function AftersalesPage() {
</Form.Item>
<Form.Item
name="issueDescription"
label="问题描述反馈"
rules={[{ required: true, message: '请填写问题描述反馈' }]}
label="现场情况说明"
rules={[{ required: true, message: '请填写现场情况说明' }]}
>
<Input.TextArea rows={4} placeholder="请描述客户反馈的问题" />
</Form.Item>
+1 -1
View File
@@ -288,7 +288,7 @@ function AftersalesConfirmPage() {
<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="label"></span>
<span className="value value-block">{order.issueDescription}</span>
</div>
{order.resolutionNote && (
+3 -3
View File
@@ -483,8 +483,8 @@ function AftersalesDetailPage() {
</Space>
<Form.Item
name="issueDescription"
label="问题描述反馈"
rules={[{ required: true, message: '请填写问题描述反馈' }]}
label="现场情况说明"
rules={[{ required: true, message: '请填写现场情况说明' }]}
>
<Input.TextArea rows={3} />
</Form.Item>
@@ -616,7 +616,7 @@ function AftersalesDetailPage() {
<td>{formatDateTime(order.confirmedAt)}</td>
</tr>
<tr>
<th></th>
<th></th>
<td colSpan={3} className="electronic-form-text">
{order.issueDescription || '-'}
</td>
+356
View File
@@ -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<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
other: '其他',
};
const SITE_IMAGE_MAX_EDGE = 1600;
const SITE_IMAGE_QUALITY = 0.78;
async function compressSiteImage(file: File): Promise<File> {
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<HTMLImageElement> {
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<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}
function ProjectOrderCompletePage() {
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
const [order, setOrder] = useState<ProjectOrderPublicView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<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 isClosed = order.workOrderStatus === 'closed';
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>
{isClosed ? (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '64px' }} />}
title="工单已完成"
subTitle={
order.completedAt
? `完成时间:${new Date(order.completedAt).toLocaleString('zh-CN')}`
: undefined
}
/>
) : (
<Result
icon={<ClockCircleOutlined style={{ color: '#1677ff', fontSize: '64px' }} />}
title="待现场完成"
subTitle="请上传现场图片、填写完成说明并完成工程师签名"
/>
)}
<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">{PROJECT_TYPE_LABEL[order.projectType] || order.projectType}</span>
</div>
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.siteDescription}</span>
</div>
{order.completionNote && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.completionNote}</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>
{order.siteImages && order.siteImages.length > 0 && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<div className="aftersales-site-images">
{order.siteImages.map((url) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img src={url} alt="现场图片" />
</a>
))}
</div>
</div>
)}
</div>
{isClosed && order.engineerSignature && (
<div className="aftersales-signature-archived">
<div className="aftersales-signature-grid">
<div className="aftersales-signature-archived-item">
<p className="aftersales-signature-tip"></p>
<img
src={order.engineerSignature}
alt="工程师签名"
className="aftersales-signature-archived-img"
/>
</div>
</div>
</div>
)}
{!isClosed && (
<>
<div className="aftersales-upload-section">
<p className="aftersales-signature-section-title"></p>
<label className="aftersales-upload-trigger">
<UploadOutlined />
<span>{uploadingImages ? '上传中...' : '上传现场图片'}</span>
<small> 6 </small>
<input
type="file"
accept="image/*"
multiple
disabled={uploadingImages}
onChange={(e) => {
handleUploadSiteImages(e.target.files);
e.currentTarget.value = '';
}}
/>
</label>
</div>
<div className="aftersales-signature-section">
<p className="aftersales-signature-section-title"></p>
<Input.TextArea
rows={4}
value={completionNote}
onChange={(e) => setCompletionNote(e.target.value)}
placeholder="请描述现场勘查、实施过程和最终完成情况"
/>
</div>
<div className="aftersales-signature-section">
<p className="aftersales-signature-section-title"></p>
<div className="aftersales-signature-card">
{engineerSignature ? (
<img
src={engineerSignature}
alt="工程师签名"
className="aftersales-signature-preview"
/>
) : (
<span className="aftersales-signature-empty"></span>
)}
<Button icon={<EditOutlined />} onClick={() => setSignatureOpen(true)}>
{engineerSignature ? '重签' : '签名'}
</Button>
</div>
</div>
<Button
type="primary"
size="large"
block
icon={<CheckCircleOutlined />}
loading={submitting}
onClick={handleComplete}
>
</Button>
</>
)}
</Card>
<SignatureOverlay
open={signatureOpen}
title="工程师签名"
onCancel={() => setSignatureOpen(false)}
onConfirm={(dataUrl) => {
setEngineerSignature(dataUrl);
setSignatureOpen(false);
}}
/>
</PublicLayout>
);
}
export default ProjectOrderCompletePage;
+660
View File
@@ -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<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
other: '其他',
};
const WORK_ORDER_STATUS_LABEL: Record<ProjectOrderStatus, string> = {
created: '待处理',
pending_completion: '待完成确认',
closed: '已完成',
};
const WORK_ORDER_STATUS_COLOR: Record<ProjectOrderStatus, string> = {
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<ProjectOrder | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const [qrModalVisible, setQrModalVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [qrUrl, setQrUrl] = useState('');
const [electronicFormVisible, setElectronicFormVisible] = useState(false);
const [electronicFormQrCodeDataUrl, setElectronicFormQrCodeDataUrl] = useState('');
const [reassignModalVisible, setReassignModalVisible] = useState(false);
const [reassignTechnicianId, setReassignTechnicianId] = useState<number | undefined>();
const [assignableUsers, setAssignableUsers] = useState<User[]>([]);
const loadOrder = async () => {
setLoading(true);
try {
const data = await 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(`
<!doctype html>
<html>
<head>
<title>${order?.serialNumber || '项目完成表单'}</title>
<style>
body { margin: 24px; color: #111827; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.aftersales-electronic-form { max-width: 1080px; margin: 0 auto; }
.electronic-form-header { display: grid; grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr); align-items: center; gap: 20px; margin-bottom: 16px; }
.electronic-form-brand { justify-self: start; }
.electronic-form-logo { height: 34px; object-fit: contain; }
.electronic-form-title { justify-self: center; text-align: center; font-size: 20px; font-weight: 700; }
.electronic-form-meta { justify-self: end; text-align: center; }
.electronic-form-qr { width: 82px; height: 82px; object-fit: contain; }
.electronic-form-hotline { margin: 6px 0 0; font-size: 12px; font-weight: 600; white-space: nowrap; }
.electronic-form-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
.electronic-form-table th, .electronic-form-table td { border: 1px solid #1f2937; padding: 9px 10px; vertical-align: top; word-break: break-word; }
.electronic-form-table th { width: 120px; background: #f3f4f6; text-align: left; font-weight: 600; }
.electronic-form-table td { min-height: 24px; }
.electronic-form-table .electronic-form-code { color: #165dff; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 16px; font-weight: 700; }
.electronic-form-text { min-height: 72px; white-space: pre-wrap; }
.electronic-form-signatures { display: flex; justify-content: space-between; gap: 48px; margin-top: 28px; }
.electronic-form-signature-box { flex: 1; display: flex; align-items: center; min-width: 0; }
.electronic-form-signature-title { flex: 0 0 auto; margin: 0 10px 0 0; font-weight: 600; white-space: nowrap; }
.electronic-form-signature-stage { flex: 1; height: 72px; min-width: 160px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #1f2937; }
.electronic-form-signature-img { max-width: 180px; max-height: 68px; object-fit: contain; }
</style>
</head>
<body>${formNode.outerHTML}</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => printWindow.print(), 300);
};
const handleForceClose = () => {
if (!order) return;
Modal.confirm({
title: '确认完成工单',
content: '确认后工单状态将变为"已完成",且不可恢复。',
okText: '确认完成',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const updated = await 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 (
<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_completion';
const canSubmit =
!isClosed &&
!isPendingCustomer &&
(isAdmin || order.technicianId === currentUser?.id);
const canEdit = !isClosed && (isAdmin || order.technicianId === currentUser?.id);
const stepStatus = isClosed ? 'finish' : 'process';
return (
<div>
<Card
title={
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/admin/project-orders')}
/>
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>
{order.serialNumber}
</span>
<Tag color={WORK_ORDER_STATUS_COLOR[order.workOrderStatus]}>
{WORK_ORDER_STATUS_LABEL[order.workOrderStatus]}
</Tag>
</Space>
}
extra={
<Space>
<Button icon={<FileTextOutlined />} onClick={openElectronicForm}>
</Button>
<Button icon={<QrcodeOutlined />} onClick={handleGenerateQrCode}>
</Button>
{isAdmin && !isClosed && (
<>
<Button icon={<UserSwitchOutlined />} onClick={openReassign}>
</Button>
<Button danger icon={<StopOutlined />} onClick={handleForceClose}>
</Button>
</>
)}
</Space>
}
>
<Steps
current={statusStepIndex(order.workOrderStatus)}
status={stepStatus}
items={[
{ title: '工单创建', description: new Date(order.createdAt).toLocaleString('zh-CN') },
{
title: '待完成确认',
description: order.scannedAt
? `工程师扫码 ${new Date(order.scannedAt).toLocaleString('zh-CN')}`
: undefined,
},
{
title: '工单完成',
description: order.completedAt
? new Date(order.completedAt).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="完成状态">
{WORK_ORDER_STATUS_LABEL[order.workOrderStatus]}
</Descriptions.Item>
</Descriptions>
{order.siteImages && order.siteImages.length > 0 && (
<div className="aftersales-detail-site-images">
<div className="aftersales-detail-section-title"></div>
<div className="aftersales-detail-site-image-grid">
{order.siteImages.map((url) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img src={url} alt="现场图片" />
</a>
))}
</div>
</div>
)}
{order.engineerSignature && (
<div className="aftersales-detail-signatures">
<div className="aftersales-detail-section-title">
{order.completedAt && (
<span>{new Date(order.completedAt).toLocaleString('zh-CN')}</span>
)}
</div>
<div className="aftersales-detail-signature-grid">
<div className="aftersales-detail-signature-card">
<div className="aftersales-detail-signature-label"></div>
<img src={order.engineerSignature} alt="工程师签名" />
</div>
</div>
</div>
)}
<Form form={form} layout="vertical" onFinish={handleSave} disabled={!canEdit}>
<Form.Item
name="companyAddress"
label="公司位置"
rules={[{ required: true, message: '请输入公司位置' }]}
>
<Input />
</Form.Item>
<Space style={{ display: 'flex' }} size={16}>
<Form.Item
name="contactName"
label="现场联系人"
rules={[{ required: true, message: '请输入现场联系人' }]}
style={{ flex: 1 }}
>
<Input />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话"
rules={[
{ required: true, message: '请输入联系电话' },
{ pattern: /^\d{11}$/, message: '请输入 11 位手机号' },
]}
style={{ flex: 1 }}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item
name="projectType"
label="项目类型"
rules={[{ required: true, message: '请选择项目类型' }]}
style={{ flex: 1 }}
>
<Select
options={(Object.keys(PROJECT_TYPE_LABEL) as ProjectType[]).map((k) => ({
value: k,
label: PROJECT_TYPE_LABEL[k],
}))}
/>
</Form.Item>
</Space>
<Form.Item
name="siteDescription"
label="现场情况说明"
rules={[{ required: true, message: '请填写现场情况说明' }]}
>
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item
name="completionNote"
label="完成说明"
tooltip="现场完成后填写,扫码页和电子表单会展示此内容"
>
<Input.TextArea rows={4} placeholder="请描述现场勘查、实施过程和最终完成情况" />
</Form.Item>
{canEdit && (
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
{canSubmit && (
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmitForConfirmation}
>
</Button>
)}
</Space>
</Form.Item>
)}
</Form>
</Card>
<Modal
title="项目工单二维码"
open={qrModalVisible}
onCancel={() => setQrModalVisible(false)}
footer={[
<Button key="download" onClick={handleDownloadQR}>
</Button>,
<Button key="close" type="primary" onClick={() => setQrModalVisible(false)}>
</Button>,
]}
>
<div style={{ textAlign: 'center' }}>
{qrCodeDataUrl && (
<>
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: 220, height: 220 }} />
<p
style={{
marginTop: 12,
fontFamily: 'monospace',
fontSize: 16,
fontWeight: 'bold',
color: '#165DFF',
}}
>
{order.serialNumber}
</p>
<p style={{ fontSize: 12, color: '#888', wordBreak: 'break-all' }}>{qrUrl}</p>
</>
)}
</div>
</Modal>
<Modal
title="项目完成表单"
open={electronicFormVisible}
onCancel={() => setElectronicFormVisible(false)}
width={960}
footer={[
<Button key="print" icon={<PrinterOutlined />} onClick={handlePrintElectronicForm}>
</Button>,
<Button key="close" type="primary" onClick={() => setElectronicFormVisible(false)}>
</Button>,
]}
>
<div className="aftersales-electronic-form">
<div className="electronic-form-header">
<div className="electronic-form-brand">
<img src={logo} alt="浙江贝凡" className="electronic-form-logo" />
</div>
<div className="electronic-form-title"></div>
<div className="electronic-form-meta">
{electronicFormQrCodeDataUrl && (
<img
src={electronicFormQrCodeDataUrl}
alt="项目码二维码"
className="electronic-form-qr"
/>
)}
<p className="electronic-form-hotline"></p>
</div>
</div>
<table className="electronic-form-table">
<tbody>
<tr>
<th></th>
<td className="electronic-form-code">{order.serialNumber}</td>
<th></th>
<td>{PROJECT_TYPE_LABEL[order.projectType]}</td>
</tr>
<tr>
<th></th>
<td>{order.companyName}</td>
<th></th>
<td>{order.companyAddress}</td>
</tr>
<tr>
<th></th>
<td>{order.contactName}</td>
<th></th>
<td>{order.contactPhone}</td>
</tr>
<tr>
<th></th>
<td>{order.technician?.name || '-'}</td>
<th></th>
<td>{order.creator?.name || '-'}</td>
</tr>
<tr>
<th></th>
<td>{WORK_ORDER_STATUS_LABEL[order.workOrderStatus]}</td>
<th></th>
<td>{formatDateTime(order.completedAt)}</td>
</tr>
<tr>
<th></th>
<td colSpan={3} className="electronic-form-text">
{order.siteDescription || '-'}
</td>
</tr>
<tr>
<th></th>
<td colSpan={3} className="electronic-form-text">
{order.completionNote || '-'}
</td>
</tr>
</tbody>
</table>
<div className="electronic-form-signatures">
<div className="electronic-form-signature-box">
<p className="electronic-form-signature-title"></p>
<div className="electronic-form-signature-stage">
{order.engineerSignature ? (
<img
src={order.engineerSignature}
alt="工程师签名"
className="electronic-form-signature-img"
/>
) : null}
</div>
</div>
</div>
</div>
</Modal>
<Modal
title="工单分配"
open={reassignModalVisible}
onCancel={() => {
setReassignModalVisible(false);
setReassignTechnicianId(undefined);
}}
onOk={handleReassign}
okText="确认"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="选择技术员" required>
<Select
placeholder="请选择技术员或管理员"
value={reassignTechnicianId}
onChange={(v) => setReassignTechnicianId(v)}
showSearch
optionFilterProp="label"
options={assignableUsers.map((u) => ({
value: u.id,
label: `${u.name}${u.username}${u.role === 'admin' ? ' · 管理员' : ' · 技术员'}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default ProjectOrderDetailPage;
+385
View File
@@ -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<ProjectOrderStatus, string> = {
created: '待处理',
pending_completion: '待完成确认',
closed: '已完成',
};
const WORK_ORDER_STATUS_COLOR: Record<ProjectOrderStatus, string> = {
created: 'default',
pending_completion: 'processing',
closed: 'success',
};
const PROJECT_TYPE_LABEL: Record<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
other: '其他',
};
function ProjectOrdersPage() {
const navigate = useNavigate();
const currentUser = authApi.getCurrentUser();
const isAdmin = currentUser?.role === 'admin';
const [orders, setOrders] = useState<ProjectOrder[]>([]);
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<ProjectOrderStatus | undefined>();
const [projectType, setProjectType] = useState<ProjectType | undefined>();
const [mineOnly, setMineOnly] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateProjectOrderRequest>();
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) => (
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>{sn}</span>
),
},
{
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) => (
<Space size={4}>
<Tag color={WORK_ORDER_STATUS_COLOR[status]}>
{WORK_ORDER_STATUS_LABEL[status]}
</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: ProjectOrder) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/admin/project-orders/${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 ProjectOrderStatus[]).map(
(k) => ({ value: k, label: WORK_ORDER_STATUS_LABEL[k] })
)}
/>
<Select
placeholder="项目类型"
allowClear
style={{ width: 130 }}
value={projectType}
onChange={(v) => {
setPage(1);
setProjectType(v);
}}
options={(Object.keys(PROJECT_TYPE_LABEL) as ProjectType[]).map((k) => ({
value: k,
label: PROJECT_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="projectType"
label="项目类型"
rules={[{ required: true, message: '请选择项目类型' }]}
>
<Select
placeholder="请选择"
options={(Object.keys(PROJECT_TYPE_LABEL) as ProjectType[]).map((k) => ({
value: k,
label: PROJECT_TYPE_LABEL[k],
}))}
/>
</Form.Item>
<Form.Item
name="siteDescription"
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 ProjectOrdersPage;
+10
View File
@@ -24,6 +24,7 @@ interface EmployeeSerialResult {
}
const AFTERSALES_PREFIX = 'zjbf-sh-';
const PROJECT_ORDER_PREFIX = 'zjbf-xm-';
function PublicQueryPage() {
const navigate = useNavigate();
@@ -35,12 +36,17 @@ function PublicQueryPage() {
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
const isAftersalesSerial = (sn: string) => sn.toLowerCase().startsWith(AFTERSALES_PREFIX);
const isProjectOrderSerial = (sn: string) => sn.toLowerCase().startsWith(PROJECT_ORDER_PREFIX);
const performQuery = async (serialToQuery: string) => {
if (isAftersalesSerial(serialToQuery)) {
navigate(`/aftersales/${serialToQuery.toLowerCase()}`, { replace: true });
return;
}
if (isProjectOrderSerial(serialToQuery)) {
navigate(`/project-orders/${serialToQuery.toLowerCase()}`, { replace: true });
return;
}
setLoading(true);
setError(null);
@@ -71,6 +77,10 @@ function PublicQueryPage() {
navigate(`/aftersales/${serialFromUrl.toLowerCase()}`, { replace: true });
return;
}
if (isProjectOrderSerial(serialFromUrl)) {
navigate(`/project-orders/${serialFromUrl.toLowerCase()}`, { replace: true });
return;
}
setSerialNumber(serialFromUrl);
setShowResult(true);
performQuery(serialFromUrl);
+126
View File
@@ -10,6 +10,13 @@ import type {
CreateAftersalesRequest,
UpdateAftersalesRequest,
CustomerConfirmRequest,
ProjectOrder,
ProjectOrderPublicView,
ProjectOrderListFilter,
ProjectOrderListResponse,
CreateProjectOrderRequest,
UpdateProjectOrderRequest,
ProjectEngineerCompleteRequest,
CreateUserRequest,
UpdateUserRequest,
UserListFilter,
@@ -460,6 +467,125 @@ export const aftersalesApi = {
},
};
export const projectOrdersApi = {
create: async (data: CreateProjectOrderRequest) => {
const response = await apiClient.post('/project-orders', data);
if (response.data.order) {
return response.data.order as ProjectOrder;
}
throw new Error(response.data.error || '创建项目工单失败');
},
list: async (filter?: ProjectOrderListFilter) => {
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?.projectType) params.append('serviceType', filter.projectType);
if (filter?.technicianId) params.append('technicianId', String(filter.technicianId));
if (filter?.mine) params.append('mine', 'true');
const url = params.toString() ? `/project-orders?${params.toString()}` : '/project-orders';
const response = await apiClient.get(url);
if (response.data) {
return response.data as ProjectOrderListResponse;
}
throw new Error('获取项目工单列表失败');
},
get: async (serialNumber: string) => {
const response = await apiClient.get(`/project-orders/${encodeURIComponent(serialNumber)}`);
if (response.data.order) {
return response.data.order as ProjectOrder;
}
throw new Error(response.data.error || '查询项目工单失败');
},
update: async (serialNumber: string, data: UpdateProjectOrderRequest) => {
const response = await apiClient.patch(`/project-orders/${encodeURIComponent(serialNumber)}`, data);
if (response.data.order) {
return response.data.order as ProjectOrder;
}
throw new Error(response.data.error || '更新项目工单失败');
},
submit: async (serialNumber: string, completionNote: string) => {
const response = await apiClient.post(`/project-orders/${encodeURIComponent(serialNumber)}/submit`, {
completionNote,
});
if (response.data.order) {
return response.data.order as ProjectOrder;
}
throw new Error(response.data.error || '提交完成资料失败');
},
generateQrCode: async (serialNumber: string, baseUrl?: string) => {
const response = await apiClient.post(`/project-orders/${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(`/project-orders/${encodeURIComponent(serialNumber)}/reassign`, {
technicianId,
});
if (response.data.order) {
return response.data.order as ProjectOrder;
}
throw new Error(response.data.error || '重新分配失败');
},
forceClose: async (serialNumber: string) => {
const response = await apiClient.post(`/project-orders/${encodeURIComponent(serialNumber)}/force-close`);
if (response.data.order) {
return response.data.order as ProjectOrder;
}
throw new Error(response.data.error || '确认完成失败');
},
delete: async (serialNumber: string) => {
const response = await apiClient.delete(`/project-orders/${encodeURIComponent(serialNumber)}`);
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '删除项目工单失败');
},
publicQuery: async (serialNumber: string) => {
const response = await apiClient.get(`/project-orders/${encodeURIComponent(serialNumber)}/query`);
if (response.data.order) {
return response.data.order as ProjectOrderPublicView;
}
throw new Error(response.data.error || '查询项目工单失败');
},
complete: async (serialNumber: string, data: ProjectEngineerCompleteRequest) => {
const response = await apiClient.post(`/project-orders/${encodeURIComponent(serialNumber)}/complete`, data);
if (response.data.order) {
return response.data.order as ProjectOrderPublicView;
}
throw new Error(response.data.error || '提交完成失败');
},
uploadSiteImages: async (serialNumber: string, files: File[]) => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
const response = await apiClient.post(
`/project-orders/${encodeURIComponent(serialNumber)}/site-images`,
formData,
);
if (response.data.siteImages) {
return response.data.siteImages as string[];
}
throw new Error(response.data.error || '上传现场图片失败');
},
};
export const usersApi = {
assignable: async () => {
const response = await apiClient.get('/users/assignable');
+82
View File
@@ -264,3 +264,85 @@ export interface CustomerConfirmRequest {
responsibleSignature?: string;
rejectReason?: string;
}
export type ProjectType = 'survey' | 'implementation' | 'maintenance' | 'other';
export type ProjectOrderStatus = 'created' | 'pending_completion' | 'closed';
export interface ProjectOrder {
id: number;
serialNumber: string;
companyName: string;
companyAddress: string;
contactName: string;
contactPhone: string;
projectType: ProjectType;
siteDescription: string;
completionNote: string;
workOrderStatus: ProjectOrderStatus;
technicianId?: number;
createdBy?: number;
scannedAt?: string;
completedAt?: string;
engineerSignature?: string;
siteImages?: string[];
createdAt: string;
updatedAt: string;
technician?: User;
creator?: User;
}
export interface ProjectOrderPublicView {
serialNumber: string;
companyName: string;
companyAddress: string;
contactName: string;
projectType: ProjectType;
siteDescription: string;
completionNote: string;
workOrderStatus: ProjectOrderStatus;
technicianName: string;
createdAt: string;
completedAt?: string;
engineerSignature?: string;
siteImages?: string[];
}
export interface CreateProjectOrderRequest {
companyName: string;
companyAddress: string;
contactName: string;
contactPhone: string;
projectType: ProjectType;
siteDescription: string;
technicianId?: number;
}
export interface UpdateProjectOrderRequest {
companyAddress?: string;
contactName?: string;
contactPhone?: string;
projectType?: ProjectType;
siteDescription?: string;
completionNote?: string;
technicianId?: number;
}
export interface ProjectOrderListFilter {
page?: number;
limit?: number;
search?: string;
workOrderStatus?: ProjectOrderStatus;
projectType?: ProjectType;
technicianId?: number;
mine?: boolean;
}
export interface ProjectOrderListResponse {
data: ProjectOrder[];
pagination: EmployeeSerialPagination;
}
export interface ProjectEngineerCompleteRequest {
engineerSignature: string;
completionNote?: string;
}