Browse Source

Merge remote changes (refactor: share QR to profile)

E2E Test Bot 1 day ago
parent
commit
6d4ce7236f

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9388,7 +9388,10 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                 jdbcTemplate.execute("INSERT INTO notification_templates (type, title, content, channels) VALUES " +
                         "('report_ready', '健康报告已完成分析', '您的健康报告已完成解析,可前往查看结果与成长方案。', 'in_app,sms'), " +
                         "('reward_granted', '问题域完成奖励已到账', '恭喜完成「{domainName}」全部任务,奖励 {reward} CF 已发放至家庭账户。', 'in_app,sms,wechat'), " +
-                        "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app')");
+                        "('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app'), " +
+                        "('energy_change', '能量变动', '{description}获得{amount}能量', 'in_app'), " +
+                        "('points_change', '积分变动', '{description}变动{amount}积分', 'in_app'), " +
+                        "('cf_change', 'CF值变动', '{remark}获得{amount} CF值', 'in_app')");
                 log.info("notification_templates 种子数据已插入");
             } else {
                 log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/UserNotification.java

@@ -11,6 +11,7 @@ import java.util.Date;
 /**
  * 站内通知(改版v1,用户级主动通知)
  * type: report_ready=报告完成 / reward_granted=奖励到账 / logistics=物流变化 / system=系统
+ *       energy_change=能量变动 / points_change=积分变动 / cf_change=CF值变动
  * 与 notices(平台公告) 并存:本表按 user_id 定向推送
  */
 @Data

+ 78 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -18,6 +18,7 @@ import javax.annotation.Resource;
 import java.util.*;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.service.NotificationDispatchService;
 
 /**
  * 家庭能量沙盘 — 核心计算服务
@@ -34,6 +35,9 @@ public class EnergyService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     @Resource
     private TaskMapper taskMapper;
 
@@ -1551,6 +1555,8 @@ public class EnergyService {
             EnergyDimension dim = getDimById(entry.getKey());
             if (dim != null) result.put(dim.getCode(), entry.getValue());
         }
+        // 发送能量变更通知
+        try { sendEnergyNotification(memberId, sourceType, totalAmount, description); } catch (Exception e) { log.warn("energy change notification failed", e); }
         return result;
     }
 
@@ -1654,7 +1660,8 @@ public class EnergyService {
         elog.setDescription(reason);
         elog.setCreatedAt(new Date());
         energyLogMapper.insert(elog);
-
+        // 发送能量扣除通知
+        try { sendEnergyDeductionNotification(memberId, sourceType != null ? sourceType : "manual", amount, reason); } catch (Exception e) { log.warn("energy deduction notification failed", e); }
         return balance.getBalance();
     }
 
@@ -1837,6 +1844,76 @@ public class EnergyService {
         return result != -1;
     }
 
+    /**
+     * 发送能量变更站内通知
+     */
+    private void sendEnergyNotification(Long memberId, String sourceType, Integer amount, String description) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        Long userId = member != null ? member.getUserId() : null;
+        if (userId == null) return;
+
+        String title = "";
+        String content = "";
+        switch (sourceType) {
+            case "task":
+                title = "任务完成获得能量";
+                content = String.format("完成了%s获得%d能量", description != null ? description : "任务", amount);
+                break;
+            case "product":
+                title = "购买商品扣除能量";
+                content = String.format("购买了%s扣除%d能量", description != null ? description : "商品", Math.abs(amount));
+                break;
+            case "medical_report":
+                title = "上传体检报告获得能量";
+                content = String.format("上传了%s获得%d能量", description != null ? description : "体检报告", amount);
+                break;
+            case "daily_record":
+                title = "日常记录获得能量";
+                content = String.format("完成了日常记录获得%d能量", amount);
+                break;
+            default:
+                title = "能量变动";
+                content = String.format("能量变动%d", amount);
+        }
+
+        Map<String, Object> vars = new HashMap<>();
+        vars.put("amount", amount > 0 ? "+" : "") + amount;
+        vars.put("source", sourceType);
+        vars.put("description", description);
+        notificationDispatchService.sendByTemplate("energy_change", userId, vars, "energyLog", memberId, title, content);
+    }
+
+    /**
+     * 发送能量扣除站内通知
+     */
+    private void sendEnergyDeductionNotification(Long memberId, String sourceType, Integer amount, String description) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        Long userId = member != null ? member.getUserId() : null;
+        if (userId == null) return;
+
+        String title = "";
+        String content = "";
+        switch (sourceType) {
+            case "task":
+                title = "任务完成获得能量";
+                content = String.format("完成了%s获得%d能量", description != null ? description : "任务", amount);
+                break;
+            case "wish":
+                title = "心愿兑换扣除能量";
+                content = String.format("完成了心愿兑换扣除%d能量", Math.abs(amount));
+                break;
+            default:
+                title = "能量扣除";
+                content = String.format("能量扣除%d", Math.abs(amount));
+        }
+
+        Map<String, Object> vars = new HashMap<>();
+        vars.put("amount", amount > 0 ? "+" : "") + amount;
+        vars.put("source", sourceType);
+        vars.put("description", description);
+        notificationDispatchService.sendByTemplate("energy_deduction", userId, vars, "energyLog", memberId, title, content);
+    }
+
     // ==================== 辅助方法 ====================
 
     /**

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java

@@ -12,12 +12,15 @@ import com.etotem.cfc.mapper.UserPlatformBalanceMapper;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+import com.etotem.cfc.service.NotificationDispatchService;
 
 import javax.annotation.Resource;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 
+@Slf4j
 @Service
 public class PlatformPointsService {
 
@@ -36,6 +39,9 @@ public class PlatformPointsService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     /** 获取或创建用户的 CF 值余额记录 */
     private UserPlatformBalance getOrCreate(Long userId) {
         UserPlatformBalance balance = balanceMapper.selectOne(
@@ -109,6 +115,7 @@ public class PlatformPointsService {
         try {
             writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
                     refType, refId, remark);
+            sendCfNotification(userId, TYPE_EARN, amount, remark);
         } catch (DuplicateKeyException e) {
             throw new RuntimeException("重复发放拦截: userId=" + userId + ", refType=" + refType + ", refId=" + refId, e);
         }
@@ -131,6 +138,7 @@ public class PlatformPointsService {
         UserPlatformBalance b = getOrCreate(userId);
         writeLog(userId, b.getFamilyId(), TYPE_SPEND, amount, safeInt(b.getAvailable()),
                 refType, refId, remark);
+        sendCfNotification(userId, TYPE_SPEND, amount, remark);
     }
 
     /** 提现申请:可用 → 冻结 */
@@ -216,4 +224,28 @@ public class PlatformPointsService {
                         .eq(PlatformBalanceLog::getUserId, userId)
                         .orderByDesc(PlatformBalanceLog::getCreatedAt));
     }
+
+    /** 发送 CF 值变更站内通知 */
+    private void sendCfNotification(Long userId, String type, int amount, String remark) {
+        try {
+            String title, content;
+            if (TYPE_EARN.equals(type)) {
+                title = "获得CF值";
+                content = String.format("%s获得%d CF值", remark != null ? remark + " " : "", amount);
+            } else if (TYPE_SPEND.equals(type)) {
+                title = "CF值支出";
+                content = String.format("%s支出%d CF值", remark != null ? remark : "消费", amount);
+            } else {
+                title = "CF值变动";
+                content = String.format("CF值变动%d", amount);
+            }
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("amount", amount);
+            vars.put("type", type);
+            vars.put("remark", remark);
+            notificationDispatchService.sendByTemplate("cf_change", userId, vars, "platformBalanceLog", null, title, content);
+        } catch (Exception e) {
+            log.warn("cf notification failed userId={}, error={}", userId, e.getMessage());
+        }
+    }
 }

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java

@@ -10,6 +10,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
 import javax.annotation.Resource;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -18,7 +19,11 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import com.etotem.cfc.util.SortUtil;
+import lombok.extern.slf4j.Slf4j;
 
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 @Service
 public class PointsService implements PointsServiceInterface {
 
@@ -31,6 +36,9 @@ public class PointsService implements PointsServiceInterface {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     public Map<String, Object> getBalance(Long memberId) {
         FamilyMember child = familyMemberMapper.selectById(memberId);
         if (child == null) {
@@ -88,6 +96,7 @@ public class PointsService implements PointsServiceInterface {
         log.setDescription(reason);
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
+        try { sendPointsNotification(memberId, "adjust", amount, reason); } catch (Exception e) { log.warn("points notification failed", e); }
 
         Map<String, Object> result = new HashMap<>();
         result.put("newBalance", newBalance);
@@ -120,6 +129,7 @@ public class PointsService implements PointsServiceInterface {
         log.setDescription(reason);
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
+        try { sendPointsNotification(memberId, "cf_earn", amount, reason); } catch (Exception e) { log.warn("cf notification failed", e); }
 
         return newCfPoints;
     }
@@ -177,6 +187,7 @@ public class PointsService implements PointsServiceInterface {
         log.setDescription(reason);
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
+        try { sendPointsNotification(memberId, "cf_spend", -amount, reason); } catch (Exception e) { log.warn("cf deduction notification failed", e); }
 
         return newCfPoints;
     }
@@ -243,4 +254,32 @@ public class PointsService implements PointsServiceInterface {
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
     }
+
+    /** 发送积分变更站内通知 */
+    private void sendPointsNotification(Long memberId, String type, Integer amount, String description) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        Long userId = member != null ? member.getUserId() : null;
+        if (userId == null) return;
+
+        String title, content;
+        if ("adjust".equals(type)) {
+            title = "积分调整";
+            content = String.format("管理员调整积分 %d", amount);
+        } else if ("cf_earn".equals(type)) {
+            title = "获得CF值";
+            content = String.format("%s获得%dCF值", description != null ? description + " " : "", amount);
+        } else if ("cf_spend".equals(type)) {
+            title = "CF值扣除";
+            content = String.format("%s扣除%dCF值", description != null ? description : "消费", Math.abs(amount));
+        } else {
+            title = "积分变动";
+            content = String.format("积分变动%d", amount);
+        }
+
+        Map<String, Object> vars = new HashMap<>();
+        vars.put("amount", amount > 0 ? "+" : "") + amount;
+        vars.put("type", type);
+        vars.put("description", description);
+        notificationDispatchService.sendByTemplate("points_change", userId, vars, "pointsLog", memberId, title, content);
+    }
 }

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -5221,7 +5221,7 @@ CREATE TABLE IF NOT EXISTS user_intents (
 CREATE TABLE IF NOT EXISTS user_notifications (
   id BIGINT AUTO_INCREMENT PRIMARY KEY,
   user_id BIGINT NOT NULL COMMENT '接收用户ID',
-  type VARCHAR(30) DEFAULT 'report_ready' COMMENT 'report_ready/reward_granted/logistics/system',
+  type VARCHAR(30) DEFAULT 'report_ready' COMMENT 'report_ready/reward_granted/logistics/system/energy_change/points_change/cf_change',
   title VARCHAR(200) NOT NULL,
   content VARCHAR(1000) DEFAULT '',
   ref_type VARCHAR(30) DEFAULT '' COMMENT '关联对象类型: report/package/task',

+ 93 - 4
cfc-frontend/custom-tab-bar/index.vue

@@ -1,9 +1,32 @@
 <template>
   <view class="tab-bar-wrap">
-    <!-- 统一TabBar:4 Tab(首页/健康/成长/我的),所有人同一套入口 -->
+    <!-- 统一TabBar:4 Tab(首页/健康/成长/我的)5 等分,中间放五维按钮,所有人同一套入口 -->
     <view class="tab-bar">
       <view
-        v-for="(item, index) in normalTabs"
+        v-for="(item, index) in leftTabs"
+        :key="index"
+        class="tab-item"
+        :class="{ active: isActive(item) }"
+        @click="switchTab(item)"
+      >
+        <image
+          class="tab-icon"
+          :src="isActive(item) ? item.selectedIcon : item.icon"
+          mode="aspectFit"
+        ></image>
+        <text class="tab-text">{{ item.text }}</text>
+      </view>
+
+      <!-- 中间五维按钮(占 20% 槽位) -->
+      <view class="center-btn" @click="openDimPanel">
+        <view class="center-btn-inner">
+          <text class="center-btn-icon">⭐</text>
+        </view>
+        <text class="center-btn-label">五维</text>
+      </view>
+
+      <view
+        v-for="(item, index) in rightTabs"
         :key="index"
         class="tab-item"
         :class="{ active: isActive(item) }"
@@ -18,7 +41,7 @@
       </view>
     </view>
 
-    <!-- 五维横排展开(五维按钮已移除,保留dim-fan弹窗) -->
+    <!-- 五维横排展开 -->
     <view class="dim-fan-overlay" v-if="showDimPanel" @click="closeDimPanel">
       <view class="dim-fan-row">
         <view
@@ -67,6 +90,15 @@ export default {
       ]
     }
   },
+  computed: {
+    // 5 等分:左 2 Tab + 中间五维按钮 + 右 2 Tab
+    leftTabs() {
+      return this.normalTabs.slice(0, 2)
+    },
+    rightTabs() {
+      return this.normalTabs.slice(2)
+    }
+  },
   mounted() {
     this.updateSelected()
   },
@@ -94,7 +126,23 @@ export default {
         uni.redirectTo({ url: item.pagePath })
       }
     },
+    openDimPanel() {
+      this.showDimPanel = true
+      this.fanActive = false
+      var self = this
+      setTimeout(function() {
+        self.fanActive = true
+      }, 30)
+    },
+    closeDimPanel() {
+      this.fanActive = false
+      var self = this
+      setTimeout(function() {
+        self.showDimPanel = false
+      }, 200)
+    },
     goDimension(dim) {
+      this.closeDimPanel()
       var dimMap = {
         action: '/pages/action-detail/index',
         mind: '/pages/mind-detail/index',
@@ -130,14 +178,18 @@ export default {
   background: #fff;
   padding-bottom: env(safe-area-inset-bottom);
   border-top: 1rpx solid #eee;
+  position: relative;
 }
 
 .tab-item {
-  flex: 1;
+  width: 20%;
+  flex: none;
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
+  position: relative;
+  z-index: 1;
 }
 
 .tab-icon {
@@ -155,6 +207,43 @@ export default {
   color: #F97316;
 }
 
+/* 中间五维按钮(占 20% 槽位) */
+.center-btn {
+  width: 20%;
+  flex: none;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: flex-start;
+  padding-top: 0;
+  position: relative;
+  z-index: 2;
+}
+
+.center-btn-inner {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: -20rpx;
+  flex-shrink: 0;
+  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.4);
+}
+
+.center-btn-icon {
+  font-size: 40rpx;
+  line-height: 1;
+}
+.center-btn-label {
+  font-size: 20rpx;
+  color: #999;
+  text-align: center;
+  margin-top: 2rpx;
+}
+
 /* 五维横排展开 */
 .dim-fan-overlay {
   position: fixed;

+ 6 - 0
cfc-frontend/pages.json

@@ -1802,6 +1802,12 @@
           "style": {
             "navigationBarTitleText": "服务角色申请"
           }
+        },
+        {
+          "path": "message-history",
+          "style": {
+            "navigationBarTitleText": "消息历史"
+          }
         }
       ]
     },

+ 4 - 1
cfc-frontend/pages/health/health-plan-summary.vue

@@ -998,13 +998,16 @@ selectedMembers: function() {
           return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
         })
       })
+      // 读取前端规划的师选择(teacher-service.vue 已写入本地缓存)
+      var teacherId = uni.getStorageSync('healthPlan_teacherId') || null
       var saveData = {
         memberIds: self.selectedMemberIds.join(','),
         dimensions: 'body',
         goal: self.userGoal.trim(),
         planContent: self.buildPlanContent(),
         planJson: planJson,
-        status: 'published'
+        status: 'pending_review',
+        teacherId: teacherId
       }
       saveHealthPlan(saveData).then(function(res) {
         self.loading = false

+ 350 - 2
cfc-frontend/pages/index-home/index.vue

@@ -103,6 +103,14 @@
       :notices="notices"
       :showHeader="true" />
 
+    <!-- 能量/积分/CF值变更通知条 -->
+    <view v-if="showEnergyBadge || showPointsBadge || showCfBadge" class="change-notification-bar" @click="clearChangeNotifications">
+      <text v-if="showEnergyBadge" class="change-item energy">{{ formatEnergyNotification }}</text>
+      <text v-if="showPointsBadge" class="change-item points">{{ formatPointsNotification }}</text>
+      <text v-if="showCfBadge" class="change-item cf">{{ formatCfNotification }}</text>
+      <text class="change-dismiss">✕</text>
+    </view>
+
     <!-- ④ 内容区 — 推荐文章 -->
 
     <view v-if="showMemberEntry" class="member-entry" @click="goMembership">
@@ -422,7 +430,7 @@ import ReportUploadCard from '../../components/report-upload-card.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
 import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
-import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, getGuidePopupList, consumeGuidePopup, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode, getAppLibrary, reportAppEnter } from '../../utils/api.js'
+import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, getGuidePopupList, consumeGuidePopup, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode, getAppLibrary, reportAppEnter, getEnergyLogs, getPointsLogs, getCfList, getCfSummary } from '../../utils/api.js'
 import { findAppByKey, getShortcutableApps } from '../../utils/app-library.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
@@ -517,7 +525,15 @@ membershipDays: 0,
       quickApps: [],
       myShortcuts: [],
       appUsage: {},
-      quickLoaded: false
+      quickLoaded: false,
+
+      // 能量/积分/CF值变更通知
+      energyMessages: [],
+      pointsMessages: [],
+      cfMessages: [],
+      showEnergyBadge: false,
+      showPointsBadge: false,
+      showCfBadge: false
 
 
     }
@@ -674,6 +690,31 @@ title: '添加家庭成员',
         btn: '逛一逛',
         color: 'gray'
       }
+    },
+    // ===== 能量/积分/CF值变更通知格式化 =====
+    formatEnergyNotification: function() {
+      var texts = []
+      var arr = this.energyMessages || []
+      for (var i = 0; i < arr.length; i++) {
+        texts.push(arr[i].description)
+      }
+      return '⚡ ' + (texts.join(';') || '能量变动')
+    },
+    formatPointsNotification: function() {
+      var texts = []
+      var arr = this.pointsMessages || []
+      for (var i = 0; i < arr.length; i++) {
+        texts.push(arr[i].description)
+      }
+      return '🪙 ' + (texts.join(';') || '积分变动')
+    },
+    formatCfNotification: function() {
+      var texts = []
+      var arr = this.cfMessages || []
+      for (var i = 0; i < arr.length; i++) {
+        texts.push(arr[i].description)
+      }
+      return '💰 ' + (texts.join(';') || 'CF值变动')
     }
   },
   onLoad(options) {
@@ -851,6 +892,7 @@ title: '添加家庭成员',
       this.loadFamilyData()
       this.loadReferralCode()
       this.loadQuickApps()
+      this.loadEnergyPointsCFChanges()
     },
     // 合并原 loadFamilyMembers + loadChildren:仅调用一次 getFamilyMemberList
     loadFamilyData: function() {
@@ -1092,6 +1134,282 @@ title: '添加家庭成员',
         }
       }).catch(function() {})
     },
+    }).catch(function() {})
+    },
+    // ===== 能量/积分/CF值变更通知 =====
+    loadEnergyPointsCFChanges: function() {
+      var self = this
+      var memberId = this.currentChildId || uni.getStorageSync('currentMemberId')
+      
+      if (!memberId) {
+        self.energyMessages = []
+        self.pointsMessages = []
+        self.cfMessages = []
+        self.showEnergyBadge = false
+        self.showPointsBadge = false
+        self.showCfBadge = false
+        return
+      }
+      
+      // 并行加载能量、积分、CF值变更
+      Promise.all([
+        this.loadEnergyChanges(),
+        this.loadPointsChanges(),
+        this.loadCfChanges()
+      ]).then(function(results) {
+        var energyRes = results[0]
+        var pointsRes = results[1]
+        var cfRes = results[2]
+        
+        // 处理能量变更
+        if (energyRes) {
+          self.energyMessages = energyRes.messages
+          self.showEnergyBadge = energyRes.showBadge
+        }
+        
+        // 处理积分变更
+        if (pointsRes) {
+          self.pointsMessages = pointsRes.messages
+          self.showPointsBadge = pointsRes.showBadge
+        }
+        
+        // 处理CF值变更
+        if (cfRes) {
+          self.cfMessages = cfRes.messages
+          self.showCfBadge = cfRes.showBadge
+        }
+      }).catch(function() {
+        self.energyMessages = []
+        self.pointsMessages = []
+        self.cfMessages = []
+        self.showEnergyBadge = false
+        self.showPointsBadge = false
+        self.showCfBadge = false
+      })
+    },
+    
+    loadEnergyChanges: function() {
+      var self = this
+      var memberId = this.currentChildId || uni.getStorageSync('currentMemberId')
+      
+      if (!memberId) return
+      
+      getEnergyLogs(memberId, null, 1, 10).then(function(res) {
+        if (res.code === 200 && res.data && res.data.records) {
+          var records = res.data.records
+          var messages = []
+          var totalAmount = 0
+          var count = 0
+          
+          for (var i = 0; i < records.length; i++) {
+            var record = records[i]
+            if (record.amount && record.createdAt) {
+              var date = self.parseDate(record.createdAt)
+              if (date && self.isToday(date)) {
+                totalAmount += record.amount
+                count++
+                
+                // 解析具体内容
+                var desc = record.description || ''
+                var sourceType = record.sourceType || ''
+                var contentText = ''
+                
+                switch(sourceType) {
+                  case 'task':
+                    contentText = '完成了' + (desc || '任务')
+                    break
+                  case 'product':
+                    contentText = '购买了' + (desc || '商品')
+                    break
+                  case 'medical_report':
+                    contentText = '上传了' + (desc || '体检报告')
+                    break
+                  case 'daily_record':
+                    contentText = '完成了' + (desc || '日常记录')
+                    break
+                  default:
+                    contentText = desc || '能量变动'
+                }
+                
+                messages.push({
+                  id: 'energy-' + record.id,
+                  type: 'energy',
+                  title: '能量变动',
+                  description: contentText,
+                  amount: record.amount,
+                  isRead: true,
+                  createdAt: record.createdAt,
+                  time: self.formatTime(record.createdAt)
+                })
+              }
+            }
+          }
+          
+          // 存储到实例属性(供其他方法使用,或可单独暴露)
+          // 注意:这里的 messages 仅在该方法调用后有效,实际建议使用单独的数据存储
+          self.energyMessages = messages
+          self.energyChangeTotal = totalAmount
+          self.energyChangeCount = count
+          self.showEnergyBadge = count > 0
+        }
+      }).catch(function() {})
+    },
+    
+    loadPointsChanges: function() {
+      var self = this
+      var memberId = this.currentChildId || uni.getStorageSync('currentMemberId')
+      
+      if (!memberId) return
+      
+      getPointsLogs(memberId, 1, 10).then(function(res) {
+        if (res.code === 200 && res.data && res.data.records) {
+          var records = res.data.records
+          var messages = []
+          var totalAmount = 0
+          var count = 0
+          
+          for (var i = 0; i < records.length; i++) {
+            var record = records[i]
+            if (record.amount && record.createdAt) {
+              var date = self.parseDate(record.createdAt)
+              if (date && self.isToday(date)) {
+                totalAmount += record.amount
+                count++
+                
+                // 解析具体内容
+                var desc = record.description || ''
+                var type = record.type || ''
+                
+                var contentText = ''
+                switch(type) {
+                  case 'task':
+                    contentText = '完成了' + (desc || '任务')
+                    contentText += '获得' + (record.amount >= 0 ? '+' : '') + record.amount + '积分'
+                    break
+                  case 'reward':
+                    contentText = '完成了' + (desc || '心愿兑换')
+                    contentText += '获得' + (record.amount >= 0 ? '+' : '') + record.amount + '积分'
+                    break
+                  case 'adjust':
+                    contentText = '管理员调整' + (record.amount >= 0 ? '+' : '') + record.amount + '积分'
+                    break
+                  case 'expire':
+                    contentText = '积分过期扣除' + Math.abs(record.amount) + '积分'
+                    break
+                  default:
+                    contentText = (desc || '积分变动') + (record.amount >= 0 ? '获得' : '扣除') + Math.abs(record.amount) + '积分'
+                }
+                
+                messages.push({
+                  id: 'points-' + record.id,
+                  type: 'points',
+                  title: '积分变动',
+                  description: contentText,
+                  amount: record.amount,
+                  isRead: true,
+                  createdAt: record.createdAt,
+                  time: self.formatTime(record.createdAt)
+                })
+              }
+            }
+          }
+          
+          self.pointsMessages = messages
+          self.pointsChangeTotal = totalAmount
+          self.pointsChangeCount = count
+          self.showPointsBadge = count > 0
+        }
+      }).catch(function() {})
+    },
+    
+    loadCfChanges: function() {
+      var self = this
+      
+      getCfSummary().then(function(res) {
+        if (res.code === 200 && res.data) {
+          var todayStr = new Date().toISOString().substring(0, 10)
+          getCfList(1, 10).then(function(cfRes) {
+            if (cfRes.code === 200 && cfRes.data && cfRes.data.records) {
+              var records = cfRes.data.records
+              var messages = []
+              var totalAmount = 0
+              var count = 0
+              
+              for (var i = 0; i < records.length; i++) {
+                var record = records[i]
+                if (record.amount && record.createdAt) {
+                  var date = self.parseDate(record.createdAt)
+                  if (date && date.toISOString().substring(0, 10) === todayStr) {
+                    totalAmount += record.amount
+                    count++
+                    
+                    // 解析具体内容
+                    var desc = record.description || ''
+                    var action = record.action || ''
+                    
+                    var contentText = ''
+                    switch(action) {
+                      case 'transfer_send':
+                        contentText = 'CF值转出' + Math.abs(record.amount) + 'CF'
+                        break
+                      case 'transfer_receive':
+                        contentText = 'CF值转入' + record.amount + 'CF'
+                        break
+                      case 'commission':
+                        contentText = '获得' + record.amount + 'CF分佣'
+                        break
+                      default:
+                        contentText = (desc || 'CF值变动') + (record.amount >= 0 ? '+' : '') + record.amount + 'CF'
+                    }
+                    
+                    messages.push({
+                      id: 'cf-' + record.id,
+                      type: 'cf',
+                      title: 'CF值变动',
+                      description: contentText,
+                      amount: record.amount,
+                      isRead: true,
+                      createdAt: record.createdAt,
+                      time: self.formatTime(record.createdAt)
+                    })
+                  }
+                }
+              }
+              
+              self.cfMessages = messages
+              self.cfChangeTotal = totalAmount
+              self.cfChangeCount = count
+              self.showCfBadge = count > 0
+            }
+          }).catch(function() {})
+        }
+      }).catch(function() {})
+    },
+    
+    // 辅助方法:解析日期
+    parseDate: function(dateStr) {
+      if (!dateStr) return null
+      // 统一日期格式处理
+      var d = new Date(dateStr)
+      if (isNaN(d)) return null
+      return d
+    },
+    
+    isToday: function(date) {
+      var today = new Date()
+      return date.getDate() === today.getDate() &&
+             date.getMonth() === today.getMonth() &&
+             date.getFullYear() === today.getFullYear()
+    },
+    
+    clearChangeNotifications: function() {
+      this.showEnergyBadge = false
+      this.showPointsBadge = false
+      this.showCfBadge = false
+      this.energyMessages = []
+      this.pointsMessages = []
+      this.cfMessages = []
+    },
     quickCompleteTask: function(task) {
       var self = this
       var memberId = (task && task.familyMemberId) || self.currentChildId || uni.getStorageSync('currentMemberId')
@@ -2506,4 +2824,34 @@ title: '添加家庭成员',
 }
 .self-test-badge { display: inline-block; align-self: flex-start; margin-top: 8rpx; font-size: 20rpx; color: #F97316; background: rgba(249,115,22,0.1); padding: 4rpx 14rpx; border-radius: 999rpx; }
 
+/* ===== 能量/积分/CF值变更通知条 ===== */
+.change-notification-bar {
+  margin: 16rpx 30rpx;
+  padding: 16rpx 24rpx;
+  background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
+  border-radius: 16rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.12);
+  flex-wrap: wrap;
+}
+.change-item {
+  font-size: 22rpx;
+  font-weight: 500;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.change-item.energy { color: #F97316; }
+.change-item.points { color: #10B981; }
+.change-item.cf { color: #6366F1; }
+.change-dismiss {
+  margin-left: auto;
+  font-size: 24rpx;
+  color: #999;
+  flex-shrink: 0;
+}
 </style>

+ 48 - 2
cfc-frontend/pages/login/login.vue

@@ -36,12 +36,22 @@
     <!-- 登录表单 -->
     <view class="login-form">
       <view class="phone-section">
+        <!--
+          disabled 必须同时受 agreed 和 loading 约束。
+          open-type="getPhoneNumber" 在「点击那一刻」就由渲染层向微信发起授权请求,
+          早于 @getphonenumber 回调执行 —— 也就是说每一次点击都会消耗一次平台调用配额,
+          JS 里的 if (!this.agreed) return 只能弹 toast,拦不住已经发生的调用。
+          未同意协议时按钮若仍可点,用户会因「点了没反应」而反复点击,最终触发微信限频:
+          [渲染层错误] invoke getPhoneNumber too frequently。
+          故未勾选协议时直接禁用按钮,让无效点击根本不会发生。
+        -->
         <button
           class="phone-btn"
           open-type="getPhoneNumber"
           @getphonenumber="getPhoneNumber"
+          @tap="onPhoneBtnTap"
           :loading="loading"
-          :disabled="loading"
+          :disabled="!agreed || loading || tapLock"
         >
           <text class="phone-btn-icon">📱</text>
           <text>手机号快捷登录</text>
@@ -75,6 +85,8 @@ export default {
   data() {
     return {
       loading: false,
+      // tapLock:在「点击」瞬间上锁,用于关闭授权弹窗弹出前的连点窗口(见 onPhoneBtnTap)
+      tapLock: false,
       agreed: false,
       nickname: '',
       avatar: '',
@@ -99,6 +111,13 @@ export default {
       // 预取后如果用户隔几分钟再点击授权,code 已失效会报 invalid code (40029)。
       // 正确做法:getPhoneNumber 中用户点击授权时实时获取。
     },
+  onUnload() {
+    // 清理 tap 锁兜底定时器,避免登录成功 reLaunch 后定时器仍在已销毁页面上置值
+    if (this._tapLockTimer) {
+      clearTimeout(this._tapLockTimer)
+      this._tapLockTimer = null
+    }
+  },
   methods: {
     handleImageError(e) {
       console.log('Logo 加载失败,使用默认显示')
@@ -120,12 +139,39 @@ export default {
       // 不登录直接进入首页
       uni.switchTab({ url: '/pages/index-home/index' })
     },
+    // 点击瞬间上锁,关闭「授权弹窗弹出前」的连点窗口。
+    //
+    // 为什么不能只在 getPhoneNumber 里判 loading:
+    // open-type="getPhoneNumber" 在点击那一刻就由渲染层向微信发起了授权调用,
+    // @getphonenumber 回调要等用户在系统弹窗里点完才返回。这中间几百毫秒内
+    // loading 还是 false、按钮仍可点,用户「以为没反应」就连点,
+    // 每次点击都真实消耗一次平台调用配额 → invoke getPhoneNumber too frequently。
+    // 在 @tap 上锁能让按钮在一个渲染周期内变为不可点,早于弹窗出现。
+    onPhoneBtnTap() {
+      if (this.loading || this.tapLock) return
+      this.tapLock = true
+      // 兜底:若因系统异常导致 getphonenumber 始终不回调,3 秒后强制解锁,
+      // 避免按钮永久锁死导致无法再登录。
+      if (this._tapLockTimer) clearTimeout(this._tapLockTimer)
+      this._tapLockTimer = setTimeout(() => {
+        this.tapLock = false
+        this._tapLockTimer = null
+      }, 3000)
+    },
     getPhoneNumber: function(e) {
+      // 授权流程已结束,释放 tap 锁与兜底定时器,按钮恢复可点。
+      // 注意:这里原有的 `if (this.loading) return` 已移除 —— loading 在本函数内才置 true,
+      // 保留它会把首个合法点击也拦掉;防重复消费改由下面的 tapLock 承担。
+      if (this._tapLockTimer) {
+        clearTimeout(this._tapLockTimer)
+        this._tapLockTimer = null
+      }
+      this.tapLock = false
+
       if (!this.agreed) {
         uni.showToast({ title: '请先同意用户协议', icon: 'none' })
         return
       }
-      if (this.loading) return
       if (!e.detail || !e.detail.code) {
         uni.showToast({ title: '授权失败,请重试', icon: 'none' })
         return

+ 7 - 0
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -4,6 +4,10 @@
       <!-- ===== 个人 ===== -->
       <view class="menu-group">
         <view class="menu-group-title">── 个人 ──</view>
+        <view class="menu-item" @click="goToMessageHistory">
+          <text>📨 消息历史</text>
+          <text class="arrow">›</text>
+        </view>
         <view class="menu-item" v-if="role === 'parent'" @click="goToFamilyMembers">
           <text>👨‍👩‍👧‍👦 家庭成员</text>
           <text class="arrow">›</text>
@@ -179,6 +183,9 @@ var res = await getReferralCode()
     goToDailyTasks() {
       uni.navigateTo({ url: '/pages/tasks/daily-tasks' })
     },
+    goToMessageHistory() {
+      uni.navigateTo({ url: '/pages/profile/message-history' })
+    },
     async onShowToFamilyChange(e) {
       const val = e.detail.value ? 1 : 0
       const uid = this.$store.state.userId || uni.getStorageSync('userId')

+ 688 - 0
cfc-frontend/pages/profile/message-history.vue

@@ -0,0 +1,688 @@
+<template>
+  <view class="container">
+    <!-- 顶部导航栏 -->
+    <view class="nav-bar">
+      <view class="nav-back" @click="goBack">
+        <text class="nav-back-icon">&#x2190;</text>
+      </view>
+      <text class="nav-title">消息历史</text>
+      <view class="nav-placeholder"></view>
+    </view>
+    
+    <!-- 筛选标签 -->
+    <view class="filter-tabs">
+      <view 
+        class="filter-tab" 
+        :class="{ active: activeFilter === 'all' }"
+        @click="switchFilter('all')">
+        <text>全部</text>
+      </view>
+      <view 
+        class="filter-tab" 
+        :class="{ active: activeFilter === 'energy' }"
+        @click="switchFilter('energy')">
+        <text>能量</text>
+      </view>
+      <view 
+        class="filter-tab" 
+        :class="{ active: activeFilter === 'points' }"
+        @click="switchFilter('points')">
+        <text>积分</text>
+      </view>
+      <view 
+        class="filter-tab" 
+        :class="{ active: activeFilter === 'cf' }"
+        @click="switchFilter('cf')">
+        <text>CF值</text>
+      </view>
+      <view 
+        class="filter-tab" 
+        :class="{ active: activeFilter === 'system' }"
+        @click="switchFilter('system')">
+        <text>系统</text>
+      </view>
+    </view>
+    
+    <!-- 消息列表 -->
+    <scroll-view 
+      class="message-list" 
+      scroll-y 
+      @scrolltolower="loadMore">
+      <view v-if="loading && messages.length === 0" class="loading-state">
+        <text>加载中...</text>
+      </view>
+      
+      <view v-else-if="messages.length === 0" class="empty-state">
+        <text class="empty-icon">📭</text>
+        <text class="empty-text">暂无消息</text>
+      </view>
+      
+      <template v-else>
+        <!-- 能量变更消息 -->
+        <view 
+          v-for="(msg, index) in energyMessages" 
+          :key="msg.id"
+          class="message-item"
+          :class="{ unread: !msg.isRead }"
+          @click="markAsRead(msg)">
+          <view class="message-icon" :style="{ background: 'rgba(249,115,22,0.15)' }">
+            <text>⚡</text>
+          </view>
+          <view class="message-content">
+            <text class="message-title">{{ msg.fullText || msg.description }}</text>
+            <text v-if="msg.amount !== 0" class="message-amount" :style="{ color: msg.amount >= 0 ? '#10B981' : '#EF4444' }">
+              {{ msg.amount >= 0 ? '+' : '' }}{{ msg.amount }}
+            </text>
+          </view>
+          <view class="message-meta">
+            <text class="message-time">{{ msg.time }}</text>
+          </view>
+        </view>
+        
+        <!-- 积分变更消息 -->
+        <view 
+          v-for="(msg, index) in pointsMessages" 
+          :key="msg.id"
+          class="message-item"
+          :class="{ unread: !msg.isRead }"
+          @click="markAsRead(msg)">
+          <view class="message-icon" :style="{ background: 'rgba(16,185,129,0.15)' }">
+            <text>🪙</text>
+          </view>
+          <view class="message-content">
+            <text class="message-title">{{ msg.fullText || msg.description }}</text>
+            <text v-if="msg.amount !== 0" class="message-amount" :style="{ color: msg.amount >= 0 ? '#10B981' : '#EF4444' }">
+              {{ msg.amount >= 0 ? '+' : '' }}{{ msg.amount }}
+            </text>
+          </view>
+          <view class="message-meta">
+            <text class="message-time">{{ msg.time }}</text>
+          </view>
+        </view>
+        
+        <!-- CF值变更消息 -->
+        <view 
+          v-for="(msg, index) in cfMessages" 
+          :key="msg.id"
+          class="message-item"
+          :class="{ unread: !msg.isRead }"
+          @click="markAsRead(msg)">
+          <view class="message-icon" :style="{ background: 'rgba(99,102,241,0.15)' }">
+            <text>💰</text>
+          </view>
+          <view class="message-content">
+            <text class="message-title">{{ msg.fullText || msg.description }}</text>
+            <text v-if="msg.amount !== 0" class="message-amount" :style="{ color: msg.amount >= 0 ? '#10B981' : '#EF4444' }">
+              {{ msg.amount >= 0 ? '+' : '' }}{{ msg.amount }}
+            </text>
+          </view>
+          <view class="message-meta">
+            <text class="message-time">{{ msg.time }}</text>
+          </view>
+        </view>
+        
+        <!-- 系统通知 -->
+        <view 
+          v-for="(msg, index) in systemMessages" 
+          :key="msg.id"
+          class="message-item"
+          :class="{ unread: !msg.isRead }"
+          @click="markAsRead(msg)">
+          <view class="message-icon" :style="{ background: 'rgba(100,116,139,0.15)' }">
+            <text>📢</text>
+          </view>
+          <view class="message-content">
+            <text class="message-title">{{ msg.title }}</text>
+            <text class="message-desc">{{ msg.description }}</text>
+          </view>
+          <view class="message-meta">
+            <text class="message-time">{{ msg.time }}</text>
+          </view>
+        </view>
+        
+        <!-- 加载更多 -->
+        <view v-if="loadingMore" class="loading-more">
+          <text>加载中...</text>
+        </view>
+        <view v-else-if="!hasMore && messages.length > 0" class="no-more">
+          <text>没有更多了</text>
+        </view>
+      </template>
+    </scroll-view>
+    
+    <!-- 底部操作 -->
+    <view class="bottom-actions">
+      <button class="btn-clear-all" @click="clearAllMessages">
+        清除所有消息
+      </button>
+      <button class="btn-mark-read" @click="markAllAsRead">
+        全部已读
+      </button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { 
+  getEnergyLogs, 
+  getPointsLogs, 
+  getCfList,
+  getNotificationList,
+  readNotification
+} from '../../utils/api.js'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  name: 'MessageHistory',
+  data() {
+    return {
+      activeFilter: 'all',
+      loading: true,
+      loadingMore: false,
+      hasMore: true,
+      currentPage: 1,
+      pageSize: 20,
+      
+      // 各类消息
+      energyMessages: [],
+      pointsMessages: [],
+      cfMessages: [],
+      systemMessages: [],
+      
+      // 用于分页
+      energyPage: 1,
+      pointsPage: 1,
+      cfPage: 1,
+      notificationPage: 1,
+    }
+  },
+  computed: {
+    messages() {
+      // 根据当前筛选返回对应消息
+      switch(this.activeFilter) {
+        case 'energy': return this.energyMessages
+        case 'points': return this.pointsMessages
+        case 'cf': return this.cfMessages
+        case 'system': return this.systemMessages
+        default: 
+          // 合并所有消息并按时间排序
+          var all = [
+            ...this.energyMessages.map(m => ({...m, type: 'energy'})),
+            ...this.pointsMessages.map(m => ({...m, type: 'points'})),
+            ...this.cfMessages.map(m => ({...m, type: 'cf'})),
+            ...this.systemMessages.map(m => ({...m, type: 'system'}))
+          ]
+          return all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+      }
+    }
+  },
+  onLoad() {
+    this.loadAllMessages()
+  },
+  onShow() {
+    // 每次显示时刷新未读数
+    this.refreshUnreadCounts()
+  },
+  methods: {
+    goBack() {
+      uni.navigateBack()
+    },
+    
+    switchFilter(filter) {
+      this.activeFilter = filter
+      // 如果还没加载过该类型消息,则加载
+      if (filter === 'energy' && this.energyMessages.length === 0) {
+        this.loadEnergyMessages()
+      } else if (filter === 'points' && this.pointsMessages.length === 0) {
+        this.loadPointsMessages()
+      } else if (filter === 'cf' && this.cfMessages.length === 0) {
+        this.loadCfMessages()
+      } else if (filter === 'system' && this.systemMessages.length === 0) {
+        this.loadSystemMessages()
+      }
+    },
+    
+    async loadAllMessages() {
+      this.loading = true
+      try {
+        await Promise.all([
+          this.loadEnergyMessages(),
+          this.loadPointsMessages(),
+          this.loadCfMessages(),
+          this.loadSystemMessages()
+        ])
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    async loadEnergyMessages() {
+      var memberId = uni.getStorageSync('currentMemberId') || this.currentChildId
+      if (!memberId) return
+      
+      var res = await getEnergyLogs(memberId, null, this.energyPage, this.pageSize)
+      if (res.code === 200 && res.data) {
+        var records = res.data.records || []
+        this.energyMessages = records.map(record => this.formatEnergyMessage(record))
+        this.energyPage++
+        this.hasMore = records.length === this.pageSize
+      }
+    },
+    
+    async loadPointsMessages() {
+      var memberId = uni.getStorageSync('currentMemberId') || this.currentChildId
+      if (!memberId) return
+      
+      var res = await getPointsLogs(memberId, this.pointsPage, this.pageSize)
+      if (res.code === 200 && res.data) {
+        var records = res.data.records || []
+        this.pointsMessages = records.map(record => this.formatPointsMessage(record))
+        this.pointsPage++
+        this.hasMore = records.length === this.pageSize
+      }
+    },
+    
+    async loadCfMessages() {
+      var res = await getCfList(this.cfPage, this.pageSize)
+      if (res.code === 200 && res.data) {
+        var records = res.data.records || []
+        this.cfMessages = records.map(record => this.formatCfMessage(record))
+        this.cfPage++
+        this.hasMore = records.length === this.pageSize
+      }
+    },
+    
+    async loadSystemMessages() {
+      var res = await getNotificationList()
+      if (res.code === 200 && res.data) {
+        var records = res.data || []
+        this.systemMessages = records.map(record => this.formatSystemMessage(record))
+        this.notificationPage++
+      }
+    },
+    
+    formatEnergyMessage(record) {
+      var desc = record.description || ''
+      var sourceType = record.sourceType || ''
+      
+      var contentText = ''
+      switch(sourceType) {
+        case 'task': contentText = '完成了' + (desc || '任务'); break
+        case 'product': contentText = '购买了' + (desc || '商品'); break
+        case 'medical_report': contentText = '上传了' + (desc || '体检报告'); break
+        case 'daily_record': contentText = '完成了' + (desc || '日常记录'); break
+        default: contentText = desc || '能量变动'
+      }
+      
+      return {
+        id: 'energy-' + record.id,
+        type: 'energy',
+        title: '能量变动',
+        fullText: '⚡ ' + contentText,
+        description: contentText,
+        amount: record.amount,
+        isRead: true,
+        createdAt: record.createdAt,
+        time: this.formatTime(record.createdAt)
+      }
+    },
+    
+    formatPointsMessage(record) {
+      var desc = record.description || ''
+      var type = record.type || ''
+      
+      var contentText = ''
+      switch(type) {
+        case 'task': 
+          contentText = '完成了' + (desc || '任务') + '获得' + (record.amount >= 0 ? '+' : '') + record.amount + '积分'; break
+        case 'reward': 
+          contentText = '完成了' + (desc || '心愿兑换') + '获得' + (record.amount >= 0 ? '+' : '') + record.amount + '积分'; break
+        case 'adjust': 
+          contentText = '管理员调整' + (record.amount >= 0 ? '+' : '') + record.amount + '积分'; break
+        case 'expire': 
+          contentText = '积分过期扣除' + Math.abs(record.amount) + '积分'; break
+        default: 
+          contentText = (desc || '积分变动') + (record.amount >= 0 ? '获得' : '扣除') + Math.abs(record.amount) + '积分'
+      }
+      
+      return {
+        id: 'points-' + record.id,
+        type: 'points',
+        fullText: '🪙 ' + contentText,
+        description: contentText,
+        amount: record.amount,
+        isRead: true,
+        createdAt: record.createdAt,
+        time: this.formatTime(record.createdAt)
+      }
+    },
+    
+    formatCfMessage(record) {
+      var desc = record.description || ''
+      var action = record.action || ''
+      
+      var contentText = ''
+      switch(action) {
+        case 'transfer_send': contentText = 'CF值转出' + Math.abs(record.amount) + 'CF'; break
+        case 'transfer_receive': contentText = 'CF值转入' + record.amount + 'CF'; break
+        case 'commission': contentText = '获得' + record.amount + 'CF分佣'; break
+        default: contentText = (desc || 'CF值变动') + (record.amount >= 0 ? '+' : '') + record.amount + 'CF'
+      }
+      
+      return {
+        id: 'cf-' + record.id,
+        type: 'cf',
+        fullText: '💰 ' + contentText,
+        description: contentText,
+        amount: record.amount,
+        isRead: true,
+        createdAt: record.createdAt,
+        time: this.formatTime(record.createdAt)
+      }
+    },
+    
+    formatSystemMessage(record) {
+      return {
+        id: 'system-' + record.id,
+        type: 'system',
+        title: record.title || '系统通知',
+        description: record.content || '',
+        amount: 0,
+        isRead: record.isRead === 1,
+        createdAt: record.createdAt,
+        time: this.formatTime(record.createdAt),
+        record: record
+      }
+    },
+    
+    formatTime(dateStr) {
+      if (!dateStr) return ''
+      var d = parseDate(dateStr)
+      if (!d) return ''
+      
+      var now = new Date()
+      var diff = now - d
+      var minutes = Math.floor(diff / 60000)
+      
+      if (minutes < 1) return '刚刚'
+      if (minutes < 60) return minutes + '分钟前'
+      
+      var hours = Math.floor(minutes / 60)
+      if (hours < 24) return hours + '小时前'
+      
+      var days = Math.floor(hours / 24)
+      if (days === 1) return '昨天'
+      if (days < 7) return days + '天前'
+      
+      return (d.getMonth() + 1) + '月' + d.getDate() + '日'
+    },
+    
+    markAsRead(msg) {
+      msg.isRead = true
+      // 如果是系统通知,调用API标记为已读
+      if (msg.type === 'system' && msg.record && msg.record.id) {
+        readNotification({ id: msg.record.id }).catch(function() {})
+      }
+    },
+    
+    markAllAsRead() {
+      // 标记所有消息为已读
+      this.energyMessages.forEach(m => m.isRead = true)
+      this.pointsMessages.forEach(m => m.isRead = true)
+      this.cfMessages.forEach(m => m.isRead = true)
+      this.systemMessages.forEach(m => m.isRead = true)
+      
+      // 批量标记系统通知为已读
+      var unreadNotifications = this.systemMessages.filter(m => !m.isRead)
+      if (unreadNotifications.length > 0) {
+        readNotification({ ids: unreadNotifications.map(m => m.record.id) }).catch(function() {})
+      }
+      
+      uni.showToast({ title: '已全部标为已读', icon: 'success' })
+    },
+    
+    clearAllMessages() {
+      uni.showModal({
+        title: '清除消息',
+        content: '确定要清除所有历史消息吗?',
+        success: (res) => {
+          if (res.confirm) {
+            this.energyMessages = []
+            this.pointsMessages = []
+            this.cfMessages = []
+            this.systemMessages = []
+            this.currentPage = 1
+            this.energyPage = 1
+            this.pointsPage = 1
+            this.cfPage = 1
+            this.notificationPage = 1
+            uni.showToast({ title: '已清除', icon: 'success' })
+          }
+        }
+      })
+    },
+    
+    refreshUnreadCounts() {
+      // 刷新未读数(可选:从后端获取)
+    },
+    
+    loadMore() {
+      if (!this.hasMore || this.loadingMore) return
+      this.loadingMore = true
+      
+      var self = this
+      Promise.all([
+        this.energyPage < 10 ? this.loadEnergyMessages() : Promise.resolve(),
+        this.pointsPage < 10 ? this.loadPointsMessages() : Promise.resolve(),
+        this.cfPage < 10 ? this.loadCfMessages() : Promise.resolve(),
+        this.notificationPage < 10 ? this.loadSystemMessages() : Promise.resolve()
+      ]).finally(() => {
+        this.loadingMore = false
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 30rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #eee;
+  position: sticky;
+  top: 0;
+  z-index: 100;
+}
+
+.nav-back {
+  width: 60rpx;
+  height: 60rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.nav-back-icon {
+  font-size: 40rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.nav-placeholder {
+  width: 60rpx;
+}
+
+.filter-tabs {
+  display: flex;
+  padding: 20rpx 30rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #eee;
+  gap: 16rpx;
+}
+
+.filter-tab {
+  padding: 12rpx 24rpx;
+  background: #f5f5f5;
+  border-radius: 20rpx;
+  font-size: 24rpx;
+  color: #666;
+}
+
+.filter-tab.active {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+
+.message-list {
+  flex: 1;
+  padding: 20rpx 30rpx;
+  overflow-y: auto;
+}
+
+.message-item {
+  display: flex;
+  align-items: flex-start;
+  padding: 24rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+
+.message-item.unread {
+  border: 2rpx solid #F97316;
+}
+
+.message-icon {
+  width: 64rpx;
+  height: 64rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+  flex-shrink: 0;
+}
+
+.message-icon text {
+  font-size: 32rpx;
+}
+
+.message-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.message-title {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #333;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+}
+
+.message-desc {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 6rpx;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+}
+
+.message-amount {
+  font-size: 28rpx;
+  font-weight: bold;
+  margin-top: 4rpx;
+}
+
+.message-meta {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 8rpx;
+}
+
+.message-time {
+  font-size: 20rpx;
+  color: #bbb;
+}
+
+.loading-state,
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 100rpx 0;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-text {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.loading-more,
+.no-more {
+  text-align: center;
+  padding: 30rpx 0;
+  font-size: 24rpx;
+  color: #999;
+}
+
+.bottom-actions {
+  display: flex;
+  gap: 20rpx;
+  padding: 30rpx;
+  background: #fff;
+  border-top: 1rpx solid #eee;
+  position: sticky;
+  bottom: 0;
+}
+
+.btn-clear-all,
+.btn-mark-read {
+  flex: 1;
+  height: 80rpx;
+  line-height: 80rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+  border: none;
+}
+
+.btn-clear-all {
+  background: #f5f5f5;
+  color: #666;
+}
+
+.btn-mark-read {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+</style>

+ 1 - 1
cfc-frontend/pages/teacher/teacher-profile.vue

@@ -171,7 +171,7 @@ export default {
         cancelText: '关闭',
         success: (res) => {
           if (res.confirm && status === 'rejected') {
-            uni.navigateTo({ url: '/pages/guide/register/index' })
+            uni.navigateTo({ url: '/pages/profile/service-role-apply' })
           }
         }
       })

+ 23 - 10
cfc-frontend/pages/teacher/team.vue

@@ -52,7 +52,7 @@
 </template>
 
 <script>
-import { getTeamMembers, getTeamStats } from '../../utils/api.js'
+import { getTeamMembers, getTeamStats, generateInviteCode } from '../../utils/api.js'
 
 export default {
   data() {
@@ -91,16 +91,29 @@ export default {
         url: `/pages/teacher/member-detail?id=${member.id}`
       })
     },
-    generateInviteCode() {
-      uni.showModal({
-        title: '生成邀请码',
-        content: '确定生成新的邀请码吗?',
-        success: (res) => {
-          if (res.confirm) {
-            uni.showToast({ title: '邀请码已生成', icon: 'success' })
-          }
+    async generateInviteCode() {
+      try {
+        const res = await generateInviteCode()
+        const code = res && res.data ? res.data.code : null
+        if (!code) {
+          uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+          return
         }
-      })
+        this.loadData()
+        uni.showModal({
+          title: '邀请码已生成',
+          content: '邀请码:' + code + '\n新邀请码生成后,旧邀请码立即失效,成员需使用新码重新加入。',
+          confirmText: '复制邀请码',
+          success: (modalRes) => {
+            if (modalRes.confirm) {
+              uni.setClipboardData({ data: code })
+            }
+          }
+        })
+      } catch (e) {
+        console.error(e)
+        uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+      }
     }
   }
 }

+ 2 - 2
cfc-frontend/utils/api.js

@@ -982,9 +982,9 @@ export const registerGuide = (data) => {
   return request('/api/guide/register', 'POST', data)
 }
 
-// 生成邀请码
+// 生成邀请码(团队/规划师端)
 export const generateInviteCode = () => {
-  return request('/api/guide/invite-code/generate', 'POST')
+  return request('/api/guide/team/invite-code/generate', 'POST')
 }
 
 // 获取邀请码列表

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-5e340d1dd652a30f93ce0c9490da7f14d27d7d25
+0320615b6f69b54f96dcb8bec40f5a9b0b713a12

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1485",
+  "version": "1.0.1489",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1485",
+      "version": "1.0.1489",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1488",
+  "version": "1.0.1489",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 43 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,49 @@
 
 ---
 
+## v1.0.1489 (2026-10-04)
+
+### 新功能
+- 家庭能量/积分/CF值变更通知 + 消息历史中心
+- add share referral code page to profile
+
+### 其他
+- - 新增消息历史页面支持筛选查看全部/能量/积分/CF值/系统
+- - 后端 EnergyService/PointsService/PlatformPointsService 集成通知发送
+- - 新增 energy_change/points_change/cf_change 通知模板种子数据
+- - ProfileMenu 新增消息历史入口
+- 
+- 早于 @getphonenumber 回调。原先 :disabled 只受 loading 约束,而 loading 要等
+- 回调进函数才置 true —— 授权弹窗弹出前的几百毫秒内按钮仍可点,用户「以为没反应」
+- 就连点,每次点击都真实消耗一次平台调用配额,最终触发微信限频:
+-   [渲染层错误] invoke getPhoneNumber too frequently
+- JS 层的 if (!this.agreed) return 拦不住已发生的调用 —— 只能事后弹 toast。
+- - 新增 tapLock,@tap 瞬间上锁,:disabled 改为 !agreed || loading || tapLock
+- - 未勾选协议时按钮直接禁用,无效点击从源头不发生
+- - getPhoneNumber 开头释放 tapLock,并移除原有 `if (this.loading) return`
+-   (loading 正是在该函数内才置 true,保留会把首个合法点击也拦掉)
+- - 3 秒兜底解锁 + onUnload 清理定时器,避免异常路径下按钮永久锁死
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 槽位的 .center-btn(五维圆形按钮)及 openDimPanel/closeDimPanel,五维入口
+- 因此无法触达,dim-fan-overlay 沦为死代码。
+- - 恢复 .center-btn / .center-btn-inner 样式与 5 等分布局(左右各 2 Tab)
+- - 恢复 openDimPanel / closeDimPanel 与 leftTabs / rightTabs computed
+- - 补回 closeDimPanel:模板 @click 此前引用了已删除的方法,一旦弹窗触发即报错
+- - 保留 984c16cef 的「统一角色入口」决策:不再有 teacher 专属 TabBar 分支
+- - :key 维持 index。两个 v-for 编译为各自独立的 block wx:for($root.l0 /
+-   $root.l1),key 只需在列表内唯一;改用 item.pagePath 会因该属性实际挂在
+-   item.$orig 上而取不到值,导致 wx:key 重复,渲染层抛 FLOW_CREATE_NODE
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- 手机号快捷登录按钮点击瞬间上锁,避免触发微信授权限频
+- 恢复底部中间五维大按钮 + 五维横排弹窗
+- 移除推荐人卡片,在用户信息栏显示推荐人,去掉家长标签
+- McpToolRegistry toolSchema 参数 name 缺失导致 properties 为空 [skip ci]
+
+
 ## v1.0.1488 (2026-10-03)
 
 ### Bug 修复

+ 44 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1488
+> 当前版本: v1.0.1489
 
 ## 历史版本
 
@@ -8,6 +8,49 @@
 
 ---
 
+## v1.0.1489 (2026-10-04)
+
+### 新功能
+- 家庭能量/积分/CF值变更通知 + 消息历史中心
+- add share referral code page to profile
+
+### 其他
+- - 新增消息历史页面支持筛选查看全部/能量/积分/CF值/系统
+- - 后端 EnergyService/PointsService/PlatformPointsService 集成通知发送
+- - 新增 energy_change/points_change/cf_change 通知模板种子数据
+- - ProfileMenu 新增消息历史入口
+- 
+- 早于 @getphonenumber 回调。原先 :disabled 只受 loading 约束,而 loading 要等
+- 回调进函数才置 true —— 授权弹窗弹出前的几百毫秒内按钮仍可点,用户「以为没反应」
+- 就连点,每次点击都真实消耗一次平台调用配额,最终触发微信限频:
+-   [渲染层错误] invoke getPhoneNumber too frequently
+- JS 层的 if (!this.agreed) return 拦不住已发生的调用 —— 只能事后弹 toast。
+- - 新增 tapLock,@tap 瞬间上锁,:disabled 改为 !agreed || loading || tapLock
+- - 未勾选协议时按钮直接禁用,无效点击从源头不发生
+- - getPhoneNumber 开头释放 tapLock,并移除原有 `if (this.loading) return`
+-   (loading 正是在该函数内才置 true,保留会把首个合法点击也拦掉)
+- - 3 秒兜底解锁 + onUnload 清理定时器,避免异常路径下按钮永久锁死
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 槽位的 .center-btn(五维圆形按钮)及 openDimPanel/closeDimPanel,五维入口
+- 因此无法触达,dim-fan-overlay 沦为死代码。
+- - 恢复 .center-btn / .center-btn-inner 样式与 5 等分布局(左右各 2 Tab)
+- - 恢复 openDimPanel / closeDimPanel 与 leftTabs / rightTabs computed
+- - 补回 closeDimPanel:模板 @click 此前引用了已删除的方法,一旦弹窗触发即报错
+- - 保留 984c16cef 的「统一角色入口」决策:不再有 teacher 专属 TabBar 分支
+- - :key 维持 index。两个 v-for 编译为各自独立的 block wx:for($root.l0 /
+-   $root.l1),key 只需在列表内唯一;改用 item.pagePath 会因该属性实际挂在
+-   item.$orig 上而取不到值,导致 wx:key 重复,渲染层抛 FLOW_CREATE_NODE
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- 手机号快捷登录按钮点击瞬间上锁,避免触发微信授权限频
+- 恢复底部中间五维大按钮 + 五维横排弹窗
+- 移除推荐人卡片,在用户信息栏显示推荐人,去掉家长标签
+- McpToolRegistry toolSchema 参数 name 缺失导致 properties 为空 [skip ci]
+
+
 ## v1.0.1488 (2026-10-03)
 
 ### Bug 修复