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:
yuzhiran
2026-06-22 20:29:51 +08:00
parent 1a45822a58
commit 81f86d995d
10 changed files with 821 additions and 165 deletions
+438 -143
View File
@@ -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>