h5微信授权
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-07-14 20:09:36 +08:00
parent 9b13d02dde
commit 1ad49f1acf
14 changed files with 569 additions and 79 deletions
+7 -1
View File
@@ -1,9 +1,15 @@
import './lib/text-encoding-polyfill'; import './lib/text-encoding-polyfill';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import WechatShareBootstrap from './components/WechatShareBootstrap';
import './app.css'; import './app.css';
function App({ children }: PropsWithChildren) { function App({ children }: PropsWithChildren) {
return children; return (
<>
<WechatShareBootstrap />
{children}
</>
);
} }
export default App; export default App;
@@ -0,0 +1,94 @@
import Taro, { useDidShow } from '@tarojs/taro';
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react';
import { finishLoginNavigate, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api';
import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function currentPagePathWithQuery(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
| { route?: string; options?: Record<string, string | undefined> }
| undefined;
if (!cur?.route) {
if (typeof window !== 'undefined') {
return `${window.location.pathname}${window.location.search}`;
}
return '';
}
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
const opts = cur.options ?? {};
const qs = Object.entries(opts)
.filter(([k, v]) => v != null && v !== '' && k !== 'code' && k !== 'state')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return qs ? `${path}?${qs}` : path;
}
/**
* H5 全局:
* 1. 默认分享卡片
* 2. 公众号 OAuth ?code= 统一回调(避免各页重复消费 code)
*/
export default function WechatShareBootstrap() {
const handlingCode = useRef(false);
useEffect(() => {
if (process.env.TARO_ENV === 'h5') {
captureIosJssdkEntryUrl();
}
}, []);
useDidShow(() => {
if (process.env.TARO_ENV !== 'h5') return;
void applyWechatShare().catch(() => {});
if (!isWechatEnv()) return;
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
if (!params.get('code')) return;
if (handlingCode.current) return;
handlingCode.current = true;
const returnFromLogin = (() => {
const path = currentPagePathWithQuery();
if (path.includes('/pages/login/')) {
try {
return decodeURIComponent(params.get('return') || '') || undefined;
} catch {
return undefined;
}
}
return undefined;
})();
handleWechatAuthCallback()
.then((result) => {
if (!result) return;
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnFromLogin, {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
return;
}
if (saveWechatLoginResult(result)) {
toast('微信授权成功', 'success');
if (returnFromLogin !== undefined || currentPagePathWithQuery().includes('/pages/login/')) {
finishLoginNavigate(returnFromLogin || params.get('return') || undefined);
}
}
})
.catch((e) => {
toast(e instanceof Error ? e.message : '微信授权失败');
})
.finally(() => {
handlingCode.current = false;
});
});
return null;
}
+36
View File
@@ -0,0 +1,36 @@
import { weixinSdk } from './weixin';
export type ClientGpsLocation = {
province?: string;
city?: string;
district?: string;
latitude: number;
longitude: number;
address?: string;
};
/** 尝试获取客户端 GPS(微信 JSSDK / 浏览器 Geolocation),失败返回 null 不阻塞下单 */
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
if (process.env.TARO_ENV === 'weapp') {
try {
const Taro = (await import('@tarojs/taro')).default;
const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
return { latitude: loc.latitude, longitude: loc.longitude };
} catch {
return null;
}
}
const loc = await weixinSdk.getLocation();
if (!loc) return null;
return {
latitude: loc.latitude,
longitude: loc.longitude,
};
}
+22 -4
View File
@@ -1,8 +1,14 @@
import { goLogin } from './auth-nav'; import { goLogin } from './auth-nav';
import { isLoggedIn } from './api'; import { isLoggedIn } from './api';
import { ensureWechatAuthForPay } from './wechat-auth';
import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat'; import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat';
/** 支付前门禁:未登录/未验手机/未绑微信时跳转登录页 */ /**
* 支付前门禁:
* - 未登录 / 未验手机 → 跳转登录页
* - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页)
* - 小程序缺绑定 → 跳转登录页 needWechat
*/
export async function ensurePayReady(returnPath: string): Promise<boolean> { export async function ensurePayReady(returnPath: string): Promise<boolean> {
if (!isLoggedIn()) { if (!isLoggedIn()) {
goLogin(returnPath); goLogin(returnPath);
@@ -15,11 +21,23 @@ export async function ensurePayReady(returnPath: string): Promise<boolean> {
goLogin(returnPath, { needPhone: '1' }); goLogin(returnPath, { needPhone: '1' });
return false; return false;
} }
if (needsWechatAuthForPay(config, profile)) { if (!needsWechatAuthForPay(config, profile)) {
goLogin(returnPath, { needWechat: '1' }); return true;
}
if (process.env.TARO_ENV === 'h5') {
const auth = await ensureWechatAuthForPay();
if (auth.ok) return true;
if ('needBindPhone' in auth && auth.needBindPhone) {
goLogin(returnPath, { bindMode: '1', wxSessionKey: auth.wxSessionKey });
return false; return false;
} }
return true; // redirecting:正在跳转微信 OAuth
return false;
}
goLogin(returnPath, { needWechat: '1' });
return false;
} catch { } catch {
goLogin(returnPath); goLogin(returnPath);
return false; return false;
+40 -29
View File
@@ -1,9 +1,13 @@
import type { ClientRuntimeConfig, WechatJsapiPrepayParams, WechatLoginResult, WechatPayOrderResult } from '@dukang/shared-types'; import type {
ClientRuntimeConfig,
WechatJsapiPrepayParams,
WechatLoginResult,
WechatPayOrderResult,
} from '@dukang/shared-types';
import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types'; import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types';
import { invokeWechatPay } from '@dukang/weixin-sdk'; import { invokeWechatPay } from '@dukang/weixin-sdk';
import Taro from '@tarojs/taro'; import { request, saveAuth, type UserProfile } from './api';
import { request, type UserProfile } from './api'; import { isWechatEnv, weixinSdk } from './weixin';
import { syncMiniWechatProfile } from './mini-wechat-profile';
export function isMiniWechatEnv(): boolean { export function isMiniWechatEnv(): boolean {
return process.env.TARO_ENV === 'weapp'; return process.env.TARO_ENV === 'weapp';
@@ -21,13 +25,35 @@ export async function fetchUserProfile(): Promise<UserProfile> {
return request<UserProfile>('/auth/me'); return request<UserProfile>('/auth/me');
} }
/** 真实微信支付且未绑定微信时需要授权 */ /** 真实微信支付且未绑定微信时需要授权(小程序 / H5 微信内) */
export function needsWechatAuthForPay( export function needsWechatAuthForPay(
config: ClientRuntimeConfig, config: ClientRuntimeConfig,
profile: UserProfile | null, profile: UserProfile | null,
): boolean { ): boolean {
if (!isWxAuthorizeEnabled(config)) return false; if (!isWxAuthorizeEnabled(config)) return false;
return !config.mockPay && config.wechatPayEnabled && isMiniWechatEnv() && !profile?.hasWechat; return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
}
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
return true;
}
/** H5:发起公众号 OAuth(可能直接跳转);小程序请用 bindWechatForUser */
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
if (process.env.TARO_ENV === 'h5') {
return weixinSdk.login();
}
throw new Error('请使用小程序微信授权');
} }
function sleep(ms: number) { function sleep(ms: number) {
@@ -49,9 +75,12 @@ export async function payOrder(orderId: string): Promise<'paid' | 'pending'> {
}); });
if (result.mode === 'jsapi' && result.prepay) { if (result.mode === 'jsapi' && result.prepay) {
await invokeWechatPay(result.prepay as WechatJsapiPrepayParams, { const prepay = result.prepay as WechatJsapiPrepayParams;
platform: isMiniWechatEnv() ? 'mini' : undefined, if (process.env.TARO_ENV === 'h5') {
}); await weixinSdk.pay(prepay);
} else {
await invokeWechatPay(prepay, { platform: 'mini' });
}
const paid = await waitOrderPaid(orderId); const paid = await waitOrderPaid(orderId);
return paid ? 'paid' : 'pending'; return paid ? 'paid' : 'pending';
} }
@@ -61,23 +90,5 @@ export async function payOrder(orderId: string): Promise<'paid' | 'pending'> {
export type WechatBindResult = export type WechatBindResult =
| { ok: true; profile?: UserProfile } | { ok: true; profile?: UserProfile }
| { ok: false; needBindPhone: true; wxSessionKey: string }; | { ok: false; needBindPhone: true; wxSessionKey: string }
| { ok: false; redirecting: true };
export async function bindWechatForUser(
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
): Promise<WechatBindResult> {
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信授权失败');
}
const data = await request<WechatLoginResult>('/auth/wechat/bind', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
if (data.needBindPhone && data.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey };
}
await syncMiniWechatProfile(prefetchedWxProfile);
const profile = await fetchUserProfile();
return { ok: true, profile };
}
+41 -3
View File
@@ -1,7 +1,9 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { request } from './api'; import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
import { API_BASE, getToken, request } from './api';
import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data'; import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data';
import { FALLBACK_CITY_CODE } from './product-images'; import { FALLBACK_CITY_CODE } from './product-images';
import { ClientApp } from '@dukang/shared-types';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
@@ -108,7 +110,7 @@ async function promptLocationAuth() {
}).catch(() => {}); }).catch(() => {});
} }
function getLocation(): Promise<Taro.getLocation.SuccessCallbackResult> { function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Taro.getLocation({ Taro.getLocation({
type: 'gcj02', type: 'gcj02',
@@ -118,6 +120,37 @@ function getLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
}); });
} }
async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
const outcome = await getWechatLocationDetailed({
apiBase: API_BASE,
clientApp: ClientApp.USER_H5,
getAccessToken: () => getToken() || null,
});
if (!outcome.location) {
await reportLocationToServer({
sdk: outcome.sdk,
status: 'fail',
errMsg: outcome.errMsg,
}).catch(() => {});
return null;
}
try {
const data = await reportLocationToServer({
latitude: outcome.location.latitude,
longitude: outcome.location.longitude,
sdk: outcome.sdk,
status: 'success',
});
const resolved = toResolved(data);
if (resolved) writeCache(resolved);
return resolved;
} catch {
return null;
}
}
/** 获取并解析用户当前城市;失败返回郑州市兜底 */ /** 获取并解析用户当前城市;失败返回郑州市兜底 */
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> { export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
if (!force) { if (!force) {
@@ -125,12 +158,17 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
if (cached) return cached; if (cached) return cached;
} }
if (process.env.TARO_ENV === 'h5') {
const fromJssdk = await resolveViaH5Jssdk();
return fromJssdk ?? FALLBACK_CITY;
}
if (process.env.TARO_ENV !== 'weapp') { if (process.env.TARO_ENV !== 'weapp') {
return FALLBACK_CITY; return FALLBACK_CITY;
} }
try { try {
const loc = await getLocation(); const loc = await getMiniLocation();
const data = await reportLocationToServer({ const data = await reportLocationToServer({
latitude: loc.latitude, latitude: loc.latitude,
longitude: loc.longitude, longitude: loc.longitude,
+111 -4
View File
@@ -1,17 +1,124 @@
import type { WechatLoginResult } from '@dukang/shared-types'; import type { WechatLoginResult } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { request } from './api'; import { saveAuth } from './api';
import { syncMiniWechatProfile } from './mini-wechat-profile';
import {
authorizeWechatForPay,
fetchClientConfig,
fetchUserProfile,
needsWechatAuthForPay,
saveWechatLoginResult,
type WechatBindResult,
} from './pay-wechat';
import { isWechatEnv, weixinSdk } from './weixin';
/** 小程序微信授权登录:Taro.login → /auth/login/wechat(资料上报由调用方 saveAuth 后执行) */ export type WechatAuthEnsureResult =
export async function loginWithWechat(): Promise<WechatLoginResult> { | { ok: true }
| { ok: false; redirecting: true }
| { ok: false; needBindPhone: true; wxSessionKey: string };
/** 小程序:Taro.login → /auth/login/wechatH5:公众号 OAuth */
export async function loginWithWechat(): Promise<WechatLoginResult | void> {
if (process.env.TARO_ENV === 'h5') {
return loginWithWechatSdk();
}
const res = await Taro.login(); const res = await Taro.login();
if (!res.code) { if (!res.code) {
throw new Error(res.errMsg || '微信登录失败,未获取到 code'); throw new Error(res.errMsg || '微信登录失败,未获取到 code');
} }
const { request } = await import('./api');
return request<WechatLoginResult>('/auth/login/wechat', { return request<WechatLoginResult>('/auth/login/wechat', {
method: 'POST', method: 'POST',
data: { code: res.code, platform: 'mini' }, data: { code: res.code, platform: 'mini' },
}); });
} }
export { bindWechatForUser } from './pay-wechat'; export async function checkNeedsWechatAuth(): Promise<boolean> {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
return needsWechatAuthForPay(config, profile);
}
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
if (!isWechatEnv()) return { ok: true };
if (!(await checkNeedsWechatAuth())) return { ok: true };
const result = await authorizeWechatForPay();
if (!result) return { ok: false, redirecting: true };
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (saveWechatLoginResult(result)) {
return { ok: true };
}
return { ok: false, redirecting: true };
}
/** 处理 URL 中 OAuth ?code= 回调(H5 公众号) */
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
if (process.env.TARO_ENV !== 'h5') return null;
if (!isWechatEnv()) return null;
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return null;
const result = await weixinSdk.handleOAuthCallback();
if (result) stripOAuthParamsFromLocation();
return result;
}
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error('请在微信内打开以使用微信一键授权');
}
return weixinSdk.login();
}
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
return saveWechatLoginResult(result);
}
/** 已登录用户绑定微信:小程序 code;H5 走公众号 OAuth(带 JWT 时服务端会 attach */
export async function bindWechatForUser(
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
): Promise<WechatBindResult> {
if (process.env.TARO_ENV === 'h5') {
const result = await authorizeWechatForPay();
if (!result) {
return { ok: false, redirecting: true };
}
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (result.accessToken) {
saveAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
}
const profile = await fetchUserProfile();
return { ok: true, profile };
}
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信授权失败');
}
const { request } = await import('./api');
const data = await request<WechatLoginResult>('/auth/wechat/bind', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
if (data.needBindPhone && data.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey };
}
await syncMiniWechatProfile(prefetchedWxProfile);
const profile = await fetchUserProfile();
return { ok: true, profile };
}
export type { WechatBindResult };
+11 -4
View File
@@ -11,7 +11,10 @@ import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton'; import WechatLoginButton from '../../components/WechatLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types'; import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import { finishLoginNavigate } from '../../lib/auth-nav'; import { finishLoginNavigate } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth'; import {
bindWechatForUser,
loginWithWechat,
} from '../../lib/wechat-auth';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone'; import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
import { import {
@@ -21,7 +24,6 @@ import {
type MiniWechatProfile, type MiniWechatProfile,
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api'; import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { loginWithWechat } from '../../lib/wechat-auth';
function normalizePhone(value: string) { function normalizePhone(value: string) {
return value.replace(/\D/g, '').slice(0, 11); return value.replace(/\D/g, '').slice(0, 11);
@@ -224,7 +226,10 @@ export default function LoginPage() {
if (completeMode === 'wechat' && isLoggedIn()) { if (completeMode === 'wechat' && isLoggedIn()) {
const result = await bindWechatForUser(wxInfo); const result = await bindWechatForUser(wxInfo);
if (!result.ok && result.needBindPhone) { if (!result.ok && 'redirecting' in result && result.redirecting) {
return;
}
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
setBindMode(true); setBindMode(true);
setWxSessionKey(result.wxSessionKey); setWxSessionKey(result.wxSessionKey);
setCompleteMode('phone'); setCompleteMode('phone');
@@ -240,11 +245,13 @@ export default function LoginPage() {
return; return;
} }
const result = await loginWithWechat(); const result = await loginWithWechat();
handleWechatLoginResult(result, wxInfo); if (result) handleWechatLoginResult(result, wxInfo);
} catch (e) { } catch (e) {
const raw = e instanceof Error ? e.message : '微信登录失败'; const raw = e instanceof Error ? e.message : '微信登录失败';
const hint = /invalid code/i.test(raw) const hint = /invalid code/i.test(raw)
? process.env.TARO_ENV === 'weapp'
? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT' ? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
: '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确'
: raw; : raw;
setMsg(hint); setMsg(hint);
} finally { } finally {
+32 -6
View File
@@ -11,10 +11,10 @@ import { bindWechatForUser } from '../../lib/wechat-auth';
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { import {
fetchMiniWechatUserInfo, fetchMiniWechatUserInfo,
getCachedWxProfile,
mergeWxDisplayProfile, mergeWxDisplayProfile,
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api'; import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
const ORDER_SHORTCUTS = [ const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: '付', label: '待付款' }, { tab: 'pending_pay', icon: '付', label: '待付款' },
@@ -111,13 +111,39 @@ export default function MinePage() {
toast('当前环境未开启微信授权'); toast('当前环境未开启微信授权');
return; return;
} }
if (process.env.TARO_ENV !== 'weapp') {
toast('请在微信小程序中完成微信授权');
return;
}
setBindingWx(true); setBindingWx(true);
try { try {
if (process.env.TARO_ENV === 'h5') {
if (!isWechatEnv()) {
toast('请在微信内打开后授权');
return;
}
const result = await bindWechatForUser();
if (!result.ok && 'redirecting' in result && result.redirecting) {
return;
}
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
return;
}
if (result.ok) {
setProfile(
mergeWxDisplayProfile({
...(result.profile ?? {}),
id: result.profile?.id ?? profile?.id ?? '',
hasWechat: true,
}),
);
loadProfile();
toast('微信授权成功', 'success');
}
return;
}
let wxInfo = null; let wxInfo = null;
try { try {
wxInfo = await fetchMiniWechatUserInfo(); wxInfo = await fetchMiniWechatUserInfo();
@@ -127,7 +153,7 @@ export default function MinePage() {
} }
const result = await bindWechatForUser(wxInfo); const result = await bindWechatForUser(wxInfo);
if (!result.ok && result.needBindPhone) { if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey }); goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
return; return;
} }
@@ -4,9 +4,10 @@ import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav'; import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { request, toast } from '../../lib/api'; import { request } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
type Address = { type Address = {
@@ -127,12 +128,19 @@ export default function OrderConfirmPage() {
} }
async function doSubmit() { async function doSubmit() {
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('/trade/orders', { const order = await request<{ id: string }>('/trade/orders', {
method: 'POST', method: 'POST',
data: { data: {
productId, productId,
quantity, quantity,
addressId, addressId,
...(clientLocation ? { clientLocation } : {}),
}, },
}); });
Taro.redirectTo({ Taro.redirectTo({
@@ -1,4 +1,6 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '订单详情', navigationBarTitleText: '订单详情',
enableShareAppMessage: true,
enableShareTimeline: true,
}); });
@@ -1,9 +1,16 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import { useRouter } from '@tarojs/taro'; import { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type OrderDetail = { type OrderDetail = {
id: string; id: string;
@@ -28,9 +35,30 @@ export default function OrderDetailPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]); }, [orderId]);
const sharePayload = useMemo(
() => ({
title: order?.productName
? `我买了${order.productName} · 杜康好客`
: DEFAULT_SHARE_TITLE,
desc: DEFAULT_SHARE_DESC,
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
}),
[order, orderId],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: orderId ? `id=${orderId}` : '',
}));
return ( return (
<PageShell variant="sub" className="order-detail-page"> <PageShell variant="sub" className="order-detail-page">
<SubPageHeader title="订单详情" /> <WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="sub-page-body"> <View className="sub-page-body">
{!order ? ( {!order ? (
<View className="u-empty"></View> <View className="u-empty"></View>
+97 -19
View File
@@ -1,22 +1,29 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro'; import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import WechatLoginButton from '../../components/WechatLoginButton';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { import {
authorizeWechatForPay,
fetchClientConfig, fetchClientConfig,
fetchUserProfile, fetchUserProfile,
isWechatAuthRequiredError, isWechatAuthRequiredError,
needsWechatAuthForPay, needsWechatAuthForPay,
payOrder, payOrder,
saveWechatLoginResult,
} from '../../lib/pay-wechat'; } from '../../lib/pay-wechat';
import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
export default function PayPage() { export default function PayPage() {
const router = useRouter(); const router = useRouter();
const orderId = router.params.orderId ?? ''; const orderId = router.params.orderId ?? '';
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true); const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false); const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -27,24 +34,28 @@ export default function PayPage() {
? `/pages/pay/index?orderId=${orderId}` ? `/pages/pay/index?orderId=${orderId}`
: '/pages/pay/index'; : '/pages/pay/index';
useEffect(() => { const refreshPayReadiness = useCallback(async () => {
if (!orderId) return;
void ensurePayReady(returnPath);
}, [orderId, returnPath]);
useEffect(() => {
async function load() {
try { try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay); setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile)); setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch { } catch {
/* ignore */ return null;
} }
}
void load();
}, []); }, []);
useDidShow(() => {
void refreshPayReadiness();
});
useEffect(() => {
if (!orderId) return;
if (process.env.TARO_ENV === 'weapp') {
void ensurePayReady(returnPath);
}
}, [orderId, returnPath]);
useEffect(() => { useEffect(() => {
if (!orderId) { if (!orderId) {
setOrderNo(''); setOrderNo('');
@@ -67,6 +78,38 @@ export default function PayPage() {
}); });
}, [orderId]); }, [orderId]);
async function wechatAuthorize() {
setAuthLoading(true);
setMsg('');
try {
if (!isWechatEnv()) {
setMsg('请在微信内打开以授权微信支付');
return;
}
if (process.env.TARO_ENV === 'weapp') {
const ready = await ensurePayReady(returnPath);
if (ready) await refreshPayReadiness();
return;
}
const result = await authorizeWechatForPay();
if (result) {
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnPath, { bindMode: '1', wxSessionKey: result.wxSessionKey });
return;
}
if (saveWechatLoginResult(result) || applyWechatLoginResult(result)) {
setMsg('');
await refreshPayReadiness();
toast('微信授权成功', 'success');
}
}
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthLoading(false);
}
}
async function pay() { async function pay() {
if (!orderId) { if (!orderId) {
toast('订单不存在'); toast('订单不存在');
@@ -74,8 +117,11 @@ export default function PayPage() {
} }
if (needsWechatAuth) { if (needsWechatAuth) {
setMsg('请先完成微信授权后再支付'); setMsg('请先完成微信授权后再支付');
const ready = await ensurePayReady(returnPath); if (process.env.TARO_ENV === 'h5') {
if (!ready) return; await wechatAuthorize();
} else {
await ensurePayReady(returnPath);
}
return; return;
} }
@@ -96,7 +142,6 @@ export default function PayPage() {
if (isWechatAuthRequiredError(e)) { if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true); setNeedsWechatAuth(true);
setMsg('微信支付需要先完成微信授权'); setMsg('微信支付需要先完成微信授权');
await ensurePayReady(returnPath);
return; return;
} }
const message = e instanceof Error ? e.message : '支付失败'; const message = e instanceof Error ? e.message : '支付失败';
@@ -124,6 +169,20 @@ export default function PayPage() {
</Text> </Text>
<Text className="pay-status-amount">¥{payAmount}</Text> <Text className="pay-status-amount">¥{payAmount}</Text>
</View> </View>
{needsWechatAuth ? (
<View className="pay-wechat-auth-card">
<Text className="pay-wechat-auth-title"></Text>
<Text className="pay-wechat-auth-desc">
</Text>
<WechatLoginButton
loading={authLoading}
onClick={() => void wechatAuthorize()}
/>
</View>
) : null}
<View className="order-card"> <View className="order-card">
<View className="order-row"> <View className="order-row">
<Text className="order-row-label"></Text> <Text className="order-row-label"></Text>
@@ -140,15 +199,34 @@ export default function PayPage() {
</Text> </Text>
</View> </View>
</View> </View>
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 12 }}>{msg}</Text> : null} {msg ? (
<Text className="pay-wechat-auth-msg" style={{ display: 'block', marginTop: 12 }}>
{msg}
</Text>
) : null}
</View> </View>
<View className="pay-bar"> <View className="pay-bar">
<View <View
className="order-confirm-submit" className="order-confirm-submit"
style={{ flex: 1, opacity: loading ? 0.7 : 1 }} style={{ flex: 1, opacity: loading || needsWechatAuth ? 0.7 : 1 }}
onClick={() => !loading && void pay()} onClick={() => {
if (loading) return;
if (needsWechatAuth) {
void wechatAuthorize();
return;
}
void pay();
}}
> >
<Text>{loading ? '支付中…' : needsWechatAuth ? '去授权' : '立即支付'}</Text> <Text>
{loading
? '支付中…'
: needsWechatAuth
? authLoading
? '授权中…'
: '微信一键授权'
: '立即支付'}
</Text>
</View> </View>
</View> </View>
</PageShell> </PageShell>
+31
View File
@@ -278,3 +278,34 @@
color: var(--color-heritage-red); color: var(--color-heritage-red);
margin-bottom: 24px; margin-bottom: 24px;
} }
.pay-wechat-auth-card {
margin: 0 0 16px;
padding: 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
text-align: center;
}
.pay-wechat-auth-title {
display: block;
font-size: 16px;
font-weight: 600;
color: var(--color-on-surface);
margin-bottom: 8px;
}
.pay-wechat-auth-desc {
display: block;
font-size: 13px;
color: var(--color-muted, #999);
margin-bottom: 16px;
line-height: 1.5;
}
.pay-wechat-auth-msg {
font-size: 13px;
color: var(--color-heritage-red, #a02d30);
line-height: 1.5;
}