feat: add project work order UI
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
Reference in New Issue
Block a user