| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779 |
- <template>
- <view class="page">
- <!-- 顶部导航栏 -->
- <view class="nav-bar">
- <view class="nav-content">
- <view class="nav-left" @click="goBack">
- <text class="back-icon">←</text>
- </view>
- <text class="page-title">确认支付</text>
- </view>
- </view>
- <!-- 支付内容 -->
- <view class="content">
- <!-- 订单信息 -->
- <view class="order-card">
- <text class="order-title">订单信息</text>
- <view class="order-info">
- <text class="info-label">商品</text>
- <text class="info-value">{{ orderInfo.planName || '-' }}</text>
- </view>
- <view class="order-info">
- <text class="info-label">订单号</text>
- <text class="info-value">{{ orderInfo.orderNo || '-' }}</text>
- </view>
- <view class="order-info highlight">
- <text class="info-label">应付金额</text>
- <text class="info-value price">¥{{ orderInfo.amount || 0 }}</text>
- </view>
- </view>
- <!-- 支付方式选择 -->
- <view class="payment-section">
- <text class="section-title">选择支付方式</text>
- <!-- 公众号环境提示 -->
- <view class="wechat-notice" v-if="inWechat">
- <text class="wechat-notice-icon">💚</text>
- <text class="wechat-notice-text">检测到微信环境,将自动使用微信支付</text>
- </view>
- <view class="payment-methods">
- <view
- class="payment-method"
- :class="{ active: paymentMethod === 'alipay' }"
- @click="paymentMethod = 'alipay'"
- >
- <view class="method-radio">
- <view class="radio-inner" v-if="paymentMethod === 'alipay'"></view>
- </view>
- <text class="method-icon">💙</text>
- <text class="method-name">支付宝</text>
- </view>
- <view
- class="payment-method"
- :class="{ active: paymentMethod === 'wechat' }"
- @click="paymentMethod = 'wechat'"
- >
- <view class="method-radio">
- <view class="radio-inner" v-if="paymentMethod === 'wechat'"></view>
- </view>
- <text class="method-icon">🟢</text>
- <text class="method-name">微信支付</text>
- <text class="method-badge" v-if="inWechat">推荐</text>
- </view>
- </view>
- </view>
- <!-- 操作按钮 -->
- <view class="actions">
- <button class="btn-secondary" @click="goBack">取消</button>
- <button class="btn-primary" @click="handlePay">确认支付</button>
- </view>
- <!-- 提示 -->
- <view class="tips">
- <text class="tips-title">温馨提示</text>
- <text class="tips-text">• 请在24小时内完成支付</text>
- <text class="tips-text">• 支付成功后权益将自动到账</text>
- <text class="tips-text">• 如支付遇到问题,请联系客服</text>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { post, get } from '../../utils/request';
- // ==================== 微信浏览器检测 ====================
- function isInWechat(): boolean {
- // #ifdef H5
- const ua = navigator.userAgent.toLowerCase();
- return ua.includes('micromessenger');
- // #endif
- // #ifndef H5
- return false;
- // #endif
- }
- // 获取不含 hash 的基础URL(用于OAuth redirect_uri)
- function getBaseUrl(): string {
- // #ifdef H5
- return window.location.origin + window.location.pathname;
- // #endif
- return '';
- }
- // 保存当前页面上下文到 sessionStorage(OAuth 来回会丢失 hash 路由状态)
- function savePageContext() {
- try {
- sessionStorage.setItem('wx_oauth_return', window.location.href);
- } catch (_) {}
- }
- // 恢复页面上下文
- function restorePageContext(): string | null {
- try {
- return sessionStorage.getItem('wx_oauth_return');
- } catch (_) { return null; }
- }
- const orderInfo = ref<{
- orderNo: string;
- amount: number;
- planName: string;
- }>({
- orderNo: '',
- amount: 0,
- planName: ''
- });
- const planId = ref<number>(0);
- const productType = ref<string>(''); // 'subscription' | 'token-pack'
- const tokenPackQuantity = ref<number>(0);
- const paymentMethod = ref<'alipay' | 'wechat'>('alipay');
- const inWechat = ref(false);
- const openid = ref('');
- const paying = ref(false);
- const lastPayClick = ref(0); // 防重:记录上次点击时间
- let checkTimer: ReturnType<typeof setInterval> | null = null;
- // ==================== 微信 OAuth 处理 ====================
- async function handleWechatOAuth() {
- // #ifdef H5
- // OAuth 回调已在 App.vue 全局处理,这里只需读取缓存的 openid
- const cachedOpenid = localStorage.getItem('wx_openid');
- if (cachedOpenid) {
- openid.value = cachedOpenid;
- console.log('[WeChat Pay] 使用缓存的 openid');
- return;
- }
- // 双重保险:检查 URL search 中的 code(如果 App.vue 未拦截到)
- const searchParams = new URLSearchParams(window.location.search);
- const code = searchParams.get('code');
- if (code) {
- uni.showLoading({ title: '授权中...' });
- try {
- const result = await post<{ openid: string }>('/payment/wechat/openid', { code });
- openid.value = result.openid;
- localStorage.setItem('wx_openid', result.openid);
- console.log('[WeChat Pay] openid 获取成功,自动重新发起支付');
- // 清除 URL 中的 code/state
- searchParams.delete('code');
- searchParams.delete('state');
- const newSearch = searchParams.toString();
- const newUrl = window.location.origin + window.location.pathname
- + (newSearch ? '?' + newSearch : '');
- window.history.replaceState({}, '', newUrl);
- // 自动重新发起支付(不用用户再点一次)
- setTimeout(() => handleWechatJsapiPay(), 100);
- } catch (e) {
- console.error('[WeChat Pay] 获取 openid 失败:', e);
- } finally {
- uni.hideLoading();
- }
- }
- // #endif
- }
- // 跳转到微信 OAuth 授权
- async function redirectToWechatOAuth() {
- // #ifdef H5
- try {
- // 保存当前完整 URL(含 hash 路由参数),OAuth 回来时恢复
- savePageContext();
- // 用不含 # 的 baseUrl 作为 redirect_uri,避免微信报"非法字符"
- const baseUrl = getBaseUrl();
- const result = await get<{ oauthUrl: string; state: string }>('/payment/wechat/oauth-url', {
- redirect: baseUrl
- });
- window.location.href = result.oauthUrl;
- } catch (e) {
- console.error('[WeChat Pay] 获取OAuth链接失败:', e);
- }
- // #endif
- }
- onMounted(async () => {
- const pages = getCurrentPages();
- const currentPage = pages[pages.length - 1] as any;
- // #ifdef APP-PLUS
- const options = currentPage?.$page?.options || {};
- // #endif
- // #ifndef APP-PLUS
- const options = currentPage?.options || {};
- // #endif
- // 检查产品类型
- productType.value = options.productType || 'subscription';
- if (productType.value === 'token-pack') {
- // 积分包支付
- tokenPackQuantity.value = parseInt(options.quantity) || 1;
- orderInfo.value.planName = decodeURIComponent(options.planName || '积分包');
- orderInfo.value.amount = parseFloat(options.amount) || 0;
- } else if (options.planId) {
- planId.value = parseInt(options.planId);
- orderInfo.value.planName = decodeURIComponent(options.planName || '会员订阅');
- orderInfo.value.amount = parseFloat(options.amount) || 0;
- }
- // 检测是否在微信浏览器中
- if (isInWechat()) {
- inWechat.value = true;
- // 默认选中微信支付
- paymentMethod.value = 'wechat';
- // 处理 OAuth 回调
- await handleWechatOAuth();
- // 微信环境不预创建订单(JSAPI 支付时另外创建)
- } else {
- // 非微信环境:进入页面时直接创建订单
- if (productType.value === 'token-pack' || planId.value) {
- await createOrder();
- }
- }
- });
- async function createOrder() {
- try {
- uni.showLoading({ title: '创建订单...' });
- const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
- let result: any;
- if (productType.value === 'token-pack') {
- // 积分包订单
- result = await post<{
- orderNo: string;
- amount: number;
- paymentUrl?: string;
- qrcode?: string;
- planName?: string;
- }>('/payment/token-packs/create', {
- quantity: tokenPackQuantity.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- } else {
- // 套餐订阅订单
- result = await post<{
- orderNo: string;
- amount: number;
- paymentUrl?: string;
- qrcode?: string;
- planName?: string;
- }>('/payment/create', {
- planId: planId.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- }
- orderInfo.value.orderNo = result.orderNo;
- uni.hideLoading();
- } catch (error) {
- uni.hideLoading();
- console.error('创建订单失败:', error);
- }
- }
- // 查询微信订单状态(用于支付后确认)
- async function checkWechatOrderStatus(orderNo: string): Promise<string | null> {
- try {
- const result = await get<any>(`/payment/wechat/query/${orderNo}`);
- const state = result?.data?.trade_state || result?.trade_state || null;
- console.log('[WeChat Pay] 订单查询结果:', orderNo, state);
- return state;
- } catch (e) {
- console.error('[WeChat Pay] 订单查询失败:', e);
- return null;
- }
- }
- // ==================== 微信 JSAPI 支付(公众号内) ====================
- // 使用 WeixinJSBridge.invoke 直接调起(官方 V3 推荐方式,无需 JSSDK 配置)
- // 参考: https://pay.weixin.qq.com/doc/v3/merchant/4012791870
- async function handleWechatJsapiPay() {
- // 如果没有 openid,需要先授权
- if (!openid.value) {
- await redirectToWechatOAuth();
- return;
- }
- paying.value = true;
- uni.showLoading({ title: '拉起支付...' });
- try {
- // 创建 JSAPI 支付订单
- let result: any;
- if (productType.value === 'token-pack') {
- result = await post<any>('/payment/wechat/jsapi', {
- productType: 'token-pack',
- quantity: tokenPackQuantity.value,
- openid: openid.value
- });
- } else {
- result = await post<any>('/payment/wechat/jsapi', {
- planId: planId.value,
- openid: openid.value
- });
- }
- orderInfo.value.orderNo = result.orderNo;
- uni.hideLoading();
- // 使用 WeixinJSBridge.invoke 调起支付
- const currentResult = result; // 保存引用,避免闭包问题
- const onBridgeReady = () => {
- if (!currentResult || !currentResult.payParams) {
- console.error('[WeChat Pay] payParams 不存在:', currentResult);
- uni.showToast({ title: '支付参数异常,请重试', icon: 'none' });
- paying.value = false;
- return;
- }
- (window as any).WeixinJSBridge.invoke(
- 'getBrandWXPayRequest',
- {
- "appId": currentResult.payParams.appId,
- "timeStamp": currentResult.payParams.timeStamp,
- "nonceStr": currentResult.payParams.nonceStr,
- "package": currentResult.payParams.package,
- "signType": currentResult.payParams.signType || "RSA",
- "paySign": currentResult.payParams.paySign,
- },
- async (res: any) => {
- console.log('[WeChat Pay] WeixinJSBridge 回调:', JSON.stringify(res));
- // 官方要求:必须调用查询订单API确认最终状态
- const orderStatus = await checkWechatOrderStatus(currentResult.orderNo);
- paying.value = false;
- if (res.err_msg === "get_brand_wcpay_request:ok") {
- if (orderStatus === 'SUCCESS') {
- uni.showModal({
- title: '支付成功',
- content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
- showCancel: false,
- success: () => uni.switchTab({ url: '/pages/mine/index' })
- });
- } else {
- uni.showToast({ title: '订单确认中,请稍后查看', icon: 'none' });
- }
- } else if (res.err_msg === "get_brand_wcpay_request:cancel") {
- uni.showToast({ title: '已取消支付', icon: 'none' });
- } else if (orderStatus === 'SUCCESS') {
- // 桥回调失败但订单已支付(延迟回调)
- uni.showModal({
- title: '支付成功',
- content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
- showCancel: false,
- success: () => uni.switchTab({ url: '/pages/mine/index' })
- });
- } else {
- uni.showModal({
- title: '支付失败',
- content: '微信支付调起失败,是否使用扫码支付?',
- confirmText: '扫码支付',
- cancelText: '取消',
- success: (modalRes: any) => {
- if (modalRes.confirm && currentResult.orderNo) {
- inWechat.value = false;
- handlePay();
- }
- }
- });
- }
- }
- );
- };
- // WeixinJSBridge 可能尚未就绪,监听就绪事件
- if (typeof (window as any).WeixinJSBridge === 'undefined') {
- document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
- } else {
- onBridgeReady();
- }
- } catch (error: any) {
- paying.value = false;
- uni.hideLoading();
- console.error('[WeChat Pay] 创建JSAPI支付失败:', error);
- if (error.message && error.message.includes('openid')) {
- localStorage.removeItem('wx_openid');
- openid.value = '';
- await redirectToWechatOAuth();
- return;
- }
- uni.showToast({ title: error.message || '支付失败', icon: 'none' });
- }
- }
- async function handlePay() {
- // 防重处理:2秒内禁止重复点击(官方要求按钮防抖)
- const now = Date.now();
- if (now - lastPayClick.value < 2000) {
- console.log('[Payment] 防重:忽略重复点击');
- return;
- }
- lastPayClick.value = now;
- if (!planId.value && productType.value !== 'token-pack') {
- uni.showToast({ title: '参数错误', icon: 'none' });
- return;
- }
- // 公众号内微信支付 → 走 JSAPI
- if (inWechat.value && paymentMethod.value === 'wechat') {
- await handleWechatJsapiPay();
- return;
- }
- try {
- uni.showLoading({ title: '创建订单...' });
- // #ifdef H5
- const returnUrl = `${window.location.origin}/#/pages/payment-result/index`;
- // #endif
- // #ifndef H5
- // App 端使用相对路径
- const returnUrl = `/#/pages/payment-result/index`;
- // #endif
- let orderResult: any;
- if (productType.value === 'token-pack') {
- orderResult = await post<any>('/payment/token-packs/create', {
- quantity: tokenPackQuantity.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- } else {
- orderResult = await post<any>('/payment/create', {
- planId: planId.value,
- paymentMethod: paymentMethod.value,
- returnUrl
- });
- }
- uni.hideLoading();
- orderInfo.value.orderNo = orderResult.orderNo;
- // 微信支付 - 显示二维码
- if (paymentMethod.value === 'wechat' && orderResult.qrcode) {
- uni.navigateTo({
- url: `/pages/alipay-qrcode/index?orderNo=${orderResult.orderNo}&amount=${orderResult.amount}&planName=${encodeURIComponent(orderResult.planName || orderInfo.value.planName)}&qrcode=${encodeURIComponent(orderResult.qrcode)}&method=wechat`
- });
- return;
- }
- // 支付宝 H5 跳转
- // #ifdef H5
- if (orderResult.paymentUrl) {
- uni.showToast({ title: '即将跳转支付宝...', icon: 'none' });
- setTimeout(() => {
- window.location.href = orderResult.paymentUrl!;
- }, 1000);
- }
- // #endif
- // 开发环境模拟
- // #ifndef H5
- uni.showModal({
- title: '模拟支付',
- content: `确认支付 ¥${orderResult.amount}?\n(开发环境模拟)`,
- success: async (res) => {
- if (res.confirm) {
- await mockPay(orderResult.orderNo);
- }
- },
- });
- // #endif
- } catch (error: any) {
- uni.hideLoading();
- console.error('创建订单失败:', error);
- uni.showToast({ title: error.message || '创建订单失败', icon: 'none' });
- }
- }
- async function mockPay(orderNo: string) {
- try {
- uni.showLoading({ title: '支付中...' });
- await post('/payment/mock', { orderNo });
- uni.hideLoading();
- uni.showModal({
- title: '支付成功',
- content: productType.value === 'token-pack' ? '积分包购买成功!' : '恭喜您订阅成功!',
- showCancel: false,
- success: () => {
- uni.switchTab({ url: '/pages/mine/index' });
- }
- });
- } catch (error) {
- uni.hideLoading();
- uni.showToast({ title: '支付失败', icon: 'none' });
- }
- }
- function goBack() {
- const pages = getCurrentPages();
- if (pages.length > 1) {
- uni.navigateBack();
- } else {
- uni.switchTab({ url: '/pages/member/index' });
- }
- }
- </script>
- <style scoped>
- .page {
- min-height: 100vh;
- background: #f5f5f5;
- }
- .nav-bar {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: 88rpx;
- padding-top: env(safe-area-inset-top);
- background: #ffffff;
- border-bottom: 1px solid #e5e7eb;
- z-index: 100;
- }
- .nav-content {
- height: 100%;
- display: flex;
- align-items: center;
- padding: 0 24rpx;
- }
- .nav-left {
- width: 80rpx;
- display: flex;
- align-items: center;
- }
- .back-icon {
- font-size: 36rpx;
- color: #333;
- }
- .page-title {
- flex: 1;
- text-align: center;
- font-size: 32rpx;
- font-weight: 500;
- color: #333;
- margin-right: 80rpx;
- }
- .content {
- padding-top: calc(120rpx + env(safe-area-inset-top));
- padding-left: 32rpx;
- padding-right: 32rpx;
- }
- .order-card {
- background: #ffffff;
- border-radius: 16rpx;
- padding: 32rpx;
- margin-bottom: 32rpx;
- }
- .order-title {
- font-size: 28rpx;
- font-weight: 600;
- color: #1f2937;
- margin-bottom: 24rpx;
- display: block;
- }
- .order-info {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16rpx 0;
- border-bottom: 1px solid #f3f4f6;
- }
- .order-info:last-child {
- border-bottom: none;
- }
- .order-info.highlight {
- margin-top: 16rpx;
- padding-top: 24rpx;
- border-top: 2rpx dashed #e5e7eb;
- border-bottom: none;
- }
- .info-label {
- font-size: 26rpx;
- color: #9ca3af;
- }
- .info-value {
- font-size: 26rpx;
- color: #1f2937;
- }
- .info-value.price {
- font-size: 36rpx;
- font-weight: 700;
- color: #ef4444;
- }
- .payment-section {
- background: #ffffff;
- border-radius: 16rpx;
- padding: 32rpx;
- margin-bottom: 32rpx;
- }
- .section-title {
- font-size: 28rpx;
- font-weight: 600;
- color: #1f2937;
- margin-bottom: 24rpx;
- display: block;
- }
- .payment-methods {
- display: flex;
- flex-direction: column;
- gap: 16rpx;
- }
- .payment-method {
- display: flex;
- align-items: center;
- padding: 24rpx;
- background: #f9fafb;
- border: 3rpx solid #e5e7eb;
- border-radius: 12rpx;
- transition: all 0.3s;
- }
- .payment-method.active {
- border-color: #667eea;
- background: #f0f2ff;
- }
- .method-radio {
- width: 40rpx;
- height: 40rpx;
- border: 3rpx solid #d1d5db;
- border-radius: 50%;
- margin-right: 16rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .payment-method.active .method-radio {
- border-color: #667eea;
- }
- .radio-inner {
- width: 24rpx;
- height: 24rpx;
- background: #667eea;
- border-radius: 50%;
- }
- .method-icon {
- font-size: 36rpx;
- margin-right: 12rpx;
- }
- .method-name {
- font-size: 28rpx;
- color: #1f2937;
- flex: 1;
- }
- .method-badge {
- font-size: 22rpx;
- color: #ffffff;
- background: #10b981;
- padding: 4rpx 12rpx;
- border-radius: 8rpx;
- margin-left: 8rpx;
- }
- .wechat-notice {
- display: flex;
- align-items: center;
- padding: 16rpx 20rpx;
- background: #ecfdf5;
- border: 1rpx solid #a7f3d0;
- border-radius: 10rpx;
- margin-bottom: 20rpx;
- }
- .wechat-notice-icon {
- font-size: 32rpx;
- margin-right: 12rpx;
- }
- .wechat-notice-text {
- font-size: 24rpx;
- color: #065f46;
- }
- .actions {
- display: flex;
- gap: 24rpx;
- margin-bottom: 32rpx;
- }
- .btn-primary,
- .btn-secondary {
- flex: 1;
- height: 88rpx;
- border-radius: 44rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 30rpx;
- border: none;
- }
- .btn-primary {
- background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
- color: #ffffff;
- }
- .btn-secondary {
- background: #ffffff;
- color: #6b7280;
- border: 2rpx solid #e5e7eb;
- }
- .tips {
- background: #fef3c7;
- border-radius: 16rpx;
- padding: 24rpx;
- margin-bottom: 48rpx;
- }
- .tips-title {
- font-size: 24rpx;
- font-weight: 600;
- color: #92400e;
- display: block;
- margin-bottom: 12rpx;
- }
- .tips-text {
- font-size: 22rpx;
- color: #78350f;
- display: block;
- margin-bottom: 6rpx;
- }
- </style>
|