Files
zhiyin/zhiyin-app/src/pages/member/member.vue
T
2026-06-16 13:18:36 +08:00

342 lines
15 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 fade-in">
<view class="hero">
<text class="hero-title">会员中心</text>
<text class="hero-sub" v-if="isLoggedIn">
当前{{ currentPlanName }}
</text>
<text class="hero-sub" v-else>选择套餐解锁全部功能</text>
</view>
<view class="plans">
<!-- 免费版 -->
<view class="plan-card free" :class="{ active: plan === 'free' && isLoggedIn }">
<view class="plan-header">
<text class="plan-name">免费版</text>
<view class="plan-price"><text class="price-num">免费</text></view>
</view>
<view class="plan-features">
<text class="feat" v-for="f in freeFeatures" :key="f"> {{ f }}</text>
</view>
<view class="plan-status" v-if="isLoggedIn && plan === 'free'">当前使用</view>
<view class="plan-status hint" v-else-if="!isLoggedIn">注册即用</view>
</view>
<!-- 成长版 -->
<view class="plan-card growth recommended" :class="{ active: plan === 'growth' && isLoggedIn }">
<view class="plan-badge"> 推荐</view>
<view class="plan-header">
<text class="plan-name">成长版</text>
<text class="plan-price"><text class="price-num">{{ growthPriceText }}</text><text class="price-unit">/</text></text>
</view>
<view class="plan-features">
<text class="feat" v-for="f in growthFeatures" :key="f"> {{ f }}</text>
</view>
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
<view class="plan-action owned" v-else-if="plan !== 'free'"> 已开通</view>
<view class="plan-action" v-else @click="startPay('growth')">{{ growthPriceText }} 立即开通</view>
</view>
<!-- 冲刺版 -->
<view class="plan-card sprint" :class="{ active: plan === 'sprint' && isLoggedIn }">
<view class="plan-badge sprint-badge">🚀 冲刺</view>
<view class="plan-header">
<text class="plan-name">冲刺版</text>
<text class="plan-price"><text class="price-num price-sprint">{{ sprintPriceText }}</text><text class="price-unit">/</text></text>
</view>
<view class="plan-features">
<text class="feat" v-for="f in sprintFeatures" :key="f"> {{ f }}</text>
</view>
<view class="plan-action" v-if="!isLoggedIn" @click="goLogin">登录后开通</view>
<view class="plan-action owned" v-else-if="plan === 'sprint'"> 已开通</view>
<view class="plan-action" v-else-if="plan === 'growth'" @click="startPay('sprint')">升级至冲刺版</view>
<view class="plan-action" v-else @click="startPay('sprint')">{{ sprintPriceText }}/ 立即开通</view>
</view>
</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>
</template>
<template v-else-if="!isMp && payCodeUrl">
<text class="modal-title">微信扫码支付</text>
<canvas canvas-id="payQrcode" class="qr-canvas"></canvas>
<text class="modal-hint">请用微信扫码完成支付</text>
<text class="modal-hint">支付成功后将自动跳转</text>
<text class="modal-close" @click="cancelPay">取消支付</text>
</template>
<template v-else-if="isMp && !payLoading">
<text class="modal-title">微信支付</text>
<text class="modal-hint">即将调起微信支付...</text>
</template>
<template v-if="payError">
<text class="modal-title pay-error">支付异常</text>
<text class="modal-hint">{{ payError }}</text>
<text class="modal-close" @click="cancelPay">关闭</text>
</template>
</view>
</view>
<!-- 支付中提示 -->
<view class="pay-success" v-if="paySuccess">
<text class="success-icon">🎉</text>
<text class="success-text">开通成功{{ payingPlanName }}已生效</text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { api } from '../../config'
import UQRCode from 'uqrcodejs'
const isLoggedIn = ref(false)
const isMp = ref(false)
const plan = ref('free')
const currentPlanName = ref('免费版')
const paySuccess = ref(false)
const showPayModal = ref(false)
const payCodeUrl = ref('')
const payLoading = ref(false)
const payError = ref('')
const payingPlanName = ref('')
const payingPlan = ref('')
const growthPriceText = ref('¥19.9')
const sprintPriceText = ref('¥49.9')
const currentOutTradeNo = ref('')
const freeFeatures = ref(['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'])
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '每场最多 10 轮 AI 对话'])
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', 'AI 实时提示功能'])
const token = () => uni.getStorageSync('token') || ''
onMounted(async () => {
// #ifdef MP-WEIXIN
isMp.value = true
// #endif
const t = token()
if (!t) return
isLoggedIn.value = true
try {
const [sres, lres] = await Promise.all([
uni.request({ url: api('/member/status'), method: 'GET', header: { 'Authorization': `Bearer ${t}` } }),
uni.request({ url: api('/member/plans'), method: 'GET' }),
])
if (sres.statusCode === 200) {
const d = sres.data
plan.value = d.plan || 'free'
currentPlanName.value = d.planName || '免费版'
}
if (lres.statusCode === 200 && lres.data) {
const plans = Array.isArray(lres.data.plans) ? lres.data.plans : (Array.isArray(lres.data) ? lres.data : [])
const growth = plans.find((p) => p.id === 'growth')
const sprint = plans.find((p) => p.id === 'sprint')
if (growth) {
growthPriceText.value = `¥${(growth.price / 100).toFixed(1)}`
if (growth.features?.length) growthFeatures.value = growth.features
}
if (sprint?.features?.length) sprintFeatures.value = sprint.features
if (sprint) sprintPriceText.value = `¥${(sprint.price / 100).toFixed(1)}`
if (lres.data.price?.monthly) {
growthPriceText.value = `¥${(lres.data.price.monthly / 100).toFixed(1)}`
}
}
} catch (e) { /* ignore */ }
})
const goLogin = () => uni.navigateTo({ url: '/pages/login/login' })
const cancelPay = () => {
showPayModal.value = false
payCodeUrl.value = ''
payLoading.value = false
payError.value = ''
}
/** 创建支付订单 */
const startPay = async (selectedPlan) => {
const t = token()
if (!t) { uni.showToast({ title: '请先登录', icon: 'none' }); return }
payingPlan.value = selectedPlan
// #ifdef MP-WEIXIN
payingPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
// #endif
// #ifndef MP-WEIXIN
payingPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
// #endif
showPayModal.value = true
payLoading.value = true
payError.value = ''
const planLabel = selectedPlan || 'growth'
if (isMp.value) {
// 小程序:JSAPI 支付
try {
const res = await uni.request({
url: api('/payment/jsapi'), method: 'POST',
data: { plan: planLabel },
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
})
payLoading.value = false
if (res.statusCode === 200 && res.data?.payParams) {
const pp = res.data.payParams
currentOutTradeNo.value = res.data.outTradeNo || ''
// 调起微信支付
uni.requestPayment({
provider: 'wxpay',
timeStamp: pp.timeStamp,
nonceStr: pp.nonceStr,
package: pp.package,
signType: pp.signType || 'RSA',
paySign: pp.paySign,
success: () => pollPayResult(res.data.prepayId, planLabel),
fail: (err) => { payError.value = '支付取消或失败'; uni.showToast({ title: '支付取消', icon: 'none' }) },
})
} else {
payLoading.value = false
payError.value = res.data?.message || '创建订单失败'
uni.showToast({ title: '创建订单失败', icon: 'none' })
}
} catch (e) {
payLoading.value = false
payError.value = '网络错误,请重试'
uni.showToast({ title: '网络错误', icon: 'none' })
}
} else {
// H5:二维码支付
try {
const res = await uni.request({
url: api('/payment/create'), method: 'POST',
data: { plan: planLabel },
header: { 'Authorization': `Bearer ${t}`, 'Content-Type': 'application/json' },
})
payLoading.value = false
if (res.statusCode === 200 && res.data?.codeUrl) {
payCodeUrl.value = res.data.codeUrl
currentOutTradeNo.value = res.data.outTradeNo
nextTick(() => {
try {
const ctx = uni.createCanvasContext('payQrcode')
const uqrcode = new UQRCode()
uqrcode.data = res.data.codeUrl
uqrcode.size = 400
uqrcode.margin = 20
uqrcode.backgroundColor = '#FFFFFF'
uqrcode.foregroundColor = '#000000'
uqrcode.make()
uqrcode.drawCanvas(ctx)
} catch(e) { console.error('二维码生成失败', e) }
})
// 轮询支付结果
pollPayResult(res.data.outTradeNo, planLabel)
} else {
payError.value = res.data?.message || '支付服务暂不可用'
uni.showToast({ title: '支付服务暂不可用', icon: 'none' })
}
} catch (e) {
payLoading.value = false
payError.value = '网络错误,请重试'
uni.showToast({ title: '网络错误', icon: 'none' })
}
}
}
/** 轮询订单状态 */
const pollPayResult = async (outTradeNo, selectedPlan) => {
if (!outTradeNo) return
const maxAttempts = 30
let attempts = 0
const poll = async () => {
attempts++
try {
const res = await uni.request({
url: api(`/payment/check/${outTradeNo}`), method: 'GET',
header: { 'Authorization': `Bearer ${token()}` },
})
if (res.statusCode === 200 && res.data?.status === 'success') {
// 支付成功,激活套餐
await activatePlan(outTradeNo, selectedPlan)
return
}
} catch (e) { /* ignore */ }
if (attempts < maxAttempts) {
setTimeout(poll, 2000)
} else {
payError.value = '支付结果查询超时,请联系客服'
uni.showToast({ title: '支付查询超时', icon: 'none' })
}
}
setTimeout(poll, 2000)
}
/** 激活套餐 */
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.data?.success) {
paySuccess.value = true
showPayModal.value = false
plan.value = selectedPlan === 'sprint' ? 'sprint' : 'growth'
currentPlanName.value = selectedPlan === 'sprint' ? '冲刺版' : '成长版'
uni.showToast({ title: '🎉 开通成功!', icon: 'success' })
} else {
uni.showToast({ title: res.data?.message || '激活失败', icon: 'none' })
}
} catch (e) {
payError.value = '激活失败,请联系客服'
uni.showToast({ title: '激活失败', icon: 'none' })
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: var(--color-bg); }
.hero { background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid), var(--color-gradient-end)); padding: 48rpx 32rpx 72rpx; border-radius: 0 0 48rpx 48rpx; }
.hero-title { font-size: 40rpx; font-weight: 700; color: #FFFFFF; }
.hero-sub { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 8rpx; display: block; }
.plans { padding: 0 32rpx; margin-top: -40rpx; display: flex; flex-direction: column; gap: 24rpx; }
.plan-card { background: #FFFFFF; border-radius: var(--radius-xl); padding: 32rpx; box-shadow: var(--shadow-sm); position: relative; }
.plan-card.growth { border: 2rpx solid var(--color-primary); }
.plan-card.sprint { border: 2rpx solid #F59E0B; }
.plan-card.active { border-color: var(--color-primary); }
.plan-badge { position: absolute; top: -12rpx; right: 24rpx; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; font-size: 20rpx; padding: 4rpx 20rpx; border-radius: var(--radius-round); font-weight: 600; }
.sprint-badge { background: linear-gradient(135deg, #F59E0B, #F97316); }
.plan-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 20rpx; }
.plan-name { font-size: 30rpx; font-weight: 700; color: var(--color-text); }
.price-num { font-size: 44rpx; font-weight: 800; color: var(--color-primary); }
.price-unit { font-size: 22rpx; color: var(--color-text-tertiary); }
.price-sprint { color: #D97706; }
.plan-features { display: flex; flex-direction: column; gap: 10rpx; margin-bottom: 24rpx; }
.feat { font-size: 24rpx; color: var(--color-text-secondary); }
.plan-status { text-align: center; background: #F3F4F6; padding: 14rpx; border-radius: var(--radius-sm); font-size: 24rpx; color: var(--color-text-secondary); }
.plan-status.hint { background: transparent; color: var(--color-text-tertiary); }
.plan-action { text-align: center; background: linear-gradient(135deg, var(--color-gradient-start), var(--color-gradient-mid)); color: #FFF; padding: 20rpx; border-radius: var(--radius-md); font-size: 28rpx; font-weight: 600; }
.plan-action.owned { background: #ECFDF5; color: var(--color-success); }
.pay-success { margin: 24rpx 32rpx; background: #ECFDF5; border-radius: var(--radius-lg); padding: 32rpx; text-align: center; }
.success-icon { font-size: 48rpx; display: block; margin-bottom: 8rpx; }
.success-text { font-size: 28rpx; font-weight: 600; 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; 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); }
.qr-canvas { width: 400rpx; height: 400rpx; background: #FFF; border-radius: var(--radius-md); }
.modal-hint { font-size: 22rpx; color: var(--color-text-tertiary); }
.modal-close { font-size: 22rpx; color: var(--color-text-tertiary); padding: 8rpx; }
</style>