feat(fulfillment): 同城运费与路由修复,配送单展示商品用户地址

同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 21:12:36 +08:00
parent 52a7d3789d
commit cc0c0a6ef8
44 changed files with 1029 additions and 218 deletions
+5 -1
View File
@@ -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
View File
@@ -1,22 +1 @@
/** 从微信扫码结果解析核销 token32 位 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';
+14
View File
@@ -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]);
+6 -3
View File
@@ -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>
+10 -15
View File
@@ -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);
}