feat: add project work order UI
This commit is contained in:
@@ -128,7 +128,7 @@ src/
|
|||||||
- `hardware`: 硬件故障
|
- `hardware`: 硬件故障
|
||||||
- `maintenance`: 售后维保
|
- `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.
|
- 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.
|
- In admin detail page, use `工单分配` as the UI label for reassign action.
|
||||||
- Signature display text should be `客户确认签名`.
|
- Signature display text should be `客户确认签名`.
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ VITE_API_BASE_URL=/api
|
|||||||
- 技术员创建工单、填写处理结果、提交客户确认
|
- 技术员创建工单、填写处理结果、提交客户确认
|
||||||
- 工单里的企业名称是售后客户信息,不会进入企业管理列表
|
- 工单里的企业名称是售后客户信息,不会进入企业管理列表
|
||||||
- 服务类型:软件故障 / 硬件故障 / 售后维保
|
- 服务类型:软件故障 / 硬件故障 / 售后维保
|
||||||
- 新建和详情字段使用“问题描述反馈”
|
- 新建和详情字段使用“现场情况说明”
|
||||||
- 管理员可进行工单分配(重新分配技术员)或强制关闭工单
|
- 管理员可进行工单分配(重新分配技术员)或强制关闭工单
|
||||||
- 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回
|
- 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回
|
||||||
- 用户资料管理
|
- 用户资料管理
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import EmployeeSerialsPage from './pages/EmployeeSerials';
|
|||||||
import AftersalesPage from './pages/Aftersales';
|
import AftersalesPage from './pages/Aftersales';
|
||||||
import AftersalesDetailPage from './pages/AftersalesDetail';
|
import AftersalesDetailPage from './pages/AftersalesDetail';
|
||||||
import AftersalesConfirmPage from './pages/AftersalesConfirm';
|
import AftersalesConfirmPage from './pages/AftersalesConfirm';
|
||||||
|
import ProjectOrdersPage from './pages/ProjectOrders';
|
||||||
|
import ProjectOrderDetailPage from './pages/ProjectOrderDetail';
|
||||||
|
import ProjectOrderCompletePage from './pages/ProjectOrderComplete';
|
||||||
|
|
||||||
const PrivateRoute = () => {
|
const PrivateRoute = () => {
|
||||||
const user = authApi.getCurrentUser();
|
const user = authApi.getCurrentUser();
|
||||||
@@ -48,6 +51,7 @@ function App() {
|
|||||||
} />
|
} />
|
||||||
<Route path="/query" element={<PublicQueryPage />} />
|
<Route path="/query" element={<PublicQueryPage />} />
|
||||||
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
|
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
|
||||||
|
<Route path="/project-orders/:serialNumber" element={<ProjectOrderCompletePage />} />
|
||||||
|
|
||||||
<Route element={<PrivateRoute />}>
|
<Route element={<PrivateRoute />}>
|
||||||
<Route element={<AdminRoutes />}>
|
<Route element={<AdminRoutes />}>
|
||||||
@@ -57,6 +61,8 @@ function App() {
|
|||||||
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
|
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
|
||||||
<Route path="/admin/aftersales" element={<AftersalesPage />} />
|
<Route path="/admin/aftersales" element={<AftersalesPage />} />
|
||||||
<Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} />
|
<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 path="/admin/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ function AdminLayout() {
|
|||||||
const isTechnician = user?.role === 'technician';
|
const isTechnician = user?.role === 'technician';
|
||||||
|
|
||||||
useEffect(() => {
|
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 });
|
navigate('/admin/aftersales', { replace: true });
|
||||||
}
|
}
|
||||||
}, [isTechnician, location.pathname, navigate]);
|
}, [isTechnician, location.pathname, navigate]);
|
||||||
@@ -47,6 +52,12 @@ function AdminLayout() {
|
|||||||
label: '员工管理',
|
label: '员工管理',
|
||||||
onClick: () => navigate('/admin/employee-serials'),
|
onClick: () => navigate('/admin/employee-serials'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'project-orders',
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
label: '项目工单',
|
||||||
|
onClick: () => navigate('/admin/project-orders'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'aftersales',
|
key: 'aftersales',
|
||||||
icon: <ToolOutlined />,
|
icon: <ToolOutlined />,
|
||||||
@@ -55,6 +66,12 @@ function AdminLayout() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const technicianMenuItems = [
|
const technicianMenuItems = [
|
||||||
|
{
|
||||||
|
key: 'project-orders',
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
label: '项目工单',
|
||||||
|
onClick: () => navigate('/admin/project-orders'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'aftersales',
|
key: 'aftersales',
|
||||||
icon: <ToolOutlined />,
|
icon: <ToolOutlined />,
|
||||||
@@ -107,6 +124,7 @@ function AdminLayout() {
|
|||||||
if (path.includes('/dashboard')) return 'dashboard';
|
if (path.includes('/dashboard')) return 'dashboard';
|
||||||
if (path.includes('/manage')) return 'manage';
|
if (path.includes('/manage')) return 'manage';
|
||||||
if (path.includes('/employee-serials')) return 'employee-serials';
|
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('/aftersales')) return 'aftersales';
|
||||||
if (path.includes('/profile')) return 'profile';
|
if (path.includes('/profile')) return 'profile';
|
||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
@@ -117,6 +135,7 @@ function AdminLayout() {
|
|||||||
if (path.includes('/dashboard')) return '控制台';
|
if (path.includes('/dashboard')) return '控制台';
|
||||||
if (path.includes('/manage')) return '企业管理';
|
if (path.includes('/manage')) return '企业管理';
|
||||||
if (path.includes('/employee-serials')) return '员工管理';
|
if (path.includes('/employee-serials')) return '员工管理';
|
||||||
|
if (path.includes('/project-orders')) return '项目工单';
|
||||||
if (path.includes('/aftersales')) return '售后工单';
|
if (path.includes('/aftersales')) return '售后工单';
|
||||||
if (path.includes('/profile')) return '用户资料';
|
if (path.includes('/profile')) return '用户资料';
|
||||||
return '控制台';
|
return '控制台';
|
||||||
|
|||||||
@@ -369,8 +369,8 @@ function AftersalesPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="issueDescription"
|
name="issueDescription"
|
||||||
label="问题描述反馈"
|
label="现场情况说明"
|
||||||
rules={[{ required: true, message: '请填写问题描述反馈' }]}
|
rules={[{ required: true, message: '请填写现场情况说明' }]}
|
||||||
>
|
>
|
||||||
<Input.TextArea rows={4} placeholder="请描述客户反馈的问题" />
|
<Input.TextArea rows={4} placeholder="请描述客户反馈的问题" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ function AftersalesConfirmPage() {
|
|||||||
<span className="value">{SERVICE_TYPE_LABEL[order.serviceType] || order.serviceType}</span>
|
<span className="value">{SERVICE_TYPE_LABEL[order.serviceType] || order.serviceType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-item detail-item-block">
|
<div className="detail-item detail-item-block">
|
||||||
<span className="label">问题描述反馈</span>
|
<span className="label">现场情况说明</span>
|
||||||
<span className="value value-block">{order.issueDescription}</span>
|
<span className="value value-block">{order.issueDescription}</span>
|
||||||
</div>
|
</div>
|
||||||
{order.resolutionNote && (
|
{order.resolutionNote && (
|
||||||
|
|||||||
@@ -483,8 +483,8 @@ function AftersalesDetailPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="issueDescription"
|
name="issueDescription"
|
||||||
label="问题描述反馈"
|
label="现场情况说明"
|
||||||
rules={[{ required: true, message: '请填写问题描述反馈' }]}
|
rules={[{ required: true, message: '请填写现场情况说明' }]}
|
||||||
>
|
>
|
||||||
<Input.TextArea rows={3} />
|
<Input.TextArea rows={3} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -616,7 +616,7 @@ function AftersalesDetailPage() {
|
|||||||
<td>{formatDateTime(order.confirmedAt)}</td>
|
<td>{formatDateTime(order.confirmedAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>问题描述反馈</th>
|
<th>现场情况说明</th>
|
||||||
<td colSpan={3} className="electronic-form-text">
|
<td colSpan={3} className="electronic-form-text">
|
||||||
{order.issueDescription || '-'}
|
{order.issueDescription || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -24,6 +24,7 @@ interface EmployeeSerialResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AFTERSALES_PREFIX = 'zjbf-sh-';
|
const AFTERSALES_PREFIX = 'zjbf-sh-';
|
||||||
|
const PROJECT_ORDER_PREFIX = 'zjbf-xm-';
|
||||||
|
|
||||||
function PublicQueryPage() {
|
function PublicQueryPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -35,12 +36,17 @@ function PublicQueryPage() {
|
|||||||
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
|
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
|
||||||
|
|
||||||
const isAftersalesSerial = (sn: string) => sn.toLowerCase().startsWith(AFTERSALES_PREFIX);
|
const isAftersalesSerial = (sn: string) => sn.toLowerCase().startsWith(AFTERSALES_PREFIX);
|
||||||
|
const isProjectOrderSerial = (sn: string) => sn.toLowerCase().startsWith(PROJECT_ORDER_PREFIX);
|
||||||
|
|
||||||
const performQuery = async (serialToQuery: string) => {
|
const performQuery = async (serialToQuery: string) => {
|
||||||
if (isAftersalesSerial(serialToQuery)) {
|
if (isAftersalesSerial(serialToQuery)) {
|
||||||
navigate(`/aftersales/${serialToQuery.toLowerCase()}`, { replace: true });
|
navigate(`/aftersales/${serialToQuery.toLowerCase()}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isProjectOrderSerial(serialToQuery)) {
|
||||||
|
navigate(`/project-orders/${serialToQuery.toLowerCase()}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -71,6 +77,10 @@ function PublicQueryPage() {
|
|||||||
navigate(`/aftersales/${serialFromUrl.toLowerCase()}`, { replace: true });
|
navigate(`/aftersales/${serialFromUrl.toLowerCase()}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isProjectOrderSerial(serialFromUrl)) {
|
||||||
|
navigate(`/project-orders/${serialFromUrl.toLowerCase()}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSerialNumber(serialFromUrl);
|
setSerialNumber(serialFromUrl);
|
||||||
setShowResult(true);
|
setShowResult(true);
|
||||||
performQuery(serialFromUrl);
|
performQuery(serialFromUrl);
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import type {
|
|||||||
CreateAftersalesRequest,
|
CreateAftersalesRequest,
|
||||||
UpdateAftersalesRequest,
|
UpdateAftersalesRequest,
|
||||||
CustomerConfirmRequest,
|
CustomerConfirmRequest,
|
||||||
|
ProjectOrder,
|
||||||
|
ProjectOrderPublicView,
|
||||||
|
ProjectOrderListFilter,
|
||||||
|
ProjectOrderListResponse,
|
||||||
|
CreateProjectOrderRequest,
|
||||||
|
UpdateProjectOrderRequest,
|
||||||
|
ProjectEngineerCompleteRequest,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
UpdateUserRequest,
|
UpdateUserRequest,
|
||||||
UserListFilter,
|
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 = {
|
export const usersApi = {
|
||||||
assignable: async () => {
|
assignable: async () => {
|
||||||
const response = await apiClient.get('/users/assignable');
|
const response = await apiClient.get('/users/assignable');
|
||||||
|
|||||||
@@ -264,3 +264,85 @@ export interface CustomerConfirmRequest {
|
|||||||
responsibleSignature?: string;
|
responsibleSignature?: string;
|
||||||
rejectReason?: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user