feat(mini-user): logistics sign photos, dial, ETA and courier callbacks (v3.4.13)

Extend Courier adapter for XFX sign photos and route callbacks; mini-user logistics UI with timeline, phone dial, and estimated arrival.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 23:34:09 +08:00
parent 62652cd072
commit 345d5db9c9
19 changed files with 710 additions and 140 deletions
+37 -16
View File
@@ -6,13 +6,17 @@ import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import {
fetchOrderTrack,
formatEstimatedArrival,
isLogisticsNotArrived,
latestTrackNode,
ORDER_LOGISTICS_PREVIEW_STATUSES,
shouldLoadOrderTrack,
type OrderTrackEstimatedArrival,
type OrderTrackNode,
} from '../../lib/order-logistics';
import { maskPhone } from '../../lib/phone';
@@ -93,19 +97,28 @@ export default function OrderDetailPage() {
const [order, setOrder] = useState<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false);
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
const [trackLoading, setTrackLoading] = useState(false);
async function loadOrderTrack(delivery: OrderDetail['delivery']) {
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
if (!orderId || !shouldLoadOrderTrack(delivery)) {
setLatestTrack(null);
setEstimatedArrival(null);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
setLatestTrack(latestTrackNode(track.nodes ?? []));
const nodes = track.nodes ?? [];
setLatestTrack(latestTrackNode(nodes));
setEstimatedArrival(
isLogisticsNotArrived(nodes, orderStatus) && track.estimatedArrival
? track.estimatedArrival
: null,
);
} catch {
setLatestTrack(null);
setEstimatedArrival(null);
} finally {
setTrackLoading(false);
}
@@ -116,7 +129,7 @@ export default function OrderDetailPage() {
request<OrderDetail>(`/trade/orders/${orderId}`)
.then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery);
void loadOrderTrack(data.delivery, data.status);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]);
@@ -127,7 +140,7 @@ export default function OrderDetailPage() {
request<OrderDetail>(`/trade/orders/${orderId}`)
.then((data) => {
setOrder(data);
void loadOrderTrack(data.delivery);
void loadOrderTrack(data.delivery, data.status);
})
.catch(() => {});
});
@@ -267,19 +280,27 @@ export default function OrderDetailPage() {
<Text className="order-proxy-hint"> {order.proxyPartnerName} </Text>
) : null}
{showLogisticsPreview ? (
<View className="order-logistics-preview" onClick={goLogistics}>
<View className="order-logistics-preview-main">
{trackLoading ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : latestTrack?.trackInfo ? (
<Text className="order-logistics-preview-info">{latestTrack.trackInfo}</Text>
) : order.delivery?.manualQueryUrl ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : (
<Text className="order-logistics-preview-info u-muted"></Text>
)}
<View className="order-logistics-preview-block">
<View className="order-logistics-preview" onClick={goLogistics}>
<View className="order-logistics-preview-main">
{trackLoading ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : latestTrack?.trackInfo ? (
<LogisticsRichText
text={latestTrack.trackInfo}
className="order-logistics-preview-info"
/>
) : order.delivery?.manualQueryUrl ? (
<Text className="order-logistics-preview-info u-muted"></Text>
) : (
<Text className="order-logistics-preview-info u-muted"></Text>
)}
</View>
<Text className="order-logistics-preview-link"></Text>
</View>
<Text className="order-logistics-preview-link"></Text>
{estimatedArrival ? (
<Text className="order-logistics-eta">{formatEstimatedArrival(estimatedArrival)}</Text>
) : null}
</View>
) : canViewLogistics ? (
<Text className="order-logistics-link" onClick={goLogistics}>
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import LogisticsRichText from '../../components/LogisticsRichText';
import { request, toast } from '../../lib/api';
import {
deliveryProviderLabel,
fetchOrderTrack,
formatTrackNodeTime,
shouldLoadOrderTrack,
sortTrackNodesNewestFirst,
sortTrackNodesOldestFirst,
type OrderDelivery,
type OrderTrackNode,
} from '../../lib/order-logistics';
@@ -28,9 +29,13 @@ export default function OrderLogisticsPage() {
usePageView('order_logistics_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [trackNodes, setTrackNodes] = useState<OrderTrackNode[]>([]);
const [signPhotoUrls, setSignPhotoUrls] = useState<string[]>([]);
const [trackLoading, setTrackLoading] = useState(false);
const sortedTrackNodes = useMemo(() => sortTrackNodesNewestFirst(trackNodes), [trackNodes]);
const sortedTrackNodes = useMemo(() => sortTrackNodesOldestFirst(trackNodes), [trackNodes]);
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
order?.status || '',
);
useEffect(() => {
if (!orderId) return;
@@ -42,14 +47,21 @@ export default function OrderLogisticsPage() {
setOrder(data);
if (!shouldLoadOrderTrack(data.delivery)) {
setTrackNodes([]);
setSignPhotoUrls([]);
return;
}
setTrackLoading(true);
try {
const track = await fetchOrderTrack(orderId);
if (!cancelled) setTrackNodes(track.nodes ?? []);
if (!cancelled) {
setTrackNodes(track.nodes ?? []);
setSignPhotoUrls(track.signPhotoUrls ?? []);
}
} catch {
if (!cancelled) setTrackNodes([]);
if (!cancelled) {
setTrackNodes([]);
setSignPhotoUrls([]);
}
} finally {
if (!cancelled) setTrackLoading(false);
}
@@ -77,6 +89,13 @@ export default function OrderLogisticsPage() {
.catch(() => toast('无法打开物流查询'));
}
function previewSignPhotos(index: number) {
if (signPhotoUrls.length === 0) return;
Taro.previewImage({ current: signPhotoUrls[index], urls: signPhotoUrls }).catch(() =>
toast('无法预览图片'),
);
}
const delivery = order?.delivery;
return (
@@ -114,6 +133,23 @@ export default function OrderLogisticsPage() {
) : null}
</View>
{signPhotoUrls.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-logistics-sign-photos">
{signPhotoUrls.map((url, index) => (
<Image
key={url}
className="order-logistics-sign-photo"
src={url}
mode="aspectFill"
onClick={() => previewSignPhotos(index)}
/>
))}
</View>
</View>
) : null}
{trackLoading ? (
<View className="order-card">
<Text className="order-card-title"></Text>
@@ -122,22 +158,38 @@ export default function OrderLogisticsPage() {
) : sortedTrackNodes.length > 0 ? (
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-logistics-timeline">
{sortedTrackNodes.map((node, index) => (
<View
key={`${node.trackInfo}-${node.createTime}-${index}`}
className={`order-logistics-timeline-item${index === 0 ? ' order-logistics-timeline-item--latest' : ''}`}
>
<View className="order-logistics-timeline-dot" />
<View className="order-logistics-timeline-body">
{node.statusName ? (
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
) : null}
<Text className="order-logistics-node-info">{node.trackInfo || '—'}</Text>
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
<View
className={`order-logistics-timeline${
timelineComplete ? ' order-logistics-timeline--complete' : ''
}`}
>
{sortedTrackNodes.map((node, index) => {
const isLatest = index === sortedTrackNodes.length - 1;
return (
<View
key={`${node.trackInfo}-${node.createTime}-${index}`}
className={[
'order-logistics-timeline-item',
'order-logistics-timeline-item--done',
isLatest ? 'order-logistics-timeline-item--latest' : '',
]
.filter(Boolean)
.join(' ')}
>
<View className="order-logistics-timeline-dot" />
<View className="order-logistics-timeline-body">
{node.statusName ? (
<Text className="order-logistics-timeline-status">{node.statusName}</Text>
) : null}
<LogisticsRichText
text={node.trackInfo || '—'}
className="order-logistics-node-info"
/>
<Text className="order-logistics-node-time">{formatTrackNodeTime(node)}</Text>
</View>
</View>
</View>
))}
);
})}
</View>
</View>
) : shouldLoadOrderTrack(delivery) ? (