Просмотр исходного кода

feat(ai): 小程序 AI 使用会员门禁前端 T-F0/T-F1/T-F2

任务书: docs/superpowers/specs/2026-11-02-ai-member-gate-design.md (commit 85e93fb56)

T-F0 utils/api.js
- 新增 aiGateStatus() -> POST /api/ai/gate,带 _noToast
- request() 支持 options._noToast:非 200/401/5001 时跳过 uni.showToast,
  直接 reject(res.data) 保留 code/message 供调用方路由
- _noToast 不再被误拼进 query(同时修复 indicator-favorite 静默降级失效)
- AI 发送类(chat/nutrition/health-coach/butler)统一 _noToast,避免 toast+弹窗叠加

T-F1 pages/ai/chat.vue + UpgradeGuideModal.vue
- onLoad 门禁由 getMyMembership 改为 aiGateStatus:
  ALLOW 正常加载;UPGRADE_REQUIRED 弹 hard 且不加载会话;
  MEMBER_EXPIRED 弹 expired、仍加载历史会话,但禁用输入/新建/发送
- 新增 data memberGate / gateStatus / gateBlocked;mascot 加载逻辑抽为 loadMascot() 保持不变
- sendMessage 开头拦截非 ALLOW;catch 识别 46003 -> hard、46004 -> expired,
  撤掉未落库的乐观消息;其它错误保留兜底文案
- 「+ 新会话」与语音输入同样受门禁拦截
- onShow 重新查门禁(从续费页返回后可恢复)
- aiGateStatus 不可用时回退 getMyMembership 旧口径,避免误伤有效会员
- 修复 chat 模式未调用 aiSendMessage 导致默认对话必然失败的历史缺陷
- UpgradeGuideModal 新增 type='expired'(会员已过期 / ⏰ / 去续费)

T-F2 会员状态条
- 导航栏下方:过期显示「会员已于 YYYY-MM-DD 到期」,临期(<=7天)显示剩余天数,
  点击跳 /pages/membership/upgrade

不纳入门禁的页面(pages/mind、舌诊、pages/diet、pages/report)调用点零改动。
Sisyphus Agent 1 неделя назад
Родитель
Сommit
739317f0ed

+ 192 - 42
cfc-frontend/pages/ai/chat.vue

@@ -14,6 +14,12 @@
       </view>
     </view>
 
+    <!-- 会员状态条:过期 / 临期时给出续费入口(T-F2) -->
+    <view class="member-status-bar" v-if="memberStatusText" @click="goRenew">
+      <text class="member-status-text">{{ memberStatusText }}</text>
+      <text class="member-status-action">去续费 ›</text>
+    </view>
+
     <!-- 会话列表(侧拉) -->
     <view class="conv-overlay" v-if="showConversations" @click="showConversations = false"></view>
     <view class="conv-sidebar" :class="{ 'conv-sidebar-open': showConversations }">
@@ -139,8 +145,8 @@
 <!-- 输入区 -->
      <view class="chat-input-area">
        <view class="chat-input-wrap">
-         <input class="chat-input" v-model="inputText" placeholder="问我关于家庭和成员的问题..."
-           :disabled="sending" @confirm="sendMessage" confirm-type="send" />
+         <input class="chat-input" v-model="inputText" :placeholder="inputPlaceholder"
+           :disabled="sending || gateBlocked" @confirm="sendMessage" confirm-type="send" />
        </view>
        <view class="chat-voice-btn" :class="{ 'voice-recording': isRecording }" @touchstart="startVoiceRecord" @touchend="stopVoiceRecord">
          <text class="voice-icon">{{ isRecording ? '🔴' : '🎤' }}</text>
@@ -161,7 +167,7 @@
 </template>
 
 <script>
-import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage, aiSendHealthCoachMessage, aiSendHealthButlerMessage, getMyMembership, getAllQuotaStatus, reportAppEnter
+import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage, aiSendHealthCoachMessage, aiSendHealthButlerMessage, aiGateStatus, getMyMembership, getAllQuotaStatus, reportAppEnter
 } from '../../utils/api.js'
 import { sttTranscribe } from '../../utils/api.js'
 import BaseEmpty from '../../components/BaseEmpty.vue'
@@ -194,7 +200,13 @@ export default {
       mascotIcon: '',
       isRecording: false,
       recorderManager: null,
-      aiChatQuota: null
+      aiChatQuota: null,
+      // 会员门禁(/api/ai/gate 返回的 AiGateResult)
+      memberGate: null,
+      gateStatus: '',
+      gateBlocked: false,
+      // 临期提醒阈值(天),T-F2 状态条使用
+      nearExpiryDays: 7
     }
   },
   computed: {
@@ -234,6 +246,27 @@ export default {
         return '从 ' + pageName + ' 发起,我可以帮你了解:'
       }
       return '我可以帮你了解家庭成员的成长情况,比如:'
+    },
+    inputPlaceholder: function() {
+      if (this.gateStatus === 'MEMBER_EXPIRED') return '会员已到期,续费后可继续对话'
+      if (this.gateStatus === 'UPGRADE_REQUIRED') return '开通会员后即可对话'
+      return '问我关于家庭和成员的问题...'
+    },
+    // T-F2 会员状态条:过期 / 临期才有文案
+    memberStatusText: function() {
+      if (!this.memberGate) return ''
+      if (this.gateStatus === 'MEMBER_EXPIRED') {
+        var end = this.memberGate.memberExpireTime || this.memberGate.endDate
+        var day = end ? String(end).slice(0, 10) : ''
+        return day ? ('会员已于 ' + day + ' 到期') : '会员已到期'
+      }
+      if (this.gateStatus === 'ALLOW' && this.memberGate.isActive) {
+        var remain = this.memberGate.daysRemaining
+        if (typeof remain === 'number' && remain >= 0 && remain <= this.nearExpiryDays) {
+          return '会员即将到期(剩余 ' + remain + ' 天)'
+        }
+      }
+      return ''
     }
   },
   onLoad: function(options) {
@@ -245,43 +278,9 @@ export default {
     if (options.firstMessage) {
       this.pendingFirstMessage = decodeURIComponent(options.firstMessage)
     }
-    // 会员检查:免费用户不可见福宝/浠宝功能
-    var self = this
-    getMyMembership().then(function(res) {
-      if (res.data && res.data.memberLevel && res.data.memberLevel !== 'FREE') {
-        // 加载用户mascot设置
-        var userInfo = uni.getStorageSync('userInfo')
-        if (userInfo) {
-          if (userInfo.mascot === 'xibao') {
-            self.mascotCode = 'xibao'
-            self.mascotName = '浠宝'
-            self.mascotIcon = '🌟'
-          } else if (userInfo.mascot === 'fubao') {
-            self.mascotCode = 'fubao'
-            self.mascotName = '福宝'
-            self.mascotIcon = '💪'
-          }
-        }
-        self.loadConversations()
-        if (self.pendingFirstMessage) {
-          self.$nextTick(function() {
-            self.inputText = self.pendingFirstMessage
-            self.pendingFirstMessage = ''
-            self.sendMessage()
-          })
-        }
-      } else {
-        uni.showToast({ title: '福宝/浠宝是会员专属功能', icon: 'none' })
-        setTimeout(function() {
-          uni.navigateTo({ url: '/pages/membership/upgrade' })
-        }, 1500)
-      }
-    }).catch(function() {
-      uni.showToast({ title: '请先开通会员', icon: 'none' })
-      setTimeout(function() {
-        uni.navigateTo({ url: '/pages/membership/upgrade' })
-      }, 1500)
-    })
+    // 会员门禁:统一走 /api/ai/gate
+    // ALLOW → 正常加载;UPGRADE_REQUIRED → 引导升级且不加载会话;MEMBER_EXPIRED → 可看历史但不可新建/发送
+    this.checkMemberGate(true)
     // 加载 AI 配额(BASIC用户可见)
     var selfQuota = this
     getAllQuotaStatus().then(function(res) {
@@ -292,7 +291,8 @@ export default {
   },
   onShow() {
     if (this.pageLoaded) {
-      this.loadConversations()
+      // 从升级/续费页返回等场景:重新查门禁,按最新状态决定能否加载/继续对话
+      this.checkMemberGate(false)
     }
     this.pageLoaded = true
   },
@@ -301,8 +301,105 @@ export default {
       uni.navigateBack()
     },
 
+    // 查询会员门禁;autoPrompt=true 时按 status 主动弹引导弹窗(首次进入)
+    checkMemberGate(autoPrompt) {
+      var self = this
+      aiGateStatus().then(function(res) {
+        self.applyMemberGate((res && res.data) || {}, autoPrompt)
+      }).catch(function(e) {
+        // 门禁接口不可用(后端未发布 / 网络异常)时回退旧口径,避免误伤有效会员。
+        // 真实权限仍由后端 AI 端点兜底,这里只是展示层降级。
+        console.warn('[AI] aiGateStatus 失败,回退 getMyMembership', e && e.code)
+        getMyMembership().then(function(res) {
+          self.applyMemberGate(self.buildLegacyGate((res && res.data) || {}), autoPrompt)
+        }).catch(function() {
+          self.applyMemberGate({ status: 'UPGRADE_REQUIRED', canUse: false }, autoPrompt)
+        })
+      })
+    },
+
+    // /api/ai/gate 不可用时的旧会员接口降级映射
+    buildLegacyGate(membership) {
+      var level = membership.memberLevel || 'FREE'
+      var allowed = level !== 'FREE'
+      return {
+        enabled: true,
+        status: allowed ? 'ALLOW' : 'UPGRADE_REQUIRED',
+        canUse: allowed,
+        canViewHistory: false,
+        memberLevel: level,
+        isActive: allowed,
+        memberExpireTime: membership.memberExpireTime || null,
+        endDate: membership.endDate || membership.memberExpireTime || null,
+        daysRemaining: 0
+      }
+    },
+
+    applyMemberGate(gate, autoPrompt) {
+      var status = gate.status || (gate.canUse === false ? 'UPGRADE_REQUIRED' : 'ALLOW')
+      this.memberGate = gate
+      this.gateStatus = status
+      this.gateBlocked = status !== 'ALLOW'
+
+      if (status === 'UPGRADE_REQUIRED') {
+        // 从未付费:不加载会话,直接引导升级
+        if (autoPrompt) this.showGateModal('hard')
+        return
+      }
+      // ALLOW 与 MEMBER_EXPIRED 都可查看历史会话
+      this.loadMascot()
+      this.loadConversations()
+      if (status === 'MEMBER_EXPIRED') {
+        if (autoPrompt) this.showGateModal('expired')
+        return
+      }
+      if (this.pendingFirstMessage) {
+        var self = this
+        this.$nextTick(function() {
+          self.inputText = self.pendingFirstMessage
+          self.pendingFirstMessage = ''
+          self.sendMessage()
+        })
+      }
+    },
+
+    // 加载用户 mascot 设置(福宝/浠宝)
+    loadMascot() {
+      var userInfo = uni.getStorageSync('userInfo')
+      if (!userInfo) return
+      if (userInfo.mascot === 'xibao') {
+        this.mascotCode = 'xibao'
+        this.mascotName = '浠宝'
+        this.mascotIcon = '🌟'
+      } else if (userInfo.mascot === 'fubao') {
+        this.mascotCode = 'fubao'
+        this.mascotName = '福宝'
+        this.mascotIcon = '💪'
+      }
+    },
+
+    // type: soft | hard | expired
+    showGateModal(type) {
+      var self = this
+      this.$nextTick(function() {
+        var modal = self.$refs.upgradeGuideModal
+        if (!modal) return
+        modal.type = type
+        modal.quotaType = ''
+        modal.show()
+      })
+    },
+
+    goRenew() {
+      uni.navigateTo({ url: '/pages/membership/upgrade' })
+    },
+
     startVoiceRecord() {
       if (this.isRecording) return
+      if (this.gateBlocked) {
+        this.showGateModal(this.gateStatus === 'MEMBER_EXPIRED' ? 'expired' : 'hard')
+        return
+      }
       this.recorderManager = uni.getRecorderManager()
       var self = this
       this.recorderManager.onStop(function(res) {
@@ -362,6 +459,11 @@ export default {
     },
 
     newConversation() {
+      // 过期 / 未开通:不可新建会话(历史仍可查看与删除)
+      if (this.gateStatus === 'MEMBER_EXPIRED' || this.gateStatus === 'UPGRADE_REQUIRED') {
+        this.showGateModal(this.gateStatus === 'MEMBER_EXPIRED' ? 'expired' : 'hard')
+        return
+      }
       this.currentConversationId = ''
       this.showConversations = false
       this.messages = []
@@ -375,6 +477,12 @@ export default {
     const text = this.inputText.trim()
     if (!text || this.sending) return
 
+    // 会员门禁拦截:ALLOW 以外不发请求,直接弹对应引导弹窗
+    if (this.gateBlocked) {
+      this.showGateModal(this.gateStatus === 'MEMBER_EXPIRED' ? 'expired' : 'hard')
+      return
+    }
+
     // BASIC 配额检查(unlimited=true 不拦截)
     if (this.aiChatQuota && !this.aiChatQuota.unlimited && this.aiChatQuota.remaining <= 0) {
       this.$refs.upgradeGuideModal.type = 'soft'
@@ -438,6 +546,7 @@ export default {
           butlerLastMsg.tasks = data.tasks || []
         }
       } else {
+        res = await aiSendMessage(params)
         data = res.data || {}
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
@@ -457,6 +566,24 @@ export default {
         this.loadConversations()
       }
       } catch (e) {
+        var errCode = e && e.code
+        if (errCode === 46003 || errCode === 46004) {
+          // 会员门禁:撤掉未落库的乐观消息,改为弹专用引导弹窗(不显示通用失败文案)
+          const lastMsg = this.messages[this.messages.length - 1]
+          if (lastMsg && String(lastMsg.id).indexOf('temp_') === 0) {
+            this.messages.pop()
+          }
+          if (errCode === 46004) {
+            this.gateStatus = 'MEMBER_EXPIRED'
+            this.gateBlocked = true
+            this.showGateModal('expired')
+          } else {
+            this.gateStatus = 'UPGRADE_REQUIRED'
+            this.gateBlocked = true
+            this.showGateModal('hard')
+          }
+          return
+        }
         // 显示错误
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
@@ -558,6 +685,29 @@ export default {
   color: var(--text-secondary, $uni-text-color-secondary);
 }
 
+/* 会员状态条(过期 / 临期续费入口) */
+.member-status-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12rpx 24rpx;
+  background: $status-warning-soft;
+  border-bottom: 1rpx solid var(--border-light, $uni-border-color-light);
+}
+
+.member-status-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+
+.member-status-action {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $brand-primary;
+  font-weight: 500;
+  flex-shrink: 0;
+  margin-left: 16rpx;
+}
+
 /* 会话列表侧拉 */
 .conv-overlay {
   position: fixed;

+ 4 - 0
cfc-frontend/pages/ai/components/UpgradeGuideModal.vue

@@ -8,6 +8,7 @@
         <view v-if="type === 'soft'" class="btn-know" @click="handleKnow">知道了</view>
         <view v-if="type === 'soft'" class="btn-learn" @click="handleLearn">了解详情</view>
         <view v-if="type === 'hard'" class="btn-upgrade" @click="handleUpgrade">升级 FAMILY</view>
+        <view v-if="type === 'expired'" class="btn-upgrade" @click="handleUpgrade">去续费</view>
       </view>
     </view>
   </view>
@@ -29,13 +30,16 @@ export default {
   },
   computed: {
     icon() {
+      if (this.type === 'expired') return '⏰'
       return this.type === 'hard' ? '🔒' : 'ℹ️'
     },
     title() {
+      if (this.type === 'expired') return '会员已过期'
       if (this.quotaType === 'REPORT_READING') return '本月额度已用完'
       return '今日额度已用完'
     },
     desc() {
+      if (this.type === 'expired') return '您的会员已到期,续费后即可继续使用 AI 功能。历史会话仍可查看与删除。'
       if (this.type === 'hard') return '该功能为 FAMILY 会员专属权益,升级后即可使用'
       if (this.quotaType === 'AI_CHAT') return '您今日 AI 对话已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次畅聊'
       if (this.quotaType === 'FOOD_RECOGNIZE') return '今日饮食识别额度已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次识别'

+ 18 - 10
cfc-frontend/utils/api.js

@@ -242,11 +242,11 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
   // 出现 ?a=1&a=1(query 重复)。fullUrl 仍用拼接后的 url,日志和去重粒度不变。
   var basePath = url
   // Append query params from options to URL if provided (e.g., { guideId } for GET requests)
-  // `timeout`/`_skipDedup`/`_noRelogin` 是请求配置项,不是 query 参数,需排除
+  // `timeout`/`_skipDedup`/`_noRelogin`/`_noToast` 是请求配置项,不是 query 参数,需排除
   if (options && Object.keys(options).length > 0) {
     const separator = url.includes('?') ? '&' : '?'
     const query = Object.entries(options)
-      .filter(([k, v]) => k !== 'timeout' && k !== '_skipDedup' && k !== '_noRelogin' && v !== undefined && v !== null && v !== '')
+      .filter(([k, v]) => k !== 'timeout' && k !== '_skipDedup' && k !== '_noRelogin' && k !== '_noToast' && v !== undefined && v !== null && v !== '')
       .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
       .join('&')
     if (query) url = url + separator + query
@@ -321,10 +321,13 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
           resolve(res.data)
         } else {
           console.warn('[API] 失败 ' + method + ' ' + fullUrl + ' code=' + res.data.code + ' msg=' + (res.data.message || ''))
-          uni.showToast({
-            title: res.data.message || '请求失败',
-            icon: 'none'
-          })
+          // _noToast:由调用方自行处理提示(如 AI 门禁 46003/46004 要弹专用弹窗,不能叠加通用 toast)
+          if (!options._noToast) {
+            uni.showToast({
+              title: res.data.message || '请求失败',
+              icon: 'none'
+            })
+          }
           reject(res.data)
         }
       },
@@ -1584,8 +1587,13 @@ export const rejectFamilyJoinRequest = (requestId) => rejectJoinRequest(requestI
 export const getMyPendingRequest = () => getMyJoinRequest()
 
 // ===== AI 聊天 =====
+// 统一会员门禁状态(chat/nutrition/health-coach/butler 同源判定)
+// _noToast:非 200 时不弹通用 toast,由调用方按 status / code 路由弹窗
+export const aiGateStatus = () => request('/api/ai/gate', 'POST', {}, { _noToast: true })
+
+// AI 发送类统一 _noToast,门禁错误(46003/46004)由页面弹专用引导弹窗,避免 toast + 弹窗叠加
 export const aiSendMessage = (data) => {
-  return request('/api/ai/chat/send', 'POST', data)
+  return request('/api/ai/chat/send', 'POST', data, { _noToast: true })
 }
 
 export const aiGetConversations = () => {
@@ -1602,17 +1610,17 @@ export const aiDeleteConversation = (id) => {
 
 // 营养助手(基于体检报告)
 export const aiSendNutritionMessage = (data) => {
-  return request('/api/ai/nutrition/send', 'POST', data)
+  return request('/api/ai/nutrition/send', 'POST', data, { _noToast: true })
 }
 
 // 健康教练
 export const aiSendHealthCoachMessage = (data) => {
-  return request('/api/ai/health-coach/send', 'POST', data)
+  return request('/api/ai/health-coach/send', 'POST', data, { _noToast: true })
 }
 
 // 健康管家(健康知识库检索 + 个性化建议 + 任务生成)
 export const aiSendHealthButlerMessage = (data) => {
-  return request('/api/ai/butler/send', 'POST', data)
+  return request('/api/ai/butler/send', 'POST', data, { _noToast: true })
 }
 
 //  幸福邻里部落 - 服务商模块