594 lines
14 KiB
Vue
594 lines
14 KiB
Vue
<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>
|