Files
frontend/src/pages/ProjectOrderComplete.tsx
T
2026-06-06 13:58:53 +08:00

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '定期维保',
business: '商务合作',
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> 18 </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-item">
<div className="aftersales-signature-header">
<p className="aftersales-signature-tip"></p>
{engineerSignature && (
<Button size="small" type="link" onClick={() => setEngineerSignature('')}>
</Button>
)}
</div>
{engineerSignature ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => setSignatureOpen(true)}
>
<img src={engineerSignature} alt="工程师签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => setSignatureOpen(true)}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div>
</div>
<Button
type="primary"
size="large"
block
icon={<CheckCircleOutlined />}
loading={submitting}
onClick={handleComplete}
style={{ marginTop: 20 }}
>
</Button>
</>
)}
</Card>
<SignatureOverlay
open={signatureOpen}
title="工程师签名"
onCancel={() => setSignatureOpen(false)}
onConfirm={(dataUrl) => {
setEngineerSignature(dataUrl);
setSignatureOpen(false);
}}
/>
</PublicLayout>
);
}
export default ProjectOrderCompletePage;