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