feat(Payment): 添加微信 JSAPI 支付页面
- 新增 WechatJsapiPayment.tsx 微信公众号支付页面 - 新增 wechat.d.ts 微信 JSSDK 类型定义 - 添加支付相关路由配置 - payment.js handler 添加 JSAPI 支付接口 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
42
src/types/wechat.d.ts
vendored
Normal file
42
src/types/wechat.d.ts
vendored
Normal file
@@ -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<string, unknown>): void;
|
||||
}
|
||||
|
||||
/** 扩展 Window 接口 */
|
||||
declare global {
|
||||
interface Window {
|
||||
WeixinJSBridge?: WeixinJSBridge;
|
||||
}
|
||||
}
|
||||
|
||||
export type { WechatPayParams, WechatPayResponse, WeixinJSBridge };
|
||||
158
src/views/Payment/WechatJsapiPayment.tsx
Normal file
158
src/views/Payment/WechatJsapiPayment.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<PaymentStatus
|
||||
status="failed"
|
||||
error={initError}
|
||||
onRetry={handleRetry}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PaymentStatus
|
||||
status={status}
|
||||
error={error}
|
||||
planName={orderInfo.planName}
|
||||
amount={orderInfo.amount}
|
||||
onRetry={handleRetry}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WechatJsapiPayment;
|
||||
171
src/views/Payment/components/PaymentStatus.tsx
Normal file
171
src/views/Payment/components/PaymentStatus.tsx
Normal file
@@ -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<PaymentStatusType, {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ElementType;
|
||||
iconColor?: string;
|
||||
showSpinner?: boolean;
|
||||
}> = {
|
||||
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<PaymentStatusProps> = ({
|
||||
status,
|
||||
error,
|
||||
planName,
|
||||
amount,
|
||||
onRetry,
|
||||
onBack,
|
||||
}) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
bg="gray.900"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
px={4}
|
||||
>
|
||||
<VStack
|
||||
spacing={6}
|
||||
p={8}
|
||||
bg="gray.800"
|
||||
borderRadius="xl"
|
||||
maxW="400px"
|
||||
w="full"
|
||||
textAlign="center"
|
||||
>
|
||||
{/* 图标或加载动画 */}
|
||||
{config.showSpinner ? (
|
||||
<Spinner size="xl" color="gold.400" thickness="4px" />
|
||||
) : config.icon ? (
|
||||
<Icon as={config.icon} boxSize={16} color={config.iconColor} />
|
||||
) : null}
|
||||
|
||||
{/* 标题 */}
|
||||
<Text fontSize="2xl" fontWeight="bold" color="white">
|
||||
{config.title}
|
||||
</Text>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text color="gray.400">
|
||||
{error || config.description}
|
||||
</Text>
|
||||
|
||||
{/* 订单信息 */}
|
||||
{planName && amount && status !== 'failed' && status !== 'cancelled' && (
|
||||
<Box
|
||||
w="full"
|
||||
p={4}
|
||||
bg="gray.700"
|
||||
borderRadius="lg"
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.300" fontSize="sm">
|
||||
订阅套餐
|
||||
</Text>
|
||||
<Text color="white" fontWeight="semibold">
|
||||
{planName}
|
||||
</Text>
|
||||
<Text color="gold.400" fontSize="xl" fontWeight="bold">
|
||||
¥{amount.toFixed(2)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<VStack spacing={3} w="full" pt={4}>
|
||||
{(status === 'failed' || status === 'cancelled') && onRetry && (
|
||||
<Button
|
||||
w="full"
|
||||
colorScheme="yellow"
|
||||
onClick={onRetry}
|
||||
>
|
||||
重新支付
|
||||
</Button>
|
||||
)}
|
||||
{onBack && (
|
||||
<Button
|
||||
w="full"
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 支付中提示 */}
|
||||
{status === 'paying' && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
请勿关闭此页面,支付完成后将自动跳转
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentStatus;
|
||||
141
src/views/Payment/hooks/useWechatPay.ts
Normal file
141
src/views/Payment/hooks/useWechatPay.ts
Normal file
@@ -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<PaymentResult>;
|
||||
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<void> => {
|
||||
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<PaymentStatus>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isWechatEnv = checkWechatEnv();
|
||||
|
||||
const invokePayment = useCallback(async (params: WechatPayParams): Promise<PaymentResult> => {
|
||||
// 环境检查
|
||||
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;
|
||||
Reference in New Issue
Block a user