Files
frontend/src/pages/AftersalesConfirm.tsx
T
2026-06-04 10:26:05 +08:00

509 lines
17 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, Modal, Input } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
EditOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { aftersalesApi } from '@/services/api';
import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import SignatureOverlay from '@/components/SignatureOverlay';
import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css';
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件故障',
hardware: '硬件故障',
maintenance: '售后维保',
};
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 AftersalesConfirmPage() {
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
const [order, setOrder] = useState<AftersalesPublicView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [customerSignatureData, setCustomerSignatureData] = useState('');
const [responsibleSignatureData, setResponsibleSignatureData] = useState('');
const [activeSignatureRole, setActiveSignatureRole] = useState<'customer' | 'responsible' | null>(
null,
);
const [uploadingImages, setUploadingImages] = useState(false);
const loadOrder = async () => {
setLoading(true);
setError(null);
try {
const data = await aftersalesApi.publicQuery(serialNumber);
setOrder(data);
} 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 handleAuthorize = async () => {
if (!customerSignatureData) {
message.error('请先完成客户签名');
return;
}
if (!responsibleSignatureData) {
message.error('请先完成处理人签名');
return;
}
setSubmitting(true);
try {
const updated = await aftersalesApi.customerConfirm(serialNumber, {
action: 'authorize',
signature: customerSignatureData,
responsibleSignature: responsibleSignatureData,
});
setOrder(updated);
message.success('感谢您的确认,工单已完成');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const openRejectDialog = () => {
setRejectReason('');
setShowRejectDialog(true);
};
const confirmReject = async () => {
const reason = rejectReason.trim();
if (!reason) {
message.error('请填写退回原因');
return;
}
setSubmitting(true);
try {
const updated = await aftersalesApi.customerConfirm(serialNumber, {
action: 'reject',
rejectReason: reason,
});
setOrder(updated);
setShowRejectDialog(false);
message.success('已通知技术员重新处理');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const handleClearSignature = (role: 'customer' | 'responsible') => {
if (role === 'customer') {
setCustomerSignatureData('');
return;
}
setResponsibleSignatureData('');
};
const openSignatureOverlay = (role: 'customer' | 'responsible') => {
setActiveSignatureRole(role);
};
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 aftersalesApi.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);
}
};
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 isPending = order.workOrderStatus === 'pending_confirmation';
const isClosed = order.workOrderStatus === 'closed';
const isRejected = order.workOrderStatus === 'rejected';
const isCreated = order.workOrderStatus === 'created';
const renderStatusBanner = () => {
if (isClosed) {
return (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '64px' }} />}
title="工单已完成"
subTitle={
order.confirmedAt
? `确认时间:${new Date(order.confirmedAt).toLocaleString('zh-CN')}`
: undefined
}
/>
);
}
if (isRejected) {
return (
<Result
icon={<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '64px' }} />}
title="工单已退回"
subTitle="技术员将重新处理,请耐心等待"
/>
);
}
if (isCreated) {
return (
<Result
icon={<ClockCircleOutlined style={{ color: '#1677ff', fontSize: '64px' }} />}
title="工单处理中"
subTitle="技术员尚未提交客户确认,请稍后再来查看"
/>
);
}
return null;
};
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>
{renderStatusBanner()}
<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">{SERVICE_TYPE_LABEL[order.serviceType] || order.serviceType}</span>
</div>
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.issueDescription}</span>
</div>
{order.resolutionNote && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.resolutionNote}</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>
)}
{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>
</div>
{isClosed && (order.signature || order.responsibleSignature) && (
<div className="aftersales-signature-archived">
<div className="aftersales-signature-grid">
{order.signature && (
<div className="aftersales-signature-archived-item">
<p className="aftersales-signature-tip"></p>
<img
src={order.signature}
alt="客户签名"
className="aftersales-signature-archived-img"
/>
</div>
)}
{order.responsibleSignature && (
<div className="aftersales-signature-archived-item">
<p className="aftersales-signature-tip"></p>
<img
src={order.responsibleSignature}
alt="处理人签名"
className="aftersales-signature-archived-img"
/>
</div>
)}
</div>
</div>
)}
{isPending && (
<>
<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/jpeg,image/png,image/webp,image/heic,image/heif"
multiple
disabled={uploadingImages}
onChange={(e) => {
handleUploadSiteImages(e.target.files);
e.currentTarget.value = '';
}}
/>
</label>
{order.siteImages && order.siteImages.length > 0 && (
<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 className="aftersales-signature-section">
<p className="aftersales-signature-section-title"></p>
<div className="aftersales-signature-grid">
<div className="aftersales-signature-item">
<div className="aftersales-signature-header">
<p className="aftersales-signature-tip"></p>
{customerSignatureData && (
<Button size="small" type="link" onClick={() => handleClearSignature('customer')}>
</Button>
)}
</div>
{customerSignatureData ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => openSignatureOverlay('customer')}
>
<img src={customerSignatureData} alt="客户签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => openSignatureOverlay('customer')}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div>
<div className="aftersales-signature-item">
<div className="aftersales-signature-header">
<p className="aftersales-signature-tip"></p>
{responsibleSignatureData && (
<Button
size="small"
type="link"
onClick={() => handleClearSignature('responsible')}
>
</Button>
)}
</div>
{responsibleSignatureData ? (
<button
type="button"
className="aftersales-signature-preview"
onClick={() => openSignatureOverlay('responsible')}
>
<img src={responsibleSignatureData} alt="处理人签名" />
<span className="aftersales-signature-preview-hint"></span>
</button>
) : (
<button
type="button"
className="aftersales-signature-trigger"
onClick={() => openSignatureOverlay('responsible')}
>
<EditOutlined />
<span></span>
<small></small>
</button>
)}
</div>
</div>
</div>
<div className="aftersales-actions">
<Button
size="large"
danger
block
loading={submitting}
onClick={openRejectDialog}
>
</Button>
<Button
type="primary"
size="large"
block
loading={submitting}
onClick={handleAuthorize}
>
</Button>
</div>
</>
)}
</Card>
<SignatureOverlay
open={activeSignatureRole !== null}
title={activeSignatureRole === 'responsible' ? '处理人签名' : '客户签名'}
onCancel={() => setActiveSignatureRole(null)}
onConfirm={(url) => {
if (activeSignatureRole === 'responsible') {
setResponsibleSignatureData(url);
} else {
setCustomerSignatureData(url);
}
setActiveSignatureRole(null);
}}
/>
<Modal
title="请说明退回原因"
open={showRejectDialog}
onCancel={() => setShowRejectDialog(false)}
onOk={confirmReject}
okText="确认退回"
cancelText="取消"
confirmLoading={submitting}
>
<Input.TextArea
rows={4}
placeholder="请简要说明未授权的原因,方便技术员改进"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
maxLength={200}
/>
</Modal>
</PublicLayout>
);
}
export default AftersalesConfirmPage;