Procházet zdrojové kódy

feat(action): 关系健康概览 + controller familyId推导

- action/index.vue: 添加关系健康区块(健康预警+里程碑+快捷入口)
- RelationshipQualityController: 注入UserMapper,getHealthAlerts/getMilestones
  从JWT token推导familyId,不再强求请求体传familyId
User před 2 měsíci
rodič
revize
aaac3aec82

+ 27 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java

@@ -4,6 +4,8 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.HealthAlertVO;
 import com.etotem.cfc.dto.MilestoneVO;
 import com.etotem.cfc.dto.RelationshipScoreVO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.RelationshipQualityService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -22,6 +24,9 @@ public class RelationshipQualityController {
     @Resource
     private RelationshipQualityService relationshipQualityService;
 
+    @Resource
+    private UserMapper userMapper;
+
     private Long getUserId(HttpServletRequest request) {
         Object userIdObj = request.getAttribute("userId");
         if (userIdObj != null) {
@@ -34,6 +39,23 @@ public class RelationshipQualityController {
         return null;
     }
 
+    private Long getFamilyIdFromRequest(HttpServletRequest request, Map<String, Object> body) {
+        // 优先从请求体获取
+        if (body != null && body.get("familyId") != null) {
+            return Long.valueOf(body.get("familyId").toString());
+        }
+        // 从JWT用户推导
+        Long userId = getUserId(request);
+        if (userId == null) {
+            return null;
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            return null;
+        }
+        return user.getFamilyId();
+    }
+
     @Operation(summary = "获取家庭成员关系质量评分")
     @PostMapping("/scores")
     public Result<List<RelationshipScoreVO>> getScores(HttpServletRequest request,
@@ -75,10 +97,9 @@ public class RelationshipQualityController {
         if (userId == null) {
             return Result.error("请先登录");
         }
-        Long familyId = body.get("familyId") != null
-                ? Long.valueOf(body.get("familyId").toString()) : null;
+        Long familyId = getFamilyIdFromRequest(request, body);
         if (familyId == null) {
-            return Result.error("familyId不能为空");
+            return Result.success(List.of());
         }
         List<HealthAlertVO> alerts = relationshipQualityService.getHealthAlerts(familyId);
         return Result.success(alerts);
@@ -92,12 +113,11 @@ public class RelationshipQualityController {
         if (userId == null) {
             return Result.error("请先登录");
         }
-        Long familyId = body.get("familyId") != null
-                ? Long.valueOf(body.get("familyId").toString()) : null;
+        Long familyId = getFamilyIdFromRequest(request, body);
         if (familyId == null) {
-            return Result.error("familyId不能为空");
+            return Result.success(List.of());
         }
-        Integer daysAhead = body.get("daysAhead") != null
+        Integer daysAhead = body != null && body.get("daysAhead") != null
                 ? Integer.valueOf(body.get("daysAhead").toString()) : 7;
         List<MilestoneVO> milestones = relationshipQualityService.getMilestones(familyId, daysAhead);
         return Result.success(milestones);

+ 239 - 3
cfc-frontend/pages/action/index.vue

@@ -1,5 +1,8 @@
 <template>
   <view class="container">
+    <!-- Tabbar 切换过渡页 -->
+    <tab-transition v-if="showTabTransition" dimCode="action" waitingFor="action" />
+
     <!-- 品牌头部 -->
     <PageBanner theme="action"
       tagline="知行合一 · 快乐成长"
@@ -117,6 +120,52 @@
       </view>
     </view>
 
+    <!-- ===== 关系健康概览 ===== -->
+    <view class="rq-section" v-if="isLoggedIn && (healthAlerts.length > 0 || milestones.length > 0)">
+      <view class="rq-header">
+        <text class="rq-title">关系健康</text>
+        <text class="rq-more" @click="goRelationshipQuestionnaire">去做问卷</text>
+      </view>
+      <!-- 健康预警列表 -->
+      <view class="rq-alerts" v-if="healthAlerts.length > 0">
+        <view class="rq-alert-card" v-for="alert in healthAlerts" :key="alert.memberId" @click="goInteractionLog(alert.memberId)">
+          <view class="rq-alert-icon">
+            <text class="rq-alert-emoji">⚠️</text>
+          </view>
+          <view class="rq-alert-info">
+            <text class="rq-alert-name">{{ alert.memberName }}</text>
+            <text class="rq-alert-desc">已{{ alert.daysSinceInteraction }}天无互动</text>
+          </view>
+          <view class="rq-alert-action">
+            <text class="rq-alert-btn">去互动</text>
+          </view>
+        </view>
+      </view>
+      <!-- 里程碑列表 -->
+      <view class="rq-milestones" v-if="milestones.length > 0">
+        <view class="rq-milestone-card" v-for="m in milestones" :key="m.memberId">
+          <view class="rq-milestone-icon">
+            <text class="rq-milestone-emoji">🎂</text>
+          </view>
+          <view class="rq-milestone-info">
+            <text class="rq-milestone-name">{{ m.memberName }}</text>
+            <text class="rq-milestone-desc">{{ m.daysUntil > 0 ? m.daysUntil + '天后生日' : '今天生日' }}</text>
+          </view>
+        </view>
+      </view>
+      <!-- 快捷操作 -->
+      <view class="rq-quick-actions">
+        <view class="rq-action-btn" @click="goInteractionLog()">
+          <text class="rq-action-icon">📝</text>
+          <text class="rq-action-label">记录互动</text>
+        </view>
+        <view class="rq-action-btn" @click="goRelationshipQuestionnaire">
+          <text class="rq-action-icon">🤝</text>
+          <text class="rq-action-label">做关系问卷</text>
+        </view>
+      </view>
+    </view>
+
     <!-- 功能入口(4个:活动、商品、课程) -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
       <view class="func-grid">
@@ -141,6 +190,7 @@
 </template>
 
 <script>
+import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
@@ -151,12 +201,13 @@ import DimensionProducts from '../../components/DimensionProducts.vue'
 import ContactCard from '../../components/ContactCard.vue'
 import ContactImport from '../../components/ContactImport.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
-import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts, getMilestones } from '../../utils/api.js'
 
 export default {
-  components: { PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, ContactCard, ContactImport, FamilyRelationGraph },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, ContactCard, ContactImport, FamilyRelationGraph },
   data() {
     return {
+      showTabTransition: false,
       isLoggedIn: false,
       role: null,
       currentChildId: null,
@@ -173,6 +224,8 @@ export default {
       articles: [],
       contactList: [],
       showImportModal: false,
+      healthAlerts: [],
+      milestones: [],
       funcList: [
         { icon: '\u{1F3CB}', label: '任务', needLogin: true, page: '/pages/tasks/tasks' },
         { icon: '\u{1F3C0}', label: '活动', needLogin: false, page: '/pages/discover/index?type=activity' },
@@ -213,6 +266,8 @@ export default {
     }
   },
   onShow() {
+    this.showTabTransition = true
+
     var token = uni.getStorageSync('token')
     this.isLoggedIn = !!token
     if (this.isLoggedIn) {
@@ -222,8 +277,12 @@ export default {
     }
     this.loadSectionConfig()
     if (this.isLoggedIn) {
-      this.loadChildren()
+      var self = this
+      this.loadChildren().finally(function() {
+        uni.setStorageSync('actionReadyTime', Date.now())
+      })
       this.loadFamilyMembersVisible()
+      this.loadRelationshipHealth()
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
@@ -456,6 +515,33 @@ export default {
     },
     goArticleDetail: function(id) {
       uni.navigateTo({ url: '/pages/action/article-detail?id=' + id })
+    },
+    loadRelationshipHealth: function() {
+      var self = this
+      getHealthAlerts({}).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.healthAlerts = Array.isArray(res.data) ? res.data : []
+        }
+      }).catch(function() {
+        self.healthAlerts = []
+      })
+      getMilestones({}, 30).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.milestones = Array.isArray(res.data) ? res.data : []
+        }
+      }).catch(function() {
+        self.milestones = []
+      })
+    },
+    goInteractionLog: function(memberId) {
+      if (memberId) {
+        uni.navigateTo({ url: '/pages/action/interaction-log?memberId=' + memberId })
+      } else {
+        uni.navigateTo({ url: '/pages/action/interaction-log' })
+      }
+    },
+    goRelationshipQuestionnaire: function() {
+      uni.navigateTo({ url: '/pages/action/relationship-questionnaire' })
     }
   }
 }
@@ -587,6 +673,156 @@ export default {
   color: #ccc;
 }
 
+/* ===== 关系健康概览 ===== */
+.rq-section {
+  margin: 20rpx 20rpx 0;
+}
+.rq-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.rq-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.rq-more {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+/* 健康预警卡片 */
+.rq-alerts {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  margin-bottom: 16rpx;
+}
+.rq-alert-card {
+  background: #FFF7ED;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  border: 1rpx solid #FED7AA;
+}
+.rq-alert-card:active {
+  opacity: 0.8;
+}
+.rq-alert-icon {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  background: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.rq-alert-emoji {
+  font-size: 32rpx;
+}
+.rq-alert-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.rq-alert-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.rq-alert-desc {
+  font-size: 24rpx;
+  color: #F97316;
+}
+.rq-alert-action {
+  flex-shrink: 0;
+}
+.rq-alert-btn {
+  font-size: 24rpx;
+  color: #fff;
+  background: #F97316;
+  padding: 8rpx 20rpx;
+  border-radius: 20rpx;
+}
+/* 里程碑卡片 */
+.rq-milestones {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+  margin-bottom: 16rpx;
+}
+.rq-milestone-card {
+  background: #F0F9FF;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+  border: 1rpx solid #BAE6FD;
+}
+.rq-milestone-icon {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 50%;
+  background: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+.rq-milestone-emoji {
+  font-size: 32rpx;
+}
+.rq-milestone-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.rq-milestone-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.rq-milestone-desc {
+  font-size: 24rpx;
+  color: #0EA5E9;
+}
+/* 快捷操作 */
+.rq-quick-actions {
+  display: flex;
+  gap: 16rpx;
+  margin-top: 8rpx;
+}
+.rq-action-btn {
+  flex: 1;
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.rq-action-btn:active {
+  opacity: 0.8;
+}
+.rq-action-icon {
+  font-size: 36rpx;
+}
+.rq-action-label {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+
 /* ===== 底部 ===== */
 .bottom-spacer {
   height: 20rpx;