Files
ai-dimension/client/src/pages/payment/payment.vue
T

594 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page-payment page-layout">
<view class="status-bar-placeholder"></view>
<!-- 导航 -->
<view class="nav-bar">
<view class="nav-back" @click="goBack">‹</view>
<text class="nav-title">订阅升级</text>
<view class="nav-placeholder"></view>
</view>
<scroll-view scroll-y class="scroll-area">
<!-- 头部 -->
<view class="payment-header">
<view class="header-icon-wrap">
<text class="header-icon">✦</text>
</view>
<text class="header-title">解锁 AI 维度 Pro</text>
<text class="header-desc">无限 AI 问答 · 全量知识 · 趋势优先推送</text>
</view>
<!-- 方案选择卡片 -->
<view class="plan-selector">
<view
class="plan-card"
:class="{ active: selectedPlan === 'pro' }"
@click="selectedPlan = 'pro'"
>
<view class="plan-top">
<text class="plan-name">Pro</text>
<view class="plan-badge" v-if="selectedPlan === 'pro'">推荐</view>
</view>
<view class="plan-price-row">
<text class="plan-price">¥19.9</text>
<text class="plan-unit">/ 月</text>
</view>
<view class="plan-features">
<text class="plan-feature">✓ AI 问答无限次</text>
<text class="plan-feature">✓ 全量知识解锁</text>
<text class="plan-feature">✓ 收藏无限容量</text>
<text class="plan-feature">✓ 趋势优先推送</text>
</view>
</view>
<view
class="plan-card vip"
:class="{ active: selectedPlan === 'vip' }"
@click="selectedPlan = 'vip'"
>
<view class="plan-top">
<text class="plan-name">VIP</text>
<view class="plan-badge vip-badge" v-if="selectedPlan === 'vip'">尊享</view>
</view>
<view class="plan-price-row">
<text class="plan-price vip-price">¥39.9</text>
<text class="plan-unit">/ 月</text>
</view>
<view class="plan-features">
<text class="plan-feature">✓ 含 Pro 全部权益</text>
<text class="plan-feature">✓ 专属客服支持</text>
<text class="plan-feature">✓ 专属会员标识</text>
<text class="plan-feature">✓ 工具教程 + 学习路径</text>
</view>
</view>
</view>
<!-- 支付按钮 -->
<view class="pay-section">
<button
class="pay-btn"
:loading="loading"
:disabled="loading"
@click="handlePay"
>
<text class="pay-btn-text">立即支付 ¥{{ currentPrice }}</text>
</button>
<view class="pay-agreement-row">
<text class="pay-agreement-text">
会员服务说明:
<text class="pay-agreement-link" @click.stop="openAgreement('terms')">《用户协议》</text>
·
<text class="pay-agreement-link" @click.stop="openAgreement('privacy')">《隐私政策》</text>
</text>
</view>
</view>
<view class="footer-spacer"></view>
</scroll-view>
<!-- H5 支付失败/备选方案 -->
<view v-if="showH5Fallback" class="h5-fallback-mask">
<view class="h5-fallback-content">
<view class="fallback-icon">📱</view>
<text class="fallback-title">请使用微信完成支付</text>
<text class="fallback-desc">
订单号:{{ h5OrderNo }}
金额:¥{{ currentPrice }}
</text>
<view class="fallback-actions">
<button class="fallback-btn primary" @click="retryH5Pay">
再次跳转微信支付
</button>
<button class="fallback-btn secondary" @click="copyH5Url">
复制链接到微信打开
</button>
<button class="fallback-btn text-btn" @click="closeH5Fallback">
稍后再说
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, computed } from 'vue'
import { paymentApi } from '@/utils/api'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const selectedPlan = ref('pro') // 'pro' | 'vip'
// 方案 → 对应数据库的商品 ID 和价格(单位:元)
const PLAN_MAP = {
pro: { productId: 'pro_monthly', price: 19.9 },
vip: { productId: 'vip_monthly', price: 39.9 }
}
const currentPrice = computed(() => PLAN_MAP[selectedPlan.value].price)
const currentProductId = computed(() => PLAN_MAP[selectedPlan.value].productId)
const loading = ref(false)
// H5 支付相关状态
const h5PayUrl = ref('')
const showH5Fallback = ref(false)
const h5OrderNo = ref('')
// 检测是否运行在 H5 环境
function isH5() {
// #ifdef H5
return true
// #endif
// #ifdef MP-WEIXIN
return false
// #endif
return false
}
async function openAgreement(type) {
uni.navigateTo({ url: '/pages/agreement/agreement?type=' + (type || 'terms') })
}
async function handlePay() {
if (!userStore.isLoggedIn) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const plan = PLAN_MAP[selectedPlan.value]
if (!plan || !plan.productId || plan.price <= 0) {
uni.showToast({ title: '请选择有效的套餐', icon: 'none' })
return
}
const productId = plan.productId
const amount = plan.price
loading.value = true
// ===== H5 环境:使用微信支付 H5 跳转链接 =====
if (isH5()) {
try {
const data = await paymentApi.createH5Order(productId, window.location.hostname, userStore.token)
h5OrderNo.value = data.orderNo
if (data.h5Url) {
h5PayUrl.value = data.h5Url
showH5Fallback.value = false
// 尝试直接跳转微信支付页面
// #ifdef H5
window.location.href = data.h5Url
// #endif
// H5 环境下跳转后不会返回,这里设置一个超时提示
setTimeout(() => {
showH5Fallback.value = true
}, 5000)
} else {
uni.showToast({ title: '获取支付链接失败', icon: 'none' })
loading.value = false
}
} catch (err) {
console.error('H5创建订单失败:', err)
uni.showToast({ title: err.message || '创建订单失败,请重试', icon: 'none' })
loading.value = false
}
return
}
// ===== 微信小程序环境:使用原生支付 =====
try {
const data = await paymentApi.createOrder(productId, userStore.token)
const payParam = data.payParams || data.wechatParam || data.paymentData
if (payParam) {
// 校验支付参数金额(分)与所选金额一致
// 微信返回的 amount 可能是 number 或 string
const payAmountFen = payParam.totalFee || payParam.amount || data.amount
const expectedFen = Math.round(amount * 100)
if (parseInt(payAmountFen, 10) !== expectedFen) {
console.error(`支付金额不匹配: 前端 ¥${amount} / 后端 ${payAmountFen} 分`)
uni.showToast({ title: '支付金额异常,请重试', icon: 'none' })
loading.value = false
return
}
uni.requestPayment({
provider: 'wxpay',
timeStamp: payParam.timeStamp || payParam.timestamp,
nonceStr: payParam.nonceStr,
package: payParam.package || payParam.packAge,
signType: payParam.signType || 'MD5',
paySign: payParam.paySign,
success: () => {
uni.showToast({ title: `支付成功!金额 ¥${amount}`, icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
},
fail: (err) => {
if (err.errMsg && err.errMsg.includes('cancel')) {
uni.showToast({ title: '已取消支付', icon: 'none' })
} else {
uni.showToast({ title: '支付失败,请重试', icon: 'none' })
}
}
})
} else {
uni.showToast({ title: '获取支付参数失败', icon: 'none' })
}
} catch (err) {
console.error('创建订单失败:', err)
uni.showToast({ title: err.message || '创建订单失败,请重试', icon: 'none' })
} finally {
loading.value = false
}
}
/** H5 支付:复制链接到微信打开 */
function copyH5Url() {
// #ifdef H5
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(h5PayUrl.value).then(() => {
uni.showToast({ title: '链接已复制,请到微信中打开', icon: 'none' })
}).catch(() => {
// fallback
const textarea = document.createElement('textarea')
textarea.value = h5PayUrl.value
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
uni.showToast({ title: '链接已复制,请到微信中打开', icon: 'none' })
})
}
// #endif
}
/** H5 支付:再次跳转 */
function retryH5Pay() {
if (h5PayUrl.value) {
// #ifdef H5
window.location.href = h5PayUrl.value
// #endif
}
}
/** H5 支付:关闭窗口返回 */
function closeH5Fallback() {
showH5Fallback.value = false
loading.value = false
}
function goBack() {
uni.navigateBack()
}
// 微信小程序分享
// #ifdef MP-WEIXIN
onShareAppMessage(() => ({
title: 'AI 维度 - 你的AI知识助手',
path: '/pages/index/index'
}))
// #endif
</script>
<style lang="scss" scoped>
.page-payment {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.status-bar-placeholder { height: 44px; }
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
flex-shrink: 0;
}
.nav-back {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--text-primary);
}
.nav-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.nav-placeholder {
width: 30px;
}
.scroll-area {
flex: 1;
padding: 0 20px;
}
// 头部
.payment-header {
text-align: center;
padding: 10px 0 24px;
}
.header-icon-wrap {
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
box-shadow: 0 8px 24px rgba(99,102,241,0.25);
}
.header-icon {
font-size: 28px;
color: white;
}
.header-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
display: block;
margin-bottom: 6px;
}
.header-desc {
font-size: 13px;
color: var(--text-tertiary);
display: block;
}
// 方案选择
.plan-selector {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.plan-card {
flex: 1;
padding: 20px 16px;
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
border-radius: 16px;
position: relative;
transition: all 0.3s;
&.active {
border-color: var(--dim1-color);
box-shadow: 0 0 0 1px rgba(99,102,241,0.2), 0 8px 24px rgba(99,102,241,0.1);
background: linear-gradient(135deg, rgba(99,102,241,0.06), rgba(99,102,241,0.02));
}
&.vip.active {
border-color: #ffa726;
box-shadow: 0 0 0 1px rgba(255,167,38,0.2), 0 8px 24px rgba(255,167,38,0.1);
background: linear-gradient(135deg, rgba(255,167,38,0.06), rgba(255,167,38,0.02));
}
}
.plan-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.plan-name {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.plan-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
background: linear-gradient(135deg, var(--dim1-color), #7c4dff);
color: white;
}
.vip-badge {
background: linear-gradient(135deg, #ffa726, #ff7043);
}
.plan-price {
font-size: 28px;
font-weight: 700;
color: var(--dim1-color);
}
.plan-price-row {
display: flex;
align-items: baseline;
margin-bottom: 12px;
}
.plan-card.vip .plan-price {
color: #ffa726;
}
.plan-unit {
font-size: 12px;
color: var(--text-tertiary);
margin-left: 2px;
}
.plan-features {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.plan-feature {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
// 支付
.pay-section {
margin-bottom: 24px;
}
.pay-btn {
width: 100%;
height: 52px;
background: linear-gradient(135deg, var(--dim1-color), var(--dim3-color));
color: white;
border: none;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgba(99,102,241,0.3);
&[disabled] {
opacity: 0.6;
}
}
.pay-btn-text {
font-size: 16px;
font-weight: 600;
}
.pay-agreement {
font-size: 11px;
color: var(--text-muted);
text-align: center;
margin-top: 12px;
display: block;
}
.footer-spacer { height: 40px; }
// ===== H5 支付备选方案 =====
.h5-fallback-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.h5-fallback-content {
background: var(--bg-primary);
border-radius: 20px;
padding: 32px 24px;
width: 85%;
max-width: 380px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.fallback-icon {
font-size: 48px;
margin-bottom: 8px;
}
.fallback-title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.fallback-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
margin-bottom: 8px;
white-space: pre-line;
}
.fallback-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
margin-top: 8px;
}
.fallback-btn {
padding: 12px 20px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
&.primary {
background: linear-gradient(135deg, #07C160, #06AE56);
color: white;
box-shadow: 0 4px 16px rgba(7, 193, 96, 0.3);
}
&.secondary {
background: var(--glass-bg);
border: 1.5px solid var(--glass-border);
color: var(--text-primary);
}
&.text-btn {
background: transparent;
color: var(--text-tertiary);
font-size: 13px;
padding: 8px;
}
}
</style>
<style scoped>
.pay-agreement-row { display: flex; align-items: center; justify-content: center; gap: 6px; margin-top: 10px; }
.pay-agreement-text { font-size: 11px; color: var(--text-tertiary); }
.pay-agreement-link { color: var(--color-brand); }
</style>