10fa361983
Co-authored-by: Cursor <cursoragent@cursor.com>
186 lines
5.7 KiB
TypeScript
186 lines
5.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Descriptions,
|
||
Drawer,
|
||
Empty,
|
||
Image,
|
||
Space,
|
||
Spin,
|
||
Timeline,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
|
||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
|
||
|
||
type Props = {
|
||
open: boolean;
|
||
orderId: string | null;
|
||
orderNo?: string | null;
|
||
orderStatus?: string | null;
|
||
onClose: () => void;
|
||
};
|
||
|
||
function providerLabel(provider?: string | null, company?: string | null) {
|
||
if (company) return company;
|
||
if (!provider) return '—';
|
||
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
|
||
if (isXfxProviderCode(provider)) return '小飞侠';
|
||
return provider;
|
||
}
|
||
|
||
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
|
||
return [...nodes].sort((a, b) => {
|
||
const ta = new Date(a.createTime).getTime();
|
||
const tb = new Date(b.createTime).getTime();
|
||
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||
if (Number.isNaN(ta)) return 1;
|
||
if (Number.isNaN(tb)) return -1;
|
||
return ta - tb;
|
||
});
|
||
}
|
||
|
||
export default function OrderTrackDrawer({
|
||
open,
|
||
orderId,
|
||
orderNo,
|
||
orderStatus,
|
||
onClose,
|
||
}: Props) {
|
||
const [loading, setLoading] = useState(false);
|
||
const [track, setTrack] = useState<OrderTrackDto | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!orderId) return;
|
||
setLoading(true);
|
||
try {
|
||
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
|
||
setTrack(data);
|
||
} catch (e) {
|
||
setTrack(null);
|
||
message.error(e instanceof Error ? e.message : '加载路由失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [orderId]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !orderId) {
|
||
setTrack(null);
|
||
return;
|
||
}
|
||
void load();
|
||
}, [open, orderId, load]);
|
||
|
||
const nodes = useMemo(
|
||
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
|
||
[track?.nodes],
|
||
);
|
||
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||
orderStatus || '',
|
||
);
|
||
|
||
return (
|
||
<Drawer
|
||
title="物流路由"
|
||
width={520}
|
||
open={open}
|
||
onClose={onClose}
|
||
extra={
|
||
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
|
||
刷新
|
||
</Button>
|
||
}
|
||
>
|
||
<Spin spinning={loading}>
|
||
{(orderNo || track?.trackingNo || track?.provider) && (
|
||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
|
||
<Descriptions.Item label="配送方式">
|
||
{providerLabel(track?.provider, track?.logisticsCompany)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
|
||
{track?.manualQueryUrl ? (
|
||
<Descriptions.Item label="物流查询">
|
||
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||
打开物流查询
|
||
</a>
|
||
</Descriptions.Item>
|
||
) : null}
|
||
{track?.estimatedArrival?.arriveTime ? (
|
||
<Descriptions.Item label="预计送达">
|
||
{fmtTime(track.estimatedArrival.arriveTime)}
|
||
{track.estimatedArrival.siteName
|
||
? `(${track.estimatedArrival.siteName})`
|
||
: ''}
|
||
</Descriptions.Item>
|
||
) : null}
|
||
</Descriptions>
|
||
)}
|
||
|
||
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||
签收照片
|
||
</Typography.Title>
|
||
<Image.PreviewGroup>
|
||
<Space wrap>
|
||
{track.signPhotoUrls.map((url) => (
|
||
<Image
|
||
key={url}
|
||
src={url}
|
||
width={96}
|
||
height={96}
|
||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||
/>
|
||
))}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
</div>
|
||
) : null}
|
||
|
||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||
物流动态
|
||
</Typography.Title>
|
||
{nodes.length > 0 ? (
|
||
<Timeline
|
||
items={nodes.map((node, index) => {
|
||
const isLatest = index === nodes.length - 1;
|
||
return {
|
||
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
|
||
children: (
|
||
<div>
|
||
{node.statusName ? (
|
||
<Typography.Text strong style={{ display: 'block' }}>
|
||
{node.statusName}
|
||
</Typography.Text>
|
||
) : null}
|
||
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
|
||
{node.trackInfo || '—'}
|
||
</Typography.Text>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{fmtTime(node.createTime)}
|
||
</Typography.Text>
|
||
</div>
|
||
),
|
||
};
|
||
})}
|
||
/>
|
||
) : (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={
|
||
track?.manualQueryUrl
|
||
? '暂无实时路由节点,可使用上方物流查询链接'
|
||
: '暂无路由信息,请稍后刷新'
|
||
}
|
||
/>
|
||
)}
|
||
</Spin>
|
||
</Drawer>
|
||
);
|
||
}
|