فهرست منبع

Merge feature/ai-member-gate-frontend: AI 使用会员门禁前端 T-F0/F1/F2 合入 cfclub

Agnes Agent 1 هفته پیش
والد
کامیت
016097715d
4فایلهای تغییر یافته به همراه289 افزوده شده و 58 حذف شده
  1. 42 0
      cfc-frontend/__tests__/api/api.test.js
  2. 219 43
      cfc-frontend/pages/ai/chat.vue
  3. 10 5
      cfc-frontend/pages/ai/components/UpgradeGuideModal.vue
  4. 18 10
      cfc-frontend/utils/api.js

+ 42 - 0
cfc-frontend/__tests__/api/api.test.js

@@ -1253,3 +1253,45 @@ describe('article related API', () => {
     expect(res.code).toBe(200)
     expect(res.code).toBe(200)
   })
   })
 })
 })
+
+// --------------- AI 会员门禁(T-F0:aiGateStatus + _noToast) ---------------
+
+describe('AI member gate API', () => {
+  beforeEach(() => {
+    global.uni._storage.token = 'test-token'
+  })
+
+  test('aiGateStatus sends POST /api/ai/gate 且不把 _noToast 拼进 URL', async () => {
+    mockSuccess({ code: 200, data: { status: 'ALLOW', canUse: true } })
+    const res = await api.aiGateStatus()
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/ai\/gate$/)
+    expect(opts.url).not.toMatch(/_noToast/)
+    expect(opts.method).toBe('POST')
+    expect(res.data.status).toBe('ALLOW')
+  })
+
+  test('_noToast:非 200 不弹 toast,reject 仍带 code/message', async () => {
+    mockSuccess({ code: 46003, message: '该功能为会员专享,请先开通会员' })
+    await expect(api.aiGateStatus()).rejects.toMatchObject({
+      code: 46003,
+      message: '该功能为会员专享,请先开通会员'
+    })
+    expect(global.uni.showToast).not.toHaveBeenCalled()
+  })
+
+  test('无 _noToast 时非 200 仍弹 toast(回归保护)', async () => {
+    mockSuccess({ code: 500, message: '服务异常' })
+    await expect(api.getMyMembership()).rejects.toMatchObject({ code: 500 })
+    expect(global.uni.showToast).toHaveBeenCalledTimes(1)
+    expect(global.uni.showToast.mock.calls[0][0].title).toBe('服务异常')
+  })
+
+  test('aiSendMessage 门禁错误不弹 toast(避免 toast + 弹窗叠加)', async () => {
+    mockSuccess({ code: 46004, message: '会员已过期,请续费后继续使用' })
+    await expect(api.aiSendMessage({ query: 'hi', conversationId: '' })).rejects.toMatchObject({
+      code: 46004
+    })
+    expect(global.uni.showToast).not.toHaveBeenCalled()
+  })
+})

+ 219 - 43
cfc-frontend/pages/ai/chat.vue

@@ -14,6 +14,12 @@
       </view>
       </view>
     </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-overlay" v-if="showConversations" @click="showConversations = false"></view>
     <view class="conv-sidebar" :class="{ 'conv-sidebar-open': showConversations }">
     <view class="conv-sidebar" :class="{ 'conv-sidebar-open': showConversations }">
@@ -139,8 +145,8 @@
 <!-- 输入区 -->
 <!-- 输入区 -->
      <view class="chat-input-area">
      <view class="chat-input-area">
        <view class="chat-input-wrap">
        <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>
        <view class="chat-voice-btn" :class="{ 'voice-recording': isRecording }" @touchstart="startVoiceRecord" @touchend="stopVoiceRecord">
        <view class="chat-voice-btn" :class="{ 'voice-recording': isRecording }" @touchstart="startVoiceRecord" @touchend="stopVoiceRecord">
          <text class="voice-icon">{{ isRecording ? '🔴' : '🎤' }}</text>
          <text class="voice-icon">{{ isRecording ? '🔴' : '🎤' }}</text>
@@ -161,7 +167,7 @@
 </template>
 </template>
 
 
 <script>
 <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'
 } from '../../utils/api.js'
 import { sttTranscribe } from '../../utils/api.js'
 import { sttTranscribe } from '../../utils/api.js'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 import BaseEmpty from '../../components/BaseEmpty.vue'
@@ -169,6 +175,7 @@ import TaskCard from '@/pages/ai/components/AITaskCard.vue'
 import QuotaProgressBar from '@/pages/ai/components/QuotaProgressBar.vue'
 import QuotaProgressBar from '@/pages/ai/components/QuotaProgressBar.vue'
 import UpgradeGuideModal from '@/pages/ai/components/UpgradeGuideModal.vue'
 import UpgradeGuideModal from '@/pages/ai/components/UpgradeGuideModal.vue'
 import config from '@/config.js'
 import config from '@/config.js'
+import { parseDate } from '../../utils/format.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 
 export default {
 export default {
@@ -194,7 +201,13 @@ export default {
       mascotIcon: '',
       mascotIcon: '',
       isRecording: false,
       isRecording: false,
       recorderManager: null,
       recorderManager: null,
-      aiChatQuota: null
+      aiChatQuota: null,
+      // 会员门禁(/api/ai/gate 返回的 AiGateResult)
+      memberGate: null,
+      gateStatus: '',
+      gateBlocked: false,
+      // 临期提醒阈值(天),T-F2 状态条使用
+      nearExpiryDays: 7
     }
     }
   },
   },
   computed: {
   computed: {
@@ -234,6 +247,28 @@ export default {
         return '从 ' + pageName + ' 发起,我可以帮你了解:'
         return '从 ' + pageName + ' 发起,我可以帮你了解:'
       }
       }
       return '我可以帮你了解家庭成员的成长情况,比如:'
       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 = this.formatGateDate(end)
+        return day ? ('会员已于 ' + day + ' 到期') : '会员已到期'
+      }
+      if (this.gateStatus === 'ALLOW' && this.memberGate.isActive) {
+        var remain = this.memberGate.daysRemaining
+        if (typeof remain === 'number' && remain >= 0 && remain <= this.nearExpiryDays) {
+          // 后端可能返回小数(按毫秒折算),展示向上取整,避免「剩余 0 天」却仍可用
+          return '会员即将到期(剩余 ' + Math.ceil(remain) + ' 天)'
+        }
+      }
+      return ''
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
@@ -245,43 +280,9 @@ export default {
     if (options.firstMessage) {
     if (options.firstMessage) {
       this.pendingFirstMessage = decodeURIComponent(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用户可见)
     // 加载 AI 配额(BASIC用户可见)
     var selfQuota = this
     var selfQuota = this
     getAllQuotaStatus().then(function(res) {
     getAllQuotaStatus().then(function(res) {
@@ -292,7 +293,8 @@ export default {
   },
   },
   onShow() {
   onShow() {
     if (this.pageLoaded) {
     if (this.pageLoaded) {
-      this.loadConversations()
+      // 从升级/续费页返回等场景:重新查门禁,按最新状态决定能否加载/继续对话
+      this.checkMemberGate(false)
     }
     }
     this.pageLoaded = true
     this.pageLoaded = true
   },
   },
@@ -301,8 +303,123 @@ export default {
       uni.navigateBack()
       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 不可用时的旧会员接口降级映射
+    // 注意:旧接口不区分「从未付费 / 曾付费已过期」,也不能给出剩余天数,
+    // 因此 daysRemaining / isActive 置空(状态条据此跳过临期提醒),避免展示假数据。
+    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: null,
+        memberExpireTime: membership.memberExpireTime || null,
+        endDate: membership.endDate || membership.memberExpireTime || null,
+        daysRemaining: null
+      }
+    },
+
+    applyMemberGate(gate, autoPrompt) {
+      // status 缺失(gate 接口返回空 data / 5001 无家庭兜底)时 fail-closed:
+      // 不默认放行,沿用上一次已知状态;首次遇到则按未开通处理(不主动弹窗,避免误伤)。
+      var status = gate.status
+      if (!status) {
+        if (gate.canUse === true) status = 'ALLOW'
+        else if (gate.canUse === false) status = 'UPGRADE_REQUIRED'
+        else status = this.gateStatus || 'UPGRADE_REQUIRED'
+      }
+      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' })
+    },
+
+    // 门禁日期展示:yyyy-MM-dd(iOS 安全解析走 parseDate,兼容时间戳/ISO/Java 格式)
+    formatGateDate(value) {
+      var d = parseDate(value)
+      if (!d) return ''
+      var m = d.getMonth() + 1
+      var day = d.getDate()
+      return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
+    },
+
     startVoiceRecord() {
     startVoiceRecord() {
       if (this.isRecording) return
       if (this.isRecording) return
+      if (this.gateBlocked) {
+        this.showGateModal(this.gateStatus === 'MEMBER_EXPIRED' ? 'expired' : 'hard')
+        return
+      }
       this.recorderManager = uni.getRecorderManager()
       this.recorderManager = uni.getRecorderManager()
       var self = this
       var self = this
       this.recorderManager.onStop(function(res) {
       this.recorderManager.onStop(function(res) {
@@ -362,6 +479,11 @@ export default {
     },
     },
 
 
     newConversation() {
     newConversation() {
+      // 过期 / 未开通:不可新建会话(历史仍可查看与删除)
+      if (this.gateStatus === 'MEMBER_EXPIRED' || this.gateStatus === 'UPGRADE_REQUIRED') {
+        this.showGateModal(this.gateStatus === 'MEMBER_EXPIRED' ? 'expired' : 'hard')
+        return
+      }
       this.currentConversationId = ''
       this.currentConversationId = ''
       this.showConversations = false
       this.showConversations = false
       this.messages = []
       this.messages = []
@@ -375,6 +497,12 @@ export default {
     const text = this.inputText.trim()
     const text = this.inputText.trim()
     if (!text || this.sending) return
     if (!text || this.sending) return
 
 
+    // 会员门禁拦截:ALLOW 以外不发请求,直接弹对应引导弹窗
+    if (this.gateBlocked) {
+      this.showGateModal(this.gateStatus === 'MEMBER_EXPIRED' ? 'expired' : 'hard')
+      return
+    }
+
     // BASIC 配额检查(unlimited=true 不拦截)
     // BASIC 配额检查(unlimited=true 不拦截)
     if (this.aiChatQuota && !this.aiChatQuota.unlimited && this.aiChatQuota.remaining <= 0) {
     if (this.aiChatQuota && !this.aiChatQuota.unlimited && this.aiChatQuota.remaining <= 0) {
       this.$refs.upgradeGuideModal.type = 'soft'
       this.$refs.upgradeGuideModal.type = 'soft'
@@ -438,6 +566,7 @@ export default {
           butlerLastMsg.tasks = data.tasks || []
           butlerLastMsg.tasks = data.tasks || []
         }
         }
       } else {
       } else {
+        res = await aiSendMessage(params)
         data = res.data || {}
         data = res.data || {}
         const lastMsg = this.messages[this.messages.length - 1]
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
         if (lastMsg) {
@@ -457,11 +586,35 @@ export default {
         this.loadConversations()
         this.loadConversations()
       }
       }
       } catch (e) {
       } 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 (!this.inputText) this.inputText = text
+          if (errCode === 46004) {
+            this.gateStatus = 'MEMBER_EXPIRED'
+          } else {
+            this.gateStatus = 'UPGRADE_REQUIRED'
+          }
+          this.gateBlocked = true
+          // 保证状态条依赖的 memberGate 不为空(会话中途降级时也能显示续费入口)
+          // 用新对象覆盖,避免直接改写接口返回体(可能被 3 秒去重缓存共享)
+          this.memberGate = Object.assign({}, this.memberGate || {}, {
+            status: this.gateStatus,
+            canUse: false
+          })
+          this.showGateModal(errCode === 46004 ? 'expired' : 'hard')
+          return
+        }
+        // 显示错误(发送类走 _noToast,这里补回后端原话,避免只剩通用兜底)
         const lastMsg = this.messages[this.messages.length - 1]
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
         if (lastMsg) {
           lastMsg.answer = '抱歉,我暂时无法回答,请稍后再试。'
           lastMsg.answer = '抱歉,我暂时无法回答,请稍后再试。'
         }
         }
+        uni.showToast({ title: (e && e.message) || '发送失败,请稍后再试', icon: 'none' })
       } finally {
       } finally {
         this.sending = false
         this.sending = false
         this.$nextTick(() => {
         this.$nextTick(() => {
@@ -558,6 +711,29 @@ export default {
   color: var(--text-secondary, $uni-text-color-secondary);
   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 {
 .conv-overlay {
   position: fixed;
   position: fixed;

+ 10 - 5
cfc-frontend/pages/ai/components/UpgradeGuideModal.vue

@@ -8,6 +8,8 @@
         <view v-if="type === 'soft'" class="btn-know" @click="handleKnow">知道了</view>
         <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 === 'soft'" class="btn-learn" @click="handleLearn">了解详情</view>
         <view v-if="type === 'hard'" class="btn-upgrade" @click="handleUpgrade">升级 FAMILY</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 v-if="type === 'expired'" class="btn-later" @click="handleKnow">稍后再说</view>
       </view>
       </view>
     </view>
     </view>
   </view>
   </view>
@@ -29,13 +31,16 @@ export default {
   },
   },
   computed: {
   computed: {
     icon() {
     icon() {
+      if (this.type === 'expired') return '⏰'
       return this.type === 'hard' ? '🔒' : 'ℹ️'
       return this.type === 'hard' ? '🔒' : 'ℹ️'
     },
     },
     title() {
     title() {
+      if (this.type === 'expired') return '会员已过期'
       if (this.quotaType === 'REPORT_READING') return '本月额度已用完'
       if (this.quotaType === 'REPORT_READING') return '本月额度已用完'
       return '今日额度已用完'
       return '今日额度已用完'
     },
     },
     desc() {
     desc() {
+      if (this.type === 'expired') return '您的会员已到期,续费后即可继续使用 AI 功能。历史会话仍可查看与删除。'
       if (this.type === 'hard') return '该功能为 FAMILY 会员专属权益,升级后即可使用'
       if (this.type === 'hard') return '该功能为 FAMILY 会员专属权益,升级后即可使用'
       if (this.quotaType === 'AI_CHAT') return '您今日 AI 对话已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次畅聊'
       if (this.quotaType === 'AI_CHAT') return '您今日 AI 对话已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次畅聊'
       if (this.quotaType === 'FOOD_RECOGNIZE') return '今日饮食识别额度已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次识别'
       if (this.quotaType === 'FOOD_RECOGNIZE') return '今日饮食识别额度已用完(' + this.total + '/' + this.total + '),升级 FAMILY 享不限次识别'
@@ -65,9 +70,9 @@ export default {
       this.$emit('upgrade')
       this.$emit('upgrade')
     },
     },
     handleMaskClick() {
     handleMaskClick() {
-      if (this.type === 'soft') {
-        this.hide()
-      }
+      // 三种类型都允许点遮罩关闭:chat 页使用自定义导航栏,遮罩会盖住唯一的返回入口,
+      // 若 hard/expired 不可关闭会把用户困在页面里(过期会员也就看不到已加载的历史会话)。
+      this.hide()
     }
     }
   }
   }
 }
 }
@@ -111,13 +116,13 @@ export default {
   flex-direction: column;
   flex-direction: column;
   gap: 16rpx;
   gap: 16rpx;
 }
 }
-.btn-know, .btn-learn, .btn-upgrade {
+.btn-know, .btn-learn, .btn-upgrade, .btn-later {
   height: 80rpx;
   height: 80rpx;
   line-height: 80rpx;
   line-height: 80rpx;
   border-radius: 24rpx;
   border-radius: 24rpx;
   font-size: calc(28rpx * var(--font-scale, 1));
   font-size: calc(28rpx * var(--font-scale, 1));
 }
 }
-.btn-know {
+.btn-know, .btn-later {
   background: $facade-fill-2;
   background: $facade-fill-2;
   color: $facade-text-body;
   color: $facade-text-body;
 }
 }

+ 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,日志和去重粒度不变。
   // 出现 ?a=1&a=1(query 重复)。fullUrl 仍用拼接后的 url,日志和去重粒度不变。
   var basePath = url
   var basePath = url
   // Append query params from options to URL if provided (e.g., { guideId } for GET requests)
   // 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) {
   if (options && Object.keys(options).length > 0) {
     const separator = url.includes('?') ? '&' : '?'
     const separator = url.includes('?') ? '&' : '?'
     const query = Object.entries(options)
     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)}`)
       .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
       .join('&')
       .join('&')
     if (query) url = url + separator + query
     if (query) url = url + separator + query
@@ -321,10 +321,13 @@ const request = (url, method = 'POST', data = {}, options = {}) => {
           resolve(res.data)
           resolve(res.data)
         } else {
         } else {
           console.warn('[API] 失败 ' + method + ' ' + fullUrl + ' code=' + res.data.code + ' msg=' + (res.data.message || ''))
           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)
           reject(res.data)
         }
         }
       },
       },
@@ -1584,8 +1587,13 @@ export const rejectFamilyJoinRequest = (requestId) => rejectJoinRequest(requestI
 export const getMyPendingRequest = () => getMyJoinRequest()
 export const getMyPendingRequest = () => getMyJoinRequest()
 
 
 // ===== AI 聊天 =====
 // ===== 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) => {
 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 = () => {
 export const aiGetConversations = () => {
@@ -1602,17 +1610,17 @@ export const aiDeleteConversation = (id) => {
 
 
 // 营养助手(基于体检报告)
 // 营养助手(基于体检报告)
 export const aiSendNutritionMessage = (data) => {
 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) => {
 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) => {
 export const aiSendHealthButlerMessage = (data) => {
-  return request('/api/ai/butler/send', 'POST', data)
+  return request('/api/ai/butler/send', 'POST', data, { _noToast: true })
 }
 }
 
 
 //  幸福邻里部落 - 服务商模块 
 //  幸福邻里部落 - 服务商模块