feat(trade): WeChat confirm-receive component + admin status log columns
Open weappOrderConfirm in mini-user so users confirm in-app instead of service notice; verify via get_order before completing. Show operator/remark on admin order status timeline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
/** 订单状态流转操作人(common_event.param3) */
|
||||
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
||||
USER: '用户',
|
||||
USER_ON_SITE: '用户·现场取货',
|
||||
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||
MOCK: 'Mock',
|
||||
MOCK_PAY: 'Mock 支付',
|
||||
SYSTEM: '系统',
|
||||
};
|
||||
|
||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||
PENDING_PAY: 'red',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
ORDER_STATUS_COLORS,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_STATUS_OPERATOR_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||
@@ -102,7 +103,13 @@ type OrderDetail = AdminOrderRow & {
|
||||
listUnitPrice?: number;
|
||||
items?: AdminOrderItem[];
|
||||
payment?: Record<string, unknown> | null;
|
||||
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
|
||||
statusLogs?: Array<{
|
||||
fromStatus: string | null;
|
||||
toStatus: string;
|
||||
operator?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
benefitCoupons?: Array<Record<string, unknown>>;
|
||||
redeemSummary?: OrderRedeemSummary | null;
|
||||
redeemRecords?: OrderRedeemRecord[];
|
||||
@@ -765,13 +772,40 @@ export default function OrdersPage() {
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="createdAt"
|
||||
rowKey={(_, i) => String(i)}
|
||||
pagination={false}
|
||||
dataSource={detail.statusLogs}
|
||||
columns={[
|
||||
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
|
||||
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
|
||||
{
|
||||
title: '从',
|
||||
dataIndex: 'fromStatus',
|
||||
width: 100,
|
||||
render: (v) => (v ? ORDER_STATUS_LABELS[v] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '到',
|
||||
dataIndex: 'toStatus',
|
||||
width: 100,
|
||||
render: (v) => ORDER_STATUS_LABELS[v] || v,
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'operator',
|
||||
width: 120,
|
||||
render: (v) => ORDER_STATUS_OPERATOR_LABELS[v] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,13 +1,66 @@
|
||||
import './lib/text-encoding-polyfill';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||
import './app.css';
|
||||
|
||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||
patchTaroH5Hooks();
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
const handlingRef = useRef(false);
|
||||
|
||||
useDidShow((options?: {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
|
||||
};
|
||||
}) => {
|
||||
if (process.env.TARO_ENV !== 'weapp') return;
|
||||
if (handlingRef.current) return;
|
||||
|
||||
const referrerInfo =
|
||||
options?.referrerInfo ||
|
||||
(typeof Taro.getEnterOptionsSync === 'function'
|
||||
? (
|
||||
Taro.getEnterOptionsSync() as {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).referrerInfo
|
||||
: undefined);
|
||||
if (!referrerInfo?.appId) return;
|
||||
|
||||
handlingRef.current = true;
|
||||
void handleWechatOrderConfirmShow({ referrerInfo })
|
||||
.then((result) => {
|
||||
if (result.redirectUrl) {
|
||||
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
|
||||
Taro.reLaunch({ url: result.redirectUrl! });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!result.handled || !result.orderId) return;
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
if (route.includes('pickup-receive')) {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
handlingRef.current = false;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WechatShareBootstrap />
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request, toast } from './api';
|
||||
|
||||
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||
|
||||
const PENDING_KEY = 'pending_wechat_order_confirm';
|
||||
|
||||
export type WechatConfirmPayload = {
|
||||
merchantId?: string | null;
|
||||
merchantTradeNo?: string | null;
|
||||
transactionId?: string | null;
|
||||
};
|
||||
|
||||
type PendingConfirm = {
|
||||
orderId: string;
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
type OpenBusinessViewFn = (opts: {
|
||||
businessType: string;
|
||||
extraData: Record<string, string>;
|
||||
success?: () => void;
|
||||
fail?: (err: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
|
||||
function getOpenBusinessView(): OpenBusinessViewFn | null {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
const taroAny = Taro as unknown as { openBusinessView?: OpenBusinessViewFn };
|
||||
if (typeof taroAny.openBusinessView === 'function') return taroAny.openBusinessView.bind(Taro);
|
||||
const wxAny = (globalThis as { wx?: { openBusinessView?: OpenBusinessViewFn } }).wx;
|
||||
if (wxAny && typeof wxAny.openBusinessView === 'function') {
|
||||
return wxAny.openBusinessView.bind(wxAny);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function savePendingWechatOrderConfirm(pending: PendingConfirm) {
|
||||
Taro.setStorageSync(PENDING_KEY, JSON.stringify(pending));
|
||||
}
|
||||
|
||||
export function takePendingWechatOrderConfirm(): PendingConfirm | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(PENDING_KEY);
|
||||
if (!raw) return null;
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
return typeof raw === 'string' ? (JSON.parse(raw) as PendingConfirm) : (raw as PendingConfirm);
|
||||
} catch {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||
*/
|
||||
export function openWechatOrderConfirm(opts: {
|
||||
orderId: string;
|
||||
payload: WechatConfirmPayload;
|
||||
redirectUrl?: string;
|
||||
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||
const open = getOpenBusinessView();
|
||||
if (!open) return Promise.resolve('unsupported');
|
||||
|
||||
const transactionId = opts.payload.transactionId?.trim();
|
||||
const merchantId = opts.payload.merchantId?.trim();
|
||||
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
|
||||
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||
return Promise.resolve('missing_pay_ref');
|
||||
}
|
||||
|
||||
const extraData: Record<string, string> = {};
|
||||
if (transactionId) extraData.transaction_id = transactionId;
|
||||
if (merchantId) extraData.merchant_id = merchantId;
|
||||
if (merchantTradeNo) extraData.merchant_trade_no = merchantTradeNo;
|
||||
|
||||
savePendingWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
open({
|
||||
businessType: 'weappOrderConfirm',
|
||||
extraData,
|
||||
success: () => resolve('opened'),
|
||||
fail: (err) => {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
|
||||
resolve('unsupported');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type ReferrerExtra = {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理确认收货组件回跳(App/页面 onShow)。
|
||||
* 成功则调用后端同步订单,并返回是否已处理。
|
||||
*/
|
||||
export async function handleWechatOrderConfirmShow(options?: {
|
||||
referrerInfo?: { appId?: string; extraData?: ReferrerExtra };
|
||||
}): Promise<{ handled: boolean; orderId?: string; redirectUrl?: string }> {
|
||||
const info = options?.referrerInfo;
|
||||
if (!info?.appId || info.appId !== WECHAT_ORDER_CONFIRM_APPID) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const pending = takePendingWechatOrderConfirm();
|
||||
const status = info.extraData?.status;
|
||||
if (status === 'cancel') {
|
||||
toast('已取消确认收货');
|
||||
return { handled: true, orderId: pending?.orderId };
|
||||
}
|
||||
if (status === 'fail') {
|
||||
toast(info.extraData?.errormsg || '微信确认收货失败');
|
||||
return { handled: true, orderId: pending?.orderId };
|
||||
}
|
||||
if (status !== 'success' || !pending?.orderId) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await request(`/trade/orders/${pending.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: { source: 'WECHAT_COMPONENT' },
|
||||
});
|
||||
toast('确认收货成功', 'success');
|
||||
return {
|
||||
handled: true,
|
||||
orderId: pending.orderId,
|
||||
redirectUrl: pending.redirectUrl,
|
||||
};
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '同步订单失败');
|
||||
return { handled: true, orderId: pending.orderId };
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */
|
||||
export async function confirmOrderReceive(opts: {
|
||||
orderId: string;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
onSitePickup?: boolean;
|
||||
redirectUrl?: string;
|
||||
/** 降级本地确认成功后的回调(不经过微信回跳) */
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'wechat' | 'local'> {
|
||||
const mode = await openWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
payload: opts.wechatConfirm || {},
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
if (mode === 'opened') return 'wechat';
|
||||
|
||||
// Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧)
|
||||
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
onSitePickup: !!opts.onSitePickup,
|
||||
source: 'USER',
|
||||
},
|
||||
});
|
||||
await opts.onLocalSuccess?.();
|
||||
return 'local';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
@@ -9,6 +9,7 @@ import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
@@ -43,6 +44,7 @@ type OrderDetail = {
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
items?: OrderItem[];
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
@@ -82,6 +84,14 @@ export default function OrderDetailPage() {
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
useDidShow(() => {
|
||||
if (!orderId) return;
|
||||
// 从微信确认收货组件返回后刷新
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const isReship = !!order?.originOrderId;
|
||||
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||
@@ -127,7 +137,9 @@ export default function OrderDetailPage() {
|
||||
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认收货?',
|
||||
content: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
content: isWeapp
|
||||
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
|
||||
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
@@ -135,12 +147,19 @@ export default function OrderDetailPage() {
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
const mode = await confirmOrderReceive({
|
||||
orderId: order.id,
|
||||
wechatConfirm: order.wechatConfirm,
|
||||
onLocalSuccess: async () => {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
|
||||
setOrder(updated);
|
||||
toast('已确认收货');
|
||||
},
|
||||
});
|
||||
setOrder(updated);
|
||||
toast('已确认收货');
|
||||
if (mode === 'wechat') {
|
||||
// 回跳后由 App.onShow / 本页 useDidShow 处理
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认收货失败');
|
||||
} finally {
|
||||
|
||||
@@ -5,6 +5,9 @@ import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
@@ -22,6 +25,7 @@ type OrderDetail = {
|
||||
};
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
};
|
||||
|
||||
export default function PickupReceivePage() {
|
||||
@@ -45,17 +49,33 @@ export default function PickupReceivePage() {
|
||||
});
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!orderId || submitting) return;
|
||||
if (!orderId || !order || submitting) return;
|
||||
|
||||
if (isWeapp) {
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认收货?',
|
||||
content: '将打开微信确认收货,完成后订单即完结,无需再点服务通知。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
if (!confirm) return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request(`/trade/orders/${orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {},
|
||||
const mode = await confirmOrderReceive({
|
||||
orderId,
|
||||
wechatConfirm: order.wechatConfirm,
|
||||
onSitePickup: true,
|
||||
redirectUrl: '/pages/orders/index?tab=done',
|
||||
onLocalSuccess: async () => {
|
||||
toast('确认收货成功', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
toast('确认收货成功', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
|
||||
}, 500);
|
||||
if (mode === 'wechat') return;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认失败');
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user