|
|
@@ -1,26 +1,82 @@
|
|
|
<template>
|
|
|
<view class="chat-page">
|
|
|
- <scroll-view class="chat-body" scroll-y :scroll-top="scrollTop">
|
|
|
+ <!-- 付费墙:开关开启且当前用户非会员时显示 -->
|
|
|
+ <view v-if="gateLocked" class="paywall">
|
|
|
+ <view class="paywall-header">
|
|
|
+ <text class="lock-icon">🔒</text>
|
|
|
+ <text class="title">AI 教练</text>
|
|
|
+ <text class="subtitle">为会员专享功能,请先开通会员</text>
|
|
|
+ </view>
|
|
|
+ <view class="paywall-body">
|
|
|
+ <text class="desc">开通会员后可无限次使用减脂 AI 教练,获得个性化减脂方案与饮食建议。</text>
|
|
|
+ <button class="unlock-btn" :loading="paying" @click="openPlans">
|
|
|
+ {{ paying ? '支付中...' : '👉 开通会员解锁' }}
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 正常对话页 -->
|
|
|
+ <scroll-view v-else class="chat-body" scroll-y :scroll-top="scrollTop">
|
|
|
<view v-for="(msg, idx) in messages" :key="idx" class="msg-item" :class="msg.role">
|
|
|
<view class="bubble">{{ msg.content }}</view>
|
|
|
</view>
|
|
|
+ <view v-if="sending" class="loading-msg">
|
|
|
+ <text class="dot"></text><text class="dot"></text><text class="dot"></text>
|
|
|
+ </view>
|
|
|
</scroll-view>
|
|
|
|
|
|
- <view class="chat-input">
|
|
|
- <input class="input" v-model="inputText" placeholder="输入您的问题..." @confirm="send" confirm-type="send" />
|
|
|
- <button class="send-btn" size="mini" @click="send">发送</button>
|
|
|
+ <view v-if="!gateLocked" class="chat-input">
|
|
|
+ <input class="input" v-model="inputText" placeholder="输入您的问题..." @confirm="send" confirm-type="send" :disabled="sending" />
|
|
|
+ <button class="send-btn" size="mini" @click="send" :loading="sending">发送</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 会员方案选择弹窗 -->
|
|
|
+ <view v-if="showPlans" class="modal-mask" @click="showPlans=false">
|
|
|
+ <view class="modal-content" @click.stop>
|
|
|
+ <view class="modal-header">
|
|
|
+ <text class="modal-title">选择会员方案</text>
|
|
|
+ <text class="modal-close" @click="showPlans=false">✕</text>
|
|
|
+ </view>
|
|
|
+ <scroll-view scroll-y class="plans-list">
|
|
|
+ <view
|
|
|
+ v-for="(plan, i) in plans"
|
|
|
+ :key="plan.levelCode + plan.period"
|
|
|
+ class="plan-item"
|
|
|
+ :class="{selected: selectedPlanIndex===i}"
|
|
|
+ @click="selectPlan(i)"
|
|
|
+ >
|
|
|
+ <view class="plan-left">
|
|
|
+ <text class="plan-name">{{ plan.levelName || plan.levelCode }}</text>
|
|
|
+ <text class="plan-period">{{ plan.period === 'monthly' ? '月付' : plan.period === 'quarterly' ? '季付' : '年付' }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="plan-right">
|
|
|
+ <text v-if="plan.originalPrice" class="plan-original">{{ (plan.originalPrice/100).toFixed(2) }}元</text>
|
|
|
+ <text class="plan-price">{{ (plan.yearly/100).toFixed(2) }}元</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ <button class="pay-btn" :loading="paying" @click="confirmPay">{{ paying ? '支付中...' : '立即开通' }}</button>
|
|
|
+ </view>
|
|
|
</view>
|
|
|
</view>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import { aiCoachSend } from '../../utils/api.js'
|
|
|
+import { aiCoachSend, getAiCoachGateStatus, getMembershipPlans, createMembershipOrder } from '../../utils/api.js'
|
|
|
export default {
|
|
|
data() {
|
|
|
return {
|
|
|
inputText: '',
|
|
|
messages: [],
|
|
|
- scrollTop: 0
|
|
|
+ scrollTop: 0,
|
|
|
+ sending: false,
|
|
|
+ // 付费开关相关
|
|
|
+ gateLocked: false,
|
|
|
+ gateInfo: null,
|
|
|
+ showPlans: false,
|
|
|
+ plans: [],
|
|
|
+ selectedPlanIndex: 0,
|
|
|
+ paying: false
|
|
|
}
|
|
|
},
|
|
|
onLoad() {
|
|
|
@@ -28,18 +84,154 @@ export default {
|
|
|
role: 'assistant',
|
|
|
content: '你好,我是你的减脂AI教练,请告诉我你的目标和当前情况,我会帮你制定合适的减脂计划。'
|
|
|
})
|
|
|
+ this.loadGate()
|
|
|
},
|
|
|
methods: {
|
|
|
+ loadGate() {
|
|
|
+ getAiCoachGateStatus().then(res => {
|
|
|
+ this.gateInfo = res.data
|
|
|
+ if (res.data && res.data.enabled && !res.data.canUse) {
|
|
|
+ this.gateLocked = true
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ // 接口异常默认放行(fail-open),不阻断体验
|
|
|
+ this.gateLocked = false
|
|
|
+ })
|
|
|
+ },
|
|
|
+ openPlans() {
|
|
|
+ this.showPlans = true
|
|
|
+ this.plans = []
|
|
|
+ this.selectedPlanIndex = 0
|
|
|
+ this.paying = false
|
|
|
+ getMembershipPlans().then(res => {
|
|
|
+ if (res.data && Array.isArray(res.data)) {
|
|
|
+ // 按 levelCode 分组:每个 levelCode 列出 year/month/quarterly 三个档期
|
|
|
+ var map = {}
|
|
|
+ res.data.forEach(p => {
|
|
|
+ if (!map[p.levelCode]) map[p.levelCode] = {}
|
|
|
+ map[p.levelCode][p.period || 'yearly'] = p
|
|
|
+ })
|
|
|
+ var list = []
|
|
|
+ Object.keys(map).forEach(code => {
|
|
|
+ var m = map[code]
|
|
|
+ var yearly = m.yearly || {}
|
|
|
+ var monthly = m.monthly || {}
|
|
|
+ var quarterly = m.quarterly || {}
|
|
|
+ var defaultPeriod = 'yearly'
|
|
|
+ ;[{period:'yearly',price:yearly.yearly||0,orig:yearly.originalYearly||0},{period:'monthly',price:monthly.monthly||0,orig:monthly.originalMonthly||0},{period:'quarterly',price:quarterly.quarterly||0,orig:quarterly.originalQuarterly||0}]
|
|
|
+ .filter(p => p.price > 0)
|
|
|
+ .forEach(p => list.push(Object.assign({levelCode:code, levelName:m[defaultPeriod].levelName||p.levelName||code}, p)))
|
|
|
+ })
|
|
|
+ this.plans = list
|
|
|
+ if (this.plans.length > 0) this.selectedPlanIndex = 0
|
|
|
+ }
|
|
|
+ }).catch(() => {
|
|
|
+ uni.showToast({ title: '获取方案失败', icon: 'none' })
|
|
|
+ this.showPlans = false
|
|
|
+ })
|
|
|
+ },
|
|
|
+ selectPlan(idx) {
|
|
|
+ this.selectedPlanIndex = idx
|
|
|
+ },
|
|
|
+ confirmPay() {
|
|
|
+ if (this.plans.length === 0) return
|
|
|
+ var plan = this.plans[this.selectedPlanIndex]
|
|
|
+ if (!plan) return
|
|
|
+ this.paying = true
|
|
|
+ var payload = {
|
|
|
+ levelCode: plan.levelCode,
|
|
|
+ paymentType: plan.period,
|
|
|
+ period: plan.period
|
|
|
+ }
|
|
|
+ createMembershipOrder(payload).then(res => {
|
|
|
+ this.handlePayResult(res.data)
|
|
|
+ }).catch(err => {
|
|
|
+ this.paying = false
|
|
|
+ uni.showToast({ title: '创建订单失败', icon: 'none' })
|
|
|
+ })
|
|
|
+ },
|
|
|
+ handlePayResult(data) {
|
|
|
+ this.paying = false
|
|
|
+ if (!data) return
|
|
|
+ if (data.payType === 'none') {
|
|
|
+ // 试用/直接开通
|
|
|
+ uni.showToast({ title: '开通成功', icon: 'success' })
|
|
|
+ this.showPlans = false
|
|
|
+ this.gateLocked = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (data.payType === 'virtual') {
|
|
|
+ // 微信虚拟支付(道具直购)
|
|
|
+ this.paying = true
|
|
|
+ var signData = JSON.stringify(data.orderInfo || {})
|
|
|
+ wx.requestVirtualPayment({
|
|
|
+ signData: signData,
|
|
|
+ extInfo: data.extInfo || {},
|
|
|
+ sign: data.sign || '',
|
|
|
+ signature: data.signature || '',
|
|
|
+ env: data.env,
|
|
|
+ success: () => {
|
|
|
+ this.paying = false
|
|
|
+ uni.showToast({ title: '开通成功', icon: 'success' })
|
|
|
+ this.showPlans = false
|
|
|
+ this.loadGate()
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ this.paying = false
|
|
|
+ console.error('虚拟支付失败', err)
|
|
|
+ uni.showModal({ title: '提示', content: '支付失败,是否改用普通微信支付?', showCancel: false, confirmText: '我知道了' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (data.payType === 'wechat') {
|
|
|
+ var payParams = data.wechatPayParams
|
|
|
+ if (!payParams) {
|
|
|
+ uni.showToast({ title: '支付参数异常', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ this.paying = true
|
|
|
+ uni.requestPayment({
|
|
|
+ ...payParams,
|
|
|
+ success: () => {
|
|
|
+ this.paying = false
|
|
|
+ uni.showToast({ title: '开通成功', icon: 'success' })
|
|
|
+ this.showPlans = false
|
|
|
+ this.loadGate()
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ this.paying = false
|
|
|
+ console.error('微信支付失败', err)
|
|
|
+ uni.showModal({ title: '提示', content: '支付未完成,请重新发起开通', showCancel: false, confirmText: '我知道了' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uni.showToast({ title: '未知支付方式', icon: 'none' })
|
|
|
+ },
|
|
|
scrollToBottom() {
|
|
|
setTimeout(() => {
|
|
|
this.scrollTop = this.messages.length * 1000
|
|
|
}, 100)
|
|
|
},
|
|
|
send() {
|
|
|
- if (!this.inputText.trim()) return
|
|
|
+ if (this.gateLocked) {
|
|
|
+ uni.showModal({
|
|
|
+ title: 'AI 教练',
|
|
|
+ content: 'AI 教练为会员专享功能,请先开通会员',
|
|
|
+ confirmText: '去开通',
|
|
|
+ cancelText: '取消',
|
|
|
+ success: (res) => {
|
|
|
+ if (res.confirm) this.openPlans()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!this.inputText.trim() || this.sending) return
|
|
|
var content = this.inputText.trim()
|
|
|
this.messages.push({ role: 'user', content: content })
|
|
|
this.inputText = ''
|
|
|
+ this.sending = true
|
|
|
this.scrollToBottom()
|
|
|
|
|
|
var payload = {
|
|
|
@@ -47,15 +239,25 @@ export default {
|
|
|
history: this.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content }))
|
|
|
}
|
|
|
aiCoachSend(payload).then(res => {
|
|
|
+ this.sending = false
|
|
|
if (res.data && res.data.response) {
|
|
|
this.messages.push({ role: 'assistant', content: res.data.response })
|
|
|
} else if (res.data && res.data.reply) {
|
|
|
this.messages.push({ role: 'assistant', content: res.data.reply })
|
|
|
+ } else if (res.code === 46003) {
|
|
|
+ // 会员门禁:跳转开通
|
|
|
+ this.gateLocked = true
|
|
|
+ uni.showModal({ title: 'AI 教练', content: res.message || 'AI 教练为会员专享功能,请先开通会员', confirmText: '去开通', cancelText: '取消', success: r => { if (r.confirm) this.openPlans() })
|
|
|
} else {
|
|
|
this.messages.push({ role: 'assistant', content: '抱歉,我暂时无法回答这个问题。' })
|
|
|
}
|
|
|
this.scrollToBottom()
|
|
|
- }).catch(() => {
|
|
|
+ }).catch(err => {
|
|
|
+ this.sending = false
|
|
|
+ // api.js 非 200 会 showToast + reject;46003 已在前端 catch 处理
|
|
|
+ if (err && err.code === 46003) {
|
|
|
+ this.gateLocked = true
|
|
|
+ }
|
|
|
this.messages.push({ role: 'assistant', content: '网络异常,请稍后重试。' })
|
|
|
this.scrollToBottom()
|
|
|
})
|
|
|
@@ -76,4 +278,36 @@ export default {
|
|
|
.chat-input { display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; align-items: center; }
|
|
|
.input { flex: 1; height: 80rpx; padding: 0 20rpx; background: #f5f5f5; border-radius: 40rpx; margin-right: 20rpx; }
|
|
|
.send-btn { background: #F97316; color: #fff; border-radius: 40rpx; }
|
|
|
+.loading-msg { display: flex; justify-content: flex-start; margin-bottom: 30rpx; padding-left: 10rpx; }
|
|
|
+.dot { width: 14rpx; height: 14rpx; background: #ccc; border-radius: 50%; margin: 0 8rpx; animation: dot-bounce 1.2s infinite; }
|
|
|
+.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
|
+.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
|
+@keyframes dot-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } 40% { transform: scale(1); opacity: 1; } }
|
|
|
+
|
|
|
+/* 付费墙 */
|
|
|
+.paywall { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 80rpx 40rpx; box-sizing: border-box; background: linear-gradient(135deg, #FFF7ED 0%, #FFF 60%); }
|
|
|
+.paywall-header { text-align: center; margin-bottom: 60rpx; }
|
|
|
+.lock-icon { font-size: 100rpx; display: block; margin-bottom: 20rpx; }
|
|
|
+.paywall-header .title { font-size: 44rpx; font-weight: 700; color: #111; display: block; }
|
|
|
+.paywall-header .subtitle { font-size: 28rpx; color: #F97316; margin-top: 12rpx; display: block; }
|
|
|
+.paywall-body { width: 100%; background: #fff; border-radius: 24rpx; padding: 40rpx; box-sizing: border-box; box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.06); }
|
|
|
+.paywall-body .desc { font-size: 28rpx; color: #666; line-height: 1.7; display: block; margin-bottom: 40rpx; }
|
|
|
+.unlock-btn { width: 100%; background: #F97316; color: #fff; border-radius: 48rpx; height: 96rpx; line-height: 96rpx; font-size: 32rpx; font-weight: 600; }
|
|
|
+
|
|
|
+/* 方案弹窗 */
|
|
|
+.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); z-index: 1000; display: flex; align-items: flex-end; }
|
|
|
+.modal-content { width: 100%; background: #fff; border-radius: 32rpx 32rpx 0 0; padding: 40rpx; box-sizing: border-box; max-height: 85vh; display: flex; flex-direction: column; }
|
|
|
+.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30rpx; }
|
|
|
+.modal-title { font-size: 34rpx; font-weight: 700; color: #111; }
|
|
|
+.modal-close { font-size: 36rpx; color: #999; padding: 10rpx; }
|
|
|
+.plans-list { flex: 1; overflow-y: auto; margin-bottom: 30rpx; }
|
|
|
+.plan-item { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border: 2rpx solid #f0f0f0; border-radius: 20rpx; margin-bottom: 20rpx; }
|
|
|
+.plan-item.selected { border-color: #F97316; background: #FFF7ED; }
|
|
|
+.plan-left { display: flex; flex-direction: column; }
|
|
|
+.plan-name { font-size: 30rpx; font-weight: 600; color: #111; }
|
|
|
+.plan-period { font-size: 24rpx; color: #999; margin-top: 6rpx; }
|
|
|
+.plan-right { text-align: right; }
|
|
|
+.plan-original { font-size: 24rpx; color: #bbb; text-decoration: line-through; }
|
|
|
+.plan-price { font-size: 32rpx; font-weight: 700; color: #F97316; }
|
|
|
+.pay-btn { width: 100%; background: #F97316; color: #fff; border-radius: 48rpx; height: 96rpx; line-height: 96rpx; font-size: 32rpx; font-weight: 600; }
|
|
|
</style>
|