merge(dev_jacy): fix phone bind session bounce and mini-user UX
This commit is contained in:
@@ -76,7 +76,18 @@ function parseBody(data: unknown): { code?: number; message?: string } {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 统一请求:注入 USER_MINI + Bearer,解包 { code, message, data } */
|
||||
function isOnLoginPage(): boolean {
|
||||
try {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
return route.includes('pages/login');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
||||
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -96,8 +107,12 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
|
||||
const body = parseBody(res.data);
|
||||
|
||||
if (status === 401 || body?.code === 401) {
|
||||
clearAuth();
|
||||
redirectToLogin();
|
||||
// 仅清掉「发起本请求时」仍在使用的 token,避免登录页旧 /auth/me 竞态清掉刚写入的新 token
|
||||
const stillCurrent = !!token && getToken() === token;
|
||||
if (stillCurrent) {
|
||||
clearAuth();
|
||||
if (!isOnLoginPage()) redirectToLogin();
|
||||
}
|
||||
throw new Error(body?.message || '登录已过期,请重新登录');
|
||||
}
|
||||
if (status === 404 && body.code === undefined) {
|
||||
|
||||
@@ -42,24 +42,31 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
||||
/** 登录成功后回到 return 页,或回退 / 首页 */
|
||||
export function finishLoginNavigate(returnTo?: string) {
|
||||
const raw = (returnTo || '').trim();
|
||||
const target = raw ? decodeURIComponent(raw) : '';
|
||||
let target = '';
|
||||
try {
|
||||
target = raw ? decodeURIComponent(raw) : '';
|
||||
} catch {
|
||||
target = raw;
|
||||
}
|
||||
// 防止 return 仍指向登录页造成死循环
|
||||
const pathOnly = target.split('?')[0];
|
||||
|
||||
if (pathOnly && TAB_PAGES.has(pathOnly)) {
|
||||
Taro.switchTab({ url: pathOnly });
|
||||
if (!pathOnly || pathOnly.includes('/pages/login')) {
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
return;
|
||||
}
|
||||
if (pathOnly && pathOnly.startsWith('/pages/')) {
|
||||
|
||||
if (TAB_PAGES.has(pathOnly)) {
|
||||
Taro.switchTab({ url: pathOnly }).catch(() => {
|
||||
Taro.reLaunch({ url: pathOnly });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathOnly.startsWith('/pages/')) {
|
||||
Taro.redirectTo({ url: target }).catch(() => {
|
||||
Taro.reLaunch({ url: pathOnly });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
@@ -35,8 +35,12 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
|
||||
|
||||
goLogin(returnPath, { needWechat: '1' });
|
||||
return false;
|
||||
} catch {
|
||||
goLogin(returnPath);
|
||||
} catch (e) {
|
||||
// 仅会话失效时踢回登录;网络/业务错误不误清登录态
|
||||
const msg = e instanceof Error ? e.message : '';
|
||||
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-da
|
||||
import { FALLBACK_CITY_CODE } from './product-images';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
|
||||
const LOCATION_DENIED_KEY = 'dukang_location_denied';
|
||||
|
||||
export type ResolvedUserCity = {
|
||||
province: string;
|
||||
@@ -30,7 +32,35 @@ const FALLBACK_CITY: ResolvedUserCity = {
|
||||
displayCity: '郑州市',
|
||||
};
|
||||
|
||||
let locationPrompted = false;
|
||||
function isLocationDenied(): boolean {
|
||||
try {
|
||||
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLocationDenied() {
|
||||
try {
|
||||
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocationDenied() {
|
||||
try {
|
||||
Taro.removeStorageSync(LOCATION_DENIED_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function isDenyMessage(errMsg?: string): boolean {
|
||||
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
|
||||
errMsg || '',
|
||||
);
|
||||
}
|
||||
|
||||
function readCache(): GpsCityCache | null {
|
||||
try {
|
||||
@@ -55,6 +85,12 @@ function writeCache(data: ResolvedUserCity) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
|
||||
function cacheFallbackAndMaybeDeny(denied: boolean) {
|
||||
if (denied) markLocationDenied();
|
||||
writeCache(FALLBACK_CITY);
|
||||
}
|
||||
|
||||
async function reportLocationToServer(payload: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
@@ -98,14 +134,12 @@ function toResolved(data: {
|
||||
};
|
||||
}
|
||||
|
||||
async function promptLocationAuth() {
|
||||
if (locationPrompted) return;
|
||||
locationPrompted = true;
|
||||
async function promptLocationAuthOnce() {
|
||||
await Taro.showModal({
|
||||
title: '位置授权',
|
||||
content: '需要获取您的位置以展示所在城市的商品与门店',
|
||||
confirmText: '去授权',
|
||||
showCancel: true,
|
||||
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
|
||||
confirmText: '知道了',
|
||||
showCancel: false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -127,11 +161,14 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
|
||||
});
|
||||
|
||||
if (!outcome.location) {
|
||||
const denied = isDenyMessage(outcome.errMsg);
|
||||
await reportLocationToServer({
|
||||
sdk: outcome.sdk,
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
// 失败一律缓存兜底,避免首页/门店每次进入再次调起微信定位弹窗
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -143,9 +180,13 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) writeCache(resolved);
|
||||
if (resolved) {
|
||||
clearLocationDenied();
|
||||
writeCache(resolved);
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
cacheFallbackAndMaybeDeny(false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +194,10 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
|
||||
/** 获取并解析用户当前城市;失败返回郑州市兜底 */
|
||||
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
|
||||
if (!force) {
|
||||
if (isLocationDenied()) {
|
||||
const cached = readCache();
|
||||
return cached ?? FALLBACK_CITY;
|
||||
}
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
}
|
||||
@@ -176,20 +221,22 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
clearLocationDenied();
|
||||
writeCache(resolved);
|
||||
return resolved;
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const denied = /auth deny|authorize|permission|拒绝/i.test(errMsg);
|
||||
if (denied) {
|
||||
await promptLocationAuth();
|
||||
const denied = isDenyMessage(errMsg);
|
||||
if (denied && !isLocationDenied()) {
|
||||
await promptLocationAuthOnce();
|
||||
}
|
||||
await reportLocationToServer({
|
||||
sdk: 'jssdk',
|
||||
status: 'fail',
|
||||
errMsg: errMsg.slice(0, 200),
|
||||
}).catch(() => {});
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
}
|
||||
|
||||
return FALLBACK_CITY;
|
||||
|
||||
@@ -66,8 +66,15 @@ export default function LoginPage() {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
|
||||
if (!needPhone && !needWechat) {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchUserProfile()
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
if (needPhone && !me.phoneVerified) {
|
||||
setCompleteMode('phone');
|
||||
return;
|
||||
@@ -76,13 +83,14 @@ export default function LoginPage() {
|
||||
setCompleteMode('wechat');
|
||||
return;
|
||||
}
|
||||
if (needPhone || needWechat) {
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
setCompleteMode(null);
|
||||
finishLoginNavigate(returnTo);
|
||||
})
|
||||
.catch(() => setCompleteMode(null));
|
||||
.catch(() => {
|
||||
if (!cancelled) setCompleteMode(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needPhone, needWechat, returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,6 +111,7 @@ export default function LoginPage() {
|
||||
data: SessionPayload | WechatLoginResult,
|
||||
phone?: string,
|
||||
wxInfo?: MiniWechatProfile | null,
|
||||
successToast = '登录成功',
|
||||
) {
|
||||
if (!data.accessToken) return;
|
||||
if (phone) saveUserPhone(phone);
|
||||
@@ -116,7 +125,7 @@ export default function LoginPage() {
|
||||
.then((me) => resolveDefaultUserPhone(me))
|
||||
.catch(() => {});
|
||||
}
|
||||
toast('登录成功', 'success');
|
||||
toast(successToast, 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
}
|
||||
|
||||
@@ -188,19 +197,26 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (completeMode === 'phone' && isLoggedIn()) {
|
||||
await request('/auth/phone/bind', {
|
||||
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
|
||||
const data = await request<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
saveUserPhone(normalized);
|
||||
toast('手机号验证成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
if (!data?.accessToken) {
|
||||
setMsg('手机号验证成功但会话未返回,请重新登录');
|
||||
return;
|
||||
}
|
||||
applySessionAndLeave(data, normalized, null, '手机号验证成功');
|
||||
return;
|
||||
}
|
||||
const data = await request<SessionPayload>('/auth/login/sms', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
if (!data?.accessToken) {
|
||||
setMsg('登录成功但未返回令牌,请重试');
|
||||
return;
|
||||
}
|
||||
applySessionAndLeave(data, normalized);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
@@ -21,6 +22,19 @@ type OrderDetail = {
|
||||
qty?: number;
|
||||
addressSnapshot?: string;
|
||||
createdAt?: string;
|
||||
originOrderId?: string | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '出库中',
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待签收',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
@@ -35,6 +49,8 @@ export default function OrderDetailPage() {
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !order.originOrderId;
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: order?.productName
|
||||
@@ -52,8 +68,13 @@ export default function OrderDetailPage() {
|
||||
query: orderId ? `id=${orderId}` : '',
|
||||
}));
|
||||
|
||||
function goPay() {
|
||||
if (!order) return;
|
||||
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-detail-page">
|
||||
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<SubPageHeader
|
||||
title="订单详情"
|
||||
@@ -66,7 +87,9 @@ export default function OrderDetailPage() {
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">订单状态</Text>
|
||||
<Text className="order-list-status">{order.status || '处理中'}</Text>
|
||||
<Text className="order-list-status">
|
||||
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
@@ -103,6 +126,20 @@ export default function OrderDetailPage() {
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{canPay && order && (
|
||||
<View className="pay-bar order-detail-pay-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">待支付</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{Number(order.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={goPay}>
|
||||
去付款
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.order-detail-page--with-pay .sub-page-body {
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.order-detail-pay-bar {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
Reference in New Issue
Block a user