feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { peekShopReturnTo, rememberShopReturnPath } from '../lib/shop-redeem-return';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||
const SELECT_STORE_PATH = '/select-store';
|
||||
@@ -18,14 +19,17 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
||||
const next = needsSelectStore ? SELECT_STORE_PATH : (peekShopReturnTo() || '/');
|
||||
return <Navigate to={next} replace />;
|
||||
}
|
||||
|
||||
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
||||
rememberShopReturnPath(location);
|
||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
rememberShopReturnPath(location);
|
||||
const profile = getStoreProfile();
|
||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
|
||||
@@ -1,22 +1 @@
|
||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
try {
|
||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||
const fromQuery = url.searchParams.get('token');
|
||||
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||
return fromQuery.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
|
||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||
}
|
||||
export { parseRedeemTokenFromScan } from '@dukang/shared-types';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||
|
||||
/** iOS 微信内用整页跳转,避免 JSSDK 入场 URL 与 SPA 路径不一致 */
|
||||
export function goShopPath(
|
||||
path: string,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
opts?: { replace?: boolean },
|
||||
): void {
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat(path);
|
||||
return;
|
||||
}
|
||||
navigate(path, { replace: opts?.replace ?? true });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 扫核销码落地 /redeem?token= 后未登录或需选店,记下回跳路径(OAuth 整页跳转会丢掉 location.state) */
|
||||
export const SHOP_REDEEM_RETURN_KEY = 'shop_redeem_return';
|
||||
|
||||
function isRedeemReturnPath(path: string): boolean {
|
||||
if (!path.startsWith('/redeem')) return false;
|
||||
if (path.startsWith('/redeem/')) return false;
|
||||
try {
|
||||
const url = new URL(path, 'https://local.invalid');
|
||||
return !!url.searchParams.get('token')?.trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberShopReturnTo(path: string): void {
|
||||
if (!isRedeemReturnPath(path)) return;
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_REDEEM_RETURN_KEY, path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberShopReturnPath(location: { pathname: string; search: string }): void {
|
||||
rememberShopReturnTo(`${location.pathname}${location.search}`);
|
||||
}
|
||||
|
||||
export function peekShopReturnTo(): string | null {
|
||||
try {
|
||||
const path = sessionStorage.getItem(SHOP_REDEEM_RETURN_KEY);
|
||||
return path && isRedeemReturnPath(path) ? path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeShopReturnTo(): string | null {
|
||||
const path = peekShopReturnTo();
|
||||
try {
|
||||
sessionStorage.removeItem(SHOP_REDEEM_RETURN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||
import { request } from '../lib/api';
|
||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||
import { consumeShopReturnTo } from '../lib/shop-redeem-return';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
@@ -47,6 +48,7 @@ export default function RedeemConfirmPage() {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
consumeShopReturnTo();
|
||||
setToken(scanned);
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { goShopPath } from '../lib/shop-nav';
|
||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||
|
||||
@@ -35,10 +36,12 @@ export default function RedeemSuccessPage() {
|
||||
});
|
||||
}, [redeemNo, amount]);
|
||||
|
||||
const goHome = () => goShopPath('/', navigate, { replace: true });
|
||||
|
||||
return (
|
||||
<div className="shop-success-page">
|
||||
<header className="shop-success-header">
|
||||
<button type="button" className="shop-redeem-back" onClick={() => navigate('/')} aria-label="返回">
|
||||
<button type="button" className="shop-redeem-back" onClick={goHome} aria-label="返回">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">杜康好客</h1>
|
||||
@@ -85,11 +88,11 @@ export default function RedeemSuccessPage() {
|
||||
</div>
|
||||
|
||||
<div className="shop-success-actions">
|
||||
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
|
||||
<button type="button" className="shop-success-primary-btn" onClick={goHome}>
|
||||
<span>继续核销</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||
</button>
|
||||
<button type="button" className="shop-success-outline-btn" onClick={() => navigate('/')}>
|
||||
<button type="button" className="shop-success-outline-btn" onClick={goHome}>
|
||||
<span>返回首页</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>home</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { hardNavigateInWechat, shouldHardNavigateForJssdk } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -10,13 +9,11 @@ import {
|
||||
type ShopSessionPayload,
|
||||
type ShopStoreOption,
|
||||
} from '../lib/api';
|
||||
import { goShopPath } from '../lib/shop-nav';
|
||||
import { consumeShopReturnTo, peekShopReturnTo } from '../lib/shop-redeem-return';
|
||||
|
||||
function goShopHome(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat('/');
|
||||
return;
|
||||
}
|
||||
navigate('/', { replace: true });
|
||||
function goAfterSelectStore(navigate: (path: string, opts?: { replace?: boolean }) => void) {
|
||||
goShopPath(consumeShopReturnTo() || '/', navigate);
|
||||
}
|
||||
|
||||
export default function SelectStorePage() {
|
||||
@@ -46,7 +43,7 @@ export default function SelectStorePage() {
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
if (storeId === currentStoreId) {
|
||||
goShopHome(navigate);
|
||||
goAfterSelectStore(navigate);
|
||||
return;
|
||||
}
|
||||
setLoadingId(storeId);
|
||||
@@ -54,7 +51,7 @@ export default function SelectStorePage() {
|
||||
try {
|
||||
const session = await selectStore(storeId);
|
||||
applySession(session);
|
||||
goShopHome(navigate);
|
||||
goAfterSelectStore(navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '选店失败');
|
||||
} finally {
|
||||
@@ -170,16 +167,14 @@ export default function SelectStorePage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** After login/wechat: route to select-store or home */
|
||||
/** After login/wechat: route to select-store, redeem return, or home */
|
||||
export function routeAfterShopLogin(
|
||||
session: ShopSessionPayload,
|
||||
navigate: (path: string, opts?: { replace?: boolean }) => void,
|
||||
) {
|
||||
const path = needsStoreSelection(session) ? '/select-store' : '/';
|
||||
// iOS 微信:必须整页跳转,让业务页成为 JSSDK 新入场 URL,否则扫码验签必挂
|
||||
if (shouldHardNavigateForJssdk()) {
|
||||
hardNavigateInWechat(path);
|
||||
if (needsStoreSelection(session)) {
|
||||
goShopPath('/select-store', navigate);
|
||||
return;
|
||||
}
|
||||
navigate(path, { replace: true });
|
||||
goShopPath(peekShopReturnTo() || '/', navigate);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user