ソースを参照

feat(unlock-gates): 前端组件+页面改造 - WuxingSandbox切换开关/gate-lock/gate-guide-bar/关卡中心页/parent-index+child-index门禁

E2E Test Bot 2 ヶ月 前
親
コミット
b8bae97bba

+ 90 - 0
cfc-frontend/components/gate-guide-bar.vue

@@ -0,0 +1,90 @@
+<template>
+<view class="gate-guide-bar" v-if="showGuide" @click="goGates">
+  <text class="guide-icon">🔓</text>
+  <text class="guide-text">解锁进度 {{ unlockedCount }}/{{ totalCount }} · {{ guideText }}</text>
+  <text class="guide-arrow">→</text>
+</view>
+</template>
+
+<script>
+/**
+ * 首页引导进度条
+ * 数据源:从父组件传入 unlockStatus(/api/unlock/status 返回)
+ * 主线全解锁后自动隐藏
+ */
+export default {
+  props: {
+    unlockStatus: {
+      type: Object,
+      default: function() { return null }
+    }
+  },
+  computed: {
+    gates: function() {
+      var s = this.unlockStatus
+      if (!s || !s.gates) return []
+      return s.gates
+    },
+    mainGates: function() {
+      return this.gates.filter(function(g) { return g.isRequired === 1 })
+    },
+    totalCount: function() {
+      return this.mainGates.length
+    },
+    unlockedCount: function() {
+      return this.mainGates.filter(function(g) { return g.status === 'UNLOCKED' }).length
+    },
+    showGuide: function() {
+      if (!this.unlockStatus) return false
+      if (this.totalCount === 0) return false
+      // 全部解锁后隐藏
+      return this.unlockedCount < this.totalCount
+    },
+    currentGate: function() {
+      var gates = this.mainGates
+      for (var i = 0; i < gates.length; i++) {
+        var g = gates[i]
+        if (g.status === 'CURRENT' || g.status === 'LOCKED') return g
+      }
+      return null
+    },
+    guideText: function() {
+      if (this.currentGate) {
+        return '完成「' + (this.currentGate.name || '') + '」→'
+      }
+      return '查看全部关卡 →'
+    }
+  },
+  methods: {
+    goGates: function() {
+      uni.navigateTo({ url: '/pages/profile-extra/gates' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.gate-guide-bar {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  margin: 16rpx 30rpx;
+  background: linear-gradient(135deg, #1A2A4A, #1E3A5F);
+  border-radius: 16rpx;
+  cursor: pointer;
+}
+.guide-icon {
+  font-size: 28rpx;
+  margin-right: 12rpx;
+}
+.guide-text {
+  flex: 1;
+  font-size: 24rpx;
+  color: #8FC5E8;
+}
+.guide-arrow {
+  font-size: 28rpx;
+  color: rgba(143,197,232,0.5);
+  margin-left: 8rpx;
+}
+</style>

+ 155 - 0
cfc-frontend/components/gate-lock.vue

@@ -0,0 +1,155 @@
+<template>
+<view class="gate-lock">
+  <view class="lock-card">
+    <view class="lock-icon">🔒</view>
+    <text class="gate-name">{{ gateData.name }}</text>
+    <text class="gate-desc">{{ gateData.description }}</text>
+    <view class="gate-reward" v-if="totalReward > 0">
+      <text class="reward-text">通关奖励</text>
+      <text class="reward-points" v-if="gateData.rewardPoints > 0">+{{ gateData.rewardPoints }}积分</text>
+      <text class="reward-energy" v-if="gateData.rewardEnergy > 0">+{{ gateData.rewardEnergy }}能量</text>
+    </view>
+    <button class="gate-btn" @click="goAction">{{ btnText }}</button>
+  </view>
+</view>
+</template>
+
+<script>
+/**
+ * 通关门禁锁定组件
+ * props: gate - 当前锁定关卡对象 { gateType, name, description, icon, rewardPoints, rewardEnergy }
+ * 根据 gateType 自动映射按钮文案和跳转目标
+ */
+export default {
+  props: {
+    gate: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  computed: {
+    gateData: function() {
+      var g = this.gate || {}
+      return {
+        gateType: g.gateType || '',
+        name: g.name || '未知关卡',
+        description: g.description || '',
+        rewardPoints: g.rewardPoints || 0,
+        rewardEnergy: g.rewardEnergy || 0
+      }
+    },
+    totalReward: function() {
+      return (this.gateData.rewardPoints || 0) + (this.gateData.rewardEnergy || 0)
+    },
+    btnText: function() {
+      var map = {
+        SELF_CHECK: '完成五维自检,解锁个人沙盘',
+        INVITE_FAMILY: '邀请家人加入,解锁全家沙盘',
+        CREATE_FAMILY_MEMBER: '创建孩子账号,解锁微行动',
+        PURCHASE: '去商城逛逛',
+        MEMBERSHIP: '查看会员方案',
+        REPORT_UPLOAD: '上传健康报告'
+      }
+      return map[this.gateData.gateType] || '前往解锁'
+    }
+  },
+  methods: {
+    goAction: function() {
+      var type = this.gateData.gateType
+      var url = ''
+      switch (type) {
+        case 'SELF_CHECK':
+          url = '/pages/family/self-check'
+          break
+        case 'INVITE_FAMILY':
+          url = '/pages/family/address-edit'
+          break
+        case 'CREATE_FAMILY_MEMBER':
+          url = '/pages/profile-extra/family-members'
+          break
+        case 'PURCHASE':
+          url = '/pages/shop/index/index'
+          break
+        case 'MEMBERSHIP':
+          url = '/pages/membership/index'
+          break
+        case 'REPORT_UPLOAD':
+          url = '/pages/health/report-upload'
+          break
+      }
+      if (url) {
+        uni.navigateTo({ url: url })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.gate-lock {
+  margin: 20rpx 30rpx;
+}
+.lock-card {
+  background: linear-gradient(135deg, #1A2A4A, #1E3A5F);
+  border-radius: 24rpx;
+  padding: 40rpx 30rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  text-align: center;
+}
+.lock-icon {
+  font-size: 64rpx;
+  margin-bottom: 16rpx;
+  opacity: 0.8;
+}
+.gate-name {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #8FC5E8;
+  margin-bottom: 12rpx;
+}
+.gate-desc {
+  font-size: 24rpx;
+  color: rgba(143,197,232,0.7);
+  margin-bottom: 20rpx;
+  line-height: 1.5;
+}
+.gate-reward {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 24rpx;
+}
+.reward-text {
+  font-size: 22rpx;
+  color: rgba(143,197,232,0.5);
+}
+.reward-points, .reward-energy {
+  font-size: 22rpx;
+  padding: 4rpx 14rpx;
+  border-radius: 16rpx;
+}
+.reward-points {
+  color: #FFD700;
+  background: rgba(255,215,0,0.12);
+}
+.reward-energy {
+  color: #42A5F5;
+  background: rgba(66,165,245,0.12);
+}
+.gate-btn {
+  width: 70%;
+  height: 80rpx;
+  line-height: 80rpx;
+  font-size: 28rpx;
+  color: #fff;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC2);
+  border: none;
+  border-radius: 40rpx;
+  text-align: center;
+}
+.gate-btn:active {
+  opacity: 0.8;
+}
+</style>

+ 76 - 0
cfc-frontend/components/wuxing-sandbox.vue

@@ -9,6 +9,18 @@
     </view>
   </slot>
 
+  <!-- 个人/全家切换开关(仅 familyUnlocked 时显示) -->
+  <view class="sandbox-toggle" v-if="familyUnlocked && mode !== 'preview'">
+    <view class="toggle-item" :class="activeClassName" @click="onToggle('personal')">
+      <text class="toggle-icon">👤</text>
+      <text class="toggle-text">个人</text>
+    </view>
+    <view class="toggle-item toggle-item--family" :class="activeFamilyClassName" @click="onToggle('family')">
+      <text class="toggle-icon">👨‍👩‍👧‍👦</text>
+      <text class="toggle-text">全家</text>
+    </view>
+  </view>
+
   <view class="sandbox-star" :style="{height: starHeight}">
     <view class="star-glow-bg"></view>
 
@@ -142,6 +154,17 @@ export default {
     starHeight: {
       type: String,
       default: '540rpx'
+    },
+    /** 是否启用全家沙盘切换(控制个人/全家开关显隐) */
+    familyUnlocked: {
+      type: Boolean,
+      default: false
+    },
+    /** 当前视角模式(由父组件控制) */
+    currentView: {
+      type: String,
+      default: 'personal',
+      validator: function(v) { return v === 'personal' || v === 'family' }
     }
   },
   data: function() {
@@ -229,6 +252,12 @@ export default {
     hasMultipleMembers: function() {
       return this.members && this.members.length > 1
     },
+    activeClassName: function() {
+      return this.currentView === 'personal' ? 'toggle-item--active' : ''
+    },
+    activeFamilyClassName: function() {
+      return this.currentView === 'family' ? 'toggle-item--active' : ''
+    },
     /** 家庭能量值(用于太极阳面) */
     familyEnergy: function() {
       if (this.isPreview) {
@@ -369,6 +398,12 @@ export default {
       })
     },
 
+    /** 个人/全家切换(由父组件控制数据源) */
+    onToggle: function(view) {
+      if (view === this.currentView) return
+      this.$emit('view-change', view)
+    },
+
     /** 维度 code → CSS 颜色类名(不依赖 API 的 element 字段) */
     dimElementColor: function(code) {
       var meta = DIM_META[code]
@@ -673,4 +708,45 @@ export default {
 .dim-item--earth { color: #8D6E63; background: rgba(141,110,99,0.12); }
 .dim-item--metal { color: #FFD700; background: rgba(255,215,0,0.12); }
 .dim-item--water { color: #42A5F5; background: rgba(66,165,245,0.12); }
+
+/* ============================================================
+个人/全家切换开关
+============================================================ */
+.sandbox-toggle {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-bottom: 12rpx;
+  position: relative;
+  z-index: 5;
+  gap: 0;
+}
+.toggle-item {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 8rpx 28rpx;
+  font-size: 24rpx;
+  color: rgba(143,197,232,0.6);
+  background: rgba(143,197,232,0.08);
+  border: 1rpx solid rgba(143,197,232,0.15);
+  border-radius: 30rpx 0 0 30rpx;
+  transition: all 0.2s;
+}
+.toggle-item--family {
+  border-radius: 0 30rpx 30rpx 0;
+  border-left: none;
+}
+.toggle-item--active {
+  color: #fff;
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC2);
+  border-color: #5B9BD5;
+}
+.toggle-icon {
+  font-size: 26rpx;
+  margin-right: 8rpx;
+}
+.toggle-text {
+  font-weight: bold;
+}
 </style>

+ 6 - 0
cfc-frontend/pages.json

@@ -1409,6 +1409,12 @@
           "style": {
             "navigationBarTitleText": "我的优惠券"
           }
+        },
+        {
+          "path": "gates",
+          "style": {
+            "navigationBarTitleText": "关卡中心"
+          }
         }
       ]
     },

+ 72 - 6
cfc-frontend/pages/home-pages/child-index.vue

@@ -33,9 +33,13 @@
   tagline="家庭健康成长俱乐部"
   quote="给全家的一站式幸福提案" />
 
+<!-- 通关引导进度条 -->
+<GateGuideBar :unlockStatus="unlockStatus" />
+
 <!-- 五维能量沙盘 -->
+<GateLock v-if="sandboxLocked && sandboxLockGate" :gate="sandboxLockGate" />
 <WuxingSandbox
-  v-if="sandboxData"
+  v-else-if="sandboxData"
   mode="live"
   :scores="sandboxScores"
   :overallScore="sandboxData.overallScore || 0"
@@ -43,8 +47,11 @@
   :dimensions="energyDimensions"
   :totalEnergy="totalEnergy"
   :totalHealthIndex="totalHealthIndex"
+  :familyUnlocked="familyUnlocked"
+  :currentView="currentSandboxView"
   starHeight="400rpx"
   showDimensionBar
+  @view-change="onSandboxViewChange"
   @point-click="onDomainClick" />
 <!-- 家庭能量横条 -->
 <FamilyEnergyBar
@@ -304,7 +311,7 @@ B) 积分卡片 — Claymorphism 暖橙黏土风格
 </template>
 
 <script>
-import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers } from '../../utils/api.js'
+import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getVisibleFamilyMembers } from '../../utils/api.js'
 import PageBanner from '../../components/PageBanner.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
@@ -314,6 +321,8 @@ import PlayfulButton from '../../components/PlayfulButton.vue'
 import BaseBadge from '../../components/BaseBadge.vue'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 import BaseLoading from '../../components/BaseLoading.vue'
+import GateLock from '../../components/gate-lock.vue'
+import GateGuideBar from '../../components/gate-guide-bar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
 import ConfettiCelebration from '../../components/ConfettiCelebration.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
@@ -355,7 +364,9 @@ export default {
     BaseLoading,
     OnboardingGuide,
     ConfettiCelebration,
-    FamilyMemberStrip
+    FamilyMemberStrip,
+    GateLock,
+    GateGuideBar
   },
   data() {
     return {
@@ -382,6 +393,11 @@ export default {
       totalHealthIndex: 0,
       energyLogs: [],
       sandboxData: null,
+      sandboxLocked: false,
+      sandboxLockGate: null,
+      currentSandboxView: 'personal',
+      familyUnlocked: false,
+      unlockStatus: null,
       dimensionActivities: [],
       dimensionProducts: [],
       familyMembersVisible: [],
@@ -589,11 +605,35 @@ export default {
           this.energyLogs = []
         }
 
-        // 加载沙盘数据
+        // 加载通关解锁状态
+        try {
+          var statusRes = await getUnlockStatus()
+          if (statusRes && statusRes.data) {
+            this.unlockStatus = statusRes.data
+          }
+        } catch (e) {
+          console.log('获取通关解锁状态失败', e)
+        }
+
+        // 加载沙盘数据(含通关门禁)
         try {
-          var sandboxRes = await getFamilyEnergySandbox()
+          var sandboxRes = await getEnergySandbox(this.currentSandboxView)
           if (sandboxRes && sandboxRes.data) {
-            this.sandboxData = sandboxRes.data
+            var ed = sandboxRes.data
+            if (ed.locked) {
+              this.sandboxLocked = true
+              this.sandboxLockGate = ed.gate || null
+              this.sandboxData = null
+            } else {
+              this.sandboxLocked = false
+              this.sandboxLockGate = null
+              if (ed.view === 'personal') {
+                this.sandboxData = ed.personalData || null
+                this.familyUnlocked = ed.familyUnlocked || false
+              } else {
+                this.sandboxData = ed.familyData || null
+              }
+            }
           }
         } catch (e) {
           console.log('获取沙盘数据失败', e)
@@ -644,6 +684,32 @@ export default {
         url: '/pages/energy/detail?memberId=' + this.memberId + '&code=' + code
       })
     },
+    /** 切换沙盘个人/全家视角 */
+    onSandboxViewChange: async function(view) {
+      this.currentSandboxView = view
+      try {
+        var sandboxRes = await getEnergySandbox(this.currentSandboxView)
+        if (sandboxRes && sandboxRes.data) {
+          var ed = sandboxRes.data
+          if (ed.locked) {
+            this.sandboxLocked = true
+            this.sandboxLockGate = ed.gate || null
+            this.sandboxData = null
+          } else {
+            this.sandboxLocked = false
+            this.sandboxLockGate = null
+            if (ed.view === 'personal') {
+              this.sandboxData = ed.personalData || null
+              this.familyUnlocked = ed.familyUnlocked || false
+            } else {
+              this.sandboxData = ed.familyData || null
+            }
+          }
+        }
+      } catch (e) {
+        console.log('切换沙盘视角失败', e)
+      }
+    },
     goToRewards() {
       this.$emit('navigate', { page: 'rewards' })
       uni.navigateTo({ url: '/pages/rewards/rewards' })

+ 85 - 8
cfc-frontend/pages/home-pages/parent-index.vue

@@ -137,9 +137,13 @@
   :intimacyMap="intimacyMapForGraph"
   @memberTap="goMemberDetail" />
 
+      <!-- 通关引导进度条 -->
+      <GateGuideBar :unlockStatus="unlockStatus" />
+
       <!-- 能量沙盘 -->
+      <GateLock v-if="sandboxLocked && sandboxLockGate" :gate="sandboxLockGate" />
       <WuxingSandbox
-        v-if="sandboxData"
+        v-else-if="sandboxData"
         mode="live"
         :scores="sandboxScores"
         :overallScore="sandboxData.overallScore || 0"
@@ -147,12 +151,15 @@
         :dimensions="energyDimensions"
         :totalEnergy="totalEnergy"
         :totalHealthIndex="totalHealthIndex"
+        :familyUnlocked="familyUnlocked"
+        :currentView="currentSandboxView"
         starHeight="400rpx"
         showDimensionBar
+        @view-change="onSandboxViewChange"
         @point-click="onDomainClick" />
 
       <!-- 能量数据加载失败兜底 -->
-      <view class="error-state" v-if="errorEnergy && !sandboxData" @click="loadData">
+      <view class="error-state" v-if="errorEnergy && !sandboxData && !sandboxLocked" @click="loadData">
         <text class="error-state-text">家庭能量数据加载失败,点击重试</text>
       </view>
 
@@ -470,7 +477,7 @@
 </template>
 
 <script>
-import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles, getActiveAlerts, dismissAlert, getFamilyRanking, getChallengeList, getCircleRanking, getInviteQrCode, switchToFamilyMember } from '../../utils/api.js'
+import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles, getActiveAlerts, dismissAlert, getFamilyRanking, getChallengeList, getCircleRanking, getInviteQrCode, switchToFamilyMember } from '../../utils/api.js'
 import store from '../../store/index.js'
 import PageBanner from '../../components/PageBanner.vue'
 import PlayfulCard from '../../components/PlayfulCard.vue'
@@ -494,10 +501,12 @@ import HealthAlertBanner from '../../components/HealthAlertBanner.vue'
 import CircleRanking from '../../components/CircleRanking.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
+import GateLock from '../../components/gate-lock.vue'
+import GateGuideBar from '../../components/gate-guide-bar.vue'
 import feedbackEngine from '../../utils/feedback-engine.js'
 
 export default {
-  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar },
+  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner, FamilyHealthRanking, FamilyChallengeCard, CircleRanking, ArticleBookshelf, OnboardingProgressBar, GateLock, GateGuideBar },
   data() {
     return {
       nickname: '',
@@ -529,6 +538,13 @@ export default {
       recommendedProducts: [],
       sandboxData: null,
       errorEnergy: false,
+      // 通关解锁状态
+      sandboxLocked: false,
+      sandboxLockGate: null,
+      currentSandboxView: 'personal',
+      familyUnlocked: false,
+      familyLockGate: null,
+      unlockStatus: null,
       errorTasks: false,
       errorActivities: false,
       errorProducts: false,
@@ -817,14 +833,44 @@ export default {
           }
         }
 
-        // 加载家庭能量沙盘数据(五维 + 所有成员)
+        // 加载通关解锁状态
         try {
-          const energyRes = await getFamilyEnergySandbox()
-          this.energyData = energyRes.data || null
-          this.sandboxData = energyRes.data || null
+          var statusRes = await getUnlockStatus()
+          if (statusRes && statusRes.data) {
+            this.unlockStatus = statusRes.data
+          }
+        } catch (e) {
+          console.log('获取通关解锁状态失败', e)
+        }
+
+        // 加载能量沙盘(含通关门禁)
+        try {
+          var energyRes = await getEnergySandbox(this.currentSandboxView)
+          if (energyRes && energyRes.data) {
+            var ed = energyRes.data
+            if (ed.locked) {
+              this.sandboxLocked = true
+              this.sandboxLockGate = ed.gate || null
+              this.sandboxData = null
+              this.energyData = null
+            } else {
+              this.sandboxLocked = false
+              this.sandboxLockGate = null
+              if (ed.view === 'personal') {
+                this.sandboxData = ed.personalData || null
+                this.energyData = ed.personalData || null
+                this.familyUnlocked = ed.familyUnlocked || false
+                this.familyLockGate = ed.familyGate || null
+              } else {
+                this.sandboxData = ed.familyData || null
+                this.energyData = ed.familyData || null
+              }
+            }
+          }
         } catch (e) {
           console.log('获取能量沙盘数据失败', e)
           this.energyData = null
+          this.sandboxData = null
           this.errorEnergy = true
         }
 
@@ -906,6 +952,37 @@ export default {
     onDomainClick(evt) {
       this.goToDomain(evt.code)
     },
+    /** 切换沙盘个人/全家视角 */
+    onSandboxViewChange: async function(view) {
+      this.currentSandboxView = view
+      this.loadSandboxData()
+    },
+    /** 单独加载沙盘数据(用于视角切换) */
+    loadSandboxData: async function() {
+      try {
+        var energyRes = await getEnergySandbox(this.currentSandboxView)
+        if (energyRes && energyRes.data) {
+          var ed = energyRes.data
+          if (ed.locked) {
+            this.sandboxLocked = true
+            this.sandboxLockGate = ed.gate || null
+            this.sandboxData = null
+          } else {
+            this.sandboxLocked = false
+            this.sandboxLockGate = null
+            if (ed.view === 'personal') {
+              this.sandboxData = ed.personalData || null
+              this.familyUnlocked = ed.familyUnlocked || false
+              this.familyLockGate = ed.familyGate || null
+            } else {
+              this.sandboxData = ed.familyData || null
+            }
+          }
+        }
+      } catch (e) {
+        console.log('加载沙盘数据失败', e)
+      }
+    },
     goToDomain(code) {
       // 跳转到能量详情页
       var memberId = this.currentChildId

+ 263 - 0
cfc-frontend/pages/profile-extra/gates.vue

@@ -0,0 +1,263 @@
+<template>
+<view class="gates-page">
+  <view class="page-section" v-if="mainGates.length > 0">
+    <text class="section-title">主线关卡</text>
+    <view class="gate-list">
+      <view class="gate-card" v-for="(g, i) in mainGates" :key="g.id || i"
+        :class="'gate-card--' + (g.status === 'UNLOCKED' ? 'done' : (g.status === 'CURRENT' ? 'current' : 'locked'))">
+        <view class="gate-status-icon">
+          <text v-if="g.status === 'UNLOCKED'">✅</text>
+          <text v-else-if="g.status === 'CURRENT'">🔓</text>
+          <text v-else>🔒</text>
+        </view>
+        <view class="gate-info">
+          <text class="gate-name">{{ g.name }}</text>
+          <text class="gate-desc">{{ g.description }}</text>
+          <view class="gate-reward" v-if="g.rewardPoints > 0 || g.rewardEnergy > 0">
+            <text class="reward-label">奖励:</text>
+            <text class="reward-points" v-if="g.rewardPoints > 0">+{{ g.rewardPoints }}积分</text>
+            <text class="reward-energy" v-if="g.rewardEnergy > 0">+{{ g.rewardEnergy }}能量</text>
+          </view>
+        </view>
+        <view class="gate-action">
+          <text v-if="g.status === 'CURRENT'" class="action-btn" @click="goGateAction(g)">去完成</text>
+          <text v-else-if="g.status === 'LOCKED'" class="action-disabled">未解锁</text>
+          <text v-else class="action-done">已解锁</text>
+        </view>
+      </view>
+    </view>
+  </view>
+
+  <view class="page-section" v-if="softGates.length > 0">
+    <text class="section-title">推荐挑战</text>
+    <view class="gate-list">
+      <view class="gate-card" :class="'gate-card--' + (g.status === 'UNLOCKED' ? 'done' : 'current')"
+        v-for="(g, i) in softGates" :key="'soft' + (g.id || i)">
+        <view class="gate-status-icon">
+          <text v-if="g.status === 'UNLOCKED'">✅</text>
+          <text v-else>🎯</text>
+        </view>
+        <view class="gate-info">
+          <text class="gate-name">{{ g.name }}</text>
+          <text class="gate-desc">{{ g.description }}</text>
+          <view class="gate-reward" v-if="g.rewardPoints > 0 || g.rewardEnergy > 0">
+            <text class="reward-label">奖励:</text>
+            <text class="reward-points" v-if="g.rewardPoints > 0">+{{ g.rewardPoints }}积分</text>
+            <text class="reward-energy" v-if="g.rewardEnergy > 0">+{{ g.rewardEnergy }}能量</text>
+          </view>
+        </view>
+        <view class="gate-action">
+          <text v-if="g.status !== 'UNLOCKED'" class="action-btn" @click="goGateAction(g)">去挑战</text>
+          <text v-else class="action-done">已完成</text>
+        </view>
+      </view>
+    </view>
+  </view>
+
+  <view class="page-section" v-if="valueGates.length > 0">
+    <text class="section-title">增值关卡</text>
+    <view class="gate-list">
+      <view class="gate-card gate-card--value" v-for="(g, i) in valueGates" :key="'val' + (g.id || i)">
+        <view class="gate-status-icon">
+          <text v-if="g.status === 'UNLOCKED'">✅</text>
+          <text v-else>💎</text>
+        </view>
+        <view class="gate-info">
+          <text class="gate-name">{{ g.name }}</text>
+          <text class="gate-desc">{{ g.description }}</text>
+          <view class="gate-reward" v-if="g.rewardPoints > 0 || g.rewardEnergy > 0">
+            <text class="reward-label">奖励:</text>
+            <text class="reward-points" v-if="g.rewardPoints > 0">+{{ g.rewardPoints }}积分</text>
+            <text class="reward-energy" v-if="g.rewardEnergy > 0">+{{ g.rewardEnergy }}能量</text>
+          </view>
+        </view>
+        <view class="gate-action">
+          <text v-if="g.status !== 'UNLOCKED'" class="action-btn" @click="goGateAction(g)">前往</text>
+          <text v-else class="action-done">已解锁</text>
+        </view>
+      </view>
+    </view>
+  </view>
+
+  <view class="empty-state" v-if="loading">
+    <text>加载中...</text>
+  </view>
+</view>
+</template>
+
+<script>
+import { getUnlockStatus } from '../../utils/api'
+
+export default {
+  data: function() {
+    return {
+      loading: true,
+      gates: []
+    }
+  },
+  computed: {
+    mainGates: function() {
+      return this.gates.filter(function(g) { return g.isRequired === 1 && g.isSoft === 0 })
+    },
+    softGates: function() {
+      return this.gates.filter(function(g) { return g.isRequired === 1 && g.isSoft === 1 })
+    },
+    valueGates: function() {
+      return this.gates.filter(function(g) { return g.isRequired === 0 })
+    }
+  },
+  onShow: function() {
+    this.loadData()
+  },
+  methods: {
+    loadData: function() {
+      var self = this
+      self.loading = true
+      getUnlockStatus().then(function(res) {
+        if (res && res.data) {
+          self.gates = res.data.gates || []
+        }
+        self.loading = false
+      }).catch(function() {
+        self.loading = false
+      })
+    },
+    goGateAction: function(g) {
+      var type = g.gateType
+      var url = ''
+      switch (type) {
+        case 'SELF_CHECK':
+          url = '/pages/family/self-check'
+          break
+        case 'INVITE_FAMILY':
+          url = '/pages/family/address-edit'
+          break
+        case 'CREATE_FAMILY_MEMBER':
+          url = '/pages/profile-extra/family-members'
+          break
+        case 'PURCHASE':
+          url = '/pages/shop/index/index'
+          break
+        case 'MEMBERSHIP':
+          url = '/pages/membership/index'
+          break
+        case 'REPORT_UPLOAD':
+          url = '/pages/health/report-upload'
+          break
+        case 'MICRO_ACTION':
+          url = '/pages/home-pages/parent-index'
+          break
+        case 'STREAK_DAYS':
+          url = '/pages/health/daily-checkin'
+          break
+      }
+      if (url) {
+        uni.navigateTo({ url: url })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.gates-page {
+  padding: 20rpx 0;
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+.page-section {
+  margin-bottom: 24rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  padding: 0 30rpx;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.gate-list {
+  padding: 0 24rpx;
+}
+.gate-card {
+  display: flex;
+  align-items: center;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.gate-card--done {
+  opacity: 0.7;
+}
+.gate-card--current {
+  border: 2rpx solid #5B9BD5;
+}
+.gate-status-icon {
+  width: 60rpx;
+  font-size: 36rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.gate-info {
+  flex: 1;
+  margin: 0 16rpx;
+}
+.gate-name {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.gate-desc {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.gate-reward {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+  margin-top: 8rpx;
+  flex-wrap: wrap;
+}
+.reward-label {
+  font-size: 20rpx;
+  color: #999;
+}
+.reward-points, .reward-energy {
+  font-size: 20rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 12rpx;
+}
+.reward-points { color: #B8860B; background: rgba(255,215,0,0.15); }
+.reward-energy { color: #3A7CC2; background: rgba(66,165,245,0.15); }
+.gate-action {
+  flex-shrink: 0;
+}
+.action-btn {
+  font-size: 24rpx;
+  color: #fff;
+  background: #5B9BD5;
+  padding: 8rpx 20rpx;
+  border-radius: 20rpx;
+}
+.action-btn:active {
+  opacity: 0.8;
+}
+.action-disabled {
+  font-size: 22rpx;
+  color: #ccc;
+}
+.action-done {
+  font-size: 22rpx;
+  color: #5B9BD5;
+}
+.empty-state {
+  text-align: center;
+  padding: 60rpx;
+  color: #999;
+}
+</style>

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

@@ -1748,6 +1748,16 @@ export const getFamilyEnergySandbox = () => {
   return request('/api/energy/sandbox', 'POST')
 }
 
+// 获取能量沙盘(支持 personal/family 双视角,含通关门禁)
+export const getEnergySandbox = (view) => {
+  return request('/api/energy/sandbox', 'POST', { view: view || 'personal' })
+}
+
+// 获取用户通关解锁全景(/api/unlock/status)
+export const getUnlockStatus = () => {
+  return request('/api/unlock/status', 'POST')
+}
+
 // ===== 文章内容系统 =====
 export const getArticleCategories = () => request('/api/articles/categories', 'POST')
 export const getArticleList = (data) => request('/api/articles/list', 'POST', data)