v1.0.18: 小程序虚拟支付上线 + 定价调整为整数
- 新增虚拟支付 (short_series_coin 代币模式,1:1 兑换) - 后端 修复为正确 VP 格式,返回 mode 参数 - 前端 VP 调用补齐 、 格式调整 - 套餐价格调整:成长版 ¥19.9 → ¥19,冲刺版 ¥49.9 → ¥49 - 数据库定价同步更新为 1900/4900(分) - 会员页未登录时也拉取 ,套餐对比数据由服务端返回 - 文档统一更新定价和 VP 说明 - 修正 AGENTS.md 引力值数据(250/600 → 80/200)
This commit is contained in:
@@ -1,229 +1,524 @@
|
||||
<template>
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="page fade-in">
|
||||
<view class="placeholder-wrap">
|
||||
<text class="placeholder-icon">✨</text>
|
||||
<text class="placeholder-text">功能已整合到各模块</text>
|
||||
<text class="placeholder-hint">请返回使用引力值充值功能</text>
|
||||
<text class="placeholder-back" @click="goBack">返回首页</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view class="page fade-in">
|
||||
<view class="hero">
|
||||
<text class="hero-icon">⚡</text>
|
||||
<text class="hero-title">补充引力值</text>
|
||||
<text class="hero-desc">购买后可获得相应引力值,用于面试、简历优化、下载</text>
|
||||
<!-- 状态栏:当前方案 + 引力值 -->
|
||||
<view class="status-bar">
|
||||
<view class="status-left">
|
||||
<text class="status-label">当前方案</text>
|
||||
<text class="status-plan">{{ currentPlanName || '免费版' }}</text>
|
||||
</view>
|
||||
<view class="status-right">
|
||||
<text class="grav-label">⚡ 引力值</text>
|
||||
<text class="grav-num">{{ gravity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="product-card">
|
||||
<view class="qty-section">
|
||||
<text class="section-label">购买数量</text>
|
||||
<view class="qty-controls">
|
||||
<text class="qty-btn" :class="{ disabled: buyQty <= 1 }" @click="changeQty(-1)">−</text>
|
||||
<input class="qty-input" type="number" v-model.number="buyQty" min="1" max="99" @blur="clampQty" />
|
||||
<text class="qty-btn" :class="{ disabled: buyQty >= 99 }" @click="changeQty(1)">+</text>
|
||||
<!-- 未登录提示 -->
|
||||
<view class="login-bar" v-if="!isLoggedIn">
|
||||
<text class="login-text">登录后可购买引力值、查看套餐</text>
|
||||
<text class="login-btn" @click="goLogin">去登录</text>
|
||||
</view>
|
||||
|
||||
<!-- 购买引力值(最上面,登录后可见) -->
|
||||
<view class="section" v-if="isLoggedIn">
|
||||
<text class="section-title">⚡ 补充引力值</text>
|
||||
<view class="buy-card">
|
||||
<view class="qty-row">
|
||||
<text class="qty-label">购买数量</text>
|
||||
<view class="qty-controls">
|
||||
<text class="qty-btn" :class="{ disabled: buyQty <= 1 }" @click="buyQty = Math.max(1, buyQty - 1)">−</text>
|
||||
<input class="qty-input" type="number" v-model.number="buyQty" min="1" max="99" @blur="buyQty = Math.max(1, Math.min(99, buyQty || 1))" />
|
||||
<text class="qty-btn" :class="{ disabled: buyQty >= 99 }" @click="buyQty = Math.min(99, buyQty + 1)">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="summary">
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">单价</text>
|
||||
<text class="summary-val">¥{{ (unitPrice / 100).toFixed(1) }} / 份</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">可得引力值</text>
|
||||
<text class="summary-val highlight">{{ buyQty * gravityPerUnit }} ⚡</text>
|
||||
</view>
|
||||
<view class="summary-row total">
|
||||
<text class="summary-label">合计</text>
|
||||
<text class="summary-val total-price">¥{{ (buyQty * unitPrice / 100).toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<button class="buy-btn" :disabled="payLoading" @click="startGravityPay">
|
||||
{{ payLoading ? '处理中...' : '立即购买' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 套餐对比 -->
|
||||
<view class="section">
|
||||
<text class="section-title">📋 套餐对比</text>
|
||||
<view class="plan-list">
|
||||
<view v-for="p in planList" :key="p.id" class="plan-card"
|
||||
:class="{ current: p.id === plan, popular: p.popular }">
|
||||
<view class="plan-header">
|
||||
<text class="plan-name">{{ p.name }}</text>
|
||||
<view class="plan-price">
|
||||
<text class="price-num">{{ p.priceDisplay }}</text>
|
||||
</view>
|
||||
<view class="plan-badge" v-if="p.id === plan">
|
||||
<text>当前方案</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="plan-features">
|
||||
<text class="feature" v-for="(f, i) in p.features" :key="i">✓ {{ f }}</text>
|
||||
</view>
|
||||
<view class="plan-footer" v-if="p.id !== 'free'">
|
||||
<view class="plan-action owned" v-if="p.id === plan">✅ 已开通</view>
|
||||
<view class="plan-action" v-else-if="!isLoggedIn" @click="goLogin">登录后开通</view>
|
||||
<view class="plan-action" v-else-if="plan === 'free'" @click="startPlanPay(p.id)">
|
||||
{{ p.priceDisplay }} 开通
|
||||
</view>
|
||||
<view class="plan-action" v-else @click="startPlanPay(p.id)">
|
||||
升级至{{ p.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="summary">
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">单价</text>
|
||||
<text class="summary-val">¥{{ (unitPrice / 100).toFixed(1) }} / 份</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text class="summary-label">可得引力值</text>
|
||||
<text class="summary-val highlight">{{ buyQty * gravityPerUnit }} 引力值</text>
|
||||
</view>
|
||||
<view class="summary-row total">
|
||||
<text class="summary-label">合计</text>
|
||||
<text class="summary-val total-price">¥{{ (buyQty * unitPrice / 100).toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="buy-btn" :disabled="payLoading" @click="startPay">
|
||||
<text v-if="!payLoading">立即购买</text>
|
||||
<text v-else>处理中...</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 支付弹窗 -->
|
||||
<view class="modal-overlay" v-if="showPayModal" @click="cancelPay">
|
||||
<view class="modal-content" @click.stop>
|
||||
<template v-if="payLoading">
|
||||
<text class="modal-title">正在创建订单...</text>
|
||||
<text class="modal-title">正在创建支付...</text>
|
||||
</template>
|
||||
<template v-else-if="payCodeUrl">
|
||||
<template v-else-if="!isMp && payCodeUrl">
|
||||
<text class="modal-title">微信扫码支付</text>
|
||||
<image class="qrcode" :src="'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=' + encodeURIComponent(payCodeUrl)" mode="widthFix" />
|
||||
<text class="modal-hint">请使用微信扫描二维码完成支付</text>
|
||||
<canvas canvas-id="payQrcode" class="qr-canvas"></canvas>
|
||||
<text class="modal-hint">请用微信扫码完成支付</text>
|
||||
<text class="modal-close" @click="cancelPay">取消支付</text>
|
||||
</template>
|
||||
<template v-else-if="paySuccess">
|
||||
<text class="modal-title">✅ 支付成功</text>
|
||||
<text class="modal-hint">引力值已到账,返回继续使用吧</text>
|
||||
<template v-else-if="vpStatus">
|
||||
<text class="modal-title" :class="vpSuccess ? '' : 'pay-error'">{{ vpSuccess ? '✅ 支付成功' : '支付失败' }}</text>
|
||||
<text class="modal-hint">{{ vpStatusText }}</text>
|
||||
<text class="modal-close" @click="cancelPay">关闭</text>
|
||||
</template>
|
||||
<template v-else-if="payError">
|
||||
<text class="modal-title pay-error">支付失败</text>
|
||||
<template v-if="payError && !vpStatus">
|
||||
<text class="modal-title pay-error">支付异常</text>
|
||||
<text class="modal-hint">{{ payError }}</text>
|
||||
<text class="modal-close" @click="cancelPay">关闭</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
// #ifdef MP-WEIXIN
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
// #endif
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import { api } from '../../config'
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
onShareAppMessage(() => ({ title: '职引 - 引力值购买 | AI模拟面试', path: '/pages/member/member' }))
|
||||
onShareTimeline(() => ({ title: '职引 - 引力值购买 | AI模拟面试' }))
|
||||
// #endif
|
||||
const isLoggedIn = ref(false)
|
||||
const isMp = ref(false)
|
||||
const plan = ref('free')
|
||||
const currentPlanName = ref('免费版')
|
||||
const gravity = ref(0)
|
||||
const planList = ref([])
|
||||
|
||||
const goBack = () => uni.switchTab({ url: '/pages/user/user' })
|
||||
|
||||
// #ifdef H5
|
||||
// 购买引力值
|
||||
const buyQty = ref(1)
|
||||
const unitPrice = ref(500)
|
||||
const gravityPerUnit = ref(5)
|
||||
const payLoading = ref(false)
|
||||
|
||||
// 支付弹窗
|
||||
const showPayModal = ref(false)
|
||||
const payCodeUrl = ref('')
|
||||
const paySuccess = ref(false)
|
||||
const payError = ref('')
|
||||
const currentOutTradeNo = ref('')
|
||||
const payingPlan = ref('')
|
||||
const paySuccess = ref(false)
|
||||
const vpStatus = ref(false)
|
||||
const vpSuccess = ref(false)
|
||||
const vpStatusText = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// 套餐特征后备值(API 取不到时使用)
|
||||
const defaultFreeFeatures = ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)']
|
||||
const defaultGrowthFeatures = ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库']
|
||||
const defaultSprintFeatures = ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选']
|
||||
|
||||
const token = () => uni.getStorageSync('token') || ''
|
||||
|
||||
const refreshState = async () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
isMp.value = true
|
||||
// #endif
|
||||
|
||||
const t = token()
|
||||
isLoggedIn.value = !!t
|
||||
|
||||
// 1. 先拉套餐配置(公开接口,未登录也拉)
|
||||
try {
|
||||
const res = await uni.request({ url: api('/member/plans'), method: 'GET' })
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.products) {
|
||||
const prod = res.data.products.interview
|
||||
if (prod) {
|
||||
unitPrice.value = prod.price || 500
|
||||
gravityPerUnit.value = prod.gravity || 5
|
||||
const pres = await uni.request({ url: api('/member/plans'), method: 'GET' })
|
||||
if (pres.statusCode >= 200 && pres.statusCode < 300 && pres.data) {
|
||||
const d = pres.data
|
||||
if (d.products?.interview) {
|
||||
unitPrice.value = d.products.interview.price || 500
|
||||
gravityPerUnit.value = d.products.interview.gravity || 5
|
||||
}
|
||||
planList.value = buildPlanList(d.plans)
|
||||
} else {
|
||||
planList.value = buildPlanList(null)
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
})
|
||||
|
||||
const changeQty = (delta: number) => {
|
||||
const next = buyQty.value + delta
|
||||
if (next >= 1 && next <= 99) buyQty.value = next
|
||||
}
|
||||
const clampQty = () => {
|
||||
if (buyQty.value < 1) buyQty.value = 1
|
||||
if (buyQty.value > 99) buyQty.value = 99
|
||||
// 2. 用户信息需要登录
|
||||
if (!t) return
|
||||
try {
|
||||
const ures = await uni.request({ url: api('/user/info'), method: 'GET', header: { 'Authorization': `Bearer ${t}` } })
|
||||
if (ures.statusCode >= 200 && ures.statusCode < 300 && ures.data) {
|
||||
const u = ures.data
|
||||
plan.value = u.plan || 'free'
|
||||
gravity.value = u.gravity ?? 0
|
||||
currentPlanName.value = ({ free: '免费版', growth: '成长版', sprint: '冲刺版' })[plan.value] || '免费版'
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
const startPay = async () => {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
if (!token) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||
const buildPlanList = (plans) => {
|
||||
const growth = plans?.find?.(p => p.id === 'growth')
|
||||
const sprint = plans?.find?.(p => p.id === 'sprint')
|
||||
return [
|
||||
{
|
||||
id: 'free', name: '免费版', priceDisplay: '免费',
|
||||
features: defaultFreeFeatures,
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
id: 'growth', name: '成长版',
|
||||
priceDisplay: growth ? `¥${(growth.price / 100).toFixed(1)}/月` : '¥19/月',
|
||||
features: growth?.features || defaultGrowthFeatures,
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: 'sprint', name: '冲刺版',
|
||||
priceDisplay: sprint ? `¥${(sprint.price / 100).toFixed(1)}/月` : '¥49/月',
|
||||
features: sprint?.features || defaultSprintFeatures,
|
||||
popular: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
onLoad(() => { /* silent */ })
|
||||
onMounted(refreshState)
|
||||
onShow(refreshState)
|
||||
|
||||
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
|
||||
|
||||
const cancelPay = () => {
|
||||
showPayModal.value = false
|
||||
payCodeUrl.value = ''
|
||||
payLoading.value = false
|
||||
payError.value = ''
|
||||
paySuccess.value = false
|
||||
vpStatus.value = false
|
||||
vpSuccess.value = false
|
||||
vpStatusText.value = ''
|
||||
}
|
||||
|
||||
/** 购买引力值 → MP 用 VP / H5 用扫码 */
|
||||
const startGravityPay = async () => {
|
||||
const t = token()
|
||||
if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||
|
||||
showPayModal.value = true
|
||||
payLoading.value = true
|
||||
payCodeUrl.value = ''
|
||||
payError.value = ''
|
||||
paySuccess.value = false
|
||||
vpStatus.value = false
|
||||
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/payment/create-product'), method: 'POST',
|
||||
data: { type: 'interview', quantity: buyQty.value },
|
||||
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
payLoading.value = false
|
||||
if (isMp.value) {
|
||||
// 小程序:虚拟支付 VP
|
||||
try {
|
||||
console.log('[VP] wx.login...')
|
||||
const [loginErr, loginRes] = await new Promise((resolve) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: r => { console.log('[VP] wx.login 成功:', JSON.stringify(r)); resolve([null, r]) },
|
||||
fail: e => { console.error('[VP] wx.login 失败:', JSON.stringify(e)); resolve([e, null]) },
|
||||
})
|
||||
})
|
||||
if (loginErr || !loginRes?.code) {
|
||||
payLoading.value = false
|
||||
payError.value = '微信登录失败: ' + (loginErr?.errMsg || 'no code')
|
||||
console.error('[VP] login failed', loginErr, loginRes)
|
||||
uni.showToast({ title: '微信登录失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
console.log('[VP] code:', loginRes.code.slice(0, 20) + '...')
|
||||
const res = await uni.request({
|
||||
url: api('/virtual-payment/create'), method: 'POST',
|
||||
data: { type: 'interview', quantity: buyQty.value, wxCode: loginRes.code },
|
||||
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||||
timeout: 30000,
|
||||
})
|
||||
console.log('[VP] 创建订单:', res.statusCode, JSON.stringify(res.data).slice(0, 500))
|
||||
payLoading.value = false
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.codeUrl) {
|
||||
payCodeUrl.value = res.data.codeUrl
|
||||
pollPayResult(res.data.outTradeNo)
|
||||
} else {
|
||||
payError.value = res.data?.message || '创建订单失败'
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.paySig) {
|
||||
const vp = res.data
|
||||
currentOutTradeNo.value = vp.outTradeNo
|
||||
wx.requestVirtualPayment({
|
||||
env: vp.env, mode: vp.mode, offerId: vp.offerId,
|
||||
signData: vp.signData,
|
||||
paySig: vp.paySig, signature: vp.signature,
|
||||
success: () => {
|
||||
console.log('[VP] 支付成功')
|
||||
vpStatus.value = true
|
||||
vpSuccess.value = true
|
||||
vpStatusText.value = '引力值已到账'
|
||||
uni.showToast({ title: '充值成功!', icon: 'success' })
|
||||
refreshState()
|
||||
},
|
||||
fail: (err2) => {
|
||||
console.error('[VP] 支付失败:', JSON.stringify(err2))
|
||||
vpStatus.value = true
|
||||
vpSuccess.value = false
|
||||
vpStatusText.value = err2?.errMsg || '支付取消'
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const msg = res.data?.message || res.data?.msg || `请求失败(${res.statusCode})`
|
||||
payError.value = msg
|
||||
console.error('[VP] 订单创建失败:', res.statusCode, JSON.stringify(res.data))
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
console.error('[VP] 异常:', e)
|
||||
}
|
||||
} else {
|
||||
// H5:扫码支付
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/payment/create-product'), method: 'POST',
|
||||
data: { type: 'interview', quantity: buyQty.value },
|
||||
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
payLoading.value = false
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.codeUrl) {
|
||||
payCodeUrl.value = res.data.codeUrl
|
||||
currentOutTradeNo.value = res.data.outTradeNo
|
||||
pollPayResult(res.data.outTradeNo, 'growth')
|
||||
} else {
|
||||
payError.value = res.data?.message || '创建订单失败'
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
}
|
||||
}
|
||||
|
||||
const pollPayResult = (outTradeNo: string) => {
|
||||
/** 套餐升级 → MP 用虚拟支付 VP / H5 用扫码支付 */
|
||||
const startPlanPay = async (selectedPlan) => {
|
||||
const t = token()
|
||||
if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
|
||||
|
||||
payingPlan.value = selectedPlan
|
||||
showPayModal.value = true
|
||||
payLoading.value = true
|
||||
payError.value = ''
|
||||
vpStatus.value = false
|
||||
|
||||
if (isMp.value) {
|
||||
// 小程序:虚拟支付 VP(套餐升级)
|
||||
try {
|
||||
console.log('[VP-plan] wx.login...')
|
||||
const [loginErr, loginRes] = await new Promise((resolve) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: r => { console.log('[VP-plan] wx.login 成功:', JSON.stringify(r)); resolve([null, r]) },
|
||||
fail: e => { console.error('[VP-plan] wx.login 失败:', JSON.stringify(e)); resolve([e, null]) },
|
||||
})
|
||||
})
|
||||
if (loginErr || !loginRes?.code) {
|
||||
payLoading.value = false
|
||||
payError.value = '微信登录失败: ' + (loginErr?.errMsg || 'no code')
|
||||
console.error('[VP-plan] login failed', loginErr, loginRes)
|
||||
uni.showToast({ title: '微信登录失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
console.log('[VP-plan] code:', loginRes.code.slice(0, 20) + '...')
|
||||
const res = await uni.request({
|
||||
url: api('/virtual-payment/create'), method: 'POST',
|
||||
data: { type: selectedPlan, quantity: 1, wxCode: loginRes.code },
|
||||
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||||
timeout: 30000,
|
||||
})
|
||||
console.log('[VP-plan] 创建订单:', res.statusCode, JSON.stringify(res.data).slice(0, 500))
|
||||
payLoading.value = false
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.paySig) {
|
||||
const vp = res.data
|
||||
currentOutTradeNo.value = vp.outTradeNo
|
||||
wx.requestVirtualPayment({
|
||||
env: vp.env, mode: vp.mode, offerId: vp.offerId,
|
||||
signData: vp.signData,
|
||||
paySig: vp.paySig, signature: vp.signature,
|
||||
success: () => {
|
||||
console.log('[VP-plan] 支付成功')
|
||||
vpStatus.value = true
|
||||
vpSuccess.value = true
|
||||
vpStatusText.value = '套餐已激活'
|
||||
uni.showToast({ title: '开通成功!', icon: 'success' })
|
||||
plan.value = selectedPlan
|
||||
currentPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
|
||||
refreshState()
|
||||
},
|
||||
fail: (err2) => {
|
||||
console.error('[VP-plan] 支付失败:', JSON.stringify(err2))
|
||||
vpStatus.value = true
|
||||
vpSuccess.value = false
|
||||
vpStatusText.value = err2?.errMsg || '支付取消'
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const msg = res.data?.message || res.data?.msg || `请求失败(${res.statusCode})`
|
||||
payError.value = msg
|
||||
console.error('[VP-plan] 订单创建失败:', res.statusCode, JSON.stringify(res.data))
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
console.error('[VP-plan] 异常:', e)
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/payment/create'), method: 'POST',
|
||||
data: { plan: selectedPlan },
|
||||
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
payLoading.value = false
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.codeUrl) {
|
||||
payCodeUrl.value = res.data.codeUrl
|
||||
currentOutTradeNo.value = res.data.outTradeNo
|
||||
pollPayResult(res.data.outTradeNo, selectedPlan)
|
||||
} else {
|
||||
payError.value = res.data?.message || '创建订单失败'
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询订单状态 */
|
||||
const pollPayResult = (outTradeNo, selectedPlan) => {
|
||||
if (!outTradeNo) return
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
let attempts = 0
|
||||
const poll = async () => {
|
||||
attempts++
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api(`/payment/check/${outTradeNo}`), method: 'GET',
|
||||
header: { 'Authorization': `Bearer ${token}` },
|
||||
header: { 'Authorization': `Bearer ${token()}` },
|
||||
})
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.status === 'success') {
|
||||
paySuccess.value = true
|
||||
payCodeUrl.value = ''
|
||||
await activatePlan(outTradeNo, selectedPlan)
|
||||
return
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
if (attempts < 30) setTimeout(poll, 2000)
|
||||
else { payError.value = '支付结果查询超时,请联系客服' }
|
||||
}
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
const cancelPay = () => {
|
||||
showPayModal.value = false
|
||||
payCodeUrl.value = ''
|
||||
payError.value = ''
|
||||
payLoading.value = false
|
||||
/** 激活套餐 */
|
||||
const activatePlan = async (outTradeNo, selectedPlan) => {
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/payment/activate'), method: 'POST',
|
||||
data: { outTradeNo },
|
||||
header: { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.success) {
|
||||
paySuccess.value = true
|
||||
showPayModal.value = false
|
||||
plan.value = selectedPlan
|
||||
currentPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
|
||||
uni.showToast({ title: '🎉 开通成功!', icon: 'success' })
|
||||
refreshState()
|
||||
} else {
|
||||
uni.showToast({ title: res.data?.message || '激活失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
payError.value = '激活失败,请联系客服'
|
||||
uni.showToast({ title: '激活失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; background: var(--color-bg); }
|
||||
.placeholder-wrap { display: flex; flex-direction: column; align-items: center; gap: 16rpx; padding: 80rpx 40rpx; }
|
||||
.placeholder-icon { font-size: 80rpx; }
|
||||
.placeholder-text { font-size: 30rpx; font-weight: 600; color: var(--color-text); }
|
||||
.placeholder-hint { font-size: 24rpx; color: var(--color-text-tertiary); }
|
||||
.placeholder-back { font-size: 26rpx; color: var(--color-primary); padding: 16rpx 40rpx; border-radius: var(--radius-md); background: #F3F4F6; margin-top: 24rpx; }
|
||||
.page { min-height: 100vh; background: var(--color-bg); padding-bottom: 40rpx; }
|
||||
|
||||
/* H5 购买页 */
|
||||
.hero { display: flex; flex-direction: column; align-items: center; padding: 48rpx 32rpx 24rpx; }
|
||||
.hero-icon { font-size: 72rpx; }
|
||||
.hero-title { font-size: 36rpx; font-weight: 700; color: var(--color-text); margin-top: 12rpx; }
|
||||
.hero-desc { font-size: 24rpx; color: var(--color-text-secondary); margin-top: 8rpx; text-align: center; }
|
||||
/* 状态栏 */
|
||||
.status-bar { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); padding: 32rpx; color: #fff; }
|
||||
.status-left { display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.status-label { font-size: 22rpx; opacity: 0.85; }
|
||||
.status-plan { font-size: 34rpx; font-weight: 700; }
|
||||
.status-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; }
|
||||
.grav-label { font-size: 22rpx; opacity: 0.85; }
|
||||
.grav-num { font-size: 40rpx; font-weight: 800; }
|
||||
|
||||
.product-card { background: #fff; border-radius: var(--radius-lg); margin: 0 32rpx; padding: 32rpx; box-shadow: var(--shadow-sm); }
|
||||
/* 登录提示 */
|
||||
.login-bar { display: flex; align-items: center; justify-content: space-between; margin: 24rpx 24rpx 0; background: #FEF3C7; border-radius: var(--radius-lg); padding: 20rpx 24rpx; }
|
||||
.login-text { font-size: 24rpx; color: #92400E; }
|
||||
.login-btn { font-size: 24rpx; color: #FFF; background: var(--color-primary); padding: 8rpx 24rpx; border-radius: var(--radius-sm); }
|
||||
|
||||
.qty-section { margin-bottom: 24rpx; }
|
||||
.section-label { font-size: 26rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 16rpx; }
|
||||
/* 区块 */
|
||||
.section { padding: 0 24rpx; margin-top: 24rpx; }
|
||||
.section-title { font-size: 28rpx; font-weight: 700; color: var(--color-text); display: block; margin-bottom: 16rpx; }
|
||||
|
||||
/* 购买区 */
|
||||
.buy-card { background: #fff; border-radius: var(--radius-lg); padding: 24rpx; box-shadow: var(--shadow-sm); }
|
||||
.qty-row { margin-bottom: 16rpx; }
|
||||
.qty-label { font-size: 24rpx; font-weight: 600; color: var(--color-text); display: block; margin-bottom: 12rpx; }
|
||||
.qty-controls { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
|
||||
.qty-btn { width: 64rpx; height: 64rpx; border-radius: 50%; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 500; color: var(--color-text); }
|
||||
.qty-btn { width: 60rpx; height: 60rpx; border-radius: 50%; background: #F3F4F6; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 500; color: var(--color-text); }
|
||||
.qty-btn.disabled { color: #D1D5DB; background: #F9FAFB; }
|
||||
.qty-input { width: 120rpx; height: 72rpx; text-align: center; font-size: 36rpx; font-weight: 700; color: var(--color-text); border: 2rpx solid #E5E7EB; border-radius: var(--radius-sm); }
|
||||
|
||||
.summary { margin-bottom: 32rpx; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 12rpx 0; border-bottom: 1rpx solid #F3F4F6; }
|
||||
.summary-row.total { border-bottom: none; padding-top: 16rpx; }
|
||||
.summary-label { font-size: 24rpx; color: var(--color-text-secondary); }
|
||||
.summary-val { font-size: 26rpx; font-weight: 600; color: var(--color-text); }
|
||||
.qty-input { width: 120rpx; height: 64rpx; text-align: center; font-size: 32rpx; font-weight: 700; color: var(--color-text); border: 2rpx solid #E5E7EB; border-radius: var(--radius-sm); }
|
||||
.summary { margin-bottom: 20rpx; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 8rpx 0; border-bottom: 1rpx solid #F3F4F6; }
|
||||
.summary-row.total { border-bottom: none; padding-top: 12rpx; }
|
||||
.summary-label { font-size: 22rpx; color: var(--color-text-secondary); }
|
||||
.summary-val { font-size: 24rpx; font-weight: 600; color: var(--color-text); }
|
||||
.summary-val.highlight { color: var(--color-primary); }
|
||||
.total-price { font-size: 36rpx; font-weight: 800; color: var(--color-primary); }
|
||||
|
||||
.buy-btn { width: 100%; height: 88rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; font-size: 30rpx; font-weight: 600; border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; border: none; }
|
||||
.buy-btn:active { opacity: 0.85; transform: scale(0.98); }
|
||||
.total-price { font-size: 32rpx; font-weight: 800; color: var(--color-primary); }
|
||||
.buy-btn { width: 100%; height: 80rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; font-size: 28rpx; font-weight: 600; border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; border: none; }
|
||||
.buy-btn:active { opacity: 0.85; }
|
||||
.buy-btn[disabled] { opacity: 0.5; }
|
||||
|
||||
/* 支付弹窗 */
|
||||
/* 套餐列表 */
|
||||
.plan-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.plan-card { background: #fff; border-radius: var(--radius-lg); padding: 24rpx; box-shadow: var(--shadow-sm); position: relative; }
|
||||
.plan-card.popular { border: 2rpx solid var(--color-primary); }
|
||||
.plan-card.current { background: #F0F7FF; border: 2rpx solid var(--color-primary); }
|
||||
.plan-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12rpx; }
|
||||
.plan-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||
.price-num { font-size: 32rpx; font-weight: 800; color: var(--color-primary); }
|
||||
.plan-badge { background: var(--color-primary); color: #fff; font-size: 20rpx; padding: 4rpx 14rpx; border-radius: 20rpx; }
|
||||
.plan-features { display: flex; flex-direction: column; gap: 8rpx; margin-bottom: 16rpx; }
|
||||
.feature { font-size: 22rpx; color: var(--color-text-secondary); }
|
||||
.plan-action { text-align: center; padding: 16rpx; border-radius: var(--radius-md); font-size: 26rpx; font-weight: 600; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #fff; }
|
||||
.plan-action.owned { background: #ECFDF5; color: var(--color-success); }
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-overlay { 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: 100; }
|
||||
.modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx 32rpx; width: 600rpx; display: flex; flex-direction: column; align-items: center; gap: 16rpx; }
|
||||
.modal-title { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
|
||||
.modal-content { background: #FFF; border-radius: var(--radius-xl); padding: 40rpx; width: 70%; display: flex; flex-direction: column; align-items: center; gap: 20rpx; }
|
||||
.modal-title { font-size: 28rpx; font-weight: 600; color: var(--color-text); }
|
||||
.pay-error { color: var(--color-error); }
|
||||
.modal-hint { font-size: 22rpx; color: #6B7280; text-align: center; }
|
||||
.modal-close { font-size: 24rpx; color: #9CA3AF; padding: 12rpx 24rpx; }
|
||||
.qrcode { width: 300rpx; height: 300rpx; margin: 8rpx 0; }
|
||||
.qr-canvas { width: 400rpx; height: 400rpx; }
|
||||
.modal-hint { font-size: 22rpx; color: var(--color-text-tertiary); }
|
||||
.modal-close { font-size: 22rpx; color: var(--color-text-tertiary); padding: 8rpx; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user