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:
@@ -0,0 +1,44 @@
|
||||
import { Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { splitLogisticsText } from '../lib/order-logistics';
|
||||
import { toast } from '../lib/api';
|
||||
|
||||
type LogisticsRichTextProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
phoneClassName?: string;
|
||||
};
|
||||
|
||||
export default function LogisticsRichText({
|
||||
text,
|
||||
className = '',
|
||||
phoneClassName = 'order-logistics-phone-link',
|
||||
}: LogisticsRichTextProps) {
|
||||
const segments = splitLogisticsText(text);
|
||||
|
||||
function dial(phone: string) {
|
||||
const normalized = phone.replace(/-/g, '');
|
||||
Taro.makePhoneCall({ phoneNumber: normalized }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className={className}>
|
||||
{segments.map((segment, index) =>
|
||||
segment.type === 'phone' ? (
|
||||
<Text
|
||||
key={`${segment.value}-${index}`}
|
||||
className={phoneClassName}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dial(segment.value);
|
||||
}}
|
||||
>
|
||||
{segment.value}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={`${index}-text`}>{segment.value}</Text>
|
||||
),
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OrderTrackDto } from '@dukang/shared-types';
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import { formatShanghaiDateTime } from './datetime';
|
||||
import { request } from './api';
|
||||
@@ -16,17 +17,18 @@ export type OrderTrackNode = {
|
||||
statusName?: string;
|
||||
};
|
||||
|
||||
export type OrderTrackResponse = {
|
||||
nodes?: OrderTrackNode[];
|
||||
manualQueryUrl?: string | null;
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
export type OrderTrackEstimatedArrival = {
|
||||
arriveTime: string;
|
||||
siteName?: string;
|
||||
};
|
||||
|
||||
export type OrderTrackResponse = OrderTrackDto;
|
||||
|
||||
/** 订单详情页展示物流摘要的状态(配送中) */
|
||||
export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const;
|
||||
|
||||
const PHONE_PATTERN = /(1[3-9]\d{9}|\d{3,4}-\d{7,8})/g;
|
||||
|
||||
export function deliveryProviderLabel(provider?: string, company?: string) {
|
||||
if (company) return company;
|
||||
if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) {
|
||||
@@ -46,7 +48,7 @@ export function formatTrackNodeTime(node: OrderTrackNode) {
|
||||
return formatShanghaiDateTime(node.createTime || node.createdAt);
|
||||
}
|
||||
|
||||
export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNode[] {
|
||||
export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const ta = new Date(a.createTime || a.createdAt || 0).getTime();
|
||||
const tb = new Date(b.createTime || b.createdAt || 0).getTime();
|
||||
@@ -57,11 +59,60 @@ export function sortTrackNodesNewestFirst(nodes: OrderTrackNode[]): OrderTrackNo
|
||||
});
|
||||
}
|
||||
|
||||
export function sortTrackNodesOldestFirst(nodes: OrderTrackNode[]) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const ta = new Date(a.createTime || a.createdAt || 0).getTime();
|
||||
const tb = new Date(b.createTime || b.createdAt || 0).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 function latestTrackNode(nodes: OrderTrackNode[]) {
|
||||
const sorted = sortTrackNodesNewestFirst(nodes);
|
||||
return sorted[0] ?? null;
|
||||
}
|
||||
|
||||
export function isLogisticsNotArrived(nodes: OrderTrackNode[], orderStatus?: string) {
|
||||
if (['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(orderStatus || '')) {
|
||||
return false;
|
||||
}
|
||||
if (nodes.length === 0) return true;
|
||||
return !nodes.some(
|
||||
(node) => node.statusName?.includes('签收') || node.trackInfo?.includes('签收'),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatEstimatedArrival(eta?: OrderTrackEstimatedArrival | null) {
|
||||
if (!eta?.arriveTime) return '';
|
||||
const time = formatShanghaiDateTime(eta.arriveTime);
|
||||
if (eta.siteName) return `预计送达:${time}(${eta.siteName})`;
|
||||
return `预计送达:${time}`;
|
||||
}
|
||||
|
||||
export type LogisticsTextSegment = { type: 'text' | 'phone'; value: string };
|
||||
|
||||
export function splitLogisticsText(text: string): LogisticsTextSegment[] {
|
||||
if (!text) return [];
|
||||
const segments: LogisticsTextSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = new RegExp(PHONE_PATTERN.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
segments.push({ type: 'phone', value: match[0] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return segments.length ? segments : [{ type: 'text', value: text }];
|
||||
}
|
||||
|
||||
export async function fetchOrderTrack(orderId: string) {
|
||||
return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
|
||||
}
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
@@ -542,6 +542,35 @@
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-preview-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.order-logistics-eta {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.order-logistics-phone-link {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-sign-photos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-logistics-sign-photo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-container);
|
||||
}
|
||||
|
||||
.order-row-value--link {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
@@ -550,6 +579,10 @@
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.order-logistics-timeline--complete .order-logistics-timeline-item:not(:last-child)::before {
|
||||
background: rgba(166, 29, 36, 0.35);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -571,6 +604,15 @@
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item--done .order-logistics-timeline-dot {
|
||||
background: var(--color-heritage-red);
|
||||
box-shadow: 0 0 0 1px var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item--done:not(:last-child)::before {
|
||||
background: rgba(166, 29, 36, 0.35);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-dot {
|
||||
flex-shrink: 0;
|
||||
width: 11px;
|
||||
@@ -584,7 +626,7 @@
|
||||
|
||||
.order-logistics-timeline-item--latest .order-logistics-timeline-dot {
|
||||
background: var(--color-heritage-red);
|
||||
box-shadow: 0 0 0 1px var(--color-heritage-red);
|
||||
box-shadow: 0 0 0 2px rgba(166, 29, 36, 0.25);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-body {
|
||||
@@ -600,8 +642,13 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item--done .order-logistics-timeline-status {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item--latest .order-logistics-timeline-status {
|
||||
color: var(--color-heritage-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-logistics-node {
|
||||
|
||||
Reference in New Issue
Block a user