feat: add project work order UI

This commit is contained in:
Frudrax Cheng
2026-06-04 10:26:05 +08:00
parent eafe55bef9
commit d8d305c051
13 changed files with 1653 additions and 9 deletions
+356
View File
@@ -0,0 +1,356 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Button, Spin, Result, message, Input } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
EditOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { projectOrdersApi } from '@/services/api';
import type { ProjectOrderPublicView, ProjectType } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import SignatureOverlay from '@/components/SignatureOverlay';
import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css';
const PROJECT_TYPE_LABEL: Record<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
other: '其他',
};
const SITE_IMAGE_MAX_EDGE = 1600;
const SITE_IMAGE_QUALITY = 0.78;
async function compressSiteImage(file: File): Promise<File> {
if (!file.type.startsWith('image/') || file.type === 'image/gif') {
return file;
}
const imageUrl = URL.createObjectURL(file);
try {
const img = await loadImage(imageUrl);
const scale = Math.min(1, SITE_IMAGE_MAX_EDGE / Math.max(img.width, img.height));
const width = Math.max(1, Math.round(img.width * scale));
const height = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(img, 0, 0, width, height);
const blob = await canvasToBlob(canvas, 'image/jpeg', SITE_IMAGE_QUALITY);
if (!blob || blob.size >= file.size) {
return file;
}
const name = file.name.replace(/\.[^.]+$/, '') || 'site-image';
return new File([blob], `${name}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
} catch {
return file;
} finally {
URL.revokeObjectURL(imageUrl);
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}
function ProjectOrderCompletePage() {
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
const [order, setOrder] = useState<ProjectOrderPublicView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [uploadingImages, setUploadingImages] = useState(false);
const [completionNote, setCompletionNote] = useState('');
const [engineerSignature, setEngineerSignature] = useState('');
const [signatureOpen, setSignatureOpen] = useState(false);
const loadOrder = async () => {
setLoading(true);
setError(null);
try {
const data = await projectOrdersApi.publicQuery(serialNumber);
setOrder(data);
setCompletionNote(data.completionNote || '');
} catch (err: any) {
setError(err?.response?.data?.message || err.message || '查询失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (serialNumber) {
loadOrder();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialNumber]);
const handleUploadSiteImages = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploadingImages(true);
try {
const compressedFiles = await Promise.all(Array.from(files).map(compressSiteImage));
const images = await projectOrdersApi.uploadSiteImages(serialNumber, compressedFiles);
setOrder((prev) => (prev ? { ...prev, siteImages: images } : prev));
message.success('现场图片上传成功');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '上传现场图片失败');
} finally {
setUploadingImages(false);
}
};
const handleComplete = async () => {
if (!order) return;
if (!order.siteImages || order.siteImages.length === 0) {
message.error('请至少上传 1 张现场图片');
return;
}
if (!completionNote.trim()) {
message.error('请填写完成说明');
return;
}
if (!engineerSignature) {
message.error('请先完成工程师签名');
return;
}
setSubmitting(true);
try {
const updated = await projectOrdersApi.complete(serialNumber, {
completionNote: completionNote.trim(),
engineerSignature,
});
setOrder(updated);
message.success('项目工单已完成');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交完成失败');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<PublicLayout>
<Card className="query-card" bordered={false}>
<div className="loading-container">
<Spin size="large" />
<p>...</p>
</div>
</Card>
</PublicLayout>
);
}
if (error || !order) {
return (
<PublicLayout>
<Card className="query-card" bordered={false}>
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title="工单不存在"
subTitle={error || '请检查您扫描的二维码是否正确'}
/>
</Card>
</PublicLayout>
);
}
const isClosed = order.workOrderStatus === 'closed';
return (
<PublicLayout>
<Card className="query-card aftersales-confirm-card" bordered={false}>
<div className="query-header">
<PublicLogo />
<h1 className="aftersales-title"></h1>
<p className="aftersales-serial">{order.serialNumber}</p>
</div>
{isClosed ? (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '64px' }} />}
title="工单已完成"
subTitle={
order.completedAt
? `完成时间:${new Date(order.completedAt).toLocaleString('zh-CN')}`
: undefined
}
/>
) : (
<Result
icon={<ClockCircleOutlined style={{ color: '#1677ff', fontSize: '64px' }} />}
title="待现场完成"
subTitle="请上传现场图片、填写完成说明并完成工程师签名"
/>
)}
<div className="result-details aftersales-details">
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.companyName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.companyAddress}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.contactName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{PROJECT_TYPE_LABEL[order.projectType] || order.projectType}</span>
</div>
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.siteDescription}</span>
</div>
{order.completionNote && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.completionNote}</span>
</div>
)}
{order.technicianName && (
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.technicianName}</span>
</div>
)}
<div className="detail-item">
<span className="label"></span>
<span className="value">{new Date(order.createdAt).toLocaleString('zh-CN')}</span>
</div>
{order.siteImages && order.siteImages.length > 0 && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<div className="aftersales-site-images">
{order.siteImages.map((url) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img src={url} alt="现场图片" />
</a>
))}
</div>
</div>
)}
</div>
{isClosed && order.engineerSignature && (
<div className="aftersales-signature-archived">
<div className="aftersales-signature-grid">
<div className="aftersales-signature-archived-item">
<p className="aftersales-signature-tip"></p>
<img
src={order.engineerSignature}
alt="工程师签名"
className="aftersales-signature-archived-img"
/>
</div>
</div>
</div>
)}
{!isClosed && (
<>
<div className="aftersales-upload-section">
<p className="aftersales-signature-section-title"></p>
<label className="aftersales-upload-trigger">
<UploadOutlined />
<span>{uploadingImages ? '上传中...' : '上传现场图片'}</span>
<small> 6 </small>
<input
type="file"
accept="image/*"
multiple
disabled={uploadingImages}
onChange={(e) => {
handleUploadSiteImages(e.target.files);
e.currentTarget.value = '';
}}
/>
</label>
</div>
<div className="aftersales-signature-section">
<p className="aftersales-signature-section-title"></p>
<Input.TextArea
rows={4}
value={completionNote}
onChange={(e) => setCompletionNote(e.target.value)}
placeholder="请描述现场勘查、实施过程和最终完成情况"
/>
</div>
<div className="aftersales-signature-section">
<p className="aftersales-signature-section-title"></p>
<div className="aftersales-signature-card">
{engineerSignature ? (
<img
src={engineerSignature}
alt="工程师签名"
className="aftersales-signature-preview"
/>
) : (
<span className="aftersales-signature-empty"></span>
)}
<Button icon={<EditOutlined />} onClick={() => setSignatureOpen(true)}>
{engineerSignature ? '重签' : '签名'}
</Button>
</div>
</div>
<Button
type="primary"
size="large"
block
icon={<CheckCircleOutlined />}
loading={submitting}
onClick={handleComplete}
>
</Button>
</>
)}
</Card>
<SignatureOverlay
open={signatureOpen}
title="工程师签名"
onCancel={() => setSignatureOpen(false)}
onConfirm={(dataUrl) => {
setEngineerSignature(dataUrl);
setSignatureOpen(false);
}}
/>
</PublicLayout>
);
}
export default ProjectOrderCompletePage;