509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
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;
|