374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
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>最多 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;
|