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:
zdl
2026-01-09 16:25:55 +08:00
parent 846c44c1ec
commit 60f65a5d68
7 changed files with 580 additions and 0 deletions

View File

@@ -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,
});
})
];

View File

@@ -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,

View File

@@ -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
View 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 };

View 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;

View 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;

View 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;