From 60f65a5d68351c9416d608634274799981bd4bf6 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Fri, 9 Jan 2026 16:25:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(Payment):=20=E6=B7=BB=E5=8A=A0=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=20JSAPI=20=E6=94=AF=E4=BB=98=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 WechatJsapiPayment.tsx 微信公众号支付页面 - 新增 wechat.d.ts 微信 JSSDK 类型定义 - 添加支付相关路由配置 - payment.js handler 添加 JSAPI 支付接口 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/mocks/handlers/payment.js | 52 ++++++ src/routes/lazy-components.js | 4 + src/routes/routeConfig.js | 12 ++ src/types/wechat.d.ts | 42 +++++ src/views/Payment/WechatJsapiPayment.tsx | 158 ++++++++++++++++ .../Payment/components/PaymentStatus.tsx | 171 ++++++++++++++++++ src/views/Payment/hooks/useWechatPay.ts | 141 +++++++++++++++ 7 files changed, 580 insertions(+) create mode 100644 src/types/wechat.d.ts create mode 100644 src/views/Payment/WechatJsapiPayment.tsx create mode 100644 src/views/Payment/components/PaymentStatus.tsx create mode 100644 src/views/Payment/hooks/useWechatPay.ts diff --git a/src/mocks/handlers/payment.js b/src/mocks/handlers/payment.js index 7ba0f4a8..753e7f53 100644 --- a/src/mocks/handlers/payment.js +++ b/src/mocks/handlers/payment.js @@ -186,5 +186,57 @@ export const paymentHandlers = [ success: true, message: '订单已取消' }); + }), + + // ==================== 微信 JSAPI 支付 ==================== + + // 5. 创建 JSAPI 支付订单(小程序 H5 支付) + http.post('/api/payment/wechat/jsapi/create-order', async ({ request }) => { + await delay(NETWORK_DELAY); + + const body = await request.json(); + const { plan_id, user_id, openid } = body; + + console.log('[Mock] 创建 JSAPI 支付订单:', { plan_id, user_id, openid }); + + if (!plan_id || !user_id || !openid) { + return HttpResponse.json({ + success: false, + error: '参数不完整:需要 plan_id, user_id, openid' + }, { status: 400 }); + } + + // 模拟套餐价格 + const planPrices = { + 'pro_monthly': { name: 'Pro 月度会员', amount: 29.9 }, + 'pro_yearly': { name: 'Pro 年度会员', amount: 299 }, + 'max_monthly': { name: 'Max 月度会员', amount: 99 }, + 'max_yearly': { name: 'Max 年度会员', amount: 999 }, + }; + + const plan = planPrices[plan_id] || { name: '测试套餐', amount: 0.01 }; + + // 创建订单 + const orderNo = `JSAPI_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + // 模拟微信支付参数(实际由后端生成) + const paymentParams = { + appId: 'wx0edeaab76d4fa414', + timeStamp: String(Math.floor(Date.now() / 1000)), + nonceStr: Math.random().toString(36).slice(2, 18), + package: `prepay_id=mock_prepay_${orderNo}`, + signType: 'MD5', + paySign: 'MOCK_SIGN_' + Math.random().toString(36).slice(2, 10).toUpperCase(), + }; + + console.log('[Mock] JSAPI 订单创建成功:', { orderNo, plan: plan.name, amount: plan.amount }); + + return HttpResponse.json({ + success: true, + order_no: orderNo, + plan_name: plan.name, + amount: plan.amount, + payment_params: paymentParams, + }); }) ]; diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index 86c1f34f..27c90fb9 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -22,6 +22,9 @@ export const lazyComponents = { UserAgreement: React.lazy(() => import('@views/Pages/UserAgreement')), WechatCallback: React.lazy(() => import('@views/Pages/WechatCallback')), + // 支付模块 + WechatJsapiPayment: React.lazy(() => import('@views/Payment/WechatJsapiPayment')), + // 社区/内容模块 Community: React.lazy(() => import('@views/Community')), ConceptCenter: React.lazy(() => import('@views/Concept')), @@ -69,6 +72,7 @@ export const { PrivacyPolicy, UserAgreement, WechatCallback, + WechatJsapiPayment, Community, ConceptCenter, StockOverview, diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js index b0d70437..d63bf420 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -214,6 +214,18 @@ export const routeConfig = [ } }, + // ==================== 支付模块 ==================== + { + path: 'payment/wechat', + component: lazyComponents.WechatJsapiPayment, + protection: PROTECTION_MODES.PUBLIC, + layout: 'none', // 无布局,独立页面 + meta: { + title: '微信支付', + description: '微信 JSAPI 支付页面' + } + }, + // ==================== Agent模块 ==================== { path: 'agent-chat', diff --git a/src/types/wechat.d.ts b/src/types/wechat.d.ts new file mode 100644 index 00000000..e87eb25a --- /dev/null +++ b/src/types/wechat.d.ts @@ -0,0 +1,42 @@ +/** + * 微信 JS Bridge 类型声明 + * 用于微信 H5 JSAPI 支付 + */ + +/** 微信支付参数 */ +interface WechatPayParams { + appId: string; + timeStamp: string; + nonceStr: string; + package: string; + signType: 'MD5' | 'HMAC-SHA256'; + paySign: string; +} + +/** 微信支付响应 */ +interface WechatPayResponse { + err_msg: string; +} + +/** WeixinJSBridge invoke 回调 */ +type WeixinJSBridgeCallback = (response: WechatPayResponse) => void; + +/** WeixinJSBridge 接口 */ +interface WeixinJSBridge { + invoke( + api: 'getBrandWCPayRequest', + params: WechatPayParams, + callback: WeixinJSBridgeCallback + ): void; + on(event: string, callback: () => void): void; + call(api: string, params?: Record): void; +} + +/** 扩展 Window 接口 */ +declare global { + interface Window { + WeixinJSBridge?: WeixinJSBridge; + } +} + +export type { WechatPayParams, WechatPayResponse, WeixinJSBridge }; diff --git a/src/views/Payment/WechatJsapiPayment.tsx b/src/views/Payment/WechatJsapiPayment.tsx new file mode 100644 index 00000000..9cbdc3af --- /dev/null +++ b/src/views/Payment/WechatJsapiPayment.tsx @@ -0,0 +1,158 @@ +/** + * 微信 JSAPI 支付页面 + * + * 用于小程序跳转 H5 完成支付的场景 + * URL 参数: + * - plan_id: 套餐 ID + * - user_id: 用户 ID + * - openid: 微信用户 openid + * - return_scheme: 支付完成后跳转的小程序 scheme + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { getApiBase } from '@/utils/apiConfig'; +import { useWechatPay } from './hooks/useWechatPay'; +import { PaymentStatus } from './components/PaymentStatus'; +import type { WechatPayParams } from '@/types/wechat'; + +/** 订单创建响应 */ +interface CreateOrderResponse { + success: boolean; + error?: string; + payment_params?: WechatPayParams; + order_no?: string; + amount?: number; + plan_name?: string; +} + +const WechatJsapiPayment: React.FC = () => { + const [searchParams] = useSearchParams(); + const { status, error, isWechatEnv, invokePayment, reset } = useWechatPay(); + + // URL 参数 + const planId = searchParams.get('plan_id'); + const userId = searchParams.get('user_id'); + const openid = searchParams.get('openid'); + const returnScheme = searchParams.get('return_scheme'); + + // 订单信息 + const [orderInfo, setOrderInfo] = useState<{ + planName?: string; + amount?: number; + orderNo?: string; + }>({}); + + // 初始化错误 + const [initError, setInitError] = useState(null); + + /** + * 创建订单并发起支付 + */ + const createOrderAndPay = useCallback(async () => { + try { + // 调用后端创建 JSAPI 订单 + const response = await fetch(`${getApiBase()}/api/payment/wechat/jsapi/create-order`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + plan_id: planId, + user_id: userId, + openid, + }), + }); + + const data: CreateOrderResponse = await response.json(); + + if (!data.success || !data.payment_params) { + throw new Error(data.error || '创建订单失败'); + } + + // 保存订单信息 + setOrderInfo({ + planName: data.plan_name, + amount: data.amount, + orderNo: data.order_no, + }); + + // 调起微信支付 + const result = await invokePayment(data.payment_params); + + // 支付成功,跳转回小程序 + if (result.success && returnScheme) { + setTimeout(() => { + window.location.href = returnScheme; + }, 2000); + } + } catch (err) { + setInitError(err instanceof Error ? err.message : '支付初始化失败'); + } + }, [planId, userId, openid, returnScheme, invokePayment]); + + /** + * 参数验证和初始化 + */ + useEffect(() => { + // 参数验证 + if (!planId || !userId || !openid) { + setInitError('缺少必要参数'); + return; + } + + // 环境检测 + if (!isWechatEnv) { + setInitError('请在微信浏览器中打开此页面'); + return; + } + + // 发起支付 + createOrderAndPay(); + }, [planId, userId, openid, isWechatEnv, createOrderAndPay]); + + /** + * 重试支付 + */ + const handleRetry = useCallback(() => { + reset(); + setInitError(null); + createOrderAndPay(); + }, [reset, createOrderAndPay]); + + /** + * 返回小程序 + */ + const handleBack = useCallback(() => { + if (returnScheme) { + window.location.href = returnScheme; + } else { + window.history.back(); + } + }, [returnScheme]); + + // 显示初始化错误 + if (initError) { + return ( + + ); + } + + return ( + + ); +}; + +export default WechatJsapiPayment; diff --git a/src/views/Payment/components/PaymentStatus.tsx b/src/views/Payment/components/PaymentStatus.tsx new file mode 100644 index 00000000..96b4c95c --- /dev/null +++ b/src/views/Payment/components/PaymentStatus.tsx @@ -0,0 +1,171 @@ +/** + * 支付状态展示组件 + * 用于显示不同支付状态的 UI + */ + +import React from 'react'; +import { + Box, + VStack, + Text, + Spinner, + Icon, + Button, +} from '@chakra-ui/react'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; +import type { PaymentStatus as PaymentStatusType } from '../hooks/useWechatPay'; + +interface PaymentStatusProps { + status: PaymentStatusType; + error?: string | null; + planName?: string; + amount?: number; + onRetry?: () => void; + onBack?: () => void; +} + +/** 状态配置 */ +const STATUS_CONFIG: Record = { + idle: { + title: '准备支付', + description: '正在初始化支付环境...', + showSpinner: true, + }, + loading: { + title: '正在加载', + description: '正在创建支付订单...', + showSpinner: true, + }, + paying: { + title: '等待支付', + description: '请在微信支付弹窗中完成支付', + showSpinner: true, + }, + success: { + title: '支付成功', + description: '您的订阅已生效', + icon: CheckCircle, + iconColor: 'green.400', + }, + failed: { + title: '支付失败', + description: '支付过程中发生错误', + icon: XCircle, + iconColor: 'red.400', + }, + cancelled: { + title: '已取消', + description: '您已取消本次支付', + icon: AlertCircle, + iconColor: 'orange.400', + }, +}; + +export const PaymentStatus: React.FC = ({ + status, + error, + planName, + amount, + onRetry, + onBack, +}) => { + const config = STATUS_CONFIG[status]; + + return ( + + + {/* 图标或加载动画 */} + {config.showSpinner ? ( + + ) : config.icon ? ( + + ) : null} + + {/* 标题 */} + + {config.title} + + + {/* 描述 */} + + {error || config.description} + + + {/* 订单信息 */} + {planName && amount && status !== 'failed' && status !== 'cancelled' && ( + + + + 订阅套餐 + + + {planName} + + + ¥{amount.toFixed(2)} + + + + )} + + {/* 操作按钮 */} + + {(status === 'failed' || status === 'cancelled') && onRetry && ( + + )} + {onBack && ( + + )} + + + {/* 支付中提示 */} + {status === 'paying' && ( + + 请勿关闭此页面,支付完成后将自动跳转 + + )} + + + ); +}; + +export default PaymentStatus; diff --git a/src/views/Payment/hooks/useWechatPay.ts b/src/views/Payment/hooks/useWechatPay.ts new file mode 100644 index 00000000..a9e40176 --- /dev/null +++ b/src/views/Payment/hooks/useWechatPay.ts @@ -0,0 +1,141 @@ +/** + * 微信 JSAPI 支付 Hook + * 封装微信支付逻辑,包括环境检测和支付调用 + */ + +import { useState, useCallback } from 'react'; +import type { WechatPayParams } from '@/types/wechat'; + +/** 支付状态 */ +export type PaymentStatus = 'idle' | 'loading' | 'paying' | 'success' | 'failed' | 'cancelled'; + +/** 支付结果 */ +interface PaymentResult { + success: boolean; + message: string; +} + +/** Hook 返回值 */ +interface UseWechatPayReturn { + status: PaymentStatus; + error: string | null; + isWechatEnv: boolean; + invokePayment: (params: WechatPayParams) => Promise; + reset: () => void; +} + +/** + * 检测是否在微信浏览器环境 + */ +const checkWechatEnv = (): boolean => { + if (typeof window === 'undefined') return false; + const ua = window.navigator.userAgent.toLowerCase(); + return ua.includes('micromessenger'); +}; + +/** + * 等待 WeixinJSBridge 就绪 + */ +const waitForBridge = (): Promise => { + return new Promise((resolve, reject) => { + if (typeof window.WeixinJSBridge !== 'undefined') { + resolve(); + return; + } + + let timeout: NodeJS.Timeout; + + const onBridgeReady = () => { + clearTimeout(timeout); + document.removeEventListener('WeixinJSBridgeReady', onBridgeReady); + resolve(); + }; + + document.addEventListener('WeixinJSBridgeReady', onBridgeReady); + + // 10秒超时 + timeout = setTimeout(() => { + document.removeEventListener('WeixinJSBridgeReady', onBridgeReady); + reject(new Error('WeixinJSBridge 加载超时')); + }, 10000); + }); +}; + +/** + * 微信 JSAPI 支付 Hook + */ +export const useWechatPay = (): UseWechatPayReturn => { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + const isWechatEnv = checkWechatEnv(); + + const invokePayment = useCallback(async (params: WechatPayParams): Promise => { + // 环境检查 + if (!isWechatEnv) { + const errMsg = '请在微信浏览器中打开'; + setError(errMsg); + setStatus('failed'); + return { success: false, message: errMsg }; + } + + setStatus('loading'); + setError(null); + + try { + // 等待 Bridge 就绪 + await waitForBridge(); + + setStatus('paying'); + + // 调用微信支付 + return new Promise((resolve) => { + window.WeixinJSBridge!.invoke( + 'getBrandWCPayRequest', + { + appId: params.appId, + timeStamp: params.timeStamp, + nonceStr: params.nonceStr, + package: params.package, + signType: params.signType, + paySign: params.paySign, + }, + (res) => { + if (res.err_msg === 'get_brand_wcpay_request:ok') { + setStatus('success'); + resolve({ success: true, message: '支付成功' }); + } else if (res.err_msg === 'get_brand_wcpay_request:cancel') { + setStatus('cancelled'); + setError('用户取消支付'); + resolve({ success: false, message: '用户取消支付' }); + } else { + setStatus('failed'); + const errMsg = res.err_msg || '支付失败'; + setError(errMsg); + resolve({ success: false, message: errMsg }); + } + } + ); + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : '支付过程发生错误'; + setStatus('failed'); + setError(errMsg); + return { success: false, message: errMsg }; + } + }, [isWechatEnv]); + + const reset = useCallback(() => { + setStatus('idle'); + setError(null); + }, []); + + return { + status, + error, + isWechatEnv, + invokePayment, + reset, + }; +}; + +export default useWechatPay;