merge(dev_jacy): fix phone bind session bounce and mini-user UX

This commit is contained in:
2026-07-15 21:11:22 +08:00
7 changed files with 176 additions and 41 deletions
+17 -2
View File
@@ -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) {
// 仅清掉「发起本请求时」仍在使用的 token,避免登录页旧 /auth/me 竞态清掉刚写入的新 token
const stillCurrent = !!token && getToken() === token;
if (stillCurrent) {
clearAuth();
redirectToLogin();
if (!isOnLoginPage()) redirectToLogin();
}
throw new Error(body?.message || '登录已过期,请重新登录');
}
if (status === 404 && body.code === undefined) {
+18 -11
View File
@@ -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' });
}
+5 -1
View File
@@ -35,8 +35,12 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
goLogin(returnPath, { needWechat: '1' });
return false;
} catch {
} catch (e) {
// 仅会话失效时踢回登录;网络/业务错误不误清登录态
const msg = e instanceof Error ? e.message : '';
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
goLogin(returnPath);
}
return false;
}
}
+58 -11
View File
@@ -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;
+26 -10
View File
@@ -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);
})
.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>
);
}
+9
View File
@@ -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);