feat(ops): add HQ admin proxy order and mini-user store session fixes
Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||
import { resetStoresSessionBootstrap } from './stores-session';
|
||||
|
||||
function resolveApiBase(): string {
|
||||
const origin =
|
||||
@@ -56,6 +57,8 @@ export function isLoggedIn(): boolean {
|
||||
|
||||
export function logout() {
|
||||
clearAuth();
|
||||
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选
|
||||
resetStoresSessionBootstrap();
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
|
||||
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
|
||||
* 仅 logout 时 clear。
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export type StoresSessionRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
};
|
||||
|
||||
export type StoresSessionCategory = {
|
||||
parentId: string;
|
||||
parentName: string;
|
||||
childId: string;
|
||||
childName: string;
|
||||
};
|
||||
|
||||
export type StoresListCache = {
|
||||
cityKey: string;
|
||||
cityCode: string;
|
||||
listRegion: StoresSessionRegion;
|
||||
items: unknown[];
|
||||
filterRegion: StoresSessionRegion;
|
||||
keyword: string;
|
||||
keywordInput: string;
|
||||
category: StoresSessionCategory;
|
||||
};
|
||||
|
||||
type StoresSession = {
|
||||
bootstrapped: boolean;
|
||||
cache: StoresListCache | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'dukang_stores_session_v1';
|
||||
|
||||
let memory: StoresSession | null = null;
|
||||
|
||||
function emptySession(): StoresSession {
|
||||
return { bootstrapped: false, cache: null };
|
||||
}
|
||||
|
||||
function readSession(): StoresSession {
|
||||
if (memory) return memory;
|
||||
try {
|
||||
const raw = Taro.getStorageSync(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
|
||||
memory = {
|
||||
bootstrapped: !!parsed.bootstrapped,
|
||||
cache: (parsed.cache as StoresListCache | null) ?? null,
|
||||
};
|
||||
return memory;
|
||||
} catch {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(next: StoresSession) {
|
||||
memory = next;
|
||||
try {
|
||||
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function isStoresSessionBootstrapped(): boolean {
|
||||
return readSession().bootstrapped;
|
||||
}
|
||||
|
||||
export function markStoresSessionBootstrapped(): void {
|
||||
const cur = readSession();
|
||||
writeSession({ ...cur, bootstrapped: true });
|
||||
}
|
||||
|
||||
export function getStoresListCache(): StoresListCache | null {
|
||||
return readSession().cache;
|
||||
}
|
||||
|
||||
export function setStoresListCache(cache: StoresListCache | null): void {
|
||||
const cur = readSession();
|
||||
writeSession({ ...cur, bootstrapped: true, cache });
|
||||
}
|
||||
|
||||
export function patchStoresFilterCache(
|
||||
patch: Partial<
|
||||
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
|
||||
>,
|
||||
): void {
|
||||
const cur = readSession();
|
||||
if (!cur.cache) {
|
||||
// 列表尚未写入时也要记下用户筛选,避免切回丢失
|
||||
writeSession({
|
||||
bootstrapped: true,
|
||||
cache: {
|
||||
cityKey: '',
|
||||
cityCode: '',
|
||||
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
|
||||
items: [],
|
||||
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
|
||||
keyword: patch.keyword ?? '',
|
||||
keywordInput: patch.keywordInput ?? '',
|
||||
category: patch.category ?? {
|
||||
parentId: '',
|
||||
parentName: '',
|
||||
childId: '',
|
||||
childName: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeSession({
|
||||
...cur,
|
||||
bootstrapped: true,
|
||||
cache: { ...cur.cache, ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
export function resetStoresSessionBootstrap(): void {
|
||||
memory = emptySession();
|
||||
try {
|
||||
Taro.removeStorageSync(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request, toast } from './api';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||
@@ -17,21 +18,54 @@ type PendingConfirm = {
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
type OpenBusinessViewFn = (opts: {
|
||||
type OpenBusinessViewOptions = {
|
||||
businessType: string;
|
||||
extraData: Record<string, string>;
|
||||
success?: () => void;
|
||||
fail?: (err: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
complete?: () => void;
|
||||
};
|
||||
|
||||
function getOpenBusinessView(): OpenBusinessViewFn | null {
|
||||
type MiniWx = {
|
||||
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取小程序原生 wx.openBusinessView。
|
||||
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
|
||||
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
|
||||
*/
|
||||
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | 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);
|
||||
|
||||
const candidates: Array<MiniWx | null | undefined> = [];
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { wx?: MiniWx };
|
||||
candidates.push(g.wx);
|
||||
|
||||
try {
|
||||
// 跳出 bundler 模块作用域,读微信运行时全局
|
||||
const fromRuntime = new Function(
|
||||
'return typeof wx !== "undefined" ? wx : null',
|
||||
)() as MiniWx | null;
|
||||
candidates.push(fromRuntime);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
for (const api of candidates) {
|
||||
if (api && typeof api.openBusinessView === 'function') {
|
||||
return api.openBusinessView.bind(api);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -51,8 +85,46 @@ export function takePendingWechatOrderConfirm(): PendingConfirm | null {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
|
||||
return {
|
||||
merchantId: payload?.merchantId?.trim() || undefined,
|
||||
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
|
||||
transactionId: payload?.transactionId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveConfirmPayload(
|
||||
orderId: string,
|
||||
hint?: WechatConfirmPayload | null,
|
||||
): Promise<WechatConfirmPayload> {
|
||||
const fromHint = normalizePayload(hint);
|
||||
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
|
||||
return fromHint;
|
||||
}
|
||||
|
||||
const order = await request<{
|
||||
orderNo?: string;
|
||||
payExternalNo?: string | null;
|
||||
payment?: { externalNo?: string | null } | null;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
}>(`/trade/orders/${orderId}`);
|
||||
|
||||
const fromApi = normalizePayload(order.wechatConfirm);
|
||||
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
|
||||
return fromApi;
|
||||
}
|
||||
|
||||
const transactionId =
|
||||
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
|
||||
return normalizePayload({
|
||||
transactionId,
|
||||
merchantTradeNo: order.orderNo,
|
||||
merchantId: fromApi.merchantId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步。
|
||||
* 拉起微信「确认收货」半屏组件。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||
*/
|
||||
export function openWechatOrderConfirm(opts: {
|
||||
@@ -61,12 +133,19 @@ export function openWechatOrderConfirm(opts: {
|
||||
redirectUrl?: string;
|
||||
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||
const open = getOpenBusinessView();
|
||||
if (!open) return Promise.resolve('unsupported');
|
||||
if (!open) {
|
||||
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
|
||||
taroEnv: process.env.TARO_ENV,
|
||||
});
|
||||
return Promise.resolve('unsupported');
|
||||
}
|
||||
|
||||
const transactionId = opts.payload.transactionId?.trim();
|
||||
const merchantId = opts.payload.merchantId?.trim();
|
||||
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
|
||||
const payload = normalizePayload(opts.payload);
|
||||
const transactionId = payload.transactionId;
|
||||
const merchantId = payload.merchantId;
|
||||
const merchantTradeNo = payload.merchantTradeNo;
|
||||
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||
console.warn('[wechat-order-confirm] missing pay ref', payload);
|
||||
return Promise.resolve('missing_pay_ref');
|
||||
}
|
||||
|
||||
@@ -81,16 +160,31 @@ export function openWechatOrderConfirm(opts: {
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
open({
|
||||
businessType: 'weappOrderConfirm',
|
||||
extraData,
|
||||
success: () => resolve('opened'),
|
||||
fail: (err) => {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
|
||||
resolve('unsupported');
|
||||
},
|
||||
});
|
||||
let settled = false;
|
||||
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(mode);
|
||||
};
|
||||
|
||||
try {
|
||||
open({
|
||||
businessType: 'weappOrderConfirm',
|
||||
extraData,
|
||||
success: () => done('opened'),
|
||||
fail: (err) => {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
|
||||
done('unsupported');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
console.error('[wechat-order-confirm] openBusinessView throw', err);
|
||||
toast('无法打开微信确认收货组件');
|
||||
done('unsupported');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,24 +237,11 @@ export async function handleWechatOrderConfirmShow(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */
|
||||
export async function confirmOrderReceive(opts: {
|
||||
async function confirmLocally(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 / 缺支付单号:本地确认(不通知微信资金侧)
|
||||
}): Promise<'local'> {
|
||||
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
@@ -171,3 +252,48 @@ export async function confirmOrderReceive(opts: {
|
||||
await opts.onLocalSuccess?.();
|
||||
return 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口:
|
||||
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
|
||||
* - Mock / H5:本地确认
|
||||
*/
|
||||
export async function confirmOrderReceive(opts: {
|
||||
orderId: string;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
onSitePickup?: boolean;
|
||||
redirectUrl?: string;
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'wechat' | 'local'> {
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
if (!isWeapp) {
|
||||
return confirmLocally(opts);
|
||||
}
|
||||
|
||||
let mockPay = false;
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
mockPay = !!cfg.mockPay;
|
||||
} catch {
|
||||
mockPay = false;
|
||||
}
|
||||
|
||||
if (mockPay) {
|
||||
return confirmLocally(opts);
|
||||
}
|
||||
|
||||
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
|
||||
const mode = await openWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
payload,
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
if (mode === 'opened') return 'wechat';
|
||||
|
||||
if (mode === 'missing_pay_ref') {
|
||||
throw new Error('缺少微信支付单号,无法打开确认收货组件');
|
||||
}
|
||||
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user