feat(mini-user): show shipping logistics preview and detail timeline (v3.4.13)
Order detail displays latest XFX route under 配送中 with link to full timeline page; docs updated. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import { formatShanghaiDateTime } from './datetime';
|
||||
import { request } from './api';
|
||||
|
||||
export type OrderDelivery = {
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
manualQueryUrl?: string;
|
||||
};
|
||||
|
||||
export type OrderTrackNode = {
|
||||
trackInfo?: string;
|
||||
createTime?: string;
|
||||
createdAt?: string;
|
||||
statusName?: string;
|
||||
};
|
||||
|
||||
export type OrderTrackResponse = {
|
||||
nodes?: OrderTrackNode[];
|
||||
manualQueryUrl?: string | null;
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
};
|
||||
|
||||
/** 订单详情页展示物流摘要的状态(配送中) */
|
||||
export const ORDER_LOGISTICS_PREVIEW_STATUSES = ['SHIPPING', 'SHIPPED'] as const;
|
||||
|
||||
export function deliveryProviderLabel(provider?: string, company?: string) {
|
||||
if (company) return company;
|
||||
if (!provider || provider === 'MOCK' || isXfxProviderCode(provider)) {
|
||||
return '小飞侠配送';
|
||||
}
|
||||
if (provider === 'LOGISTICS') return '快递配送';
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function shouldLoadOrderTrack(delivery?: OrderDelivery | null) {
|
||||
if (!delivery) return false;
|
||||
if (delivery.trackingNo) return true;
|
||||
return isXfxProviderCode(delivery.provider || '');
|
||||
}
|
||||
|
||||
export function formatTrackNodeTime(node: OrderTrackNode) {
|
||||
return formatShanghaiDateTime(node.createTime || node.createdAt);
|
||||
}
|
||||
|
||||
export function latestTrackNode(nodes: OrderTrackNode[]) {
|
||||
if (nodes.length === 0) return null;
|
||||
return nodes[0];
|
||||
}
|
||||
|
||||
export async function fetchOrderTrack(orderId: string) {
|
||||
return request<OrderTrackResponse>(`/trade/orders/${orderId}/track`);
|
||||
}
|
||||
@@ -8,6 +8,13 @@ import WechatShareReady from '../../components/WechatShareReady';
|
||||
import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import {
|
||||
fetchOrderTrack,
|
||||
latestTrackNode,
|
||||
ORDER_LOGISTICS_PREVIEW_STATUSES,
|
||||
shouldLoadOrderTrack,
|
||||
type OrderTrackNode,
|
||||
} from '../../lib/order-logistics';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
import {
|
||||
@@ -85,11 +92,32 @@ export default function OrderDetailPage() {
|
||||
usePageView('order_detail_view', orderId ? { orderId } : undefined);
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
||||
const [trackLoading, setTrackLoading] = useState(false);
|
||||
|
||||
async function loadOrderTrack(delivery: OrderDetail['delivery']) {
|
||||
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
||||
setLatestTrack(null);
|
||||
return;
|
||||
}
|
||||
setTrackLoading(true);
|
||||
try {
|
||||
const track = await fetchOrderTrack(orderId);
|
||||
setLatestTrack(latestTrackNode(track.nodes ?? []));
|
||||
} catch {
|
||||
setLatestTrack(null);
|
||||
} finally {
|
||||
setTrackLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.then((data) => {
|
||||
setOrder(data);
|
||||
void loadOrderTrack(data.delivery);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
@@ -97,7 +125,10 @@ export default function OrderDetailPage() {
|
||||
if (!orderId) return;
|
||||
// 从微信确认收货组件返回后刷新
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.then((data) => {
|
||||
setOrder(data);
|
||||
void loadOrderTrack(data.delivery);
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -113,6 +144,11 @@ export default function OrderDetailPage() {
|
||||
['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED', 'PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||||
order.status || '',
|
||||
);
|
||||
const showLogisticsPreview =
|
||||
canViewLogistics &&
|
||||
ORDER_LOGISTICS_PREVIEW_STATUSES.includes(
|
||||
(order?.status || '') as (typeof ORDER_LOGISTICS_PREVIEW_STATUSES)[number],
|
||||
);
|
||||
|
||||
const item = order?.items?.[0];
|
||||
const productName = item?.productName || order?.productName || '杜康商品';
|
||||
@@ -230,7 +266,22 @@ export default function OrderDetailPage() {
|
||||
{isProxy && order.proxyPartnerName ? (
|
||||
<Text className="order-proxy-hint">由合伙人 {order.proxyPartnerName} 代下</Text>
|
||||
) : null}
|
||||
{canViewLogistics ? (
|
||||
{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>
|
||||
<Text className="order-logistics-preview-link">物流详情</Text>
|
||||
</View>
|
||||
) : canViewLogistics ? (
|
||||
<Text className="order-logistics-link" onClick={goLogistics}>
|
||||
查看物流追踪
|
||||
</Text>
|
||||
|
||||
@@ -4,20 +4,16 @@ import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import {
|
||||
deliveryProviderLabel,
|
||||
fetchOrderTrack,
|
||||
formatTrackNodeTime,
|
||||
shouldLoadOrderTrack,
|
||||
type OrderDelivery,
|
||||
type OrderTrackNode,
|
||||
} from '../../lib/order-logistics';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
|
||||
type OrderDelivery = {
|
||||
provider?: string;
|
||||
trackingNo?: string;
|
||||
logisticsCompany?: string;
|
||||
manualQueryUrl?: string;
|
||||
};
|
||||
|
||||
type TrackNode = {
|
||||
trackInfo?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
@@ -35,25 +31,13 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
COMPLETED: '已完成',
|
||||
};
|
||||
|
||||
function deliveryProviderLabel(provider?: string, company?: string) {
|
||||
if (company) return company;
|
||||
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') {
|
||||
return '小飞侠配送';
|
||||
}
|
||||
if (provider === 'LOGISTICS') return '快递配送';
|
||||
return provider;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return String(value).slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
export default function OrderLogisticsPage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? '';
|
||||
usePageView('order_logistics_view', orderId ? { orderId } : undefined);
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
|
||||
const [trackNodes, setTrackNodes] = useState<OrderTrackNode[]>([]);
|
||||
const [trackLoading, setTrackLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
@@ -63,13 +47,18 @@ export default function OrderLogisticsPage() {
|
||||
const data = await request<OrderDetail>(`/trade/orders/${orderId}`);
|
||||
if (cancelled) return;
|
||||
setOrder(data);
|
||||
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
|
||||
try {
|
||||
const track = await request<{ nodes?: TrackNode[] }>(`/trade/orders/${orderId}/track`);
|
||||
if (!cancelled) setTrackNodes(track.nodes ?? []);
|
||||
} catch {
|
||||
if (!cancelled) setTrackNodes([]);
|
||||
}
|
||||
if (!shouldLoadOrderTrack(data.delivery)) {
|
||||
setTrackNodes([]);
|
||||
return;
|
||||
}
|
||||
setTrackLoading(true);
|
||||
try {
|
||||
const track = await fetchOrderTrack(orderId);
|
||||
if (!cancelled) setTrackNodes(track.nodes ?? []);
|
||||
} catch {
|
||||
if (!cancelled) setTrackNodes([]);
|
||||
} finally {
|
||||
if (!cancelled) setTrackLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) toast(e instanceof Error ? e.message : '加载失败');
|
||||
@@ -99,7 +88,7 @@ export default function OrderLogisticsPage() {
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-logistics-page">
|
||||
<SubPageHeader title="物流追踪" />
|
||||
<SubPageHeader title="物流详情" />
|
||||
<View className="sub-page-body">
|
||||
{!order ? (
|
||||
<View className="u-empty">加载中…</View>
|
||||
@@ -139,17 +128,36 @@ export default function OrderLogisticsPage() {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{trackNodes.length > 0 ? (
|
||||
{trackLoading ? (
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">物流动态</Text>
|
||||
{trackNodes.map((node, index) => (
|
||||
<View key={`${node.trackInfo}-${index}`} className="order-logistics-node">
|
||||
<Text className="order-logistics-node-time">
|
||||
{node.createdAt ? formatDateTime(node.createdAt) : '—'}
|
||||
</Text>
|
||||
<Text className="order-logistics-node-info">{node.trackInfo || '—'}</Text>
|
||||
</View>
|
||||
))}
|
||||
<Text className="u-muted">加载中…</Text>
|
||||
</View>
|
||||
) : trackNodes.length > 0 ? (
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">物流动态</Text>
|
||||
<View className="order-logistics-timeline">
|
||||
{trackNodes.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>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
) : shouldLoadOrderTrack(delivery) ? (
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">物流动态</Text>
|
||||
<Text className="u-muted">暂无路由信息,请稍后刷新</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -501,10 +501,101 @@
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(166, 29, 36, 0.04);
|
||||
}
|
||||
|
||||
.order-logistics-preview-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-logistics-preview-info {
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.order-logistics-preview-link {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-row-value--link {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-timeline {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 14px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-dot {
|
||||
flex-shrink: 0;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-container-high, #e8e4df);
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item--latest .order-logistics-timeline-dot {
|
||||
background: var(--color-heritage-red);
|
||||
box-shadow: 0 0 0 1px var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-timeline-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-logistics-timeline-status {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.order-logistics-timeline-item--latest .order-logistics-timeline-status {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.order-logistics-node {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
|
||||
Reference in New Issue
Block a user