From 1ad49f1acf55da92df1ad1f7fa62fea0430769b6 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 14 Jul 2026 20:09:36 +0800 Subject: [PATCH] =?UTF-8?q?h5=E5=BE=AE=E4=BF=A1=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/src/app.tsx | 8 +- .../src/components/WechatShareBootstrap.tsx | 94 +++++++++++++ apps/mini-user/src/lib/client-location.ts | 36 +++++ apps/mini-user/src/lib/pay-ready.ts | 26 +++- apps/mini-user/src/lib/pay-wechat.ts | 69 ++++++---- apps/mini-user/src/lib/user-location.ts | 44 ++++++- apps/mini-user/src/lib/wechat-auth.ts | 115 +++++++++++++++- apps/mini-user/src/pages/login/index.tsx | 17 ++- apps/mini-user/src/pages/mine/index.tsx | 38 +++++- .../src/pages/order-confirm/index.tsx | 10 +- .../src/pages/order-detail/index.config.ts | 2 + .../src/pages/order-detail/index.tsx | 34 ++++- apps/mini-user/src/pages/pay/index.tsx | 124 ++++++++++++++---- apps/mini-user/src/styles/order.css | 31 +++++ 14 files changed, 569 insertions(+), 79 deletions(-) create mode 100644 apps/mini-user/src/components/WechatShareBootstrap.tsx create mode 100644 apps/mini-user/src/lib/client-location.ts diff --git a/apps/mini-user/src/app.tsx b/apps/mini-user/src/app.tsx index 85fa689..cb333b4 100644 --- a/apps/mini-user/src/app.tsx +++ b/apps/mini-user/src/app.tsx @@ -1,9 +1,15 @@ import './lib/text-encoding-polyfill'; import { PropsWithChildren } from 'react'; +import WechatShareBootstrap from './components/WechatShareBootstrap'; import './app.css'; function App({ children }: PropsWithChildren) { - return children; + return ( + <> + + {children} + + ); } export default App; diff --git a/apps/mini-user/src/components/WechatShareBootstrap.tsx b/apps/mini-user/src/components/WechatShareBootstrap.tsx new file mode 100644 index 0000000..da5132f --- /dev/null +++ b/apps/mini-user/src/components/WechatShareBootstrap.tsx @@ -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 } + | 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; +} diff --git a/apps/mini-user/src/lib/client-location.ts b/apps/mini-user/src/lib/client-location.ts new file mode 100644 index 0000000..c9c772e --- /dev/null +++ b/apps/mini-user/src/lib/client-location.ts @@ -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 { + 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, + }; +} diff --git a/apps/mini-user/src/lib/pay-ready.ts b/apps/mini-user/src/lib/pay-ready.ts index e988a1d..704dfa7 100644 --- a/apps/mini-user/src/lib/pay-ready.ts +++ b/apps/mini-user/src/lib/pay-ready.ts @@ -1,8 +1,14 @@ import { goLogin } from './auth-nav'; import { isLoggedIn } from './api'; +import { ensureWechatAuthForPay } from './wechat-auth'; import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat'; -/** 支付前门禁:未登录/未验手机/未绑微信时跳转登录页 */ +/** + * 支付前门禁: + * - 未登录 / 未验手机 → 跳转登录页 + * - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页) + * - 小程序缺绑定 → 跳转登录页 needWechat + */ export async function ensurePayReady(returnPath: string): Promise { if (!isLoggedIn()) { goLogin(returnPath); @@ -15,11 +21,23 @@ export async function ensurePayReady(returnPath: string): Promise { goLogin(returnPath, { needPhone: '1' }); return false; } - if (needsWechatAuthForPay(config, profile)) { - goLogin(returnPath, { needWechat: '1' }); + if (!needsWechatAuthForPay(config, profile)) { + 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; + } + // redirecting:正在跳转微信 OAuth return false; } - return true; + + goLogin(returnPath, { needWechat: '1' }); + return false; } catch { goLogin(returnPath); return false; diff --git a/apps/mini-user/src/lib/pay-wechat.ts b/apps/mini-user/src/lib/pay-wechat.ts index 2e1c3c6..0983953 100644 --- a/apps/mini-user/src/lib/pay-wechat.ts +++ b/apps/mini-user/src/lib/pay-wechat.ts @@ -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 { invokeWechatPay } from '@dukang/weixin-sdk'; -import Taro from '@tarojs/taro'; -import { request, type UserProfile } from './api'; -import { syncMiniWechatProfile } from './mini-wechat-profile'; +import { request, saveAuth, type UserProfile } from './api'; +import { isWechatEnv, weixinSdk } from './weixin'; export function isMiniWechatEnv(): boolean { return process.env.TARO_ENV === 'weapp'; @@ -21,13 +25,35 @@ export async function fetchUserProfile(): Promise { return request('/auth/me'); } -/** 真实微信支付且未绑定微信时需要授权 */ +/** 真实微信支付且未绑定微信时需要授权(小程序 / H5 微信内) */ export function needsWechatAuthForPay( config: ClientRuntimeConfig, profile: UserProfile | null, ): boolean { 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 { + 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) { @@ -49,9 +75,12 @@ export async function payOrder(orderId: string): Promise<'paid' | 'pending'> { }); if (result.mode === 'jsapi' && result.prepay) { - await invokeWechatPay(result.prepay as WechatJsapiPrepayParams, { - platform: isMiniWechatEnv() ? 'mini' : undefined, - }); + const prepay = result.prepay as WechatJsapiPrepayParams; + if (process.env.TARO_ENV === 'h5') { + await weixinSdk.pay(prepay); + } else { + await invokeWechatPay(prepay, { platform: 'mini' }); + } const paid = await waitOrderPaid(orderId); return paid ? 'paid' : 'pending'; } @@ -61,23 +90,5 @@ export async function payOrder(orderId: string): Promise<'paid' | 'pending'> { export type WechatBindResult = | { ok: true; profile?: UserProfile } - | { ok: false; needBindPhone: true; wxSessionKey: string }; - -export async function bindWechatForUser( - prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null, -): Promise { - const res = await Taro.login(); - if (!res.code) { - throw new Error(res.errMsg || '微信授权失败'); - } - const data = await request('/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 }; -} + | { ok: false; needBindPhone: true; wxSessionKey: string } + | { ok: false; redirecting: true }; diff --git a/apps/mini-user/src/lib/user-location.ts b/apps/mini-user/src/lib/user-location.ts index d00bfc5..593a8ee 100644 --- a/apps/mini-user/src/lib/user-location.ts +++ b/apps/mini-user/src/lib/user-location.ts @@ -1,7 +1,9 @@ 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 { FALLBACK_CITY_CODE } from './product-images'; +import { ClientApp } from '@dukang/shared-types'; export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; @@ -108,7 +110,7 @@ async function promptLocationAuth() { }).catch(() => {}); } -function getLocation(): Promise { +function getMiniLocation(): Promise { return new Promise((resolve, reject) => { Taro.getLocation({ type: 'gcj02', @@ -118,6 +120,37 @@ function getLocation(): Promise { }); } +async function resolveViaH5Jssdk(): Promise { + 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 { if (!force) { @@ -125,12 +158,17 @@ export async function resolveUserCity(force = false): Promise if (cached) return cached; } + if (process.env.TARO_ENV === 'h5') { + const fromJssdk = await resolveViaH5Jssdk(); + return fromJssdk ?? FALLBACK_CITY; + } + if (process.env.TARO_ENV !== 'weapp') { return FALLBACK_CITY; } try { - const loc = await getLocation(); + const loc = await getMiniLocation(); const data = await reportLocationToServer({ latitude: loc.latitude, longitude: loc.longitude, diff --git a/apps/mini-user/src/lib/wechat-auth.ts b/apps/mini-user/src/lib/wechat-auth.ts index cb763a9..5e3fb73 100644 --- a/apps/mini-user/src/lib/wechat-auth.ts +++ b/apps/mini-user/src/lib/wechat-auth.ts @@ -1,17 +1,124 @@ 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 { 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 async function loginWithWechat(): Promise { +export type WechatAuthEnsureResult = + | { ok: true } + | { ok: false; redirecting: true } + | { ok: false; needBindPhone: true; wxSessionKey: string }; + +/** 小程序:Taro.login → /auth/login/wechat;H5:公众号 OAuth */ +export async function loginWithWechat(): Promise { + if (process.env.TARO_ENV === 'h5') { + return loginWithWechatSdk(); + } const res = await Taro.login(); if (!res.code) { throw new Error(res.errMsg || '微信登录失败,未获取到 code'); } + const { request } = await import('./api'); return request('/auth/login/wechat', { method: 'POST', data: { code: res.code, platform: 'mini' }, }); } -export { bindWechatForUser } from './pay-wechat'; +export async function checkNeedsWechatAuth(): Promise { + const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); + return needsWechatAuthForPay(config, profile); +} + +/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */ +export async function ensureWechatAuthForPay(): Promise { + 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 { + 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 { + 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 { + 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('/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 }; diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index ad49a56..429a696 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -11,7 +11,10 @@ import PageShell from '../../components/PageShell'; import WechatLoginButton from '../../components/WechatLoginButton'; import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types'; 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 { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone'; import { @@ -21,7 +24,6 @@ import { type MiniWechatProfile, } from '../../lib/mini-wechat-profile'; import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api'; -import { loginWithWechat } from '../../lib/wechat-auth'; function normalizePhone(value: string) { return value.replace(/\D/g, '').slice(0, 11); @@ -224,7 +226,10 @@ export default function LoginPage() { if (completeMode === 'wechat' && isLoggedIn()) { 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); setWxSessionKey(result.wxSessionKey); setCompleteMode('phone'); @@ -240,11 +245,13 @@ export default function LoginPage() { return; } const result = await loginWithWechat(); - handleWechatLoginResult(result, wxInfo); + if (result) handleWechatLoginResult(result, wxInfo); } catch (e) { const raw = e instanceof Error ? e.message : '微信登录失败'; const hint = /invalid code/i.test(raw) - ? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT' + ? process.env.TARO_ENV === 'weapp' + ? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT' + : '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确' : raw; setMsg(hint); } finally { diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx index 6ef4727..66b22ca 100644 --- a/apps/mini-user/src/pages/mine/index.tsx +++ b/apps/mini-user/src/pages/mine/index.tsx @@ -11,10 +11,10 @@ import { bindWechatForUser } from '../../lib/wechat-auth'; import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchMiniWechatUserInfo, - getCachedWxProfile, mergeWxDisplayProfile, } from '../../lib/mini-wechat-profile'; import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api'; +import { isWechatEnv } from '../../lib/weixin'; const ORDER_SHORTCUTS = [ { tab: 'pending_pay', icon: '付', label: '待付款' }, @@ -111,13 +111,39 @@ export default function MinePage() { toast('当前环境未开启微信授权'); return; } - if (process.env.TARO_ENV !== 'weapp') { - toast('请在微信小程序中完成微信授权'); - return; - } setBindingWx(true); 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; try { wxInfo = await fetchMiniWechatUserInfo(); @@ -127,7 +153,7 @@ export default function MinePage() { } 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 }); return; } diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx index 435c111..946f175 100644 --- a/apps/mini-user/src/pages/order-confirm/index.tsx +++ b/apps/mini-user/src/pages/order-confirm/index.tsx @@ -4,9 +4,10 @@ import Taro, { useRouter } from '@tarojs/taro'; import PageShell from '../../components/PageShell'; import SubPageHeader from '../../components/SubPageHeader'; import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav'; +import { tryGetClientGpsLocation } from '../../lib/client-location'; import { maskPhone } from '../../lib/phone'; import { ensurePayReady } from '../../lib/pay-ready'; -import { request, toast } from '../../lib/api'; +import { request } from '../../lib/api'; import { getProductMainImage } from '../../lib/product-images'; type Address = { @@ -127,12 +128,19 @@ export default function OrderConfirmPage() { } async function doSubmit() { + let clientLocation = null; + try { + clientLocation = await tryGetClientGpsLocation(); + } catch { + /* GPS 获取失败不阻塞下单 */ + } const order = await request<{ id: string }>('/trade/orders', { method: 'POST', data: { productId, quantity, addressId, + ...(clientLocation ? { clientLocation } : {}), }, }); Taro.redirectTo({ diff --git a/apps/mini-user/src/pages/order-detail/index.config.ts b/apps/mini-user/src/pages/order-detail/index.config.ts index 58da757..06ef8fb 100644 --- a/apps/mini-user/src/pages/order-detail/index.config.ts +++ b/apps/mini-user/src/pages/order-detail/index.config.ts @@ -1,4 +1,6 @@ export default definePageConfig({ navigationStyle: 'custom', navigationBarTitleText: '订单详情', + enableShareAppMessage: true, + enableShareTimeline: true, }); diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx index ca56b54..34e0d3b 100644 --- a/apps/mini-user/src/pages/order-detail/index.tsx +++ b/apps/mini-user/src/pages/order-detail/index.tsx @@ -1,9 +1,16 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { View, Text } from '@tarojs/components'; -import { useRouter } from '@tarojs/taro'; +import { 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 { + DEFAULT_SHARE_DESC, + DEFAULT_SHARE_TITLE, + toWeappShareMessage, +} from '../../lib/wechat-share'; type OrderDetail = { id: string; @@ -28,9 +35,30 @@ export default function OrderDetailPage() { .catch((e) => toast(e instanceof Error ? e.message : '加载失败')); }, [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 ( - + + } + /> {!order ? ( 加载中… diff --git a/apps/mini-user/src/pages/pay/index.tsx b/apps/mini-user/src/pages/pay/index.tsx index 7d9f506..fa2ff8e 100644 --- a/apps/mini-user/src/pages/pay/index.tsx +++ b/apps/mini-user/src/pages/pay/index.tsx @@ -1,22 +1,29 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; 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 SubPageHeader from '../../components/SubPageHeader'; +import WechatLoginButton from '../../components/WechatLoginButton'; import { ensurePayReady } from '../../lib/pay-ready'; import { + authorizeWechatForPay, fetchClientConfig, fetchUserProfile, isWechatAuthRequiredError, needsWechatAuthForPay, payOrder, + saveWechatLoginResult, } 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'; export default function PayPage() { const router = useRouter(); const orderId = router.params.orderId ?? ''; const [loading, setLoading] = useState(false); + const [authLoading, setAuthLoading] = useState(false); const [mockMode, setMockMode] = useState(true); const [needsWechatAuth, setNeedsWechatAuth] = useState(false); const [msg, setMsg] = useState(''); @@ -27,23 +34,27 @@ export default function PayPage() { ? `/pages/pay/index?orderId=${orderId}` : '/pages/pay/index'; - useEffect(() => { - if (!orderId) return; - void ensurePayReady(returnPath); - }, [orderId, returnPath]); + const refreshPayReadiness = useCallback(async () => { + try { + const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); + setMockMode(config.mockPay); + setNeedsWechatAuth(needsWechatAuthForPay(config, profile)); + return profile; + } catch { + return null; + } + }, []); + + useDidShow(() => { + void refreshPayReadiness(); + }); useEffect(() => { - async function load() { - try { - const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]); - setMockMode(config.mockPay); - setNeedsWechatAuth(needsWechatAuthForPay(config, profile)); - } catch { - /* ignore */ - } + if (!orderId) return; + if (process.env.TARO_ENV === 'weapp') { + void ensurePayReady(returnPath); } - void load(); - }, []); + }, [orderId, returnPath]); useEffect(() => { if (!orderId) { @@ -67,6 +78,38 @@ export default function PayPage() { }); }, [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() { if (!orderId) { toast('订单不存在'); @@ -74,8 +117,11 @@ export default function PayPage() { } if (needsWechatAuth) { setMsg('请先完成微信授权后再支付'); - const ready = await ensurePayReady(returnPath); - if (!ready) return; + if (process.env.TARO_ENV === 'h5') { + await wechatAuthorize(); + } else { + await ensurePayReady(returnPath); + } return; } @@ -96,7 +142,6 @@ export default function PayPage() { if (isWechatAuthRequiredError(e)) { setNeedsWechatAuth(true); setMsg('微信支付需要先完成微信授权'); - await ensurePayReady(returnPath); return; } const message = e instanceof Error ? e.message : '支付失败'; @@ -124,6 +169,20 @@ export default function PayPage() { ¥{payAmount} + + {needsWechatAuth ? ( + + 尚未授权微信 + + 授权后可安全调起微信支付,不会重复扣款 + + void wechatAuthorize()} + /> + + ) : null} + 订单号 @@ -140,15 +199,34 @@ export default function PayPage() { - {msg ? {msg} : null} + {msg ? ( + + {msg} + + ) : null} !loading && void pay()} + style={{ flex: 1, opacity: loading || needsWechatAuth ? 0.7 : 1 }} + onClick={() => { + if (loading) return; + if (needsWechatAuth) { + void wechatAuthorize(); + return; + } + void pay(); + }} > - {loading ? '支付中…' : needsWechatAuth ? '去授权' : '立即支付'} + + {loading + ? '支付中…' + : needsWechatAuth + ? authLoading + ? '授权中…' + : '微信一键授权' + : '立即支付'} + diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css index 16e3505..5c7ebd3 100644 --- a/apps/mini-user/src/styles/order.css +++ b/apps/mini-user/src/styles/order.css @@ -278,3 +278,34 @@ color: var(--color-heritage-red); 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; +}