소스 검색

Merge remote-tracking branch 'origin/cfclub' into cfclub

liaoxg 2 일 전
부모
커밋
b98d0e7528
34개의 변경된 파일과 2326개의 추가작업 그리고 487개의 파일을 삭제
  1. 12 1
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 25 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java
  3. 15 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
  4. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  5. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusKeyPerson.java
  6. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/UserNotification.java
  7. 78 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java
  8. 78 1
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  9. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java
  10. 94 3
      cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
  11. 32 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PlatformPointsService.java
  12. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PointsService.java
  13. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
  14. 4 1
      cfc-backend/src/main/resources/schema.sql
  15. 2 2
      cfc-frontend/pages.json
  16. 17 3
      cfc-frontend/pages/action-detail/components/OctopusDiagram.vue
  17. 102 2
      cfc-frontend/pages/action-detail/octopus-add-effect.vue
  18. 160 4
      cfc-frontend/pages/action-detail/octopus-add-key-person.vue
  19. 7 1
      cfc-frontend/pages/health/health-plan-summary.vue
  20. 350 2
      cfc-frontend/pages/index-home/index.vue
  21. 270 3
      cfc-frontend/pages/profile-main/profile.vue
  22. 7 56
      cfc-frontend/pages/profile/components/ProfileMenu.vue
  23. 688 0
      cfc-frontend/pages/profile/message-history.vue
  24. 0 381
      cfc-frontend/pages/profile/share.vue
  25. 1 1
      cfc-frontend/pages/teacher/teacher-profile.vue
  26. 23 10
      cfc-frontend/pages/teacher/team.vue
  27. 12 3
      cfc-frontend/utils/api.js
  28. 1 1
      cfc-web/.last_build_commit
  29. 2 2
      cfc-web/package-lock.json
  30. 1 1
      cfc-web/package.json
  31. 79 0
      cfc-web/public/CHANGELOG-v1.0.md
  32. 80 1
      cfc-web/public/CHANGELOG.md
  33. 1 1
      docs/superpowers/PROJECT-OVERVIEW.md
  34. 87 1
      docs/superpowers/api/API_REFERENCE.md

+ 12 - 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 " +
                 jdbcTemplate.execute("INSERT INTO notification_templates (type, title, content, channels) VALUES " +
                         "('report_ready', '健康报告已完成分析', '您的健康报告已完成解析,可前往查看结果与成长方案。', 'in_app,sms'), " +
                         "('report_ready', '健康报告已完成分析', '您的健康报告已完成解析,可前往查看结果与成长方案。', 'in_app,sms'), " +
                         "('reward_granted', '问题域完成奖励已到账', '恭喜完成「{domainName}」全部任务,奖励 {reward} CF 已发放至家庭账户。', 'in_app,sms,wechat'), " +
                         "('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 种子数据已插入");
                 log.info("notification_templates 种子数据已插入");
             } else {
             } else {
                 log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
                 log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
@@ -11484,6 +11487,14 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
         }
 
 
         ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
         ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
+
+        // 迁移342: octopus_key_person 添加 first_meet_time / referrer_id / system_user_id 列(感恩记录新增字段)
+        ensureColumn("octopus_key_person", "first_meet_time", "DATETIME COMMENT '相识具体时间'");
+        log.info("迁移342: octopus_key_person.first_meet_time 列已添加");
+        ensureColumn("octopus_key_person", "referrer_id", "BIGINT DEFAULT NULL COMMENT '介绍人关键人ID(指向同用户另一条关键人)'");
+        log.info("迁移342: octopus_key_person.referrer_id 列已添加");
+        ensureColumn("octopus_key_person", "system_user_id", "BIGINT DEFAULT NULL COMMENT '匹配到的系统用户ID(如有)'");
+        log.info("迁移342: octopus_key_person.system_user_id 列已添加");
     }
     }
 
 
     /**
     /**

+ 25 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/KeyPersonController.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.controller;
 package com.etotem.cfc.controller;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.service.KeyPersonService;
 import com.etotem.cfc.service.KeyPersonService;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
@@ -74,16 +75,19 @@ public class KeyPersonController {
     // ===== 2. 找关键人 =====
     // ===== 2. 找关键人 =====
     @PostMapping("/key-person/add")
     @PostMapping("/key-person/add")
     public Result<com.etotem.cfc.entity.OctopusKeyPerson> addKeyPerson(@RequestBody Map<String, Object> params,
     public Result<com.etotem.cfc.entity.OctopusKeyPerson> addKeyPerson(@RequestBody Map<String, Object> params,
-                                                                       @RequestAttribute("userId") Long userId) {
+                                                                        @RequestAttribute("userId") Long userId) {
         String name = (String) params.get("name");
         String name = (String) params.get("name");
         String organization = (String) params.get("organization");
         String organization = (String) params.get("organization");
         String department = (String) params.get("department");
         String department = (String) params.get("department");
         String title = (String) params.get("title");
         String title = (String) params.get("title");
         String firstMeetScene = (String) params.get("firstMeetScene");
         String firstMeetScene = (String) params.get("firstMeetScene");
+        String firstMeetTime = (String) params.get("firstMeetTime");
         String knowWay = (String) params.get("knowWay");
         String knowWay = (String) params.get("knowWay");
         String contactInfo = (String) params.get("contactInfo");
         String contactInfo = (String) params.get("contactInfo");
+        Long referrerId = params.get("referrerId") != null
+                ? Long.valueOf(params.get("referrerId").toString()) : null;
         return keyPersonService.addKeyPerson(userId, name, organization, department, title,
         return keyPersonService.addKeyPerson(userId, name, organization, department, title,
-                firstMeetScene, knowWay, contactInfo);
+                firstMeetScene, firstMeetTime, knowWay, contactInfo, referrerId);
     }
     }
 
 
     @PostMapping("/key-person/update")
     @PostMapping("/key-person/update")
@@ -123,4 +127,23 @@ public class KeyPersonController {
     public Result<List<Map<String, Object>>> expansionSuggest(@RequestAttribute("userId") Long userId) {
     public Result<List<Map<String, Object>>> expansionSuggest(@RequestAttribute("userId") Long userId) {
         return keyPersonService.expansionSuggest(userId);
         return keyPersonService.expansionSuggest(userId);
     }
     }
+
+    /** 通过手机号匹配系统内用户(用于关键人绑定真实用户) */
+    @PostMapping("/key-person/match-by-phone")
+    public Result<Map<String, Object>> matchKeyPersonByPhone(@RequestBody Map<String, Object> params,
+                                                              @RequestAttribute("userId") Long userId) {
+        String phone = params.get("phone") != null ? params.get("phone").toString().trim() : null;
+        return keyPersonService.matchKeyPersonByPhone(userId, phone);
+    }
+
+    /** 将关键人与系统用户绑定(持久化匹配状态) */
+    @PostMapping("/key-person/bind-system-user")
+    public Result<OctopusKeyPerson> bindSystemUser(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("userId") Long userId) {
+        Long keyPersonId = params.get("keyPersonId") != null
+                ? Long.valueOf(params.get("keyPersonId").toString()) : null;
+        Long systemUserId = params.get("systemUserId") != null
+                ? Long.valueOf(params.get("systemUserId").toString()) : null;
+        return keyPersonService.bindSystemUser(userId, keyPersonId, systemUserId);
+    }
 }
 }

+ 15 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java

@@ -90,7 +90,11 @@ public class ServiceRoleApplicationController {
 
 
     @PostMapping("/admin/list")
     @PostMapping("/admin/list")
     @Operation(summary = "管理端: 获取申请列表")
     @Operation(summary = "管理端: 获取申请列表")
-    public Result adminList(@RequestParam(required = false, defaultValue = "0") Integer status) {
+    public Result adminList(@RequestParam(required = false, defaultValue = "0") Integer status,
+                            @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅管理员/运营可查看服务角色申请列表");
+        }
         List<ServiceRoleApplication> list = serviceRoleApplicationService.listApplications(status);
         List<ServiceRoleApplication> list = serviceRoleApplicationService.listApplications(status);
         return Result.success(list);
         return Result.success(list);
     }
     }
@@ -98,7 +102,11 @@ public class ServiceRoleApplicationController {
     @PostMapping("/admin/approve")
     @PostMapping("/admin/approve")
     @Operation(summary = "管理端: 批准申请")
     @Operation(summary = "管理端: 批准申请")
     public Result adminApprove(@RequestBody Map<String, Object> params,
     public Result adminApprove(@RequestBody Map<String, Object> params,
-                               @RequestAttribute("userId") Long adminId) {
+                               @RequestAttribute("userId") Long adminId,
+                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅管理员/运营可审批服务角色申请");
+        }
         Long id = ParamUtils.getLong(params.get("id"));
         Long id = ParamUtils.getLong(params.get("id"));
         return serviceRoleApplicationService.approve(id, adminId);
         return serviceRoleApplicationService.approve(id, adminId);
     }
     }
@@ -106,7 +114,11 @@ public class ServiceRoleApplicationController {
     @PostMapping("/admin/reject")
     @PostMapping("/admin/reject")
     @Operation(summary = "管理端: 驳回申请")
     @Operation(summary = "管理端: 驳回申请")
     public Result adminReject(@RequestBody Map<String, Object> params,
     public Result adminReject(@RequestBody Map<String, Object> params,
-                              @RequestAttribute("userId") Long adminId) {
+                              @RequestAttribute("userId") Long adminId,
+                              @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"operator".equals(role)) {
+            return Result.error("无权限:仅管理员/运营可审批服务角色申请");
+        }
         Long id = ParamUtils.getLong(params.get("id"));
         Long id = ParamUtils.getLong(params.get("id"));
         String reason = params.get("reason") != null ? params.get("reason").toString() : "";
         String reason = params.get("reason") != null ? params.get("reason").toString() : "";
         return serviceRoleApplicationService.reject(id, reason, adminId);
         return serviceRoleApplicationService.reject(id, reason, adminId);

+ 32 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -8,11 +8,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.dto.TaskReviewDTO;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GuideFamily;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.GuideFamilyService;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.service.TaskService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
@@ -23,6 +25,7 @@ import java.time.LocalDate;
 import java.time.ZoneId;
 import java.time.ZoneId;
 import java.util.*;
 import java.util.*;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
+import com.etotem.cfc.util.ParamUtils;
 
 
 @Tag(name = "指导师-家庭任务管理", description = "指导师查看家庭任务、审核任务等接口")
 @Tag(name = "指导师-家庭任务管理", description = "指导师查看家庭任务、审核任务等接口")
 @RestController
 @RestController
@@ -44,6 +47,35 @@ public class GuideFamilyTaskController {
     @Resource
     @Resource
     private MembershipService membershipService;
     private MembershipService membershipService;
 
 
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
+    /**
+     * 规划师解除家庭绑定(仅限解除属于自己的绑定)
+     */
+    @Operation(summary = "规划师解除家庭绑定")
+    @PostMapping("/unbind")
+    public Result<Map<String, Object>> unbindFamily(@RequestAttribute("userId") Long guideId,
+                                                   @RequestAttribute("role") String role,
+                                                   @RequestBody Map<String, Object> params) {
+        if (!"teacher".equals(role)) {
+            return Result.error("Access denied");
+        }
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) {
+            return Result.error("缺少绑定记录ID");
+        }
+        try {
+            GuideFamily gf = guideFamilyService.unbindByGuide(id, guideId);
+            Map<String, Object> result = new HashMap<>();
+            result.put("familyId", gf.getFamilyId());
+            result.put("serviceType", gf.getServiceType());
+            return Result.success(result);
+        } catch (RuntimeException e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+
     /**
     /**
      * 会员任务校验:task.memberOnly==1 且家庭非会员 → 拒绝(提示开通会员)
      * 会员任务校验:task.memberOnly==1 且家庭非会员 → 拒绝(提示开通会员)
      */
      */

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/OctopusKeyPerson.java

@@ -31,6 +31,12 @@ public class OctopusKeyPerson implements Serializable {
 
 
     private String contactInfo;
     private String contactInfo;
 
 
+    private Date firstMeetTime;
+
+    private Long referrerId;
+
+    private Long systemUserId; // 匹配到的系统用户ID(如有)
+
     private Date createdAt;
     private Date createdAt;
 
 
     // 非持久化:角色名
     // 非持久化:角色名

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

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

+ 78 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -3,10 +3,16 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.CfRateTier;
 import com.etotem.cfc.entity.CfTransferRecord;
 import com.etotem.cfc.entity.CfTransferRecord;
+import com.etotem.cfc.entity.OctopusEffectKeyPerson;
+import com.etotem.cfc.entity.OctopusEffectRecord;
+import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.CfTransferRecordMapper;
 import com.etotem.cfc.mapper.CfTransferRecordMapper;
+import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
+import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
+import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -49,6 +55,12 @@ public class CfCommissionService {
     private SysConfigService sysConfigService;
     private SysConfigService sysConfigService;
     @Resource
     @Resource
     private ProductMapper productMapper;
     private ProductMapper productMapper;
+    @Resource
+    private OctopusKeyPersonMapper octopusKeyPersonMapper;
+    @Resource
+    private OctopusEffectRecordMapper octopusEffectRecordMapper;
+    @Resource
+    private OctopusEffectKeyPersonMapper octopusEffectKeyPersonMapper;
 
 
     /**
     /**
      * 通用分佣(双返):当前人 + 推荐人
      * 通用分佣(双返):当前人 + 推荐人
@@ -148,6 +160,8 @@ public class CfCommissionService {
                 platformPointsService.earn(referrerId, share, "referral_dist", orderId,
                 platformPointsService.earn(referrerId, share, "referral_dist", orderId,
                         "推荐分润:" + orderType);
                         "推荐分润:" + orderType);
                 record(referrerId, null, referrer.getFamilyId(), share, "allocate", "referral_dist", orderId, "推荐分润");
                 record(referrerId, null, referrer.getFamilyId(), share, "allocate", "referral_dist", orderId, "推荐分润");
+                // 自动创建感恩记录:邀请人的被邀请人购买后,记录为关键人关系
+                autoCreateGratitudeEffect(referrerId, buyerUserId, share, orderId);
             } catch (Exception e) {
             } catch (Exception e) {
                 log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
                 log.error("推荐人分润失败: referrer={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
             }
             }
@@ -199,4 +213,68 @@ public class CfCommissionService {
             log.warn("写流转记录失败: {}", e.getMessage());
             log.warn("写流转记录失败: {}", e.getMessage());
         }
         }
     }
     }
+
+    /**
+     * 当邀请人的被邀请人(系统用户)消费产生 CF 时,自动在邀请人侧创建感恩记录。
+     * 将买家作为关键人,CF 金额作为成效金额,角色固定为"其他"。
+     */
+    private void autoCreateGratitudeEffect(Long referrerId, Long buyerUserId, int cfAmount, Long orderId) {
+        try {
+            // 1. 查找或创建买家为关键人
+            Long buyerKeyPersonId = findOrCreateBuyerKeyPerson(referrerId, buyerUserId);
+            if (buyerKeyPersonId == null) return;
+
+            // 2. 创建 OctopusEffectRecord
+            OctopusEffectRecord effect = new OctopusEffectRecord();
+            effect.setUserId(referrerId);
+            effect.setMemberOrderId(orderId);
+            effect.setEffectType(4); // 新成交
+            effect.setEffectAmount(java.math.BigDecimal.valueOf(cfAmount));
+            effect.setEffectDesc("邀请人消费返CF:" + cfAmount + "分");
+            effect.setStatus(1);
+            effect.setCreatedAt(new Date());
+            octopusEffectRecordMapper.insert(effect);
+
+            // 3. 关联关键人
+            OctopusEffectKeyPerson rel = new OctopusEffectKeyPerson();
+            rel.setEffectId(effect.getId());
+            rel.setKeyPersonId(buyerKeyPersonId);
+            rel.setRole(3); // 其他
+            rel.setCreatedAt(new Date());
+            octopusEffectKeyPersonMapper.insert(rel);
+        } catch (Exception e) {
+            log.warn("自动创建感恩记录失败: referrerId={}, orderId={}, err={}", referrerId, orderId, e.getMessage());
+        }
+    }
+
+    private Long findOrCreateBuyerKeyPerson(Long referrerId, Long buyerUserId) {
+        // 查找是否已有关键人关联到该 buyerUserId
+        List<OctopusKeyPerson> existing = octopusKeyPersonMapper.selectList(
+                new LambdaQueryWrapper<OctopusKeyPerson>()
+                        .eq(OctopusKeyPerson::getUserId, referrerId)
+                        .eq(OctopusKeyPerson::getContactInfo, resolveBuyerPhone(buyerUserId)));
+        if (!existing.isEmpty()) return existing.get(0).getId();
+
+        // 查找用户信息
+        User buyer = userMapper.selectById(buyerUserId);
+        if (buyer == null) return null;
+
+        // 创建关键人
+        OctopusKeyPerson kp = new OctopusKeyPerson();
+        kp.setUserId(referrerId);
+        kp.setName(buyer.getNickname() != null ? buyer.getNickname() : "用户" + buyerUserId);
+        kp.setContactInfo(buyer.getPhone());
+        kp.setCreatedAt(new Date());
+        octopusKeyPersonMapper.insert(kp);
+        return kp.getId();
+    }
+
+    private String resolveBuyerPhone(Long buyerUserId) {
+        try {
+            User u = userMapper.selectById(buyerUserId);
+            return u != null ? u.getPhone() : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
 }
 }

+ 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.*;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.service.NotificationDispatchService;
 
 
 /**
 /**
  * 家庭能量沙盘 — 核心计算服务
  * 家庭能量沙盘 — 核心计算服务
@@ -34,6 +35,9 @@ public class EnergyService {
     @Resource
     @Resource
     private FamilyMemberMapper familyMemberMapper;
     private FamilyMemberMapper familyMemberMapper;
 
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     @Resource
     @Resource
     private TaskMapper taskMapper;
     private TaskMapper taskMapper;
 
 
@@ -1551,6 +1555,8 @@ public class EnergyService {
             EnergyDimension dim = getDimById(entry.getKey());
             EnergyDimension dim = getDimById(entry.getKey());
             if (dim != null) result.put(dim.getCode(), entry.getValue());
             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;
         return result;
     }
     }
 
 
@@ -1654,7 +1660,8 @@ public class EnergyService {
         elog.setDescription(reason);
         elog.setDescription(reason);
         elog.setCreatedAt(new Date());
         elog.setCreatedAt(new Date());
         energyLogMapper.insert(elog);
         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();
         return balance.getBalance();
     }
     }
 
 
@@ -1837,6 +1844,76 @@ public class EnergyService {
         return result != -1;
         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);
+    }
+
     // ==================== 辅助方法 ====================
     // ==================== 辅助方法 ====================
 
 
     /**
     /**

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuideFamilyService.java

@@ -116,6 +116,25 @@ public class GuideFamilyService {
         return gf;
         return gf;
     }
     }
 
 
+    /**
+     * 规划师解除绑定(仅允许解除属于自己的绑定)
+     */
+    public GuideFamily unbindByGuide(Long id, Long guideId) {
+        GuideFamily gf = guideFamilyMapper.selectOne(
+            new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<GuideFamily>()
+                .eq("id", id)
+                .eq("guide_id", guideId)
+                .eq("status", "binding")
+        );
+        if (gf == null) {
+            throw new RuntimeException("绑定关系不存在或已解除");
+        }
+        gf.setStatus("cancelled");
+        gf.setUpdatedAt(new Date());
+        guideFamilyMapper.updateById(gf);
+        return gf;
+    }
+
     /**
     /**
      * 解除绑定
      * 解除绑定
      */
      */

+ 94 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -3,12 +3,16 @@ package com.etotem.cfc.service;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.entity.OctopusKeyPerson;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -25,6 +29,12 @@ public class KeyPersonService {
     private OctopusKeyPersonMapper keyPersonMapper;
     private OctopusKeyPersonMapper keyPersonMapper;
     @Resource
     @Resource
     private OctopusEffectKeyPersonMapper ekpMapper;
     private OctopusEffectKeyPersonMapper ekpMapper;
+    @Resource
+    private UserMapper userMapper;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+    @Resource
+    private EnergyService energyService;
 
 
     // ========== 1. 盘点项目 ==========
     // ========== 1. 盘点项目 ==========
 
 
@@ -174,6 +184,21 @@ public class KeyPersonService {
                 ekpMapper.insert(rel);
                 ekpMapper.insert(rel);
             }
             }
         }
         }
+        // 行维度能量奖励:每填一条感恩记录 +1,逢5条再额外+1
+        try {
+            Long newCount = effectRecordMapper.selectCount(
+                    new LambdaQueryWrapper<OctopusEffectRecord>().eq(OctopusEffectRecord::getUserId, userId));
+            int bonus = 1;
+            if (newCount != null && newCount % 5 == 0) bonus++;
+            // 查找当前用户的 family_member 记录作为 memberId
+            List<FamilyMember> members = familyMemberMapper.selectList(
+                    new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, userId).last("LIMIT 1"));
+            if (!members.isEmpty()) {
+                energyService.awardEnergyByCode(members.get(0).getId(), "action", bonus, "填写感恩记录奖励");
+            }
+        } catch (Exception e) {
+            log.warn("发放感恩记录能量奖励失败 userId={}: {}", userId, e.getMessage());
+        }
         return Result.success(r);
         return Result.success(r);
     }
     }
 
 
@@ -198,6 +223,13 @@ public class KeyPersonService {
                         .orderByDesc(OctopusKeyPerson::getCreatedAt)
                         .orderByDesc(OctopusKeyPerson::getCreatedAt)
         );
         );
         List<Map<String, Object>> list = new ArrayList<>();
         List<Map<String, Object>> list = new ArrayList<>();
+        Map<Long, String> refNameMap = new HashMap<>();
+        for (OctopusKeyPerson kp : kps) {
+            if (kp.getReferrerId() != null) {
+                OctopusKeyPerson ref = keyPersonMapper.selectById(kp.getReferrerId());
+                if (ref != null) refNameMap.put(kp.getReferrerId(), ref.getName());
+            }
+        }
         for (OctopusKeyPerson kp : kps) {
         for (OctopusKeyPerson kp : kps) {
             Map<String, Object> m = new LinkedHashMap<>();
             Map<String, Object> m = new LinkedHashMap<>();
             m.put("id", kp.getId());
             m.put("id", kp.getId());
@@ -206,18 +238,73 @@ public class KeyPersonService {
             m.put("department", kp.getDepartment());
             m.put("department", kp.getDepartment());
             m.put("title", kp.getTitle());
             m.put("title", kp.getTitle());
             m.put("firstMeetScene", kp.getFirstMeetScene());
             m.put("firstMeetScene", kp.getFirstMeetScene());
+            m.put("firstMeetTime", kp.getFirstMeetTime() != null ? kp.getFirstMeetTime().toString() : "");
             m.put("knowWay", kp.getKnowWay());
             m.put("knowWay", kp.getKnowWay());
             m.put("contactInfo", kp.getContactInfo());
             m.put("contactInfo", kp.getContactInfo());
+            m.put("referrerId", kp.getReferrerId());
+            m.put("referrerName", kp.getReferrerId() != null ? refNameMap.get(kp.getReferrerId()) : "");
+            m.put("systemUserId", kp.getSystemUserId());
+            m.put("matched", kp.getSystemUserId() != null);
             m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
             m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
             list.add(m);
             list.add(m);
         }
         }
         return Result.success(list);
         return Result.success(list);
     }
     }
 
 
-    public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String organization,
+    /** 通过手机号匹配系统内用户(用于关键人绑定真实用户) */
+    public Result<Map<String, Object>> matchKeyPersonByPhone(Long userId, String phone) {
+        if (phone == null || phone.trim().isEmpty()) {
+            return Result.error("手机号不能为空");
+        }
+        phone = phone.trim();
+        // 1. 查 users 表
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>().eq(User::getPhone, phone).last("LIMIT 1")
+        );
+        User matchedUser = (users != null && !users.isEmpty()) ? users.get(0) : null;
+        Map<String, Object> data = new LinkedHashMap<>();
+        if (matchedUser != null) {
+            data.put("userId", matchedUser.getId());
+            data.put("nickname", matchedUser.getNickname());
+            data.put("realName", matchedUser.getRealName());
+            data.put("avatar", matchedUser.getAvatar());
+            data.put("familyId", matchedUser.getFamilyId());
+            data.put("phone", matchedUser.getPhone());
+            // 尝试通过家庭成员表找到 memberId(能力图/能量发放需要)
+            if (matchedUser.getFamilyId() != null) {
+                List<FamilyMember> members = familyMemberMapper.selectList(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getFamilyId, matchedUser.getFamilyId())
+                                .eq(FamilyMember::getUserId, matchedUser.getId())
+                                .last("LIMIT 1")
+                );
+                if (members != null && !members.isEmpty()) {
+                    data.put("memberId", members.get(0).getId());
+                    data.put("memberName", members.get(0).getNickname());
+                }
+            }
+            data.put("systemUserId", matchedUser.getId());
+        }
+        data.put("matched", matchedUser != null);
+        return Result.success(data);
+    }
+
+    /** 将关键人与系统用户绑定(持久化匹配状态) */
+    public Result<OctopusKeyPerson> bindSystemUser(Long userId, Long keyPersonId, Long systemUserId) {
+        OctopusKeyPerson kp = keyPersonMapper.selectById(keyPersonId);
+        if (kp == null || !kp.getUserId().equals(userId)) {
+            return Result.error("关键人不存在或无权操作");
+        }
+        kp.setSystemUserId(systemUserId);
+        keyPersonMapper.updateById(kp);
+        return Result.success(kp);
+    }
+
+public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String organization,
                                                  String department, String title,
                                                  String department, String title,
-                                                 String firstMeetScene, String knowWay,
-                                                 String contactInfo) {
+                                                 String firstMeetScene, String firstMeetTime,
+                                                 String knowWay, String contactInfo,
+                                                 Long referrerId) {
         if (name == null || name.trim().isEmpty()) {
         if (name == null || name.trim().isEmpty()) {
             return Result.error("关键人姓名不能为空");
             return Result.error("关键人姓名不能为空");
         }
         }
@@ -228,8 +315,10 @@ public class KeyPersonService {
         kp.setDepartment(department != null ? department.trim() : null);
         kp.setDepartment(department != null ? department.trim() : null);
         kp.setTitle(title != null ? title.trim() : null);
         kp.setTitle(title != null ? title.trim() : null);
         kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
         kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
+        kp.setFirstMeetTime(firstMeetTime != null ? java.sql.Timestamp.valueOf(firstMeetTime) : null);
         kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
         kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
         kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
         kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
+        kp.setReferrerId(referrerId);
         kp.setCreatedAt(new Date());
         kp.setCreatedAt(new Date());
         keyPersonMapper.insert(kp);
         keyPersonMapper.insert(kp);
         return Result.success(kp);
         return Result.success(kp);
@@ -292,6 +381,8 @@ public class KeyPersonService {
             m.put("contactInfo", kp.getContactInfo());
             m.put("contactInfo", kp.getContactInfo());
             m.put("role", roleMap.get(kpId));
             m.put("role", roleMap.get(kpId));
             m.put("roleName", roleName(roleMap.get(kpId)));
             m.put("roleName", roleName(roleMap.get(kpId)));
+            m.put("systemUserId", kp.getSystemUserId());
+            m.put("matched", kp.getSystemUserId() != null);
             m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
             m.put("createdAt", kp.getCreatedAt() != null ? kp.getCreatedAt().toString() : "");
             list.add(m);
             list.add(m);
         }
         }

+ 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.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
+import lombok.extern.slf4j.Slf4j;
+import com.etotem.cfc.service.NotificationDispatchService;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Map;
 
 
+@Slf4j
 @Service
 @Service
 public class PlatformPointsService {
 public class PlatformPointsService {
 
 
@@ -36,6 +39,9 @@ public class PlatformPointsService {
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     /** 获取或创建用户的 CF 值余额记录 */
     /** 获取或创建用户的 CF 值余额记录 */
     private UserPlatformBalance getOrCreate(Long userId) {
     private UserPlatformBalance getOrCreate(Long userId) {
         UserPlatformBalance balance = balanceMapper.selectOne(
         UserPlatformBalance balance = balanceMapper.selectOne(
@@ -109,6 +115,7 @@ public class PlatformPointsService {
         try {
         try {
             writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
             writeLog(userId, b.getFamilyId(), TYPE_EARN, amount, safeInt(b.getAvailable()),
                     refType, refId, remark);
                     refType, refId, remark);
+            sendCfNotification(userId, TYPE_EARN, amount, remark);
         } catch (DuplicateKeyException e) {
         } catch (DuplicateKeyException e) {
             throw new RuntimeException("重复发放拦截: userId=" + userId + ", refType=" + refType + ", refId=" + refId, e);
             throw new RuntimeException("重复发放拦截: userId=" + userId + ", refType=" + refType + ", refId=" + refId, e);
         }
         }
@@ -131,6 +138,7 @@ public class PlatformPointsService {
         UserPlatformBalance b = getOrCreate(userId);
         UserPlatformBalance b = getOrCreate(userId);
         writeLog(userId, b.getFamilyId(), TYPE_SPEND, amount, safeInt(b.getAvailable()),
         writeLog(userId, b.getFamilyId(), TYPE_SPEND, amount, safeInt(b.getAvailable()),
                 refType, refId, remark);
                 refType, refId, remark);
+        sendCfNotification(userId, TYPE_SPEND, amount, remark);
     }
     }
 
 
     /** 提现申请:可用 → 冻结 */
     /** 提现申请:可用 → 冻结 */
@@ -216,4 +224,28 @@ public class PlatformPointsService {
                         .eq(PlatformBalanceLog::getUserId, userId)
                         .eq(PlatformBalanceLog::getUserId, userId)
                         .orderByDesc(PlatformBalanceLog::getCreatedAt));
                         .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.FamilyMemberMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.NotificationDispatchService;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
@@ -18,7 +19,11 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Map;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
+import lombok.extern.slf4j.Slf4j;
 
 
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 @Service
 @Service
 public class PointsService implements PointsServiceInterface {
 public class PointsService implements PointsServiceInterface {
 
 
@@ -31,6 +36,9 @@ public class PointsService implements PointsServiceInterface {
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
     public Map<String, Object> getBalance(Long memberId) {
     public Map<String, Object> getBalance(Long memberId) {
         FamilyMember child = familyMemberMapper.selectById(memberId);
         FamilyMember child = familyMemberMapper.selectById(memberId);
         if (child == null) {
         if (child == null) {
@@ -88,6 +96,7 @@ public class PointsService implements PointsServiceInterface {
         log.setDescription(reason);
         log.setDescription(reason);
         log.setCreatedAt(new Date());
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
         pointsLogMapper.insert(log);
+        try { sendPointsNotification(memberId, "adjust", amount, reason); } catch (Exception e) { log.warn("points notification failed", e); }
 
 
         Map<String, Object> result = new HashMap<>();
         Map<String, Object> result = new HashMap<>();
         result.put("newBalance", newBalance);
         result.put("newBalance", newBalance);
@@ -120,6 +129,7 @@ public class PointsService implements PointsServiceInterface {
         log.setDescription(reason);
         log.setDescription(reason);
         log.setCreatedAt(new Date());
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
         pointsLogMapper.insert(log);
+        try { sendPointsNotification(memberId, "cf_earn", amount, reason); } catch (Exception e) { log.warn("cf notification failed", e); }
 
 
         return newCfPoints;
         return newCfPoints;
     }
     }
@@ -177,6 +187,7 @@ public class PointsService implements PointsServiceInterface {
         log.setDescription(reason);
         log.setDescription(reason);
         log.setCreatedAt(new Date());
         log.setCreatedAt(new Date());
         pointsLogMapper.insert(log);
         pointsLogMapper.insert(log);
+        try { sendPointsNotification(memberId, "cf_spend", -amount, reason); } catch (Exception e) { log.warn("cf deduction notification failed", e); }
 
 
         return newCfPoints;
         return newCfPoints;
     }
     }
@@ -243,4 +254,32 @@ public class PointsService implements PointsServiceInterface {
         user.setUpdatedAt(new Date());
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         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/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -558,7 +558,7 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<HealthPlan> w = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
         com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<HealthPlan> w = new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
         w.eq("family_id", familyId);
         w.eq("family_id", familyId);
         w.in("status", "draft", "pending_review");
         w.in("status", "draft", "pending_review");
-        if (teacherId != null) w.eq("teacher_id", teacherId);
+        if (teacherId != null) w.and(q -> q.eq("teacher_id", teacherId).or().isNull("teacher_id"));
         w.orderByDesc("created_at");
         w.orderByDesc("created_at");
         return healthPlanMapper.selectList(w);
         return healthPlanMapper.selectList(w);
     }
     }

+ 4 - 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 (
 CREATE TABLE IF NOT EXISTS user_notifications (
   id BIGINT AUTO_INCREMENT PRIMARY KEY,
   id BIGINT AUTO_INCREMENT PRIMARY KEY,
   user_id BIGINT NOT NULL COMMENT '接收用户ID',
   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,
   title VARCHAR(200) NOT NULL,
   content VARCHAR(1000) DEFAULT '',
   content VARCHAR(1000) DEFAULT '',
   ref_type VARCHAR(30) DEFAULT '' COMMENT '关联对象类型: report/package/task',
   ref_type VARCHAR(30) DEFAULT '' COMMENT '关联对象类型: report/package/task',
@@ -5382,6 +5382,9 @@ CREATE TABLE IF NOT EXISTS octopus_key_person (
     first_meet_scene VARCHAR(128) COMMENT '初识场合',
     first_meet_scene VARCHAR(128) COMMENT '初识场合',
     know_way VARCHAR(255) COMMENT '如何认识(认识路径)',
     know_way VARCHAR(255) COMMENT '如何认识(认识路径)',
     contact_info VARCHAR(255) COMMENT '联系方式(可选)',
     contact_info VARCHAR(255) COMMENT '联系方式(可选)',
+    first_meet_time DATETIME DEFAULT NULL COMMENT '相识具体时间',
+    referrer_id BIGINT DEFAULT NULL COMMENT '介绍人关键人ID(指向同用户另一条关键人)',
+    system_user_id BIGINT DEFAULT NULL COMMENT '匹配到的系统用户ID(如有)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     INDEX idx_okp_user (user_id),
     INDEX idx_okp_user (user_id),
     INDEX idx_okp_org (organization)
     INDEX idx_okp_org (organization)

+ 2 - 2
cfc-frontend/pages.json

@@ -1792,9 +1792,9 @@
           }
           }
         },
         },
         {
         {
-          "path": "share",
+          "path": "message-history",
           "style": {
           "style": {
-            "navigationBarTitleText": "分享推广码"
+            "navigationBarTitleText": "消息历史"
           }
           }
         }
         }
       ]
       ]

+ 17 - 3
cfc-frontend/pages/action-detail/components/OctopusDiagram.vue

@@ -21,9 +21,10 @@
         </view>
         </view>
         <view class="popup-keypersons" v-if="keyPersons[selectedEffect.id] && keyPersons[selectedEffect.id].length > 0">
         <view class="popup-keypersons" v-if="keyPersons[selectedEffect.id] && keyPersons[selectedEffect.id].length > 0">
           <text class="popup-title">关键人</text>
           <text class="popup-title">关键人</text>
-          <text class="kp-item" v-for="kp in keyPersons[selectedEffect.id]" :key="kp.id">
-            {{ kp.name }} · {{ kp.title || kp.organization || '' }}
-          </text>
+          <view class="kp-item-row" v-for="kp in keyPersons[selectedEffect.id]" :key="kp.id">
+            <text class="kp-item">{{ kp.name }} · {{ kp.title || kp.organization || '' }}</text>
+            <text class="kp-sys-badge" v-if="kp.matched">✓ 系统用户</text>
+          </view>
         </view>
         </view>
         <view class="popup-empty" v-else>
         <view class="popup-empty" v-else>
           <text class="empty-text">暂无关键人,点击添加</text>
           <text class="empty-text">暂无关键人,点击添加</text>
@@ -663,6 +664,19 @@ export default {
   display: block;
   display: block;
   margin-bottom: 4rpx;
   margin-bottom: 4rpx;
 }
 }
+.kp-item-row {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+.kp-sys-badge {
+  font-size: 18rpx;
+  color: #10B981;
+  background: #ECFDF5;
+  padding: 2rpx 10rpx;
+  border-radius: 6rpx;
+  flex-shrink: 0;
+}
 .popup-empty {
 .popup-empty {
   padding: 8rpx 0;
   padding: 8rpx 0;
 }
 }

+ 102 - 2
cfc-frontend/pages/action-detail/octopus-add-effect.vue

@@ -44,6 +44,9 @@
             <text class="kp-name">{{ kp.name }}</text>
             <text class="kp-name">{{ kp.name }}</text>
             <text class="kp-org">{{ kp.organization || kp.title || '' }}</text>
             <text class="kp-org">{{ kp.organization || kp.title || '' }}</text>
           </view>
           </view>
+          <!-- 已有联系方式 → 可匹配系统用户 -->
+          <text class="kp-match-btn" v-if="kp.contactInfo" @click.stop="onMatchByPhone(kp)">匹配用户</text>
+          <text class="kp-sys-badge" v-if="kp.matched">✓ 系统用户</text>
           <!-- 角色选择(选中时显示) -->
           <!-- 角色选择(选中时显示) -->
           <view class="kp-role" v-if="selectedMap[kp.id]" @click.stop>
           <view class="kp-role" v-if="selectedMap[kp.id]" @click.stop>
             <text class="role-tag" :class="{active: roleMap[kp.id] === 1}" @click="setRole(kp.id, 1)">引荐</text>
             <text class="role-tag" :class="{active: roleMap[kp.id] === 1}" @click="setRole(kp.id, 1)">引荐</text>
@@ -55,6 +58,16 @@
       <text class="empty-hint" v-else>暂无关键人,点击「+ 新建」添加</text>
       <text class="empty-hint" v-else>暂无关键人,点击「+ 新建」添加</text>
     </view>
     </view>
 
 
+    <!-- 按手机号快速建关键人 -->
+    <view class="form-section">
+      <text class="section-label">手机号匹配(选填)</text>
+      <view class="phone-match-row">
+        <input class="phone-input" v-model="matchPhone" placeholder="输入手机号自动匹配" maxlength="11" type="number" />
+        <text class="phone-match-btn" @tap="doPhoneMatch">匹配</text>
+      </view>
+      <text class="phone-match-result" v-if="matchResult">{{ matchResult }}</text>
+    </view>
+
     <!-- 提交按钮 -->
     <!-- 提交按钮 -->
     <view class="submit-bar">
     <view class="submit-bar">
       <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存感恩记录</button>
       <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存感恩记录</button>
@@ -63,7 +76,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { addOctopusEffect, getOctopusKeyPersons } from '../../utils/api.js'
+import { addOctopusEffect, getOctopusKeyPersons, matchOctopusKeyPersonByPhone, bindOctopusKeyPersonSystemUser } from '../../utils/api.js'
 
 
 export default {
 export default {
   data() {
   data() {
@@ -81,7 +94,9 @@ export default {
       keyPersons: [],
       keyPersons: [],
       selectedMap: {},   // kpId -> true
       selectedMap: {},   // kpId -> true
       roleMap: {},        // kpId -> role (1引荐/2决策/3其他)
       roleMap: {},        // kpId -> role (1引荐/2决策/3其他)
-      _onLoadFired: false
+      _onLoadFired: false,
+      matchPhone: '',
+      matchResult: ''
     }
     }
   },
   },
   computed: {
   computed: {
@@ -109,6 +124,43 @@ export default {
         }
         }
       } catch (e) { /* 静默 */ }
       } catch (e) { /* 静默 */ }
     },
     },
+    /** 按关键人联系方式匹配系统用户 */
+    onMatchByPhone: function(kp) {
+      if (!kp.contactInfo) return
+      this.doPhoneMatchInternal(kp.contactInfo, kp.id)
+    },
+    /** 手机号匹配输入框触发 */
+    doPhoneMatch: function() {
+      var phone = this.matchPhone.trim()
+      if (!phone) {
+        this.matchResult = '请输入手机号'
+        return
+      }
+      this.matchResult = '匹配中...'
+      this.doPhoneMatchInternal(phone, null)
+    },
+    async doPhoneMatchInternal(phone, kpId) {
+      try {
+        var res = await matchOctopusKeyPersonByPhone({ phone: phone })
+        if (res.code === 200 && res.data && res.data.matched) {
+          var matchedName = res.data.nickname || res.data.realName || res.data.memberName || ''
+          var extra = res.data.memberId ? ' (memberId:' + res.data.memberId + ')' : ''
+          this.matchResult = '已匹配: ' + matchedName + extra
+          if (kpId && res.data.systemUserId) {
+            // 持久化:将关键人与系统用户绑定
+            var bindRes = await bindOctopusKeyPersonSystemUser({ keyPersonId: kpId, systemUserId: res.data.systemUserId })
+            if (bindRes.code === 200) {
+              this.$set(this.keyPersons[this.keyPersons.findIndex(function(k) { return k.id === kpId })], 'matched', true)
+              this.matchResult = '已匹配并保存: ' + matchedName + extra
+            }
+          }
+        } else {
+          this.matchResult = res.message || '未找到该手机号对应的用户'
+        }
+      } catch (e) {
+        this.matchResult = '匹配失败,请稍后重试'
+      }
+    },
     goAddKeyPerson() {
     goAddKeyPerson() {
       uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person' })
       uni.navigateTo({ url: '/pages/action-detail/octopus-add-key-person' })
     },
     },
@@ -249,6 +301,23 @@ export default {
   font-size: 22rpx;
   font-size: 22rpx;
   color: #999;
   color: #999;
 }
 }
+.kp-match-btn {
+  font-size: 22rpx;
+  color: #0EA5E9;
+  padding: 4rpx 12rpx;
+  border: 1rpx solid #0EA5E9;
+  border-radius: 8rpx;
+  background: #F0F9FF;
+  flex-shrink: 0;
+}
+.kp-sys-badge {
+  font-size: 20rpx;
+  color: #10B981;
+  background: #ECFDF5;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+  margin-left: 8rpx;
+}
 .kp-role {
 .kp-role {
   display: flex;
   display: flex;
   margin-top: 12rpx;
   margin-top: 12rpx;
@@ -306,4 +375,35 @@ export default {
   font-size: 28rpx;
   font-size: 28rpx;
   color: #ccc;
   color: #ccc;
 }
 }
+/* 手机号匹配 */
+.phone-match-row {
+  display: flex;
+  align-items: center;
+  gap: 16rpx;
+}
+.phone-input {
+  flex: 1;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.phone-match-btn {
+  padding: 0 28rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: #0EA5E9;
+  color: #fff;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  flex-shrink: 0;
+}
+.phone-match-result {
+  font-size: 24rpx;
+  color: #0EA5E9;
+  margin-top: 12rpx;
+  display: block;
+}
 </style>
 </style>

+ 160 - 4
cfc-frontend/pages/action-detail/octopus-add-key-person.vue

@@ -29,11 +29,28 @@
       <input class="name-input" v-model="firstMeetScene" placeholder="如:家长会、行业峰会、朋友介绍" maxlength="50" />
       <input class="name-input" v-model="firstMeetScene" placeholder="如:家长会、行业峰会、朋友介绍" maxlength="50" />
     </view>
     </view>
 
 
+    <view class="form-section">
+      <text class="section-label">相识时间(选填)</text>
+      <picker mode="date" :value="firstMeetTime" @change="onDateChange">
+        <view class="form-picker">{{ firstMeetTime || '选择日期' }}</view>
+      </picker>
+    </view>
+
     <view class="form-section">
     <view class="form-section">
       <text class="section-label">如何认识</text>
       <text class="section-label">如何认识</text>
       <textarea class="desc-input" v-model="knowWay" placeholder="如:孩子班主任介绍认识、同事推荐" maxlength="200"></textarea>
       <textarea class="desc-input" v-model="knowWay" placeholder="如:孩子班主任介绍认识、同事推荐" maxlength="200"></textarea>
     </view>
     </view>
 
 
+    <view class="form-section">
+      <text class="section-label">介绍人(选填,从现有关键人中选择)</text>
+      <view class="referrer-select" v-if="existingKeyPersons.length > 0" @tap="showReferrerPicker = true">
+        <text class="referrer-selected" v-if="referrerId">{{ getReferrerName(referrerId) }}</text>
+        <text class="referrer-placeholder" v-else>选择介绍人...</text>
+        <text class="picker-arrow">›</text>
+      </view>
+      <text class="empty-referrer" v-else>暂无其他关键人可作为介绍人</text>
+    </view>
+
     <view class="form-section">
     <view class="form-section">
       <text class="section-label">联系方式(选填)</text>
       <text class="section-label">联系方式(选填)</text>
       <input class="name-input" v-model="contactInfo" placeholder="电话/微信/邮箱" maxlength="50" />
       <input class="name-input" v-model="contactInfo" placeholder="电话/微信/邮箱" maxlength="50" />
@@ -42,11 +59,28 @@
     <view class="submit-bar">
     <view class="submit-bar">
       <button class="btn-submit" @click="onSubmit" :disabled="!name.trim()">保存关键人</button>
       <button class="btn-submit" @click="onSubmit" :disabled="!name.trim()">保存关键人</button>
     </view>
     </view>
+
+    <!-- 介绍人选择弹窗 -->
+    <view class="referrer-modal-mask" v-if="showReferrerPicker" @tap="showReferrerPicker = false">
+      <view class="referrer-modal" @tap.stop>
+        <view class="referrer-modal-header">
+          <text class="referrer-modal-title">选择介绍人</text>
+          <text class="referrer-modal-close" @tap="showReferrerPicker = false">✕</text>
+        </view>
+        <view class="referrer-option" @tap="selectReferrer(null)">
+          <text>不选择</text>
+        </view>
+        <view class="referrer-option" v-for="kp in existingKeyPersons" :key="kp.id" @tap="selectReferrer(kp.id)">
+          <text class="referrer-option-name">{{ kp.name }}</text>
+          <text class="referrer-option-org">{{ kp.organization || kp.title || '' }}</text>
+        </view>
+      </view>
+    </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { addOctopusKeyPerson } from '../../utils/api.js'
+import { addOctopusKeyPerson, getOctopusKeyPersons } from '../../utils/api.js'
 
 
 export default {
 export default {
   data() {
   data() {
@@ -56,8 +90,12 @@ export default {
       department: '',
       department: '',
       title: '',
       title: '',
       firstMeetScene: '',
       firstMeetScene: '',
+      firstMeetTime: '',
       knowWay: '',
       knowWay: '',
-      contactInfo: ''
+      contactInfo: '',
+      referrerId: null,
+      existingKeyPersons: [],
+      showReferrerPicker: false
     }
     }
   },
   },
   onLoad(options) {
   onLoad(options) {
@@ -67,8 +105,30 @@ export default {
         Object.assign(this.$data, prefill)
         Object.assign(this.$data, prefill)
       } catch (e) { /* 忽略 */ }
       } catch (e) { /* 忽略 */ }
     }
     }
+    this.loadExistingKeyPersons()
   },
   },
   methods: {
   methods: {
+    async loadExistingKeyPersons() {
+      try {
+        var res = await getOctopusKeyPersons()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.existingKeyPersons = res.data
+        }
+      } catch (e) { /* 静默 */ }
+    },
+    getReferrerName(id) {
+      for (var i = 0; i < this.existingKeyPersons.length; i++) {
+        if (this.existingKeyPersons[i].id === id) return this.existingKeyPersons[i].name
+      }
+      return ''
+    },
+    selectReferrer(id) {
+      this.referrerId = id
+      this.showReferrerPicker = false
+    },
+    onDateChange(e) {
+      this.firstMeetTime = e.detail.value
+    },
     async onSubmit() {
     async onSubmit() {
       if (!this.name.trim()) {
       if (!this.name.trim()) {
         uni.showToast({ title: '请填写姓名', icon: 'none' })
         uni.showToast({ title: '请填写姓名', icon: 'none' })
@@ -80,8 +140,10 @@ export default {
         department: this.department.trim(),
         department: this.department.trim(),
         title: this.title.trim(),
         title: this.title.trim(),
         firstMeetScene: this.firstMeetScene.trim(),
         firstMeetScene: this.firstMeetScene.trim(),
+        firstMeetTime: this.firstMeetTime,
         knowWay: this.knowWay.trim(),
         knowWay: this.knowWay.trim(),
-        contactInfo: this.contactInfo.trim()
+        contactInfo: this.contactInfo.trim(),
+        referrerId: this.referrerId || null
       }
       }
       try {
       try {
         var res = await addOctopusKeyPerson(data)
         var res = await addOctopusKeyPerson(data)
@@ -147,6 +209,100 @@ export default {
   font-size: 28rpx;
   font-size: 28rpx;
   box-sizing: border-box;
   box-sizing: border-box;
 }
 }
+.form-picker {
+  width: 100%;
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #333;
+  display: flex;
+  align-items: center;
+  box-sizing: border-box;
+}
+/* 介绍人选择器 */
+.referrer-select {
+  height: 88rpx;
+  background: #fafafa;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  box-sizing: border-box;
+}
+.referrer-selected {
+  font-size: 28rpx;
+  color: #F97316;
+}
+.referrer-placeholder {
+  font-size: 28rpx;
+  color: #ccc;
+}
+.picker-arrow {
+  font-size: 32rpx;
+  color: #999;
+}
+.empty-referrer {
+  font-size: 26rpx;
+  color: #999;
+}
+/* 介绍人弹窗 */
+.referrer-modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.4);
+  display: flex;
+  align-items: flex-end;
+  z-index: 100;
+}
+.referrer-modal {
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  width: 100%;
+  max-height: 60vh;
+  overflow-y: auto;
+  padding-bottom: env(safe-area-inset-bottom);
+}
+.referrer-modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 24rpx 28rpx;
+  border-bottom: 1rpx solid #E5E7EB;
+  position: sticky;
+  top: 0;
+  background: #fff;
+}
+.referrer-modal-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.referrer-modal-close {
+  font-size: 32rpx;
+  color: #999;
+}
+.referrer-option {
+  padding: 24rpx 28rpx;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.referrer-option-name {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  display: block;
+}
+.referrer-option-org {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
 .submit-bar {
 .submit-bar {
   margin-top: 40rpx;
   margin-top: 40rpx;
 }
 }
@@ -162,4 +318,4 @@ export default {
 .btn-submit[disabled] {
 .btn-submit[disabled] {
   background: #ccc;
   background: #ccc;
 }
 }
-</style>
+</style>

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

@@ -998,17 +998,23 @@ selectedMembers: function() {
           return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
           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 = {
       var saveData = {
         memberIds: self.selectedMemberIds.join(','),
         memberIds: self.selectedMemberIds.join(','),
         dimensions: 'body',
         dimensions: 'body',
         goal: self.userGoal.trim(),
         goal: self.userGoal.trim(),
         planContent: self.buildPlanContent(),
         planContent: self.buildPlanContent(),
         planJson: planJson,
         planJson: planJson,
-        status: 'published'
+        status: 'pending_review',
+        teacherId: teacherId
       }
       }
       saveHealthPlan(saveData).then(function(res) {
       saveHealthPlan(saveData).then(function(res) {
         self.loading = false
         self.loading = false
         if (res && res.code === 200) {
         if (res && res.code === 200) {
+          // 清理本次规划师选择缓存,避免下次生成方案时误用上一次的 teacherId
+          uni.removeStorageSync('healthPlan_teacherId')
+          uni.removeStorageSync('healthPlan_serviceType')
           uni.showToast({ title: '方案已生成', icon: 'none', duration: 2000 })
           uni.showToast({ title: '方案已生成', icon: 'none', duration: 2000 })
           setTimeout(function() { uni.navigateBack() }, 1200)
           setTimeout(function() { uni.navigateBack() }, 1200)
           self.taskSubmitted = true
           self.taskSubmitted = true

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

@@ -103,6 +103,14 @@
       :notices="notices"
       :notices="notices"
       :showHeader="true" />
       :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">
     <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 OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
 import ConfigurableGuide from '../../components/ConfigurableGuide.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 { findAppByKey, getShortcutableApps } from '../../utils/app-library.js'
 import nav from '../../utils/nav.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import config from '@/config.js'
@@ -517,7 +525,15 @@ membershipDays: 0,
       quickApps: [],
       quickApps: [],
       myShortcuts: [],
       myShortcuts: [],
       appUsage: {},
       appUsage: {},
-      quickLoaded: false
+      quickLoaded: false,
+
+      // 能量/积分/CF值变更通知
+      energyMessages: [],
+      pointsMessages: [],
+      cfMessages: [],
+      showEnergyBadge: false,
+      showPointsBadge: false,
+      showCfBadge: false
 
 
 
 
     }
     }
@@ -674,6 +690,31 @@ title: '添加家庭成员',
         btn: '逛一逛',
         btn: '逛一逛',
         color: 'gray'
         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) {
   onLoad(options) {
@@ -851,6 +892,7 @@ title: '添加家庭成员',
       this.loadFamilyData()
       this.loadFamilyData()
       this.loadReferralCode()
       this.loadReferralCode()
       this.loadQuickApps()
       this.loadQuickApps()
+      this.loadEnergyPointsCFChanges()
     },
     },
     // 合并原 loadFamilyMembers + loadChildren:仅调用一次 getFamilyMemberList
     // 合并原 loadFamilyMembers + loadChildren:仅调用一次 getFamilyMemberList
     loadFamilyData: function() {
     loadFamilyData: function() {
@@ -1092,6 +1134,282 @@ title: '添加家庭成员',
         }
         }
       }).catch(function() {})
       }).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) {
     quickCompleteTask: function(task) {
       var self = this
       var self = this
       var memberId = (task && task.familyMemberId) || self.currentChildId || uni.getStorageSync('currentMemberId')
       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; }
 .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>
 </style>

+ 270 - 3
cfc-frontend/pages/profile-main/profile.vue

@@ -35,7 +35,6 @@
               <text class="nickname">{{ nickname || '未设置昵称' }}</text>
               <text class="nickname">{{ nickname || '未设置昵称' }}</text>
               <text v-if="roleLabel" class="role-tag">{{ roleLabel }}</text>
               <text v-if="roleLabel" class="role-tag">{{ roleLabel }}</text>
             </view>
             </view>
-            <text v-if="myReferrer && myReferrer.nickname" class="referrer-line">推荐人:{{ myReferrer.nickname }}</text>
             <view class="user-sub" v-if="memberLevel">
             <view class="user-sub" v-if="memberLevel">
               <view class="level-badge" :class="'level-' + memberLevel">{{ memberLevelText }}</view>
               <view class="level-badge" :class="'level-' + memberLevel">{{ memberLevelText }}</view>
               <text v-if="isLifetime" class="days-left">永久有效</text>
               <text v-if="isLifetime" class="days-left">永久有效</text>
@@ -58,7 +57,12 @@
         </view>
         </view>
       </view>
       </view>
 
 
-      <!-- 家庭成员速览 -->
+      <!-- 我的推广码浮动按钮 -->
+      <view class="share-qr-float" @click="showShareQr">
+        <text class="share-qr-icon">📱</text>
+      </view>
+
+      <!-- 家族成员速览 -->
       <view class="family-strip" v-if="familyMembers.length > 0">
       <view class="family-strip" v-if="familyMembers.length > 0">
         <view class="family-strip-header">
         <view class="family-strip-header">
           <text class="family-strip-title">我的家庭</text>
           <text class="family-strip-title">我的家庭</text>
@@ -261,6 +265,37 @@
       </view>
       </view>
 
 
       </template>
       </template>
+    <view class="share-qr-modal" v-if="showShareQrModal" @click="closeShareQrModal">
+      <view class="modal-mask" @click.stop />
+      <view class="modal-qr" @click.stop>
+        <view class="modal-qr-header">
+          <text class="modal-qr-title">我的分享海报</text>
+          <text class="modal-qr-close" @click="closeShareQrModal">✕</text>
+        </view>
+        <view class="modal-qr-content">
+          <view v-if="qrLoading" class="qrcode-loading">
+            <text class="loading-spinner">⏳</text>
+            <text class="loading-text">生成中...</text>
+          </view>
+          <view v-else="!qrBase64" class="qrcode-empty">
+            <text>暂无二维码</text>
+          </view>
+          <view v-else class="qrcode-image">
+            <image class="qrcode-img" :src="qrBase64" mode="aspectFit"></image>
+          </view>
+          <view class="qrcode-info">
+            <text class="qrcode-text">邀请码: {{ referralCode }}</text>
+            <text class="qrcode-desc">扫码注册,好友成为您的推荐人</text>
+          </view>
+        </view>
+        <view class="modal-qr-actions">
+          <view class="action-btn share-btn" @click="shareToWechat">
+            <text class="share-icon">📤</text>
+            <text class="share-text">分享给朋友</text>
+          </view>
+        </view>
+      </view>
+    </view>
     <FloatingAvatar />
     <FloatingAvatar />
   </view>
   </view>
 </template>
 </template>
@@ -273,7 +308,8 @@ import {
   verifyPassword, getPointsBalance, getUserInfo,
   verifyPassword, getPointsBalance, getUserInfo,
   cartCount, getOrderCounts, getReferralSummary,
   cartCount, getOrderCounts, getReferralSummary,
   getProfileStats, getMyReferrer, getTeamConsumption,
   getProfileStats, getMyReferrer, getTeamConsumption,
-  getTeacherInfo, getTeacherPendingTasks, getTeacherUnreadCount
+  getTeacherInfo, getTeacherPendingTasks, getTeacherUnreadCount,
+  getReferralCode, extractReferralCode, getShareQrCode
 } from '../../utils/api.js'
 } from '../../utils/api.js'
 
 
 export default {
 export default {
@@ -311,6 +347,12 @@ export default {
       teacherStatus: 'pending',
       teacherStatus: 'pending',
       profileProgress: 0,
       profileProgress: 0,
       myReferrer: null,
       myReferrer: null,
+      // 分享推广码
+      showShareQrModal: false,
+      referralCode: '',
+      qrBase64: '',
+      qrLoading: false,
+      referralCodeLoaded: false,
     }
     }
   },
   },
   computed: {
   computed: {
@@ -505,6 +547,72 @@ export default {
         console.error('获取推荐人失败', e)
         console.error('获取推荐人失败', e)
       }
       }
     },
     },
+    // ===== 分享推广码 =====
+    showShareQr() {
+      this.showShareQrModal = true
+      if (!this.referralCodeLoaded) {
+        this.loadReferralCode()
+      } else if (this.referralCode && !this.qrBase64) {
+        this.generateQrcode()
+      }
+    },
+    closeShareQrModal() {
+      this.showShareQrModal = false
+    },
+    async loadReferralCode() {
+      if (this.referralCodeLoaded) return
+      var self = this
+      try {
+        var res = await getReferralCode()
+        var code = extractReferralCode(res)
+        self.referralCode = code || ''
+        self.referralCodeLoaded = true
+        if (code) {
+          self.generateQrcode()
+        }
+      } catch (e) {
+        console.error('获取推广码失败', e)
+        self.referralCodeLoaded = true
+      }
+    },
+    generateQrcode() {
+      if (!this.referralCode) return
+      this.qrLoading = true
+      var self = this
+      var scene = 'ref=' + this.referralCode
+      // 首页路径,扫码后进入首页并绑定推荐人
+      getShareQrCode('/pages/index-home/index', scene)
+        .then(function(res) {
+          if (res && res.code === 200 && res.data) {
+            self.qrBase64 = res.data.qrCodeBase64
+          }
+        })
+        .catch(function(e) {
+          console.error('生成二维码失败', e)
+          uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+        })
+        .finally(function() {
+          self.qrLoading = false
+        })
+    },
+    shareToWechat() {
+      if (!this.referralCode) return
+      var path = '/pages/index-home/index?refCode=' + this.referralCode
+      uni.share({
+        provider: 'weixin',
+        scene: { type: 0 },
+        type: 5,
+        imageUrl: this.qrBase64,
+        path: path,
+        title: '浠艾福 — 邀请你加入',
+        success: function() {
+          uni.showToast({ title: '分享成功', icon: 'success' })
+        },
+        fail: function() {
+          uni.showToast({ title: '分享失败', icon: 'none' })
+        }
+      })
+    },
     goToServiceCenter() {
     goToServiceCenter() {
       uni.navigateTo({ url: '/pages/service-center/index' })
       uni.navigateTo({ url: '/pages/service-center/index' })
     },
     },
@@ -1170,4 +1278,163 @@ export default {
   color: #FF4D4F;
   color: #FF4D4F;
   font-weight: 500;
   font-weight: 500;
 }
 }
+
+/* 分享推广码浮动按钮 */
+.share-qr-float {
+  position: fixed;
+  top: 40rpx;
+  right: 30rpx;
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.15);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99;
+}
+.share-qr-icon {
+  font-size: 40rpx;
+  line-height: 1;
+}
+
+/* 分享海报弹窗 */
+.share-qr-modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.modal-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+}
+.modal-qr {
+  position: relative;
+  width: 70%;
+  max-width: 600rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  padding: 30rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.modal-qr-header {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.modal-qr-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.modal-qr-close {
+  font-size: 32rpx;
+  color: #999;
+  padding: 10rpx;
+}
+.modal-qr-content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+}
+.qrcode-loading,
+.qrcode-empty {
+  width: 340rpx;
+  height: 340rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  background: #FAFAFA;
+  border-radius: 16rpx;
+  border: 2rpx dashed #E5E7EB;
+}
+.loading-spinner {
+  font-size: 48rpx;
+  margin-bottom: 16rpx;
+}
+.loading-text {
+  font-size: 24rpx;
+  color: #94A3B8;
+}
+.qrcode-empty text {
+  font-size: 24rpx;
+  color: #94A3B8;
+}
+.qrcode-image {
+  width: 340rpx;
+  height: 340rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #fff;
+  border-radius: 16rpx;
+  border: 1rpx solid #E5E7EB;
+  overflow: hidden;
+}
+.qrcode-img {
+  width: 330rpx;
+  height: 330rpx;
+}
+.qrcode-info {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 20rpx;
+  gap: 8rpx;
+}
+.qrcode-text {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.qrcode-desc {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.modal-qr-actions {
+  width: 100%;
+  display: flex;
+  gap: 20rpx;
+  margin-top: 24rpx;
+}
+.action-btn {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8rpx;
+  padding: 24rpx;
+  border-radius: 16rpx;
+  font-size: 24rpx;
+  font-weight: bold;
+}
+.share-btn {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #fff;
+  width: 100%;
+}
+.share-icon {
+  font-size: 40rpx;
+}
+.share-text {
+  font-size: 24rpx;
+  font-weight: bold;
+}
 </style>
 </style>

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

@@ -4,6 +4,10 @@
       <!-- ===== 个人 ===== -->
       <!-- ===== 个人 ===== -->
       <view class="menu-group">
       <view class="menu-group">
         <view class="menu-group-title">── 个人 ──</view>
         <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">
         <view class="menu-item" v-if="role === 'parent'" @click="goToFamilyMembers">
           <text>👨‍👩‍👧‍👦 家庭成员</text>
           <text>👨‍👩‍👧‍👦 家庭成员</text>
           <text class="arrow">›</text>
           <text class="arrow">›</text>
@@ -74,14 +78,6 @@
           <text>👁 对家庭成员可见</text>
           <text>👁 对家庭成员可见</text>
           <switch :checked="showToFamily === 1" @change="onShowToFamilyChange" color="#5B9BD5" />
           <switch :checked="showToFamily === 1" @change="onShowToFamilyChange" color="#5B9BD5" />
         </view>
         </view>
-        <view class="menu-item" @click="goToShare">
-          <text>📤 分享推广码</text>
-          <text class="arrow">›</text>
-        </view>
-        <view class="menu-item" @click="showInviteActionSheet">
-          <text>📨 邀请加入</text>
-          <text class="arrow">›</text>
-        </view>
         <view class="menu-item" @click="logout">
         <view class="menu-item" @click="logout">
           <text>🚪 退出登录</text>
           <text>🚪 退出登录</text>
           <text class="arrow">›</text>
           <text class="arrow">›</text>
@@ -187,6 +183,9 @@ var res = await getReferralCode()
     goToDailyTasks() {
     goToDailyTasks() {
       uni.navigateTo({ url: '/pages/tasks/daily-tasks' })
       uni.navigateTo({ url: '/pages/tasks/daily-tasks' })
     },
     },
+    goToMessageHistory() {
+      uni.navigateTo({ url: '/pages/profile/message-history' })
+    },
     async onShowToFamilyChange(e) {
     async onShowToFamilyChange(e) {
       const val = e.detail.value ? 1 : 0
       const val = e.detail.value ? 1 : 0
       const uid = this.$store.state.userId || uni.getStorageSync('userId')
       const uid = this.$store.state.userId || uni.getStorageSync('userId')
@@ -219,54 +218,6 @@ var res = await getReferralCode()
     goToAfterSales() {
     goToAfterSales() {
       uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
       uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
     },
     },
-    
-    goToShare() {
-      uni.navigateTo({ url: '/pages/profile/share' })
-    },
-    showInviteActionSheet() {
-      var self = this
-      var ui = uni.getStorageSync('userInfo') || {}
-      if (!ui.familyId) {
-        ensureFamily().then(function(result) {
-          if (result && result.created) {
-            self.showInviteActionSheet()
-          }
-        })
-        return
-      }
-      const userInfo = ui
-      const items = ['👨‍👩‍👧‍👦 邀请家人', '👨‍🏫 邀请成长规划师']
-      const types = ['family', 'guide']
-      uni.showActionSheet({
-        itemList: items,
-        success: (res) => {
-          self.inviteGenerate(types[res.tapIndex], userInfo)
-        }
-      })
-    },
-    inviteGenerate(type, userInfo) {
-      const inviteTypes = {
-        family: {
-          title: (userInfo && userInfo.nickname ? userInfo.nickname : '家人') + ' 邀请你加入家庭',
-          path: '/pages/login/login?invite_type=family'
-        },
-        guide: {
-          title: (userInfo && userInfo.nickname ? userInfo.nickname : '家长') + ' 邀请你成为家庭成长规划师',
-          path: '/pages/login/login?invite_type=guide'
-        }
-      }
-      const config = inviteTypes[type] || inviteTypes.family
-      var path = config.path
-      if (this.referralCode) {
-        path = path + '&refCode=' + this.referralCode
-      }
-      this.$emit('invite-generate', {
-        title: config.title,
-        path: path,
-        imageUrl: '/static/invite-card.png'
-      })
-      uni.showToast({ title: '点击右上角转发给TA', icon: 'none' })
-    },
     async savePassword() {
     async savePassword() {
       if (this.newPassword.length !== 6) {
       if (this.newPassword.length !== 6) {
         uni.showToast({ title: '请输入6位密码', icon: 'none' })
         uni.showToast({ title: '请输入6位密码', icon: 'none' })

+ 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>

+ 0 - 381
cfc-frontend/pages/profile/share.vue

@@ -1,381 +0,0 @@
-<template>
-  <view class="container">
-    <!-- 未登录 -->
-    <view v-if="!isLoggedIn" class="empty-state">
-      <text class="empty-icon">👤</text>
-      <text class="empty-title">请先登录</text>
-      <text class="empty-desc">登录后即可生成您的专属推广码</text>
-      <button class="btn-login" @click="goLogin">去登录</button>
-    </view>
-
-    <!-- 已登录 -->
-    <view v-else class="content">
-      <!-- 头部 -->
-      <view class="header">
-        <text class="title">分享推广码</text>
-        <text class="subtitle">将二维码分享给好友,扫码注册即成为您的下线</text>
-      </view>
-
-      <!-- 我的推广码 -->
-      <view class="code-card">
-        <view class="code-label">
-          <text class="label-text">我的推广码</text>
-          <view class="copy-btn" @click="copyCode">
-            <text class="copy-icon">📋</text>
-            <text class="copy-text">{{ copied ? '已复制' : '复制' }}</text>
-          </view>
-        </view>
-        <text class="code-value">{{ referralCode || '加载中...' }}</text>
-      </view>
-
-      <!-- 二维码区域 -->
-      <view class="qrcode-section" v-if="qrLoading || qrBase64">
-        <view class="qrcode-label">
-          <text class="label-text">扫码进入首页</text>
-        </view>
-        <view class="qrcode-wrap">
-          <image
-            v-if="qrBase64"
-            class="qrcode-img"
-            :src="qrBase64"
-            mode="aspectFit"
-          />
-          <view v-else class="qrcode-loading">
-            <text class="loading-spinner">⏳</text>
-            <text class="loading-text">生成中...</text>
-          </view>
-        </view>
-      </view>
-
-      <!-- 操作按钮 -->
-      <view class="actions" v-if="qrBase64">
-        <view class="action-btn save-btn" @click="saveQrcode">
-          <text class="action-icon">💾</text>
-          <text class="action-text">保存图片</text>
-        </view>
-        <view class="action-btn share-btn" @click="shareToWeixin">
-          <text class="action-icon">📤</text>
-          <text class="action-text">分享给朋友</text>
-        </view>
-      </view>
-
-      <!-- 说明 -->
-      <view class="tips">
-        <view class="tip-item">
-          <text class="tip-icon">💡</text>
-          <text class="tip-text">好友扫码后自动绑定推荐关系</text>
-        </view>
-        <view class="tip-item">
-          <text class="tip-icon">💡</text>
-          <text class="tip-text">好友注册后您将获得积分奖励</text>
-        </view>
-        <view class="tip-item">
-          <text class="tip-icon">💡</text>
-          <text class="tip-text">推广码可长期使用,无过期时间</text>
-        </view>
-      </view>
-    </view>
-  </view>
-</template>
-
-<script>
-import { getReferralCode, extractReferralCode, getShareQrCode } from '../../utils/api.js'
-
-export default {
-  data() {
-    return {
-      referralCode: '',
-      qrBase64: '',
-      qrLoading: false,
-      copied: false
-    }
-  },
-  computed: {
-    isLoggedIn() {
-      return !!uni.getStorageSync('token')
-    }
-  },
-  onLoad() {
-    this.loadReferralCode()
-    this._onLoadFired = true
-  },
-  onShow() {
-    if (this._onLoadFired) {
-      this._onLoadFired = false
-    } else if (this.isLoggedIn && !this.referralCode) {
-      this.loadReferralCode()
-    }
-  },
-  methods: {
-    async loadReferralCode() {
-      if (!this.isLoggedIn) return
-      try {
-        var res = await getReferralCode()
-        var code = extractReferralCode(res)
-        this.referralCode = code || ''
-        if (this.referralCode) {
-          this.generateQrcode()
-        }
-      } catch (e) {
-        console.error('获取推广码失败', e)
-      }
-    },
-    generateQrcode() {
-      if (!this.referralCode) return
-      this.qrLoading = true
-      var scene = 'ref=' + this.referralCode
-      // 首页路径,扫码后进入首页并绑定推荐人
-      getShareQrCode('/pages/index-home/index', scene)
-        .then(function(res) {
-          if (res && res.code === 200 && res.data) {
-            this.qrBase64 = res.data.qrCodeBase64
-          }
-        }.bind(this))
-        .catch(function(e) {
-          console.error('生成二维码失败', e)
-          uni.showToast({ title: '生成失败,请重试', icon: 'none' })
-        })
-        .finally(function() {
-          this.qrLoading = false
-        }.bind(this))
-    },
-    copyCode() {
-      if (!this.referralCode) return
-      this.copied = true
-      uni.setClipboardData({
-        data: this.referralCode,
-        success: function() {
-          uni.showToast({ title: '推广码已复制', icon: 'success' })
-          setTimeout(function() {
-            this.copied = false
-          }.bind(this), 2000)
-        }.bind(this)
-      })
-    },
-    saveQrcode() {
-      if (!this.qrBase64) return
-      // 将 base64 转为临时文件再保存
-      var self = this
-      var rawBase64 = this.qrBase64.indexOf('base64,') >= 0
-        ? this.qrBase64.split('base64,')[1]
-        : this.qrBase64
-      var fsm = uni.getFileSystemManager()
-      var tempPath = wx.env.USER_DATA_PATH + '/share_qrcode_' + Date.now() + '.png'
-      fsm.writeFileSync(tempPath, rawBase64, 'base64')
-      uni.saveImageToPhotosAlbum({
-        filePath: tempPath,
-        success: function() {
-          uni.showToast({ title: '保存成功', icon: 'success' })
-        },
-        fail: function() {
-          uni.showToast({ title: '保存失败', icon: 'none' })
-        }
-      })
-    },
-    shareToWeixin() {
-      if (!this.referralCode) return
-      // 通过 uni.share 分享小程序卡片
-      var path = '/pages/index-home/index?refCode=' + this.referralCode
-      uni.share({
-        provider: 'weixin',
-        scene: { type: 0 },
-        type: 5,
-        imageUrl: this.qrBase64,
-        path: path,
-        title: '浠艾福 — 邀请你加入',
-        success: function() {
-          uni.showToast({ title: '分享成功', icon: 'success' })
-        },
-        fail: function() {
-          uni.showToast({ title: '分享失败', icon: 'none' })
-        }
-      })
-    },
-    goLogin() {
-      uni.navigateTo({ url: '/pages/login/login' })
-    }
-  }
-}
-</script>
-
-<style scoped>
-.container {
-  min-height: 100vh;
-  background: #F8F4EF;
-  padding-bottom: 60rpx;
-}
-
-/* 未登录 */
-.empty-state {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  min-height: 70vh;
-  padding: 60rpx;
-}
-.empty-icon { font-size: 100rpx; margin-bottom: 24rpx; }
-.empty-title { font-size: 36rpx; font-weight: bold; color: #333; margin-bottom: 12rpx; }
-.empty-desc { font-size: 26rpx; color: #999; margin-bottom: 40rpx; }
-.btn-login {
-  width: 60%;
-  height: 80rpx;
-  line-height: 80rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
-  color: #fff;
-  font-size: 30rpx;
-  font-weight: bold;
-  border-radius: 40rpx;
-  text-align: center;
-  border: none;
-}
-.btn-login::after { border: none; }
-
-/* 内容区 */
-.content { padding: 0 30rpx; }
-
-.header {
-  text-align: center;
-  padding: 60rpx 0 40rpx;
-}
-.title {
-  font-size: 44rpx;
-  font-weight: bold;
-  color: #1E293B;
-  display: block;
-  margin-bottom: 12rpx;
-}
-.subtitle {
-  font-size: 26rpx;
-  color: #94A3B8;
-  display: block;
-}
-
-/* 推广码卡片 */
-.code-card {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 32rpx;
-  margin-bottom: 30rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.code-label {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.label-text {
-  font-size: 26rpx;
-  color: #94A3B8;
-}
-.copy-btn {
-  display: flex;
-  align-items: center;
-  gap: 8rpx;
-  padding: 8rpx 20rpx;
-  background: #FFF7ED;
-  border-radius: 20rpx;
-  border: 1rpx solid #FED7AA;
-}
-.copy-icon { font-size: 24rpx; }
-.copy-text { font-size: 24rpx; color: #F97316; }
-.code-value {
-  font-size: 48rpx;
-  font-weight: bold;
-  color: #F97316;
-  font-family: monospace;
-  text-align: center;
-  display: block;
-  letter-spacing: 8rpx;
-}
-
-/* 二维码区域 */
-.qrcode-section {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 32rpx;
-  margin-bottom: 30rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-.qrcode-label {
-  margin-bottom: 24rpx;
-}
-.qrcode-wrap {
-  width: 360rpx;
-  height: 360rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #FAFAFA;
-  border-radius: 16rpx;
-  border: 2rpx dashed #E5E7EB;
-}
-.qrcode-img {
-  width: 340rpx;
-  height: 340rpx;
-  border-radius: 12rpx;
-}
-.qrcode-loading {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 16rpx;
-}
-.loading-spinner { font-size: 48rpx; }
-.loading-text { font-size: 24rpx; color: #94A3B8; }
-
-/* 操作按钮 */
-.actions {
-  display: flex;
-  gap: 20rpx;
-  margin-bottom: 30rpx;
-}
-.action-btn {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 12rpx;
-  padding: 32rpx;
-  border-radius: 20rpx;
-}
-.save-btn {
-  background: #fff;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.share-btn {
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-}
-.action-icon { font-size: 48rpx; }
-.action-text {
-  font-size: 26rpx;
-  font-weight: bold;
-}
-.save-btn .action-text { color: #333; }
-.share-btn .action-text { color: #fff; }
-
-/* 说明 */
-.tips {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 32rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.tip-item {
-  display: flex;
-  align-items: flex-start;
-  gap: 12rpx;
-  margin-bottom: 16rpx;
-}
-.tip-item:last-child { margin-bottom: 0; }
-.tip-icon { font-size: 28rpx; flex-shrink: 0; }
-.tip-text {
-  font-size: 24rpx;
-  color: #64748B;
-  line-height: 1.6;
-}
-</style>

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

@@ -171,7 +171,7 @@ export default {
         cancelText: '关闭',
         cancelText: '关闭',
         success: (res) => {
         success: (res) => {
           if (res.confirm && status === 'rejected') {
           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>
 </template>
 
 
 <script>
 <script>
-import { getTeamMembers, getTeamStats } from '../../utils/api.js'
+import { getTeamMembers, getTeamStats, generateTeamInviteCode } from '../../utils/api.js'
 
 
 export default {
 export default {
   data() {
   data() {
@@ -91,16 +91,29 @@ export default {
         url: `/pages/teacher/member-detail?id=${member.id}`
         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 generateTeamInviteCode()
+        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' })
+      }
     }
     }
   }
   }
 }
 }

+ 12 - 3
cfc-frontend/utils/api.js

@@ -896,9 +896,9 @@ export const confirmBind = (data) => {
   return request('/api/guide/bind/confirm', 'POST', data)
   return request('/api/guide/bind/confirm', 'POST', data)
 }
 }
 
 
-// 解除绑定
+// 规划师解除家庭绑定
 export const unbindFamily = (id) => {
 export const unbindFamily = (id) => {
-  return request(`/api/guide/bind/${id}`, 'POST', { _method: 'DELETE' })
+  return request('/api/guide/families/unbind', 'POST', { id })
 }
 }
 
 
 // 创建任务模板
 // 创建任务模板
@@ -982,11 +982,18 @@ export const registerGuide = (data) => {
   return request('/api/guide/register', 'POST', data)
   return request('/api/guide/register', 'POST', data)
 }
 }
 
 
-// 生成邀请码
+// 生成邀请码(规划师管理端,与 getInviteCodes 同属 GuideManagementController)
 export const generateInviteCode = () => {
 export const generateInviteCode = () => {
   return request('/api/guide/invite-code/generate', 'POST')
   return request('/api/guide/invite-code/generate', 'POST')
 }
 }
 
 
+// 生成团队邀请码(成长规划师团队端 GuideTeamController)
+// 与管理端邀请码不同:GuideTeamController.getTeamMembers 按 User.teacherNo == 邀请码 反查成员,
+// 故团队页必须用本端点生成,否则生成的码与成员查询不匹配
+export const generateTeamInviteCode = () => {
+  return request('/api/guide/team/invite-code/generate', 'POST')
+}
+
 // 获取邀请码列表
 // 获取邀请码列表
 export const getInviteCodes = () => {
 export const getInviteCodes = () => {
   return request('/api/guide/invite-codes', 'POST', {})
   return request('/api/guide/invite-codes', 'POST', {})
@@ -2476,6 +2483,8 @@ export const deleteOctopusEffect = (data) => request('/api/octopus/effect/delete
 
 
 export const addOctopusKeyPerson = (data) => request('/api/octopus/key-person/add', 'POST', data)
 export const addOctopusKeyPerson = (data) => request('/api/octopus/key-person/add', 'POST', data)
 export const getOctopusKeyPersons = (data) => request('/api/octopus/key-person/list', 'POST', data)
 export const getOctopusKeyPersons = (data) => request('/api/octopus/key-person/list', 'POST', data)
+export const matchOctopusKeyPersonByPhone = (data) => request('/api/octopus/key-person/match-by-phone', 'POST', data)
+export const bindOctopusKeyPersonSystemUser = (data) => request('/api/octopus/key-person/bind-system-user', 'POST', data)
 export const updateOctopusKeyPerson = (data) => request('/api/octopus/key-person/update', 'POST', data)
 export const updateOctopusKeyPerson = (data) => request('/api/octopus/key-person/update', 'POST', data)
 export const deleteOctopusKeyPerson = (data) => request('/api/octopus/key-person/delete', 'POST', data)
 export const deleteOctopusKeyPerson = (data) => request('/api/octopus/key-person/delete', 'POST', data)
 export const getOctopusEffectKeyPersons = (data) => request('/api/octopus/effect/key-persons', 'POST', data)
 export const getOctopusEffectKeyPersons = (data) => request('/api/octopus/effect/key-persons', 'POST', data)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-5e340d1dd652a30f93ce0c9490da7f14d27d7d25
+82499b9a2f145cb7746cabe585adee7fdeaf44b2

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,85 @@
 
 
 ---
 ---
 
 
+## v1.0.1492 (2026-10-04)
+
+### 重构
+- remove copy button from share modal, only share poster
+
+
+## v1.0.1491 (2026-10-04)
+
+### 新功能
+- 章鱼图感恩记录增强 - 关键人新字段/手机号匹配/能量奖励/自动感恩记录
+
+### 其他
+-    - first_meet_time 相识具体时间(date picker)
+-    - referrer_id 介绍人(从现有关键人下拉选择,可为空仅可选)
+-    - system_user_id 匹配到的系统用户ID(持久化匹配状态)
+- 2. 手机号匹配系统用户:
+-    - 新增 POST /api/octopus/key-person/match-by-phone
+-    - 新增 POST /api/octopus/key-person/bind-system-user(持久化绑定)
+-    - 前端 octopus-add-effect.vue 增加手机号匹配输入框 + KP 匹配按钮 + '✓ 系统用户'徽标
+-    - 章鱼图弹窗关键人列表显示系统用户徽标
+- 3. 行维度能量奖励:
+-    - 每填一条感恩记录 +1 点行维度能量,逢5条再+1(KeyPersonService.addEffect)
+- 4. 自动感恩记录(CF 触发):
+-    - CfCommissionService 推荐人分润成功后自动创建 OctopusEffectRecord
+-    - 将买家自动建为邀请人关键人,CF金额作为成效金额
+- 5. 数据库迁移342:octopus_key_person 添加 first_meet_time/referrer_id/system_user_id 列(schema.sql 同步)
+- 验证:后端编译通过(仅剩既有 McpController 缺失 McpAuthService 的预存错误);前端语法 OK;API 去重审计 0 error
+- 
+
+
+## v1.0.1490 (2026-10-04)
+
+### 重构
+- move share QR to floating button on profile page - remove invite share menu item from ProfileMenu - remove share.vue page and its registration - add floating QR code button in profile-main/profile.vue - add share poster modal with QR code, copy and share actions - add support for ref code on homepage onLoad
+
+
+## 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)
 ## v1.0.1488 (2026-10-03)
 
 
 ### Bug 修复
 ### Bug 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1488
+> 当前版本: v1.0.1492
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,85 @@
 
 
 ---
 ---
 
 
+## v1.0.1492 (2026-10-04)
+
+### 重构
+- remove copy button from share modal, only share poster
+
+
+## v1.0.1491 (2026-10-04)
+
+### 新功能
+- 章鱼图感恩记录增强 - 关键人新字段/手机号匹配/能量奖励/自动感恩记录
+
+### 其他
+-    - first_meet_time 相识具体时间(date picker)
+-    - referrer_id 介绍人(从现有关键人下拉选择,可为空仅可选)
+-    - system_user_id 匹配到的系统用户ID(持久化匹配状态)
+- 2. 手机号匹配系统用户:
+-    - 新增 POST /api/octopus/key-person/match-by-phone
+-    - 新增 POST /api/octopus/key-person/bind-system-user(持久化绑定)
+-    - 前端 octopus-add-effect.vue 增加手机号匹配输入框 + KP 匹配按钮 + '✓ 系统用户'徽标
+-    - 章鱼图弹窗关键人列表显示系统用户徽标
+- 3. 行维度能量奖励:
+-    - 每填一条感恩记录 +1 点行维度能量,逢5条再+1(KeyPersonService.addEffect)
+- 4. 自动感恩记录(CF 触发):
+-    - CfCommissionService 推荐人分润成功后自动创建 OctopusEffectRecord
+-    - 将买家自动建为邀请人关键人,CF金额作为成效金额
+- 5. 数据库迁移342:octopus_key_person 添加 first_meet_time/referrer_id/system_user_id 列(schema.sql 同步)
+- 验证:后端编译通过(仅剩既有 McpController 缺失 McpAuthService 的预存错误);前端语法 OK;API 去重审计 0 error
+- 
+
+
+## v1.0.1490 (2026-10-04)
+
+### 重构
+- move share QR to floating button on profile page - remove invite share menu item from ProfileMenu - remove share.vue page and its registration - add floating QR code button in profile-main/profile.vue - add share poster modal with QR code, copy and share actions - add support for ref code on homepage onLoad
+
+
+## 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)
 ## v1.0.1488 (2026-10-03)
 
 
 ### Bug 修复
 ### Bug 修复

+ 1 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -374,7 +374,7 @@
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
-| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`) | 后台接口参考文档 |
+| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则)) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |

+ 87 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -77,6 +77,9 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `OctopusController` | `/api/octopus` | 章鱼图(帮助记录/触手聚合) | — |
 | `OctopusController` | `/api/octopus` | 章鱼图(帮助记录/触手聚合) | — |
 | `PearlController` | `/api/pearl` | 珍珠图(25 类社会连接聚合 + 互动/提醒/优先级) | — |
 | `PearlController` | `/api/pearl` | 珍珠图(25 类社会连接聚合 + 互动/提醒/优先级) | — |
 | `AbilityController` | `/api/ability` | 能力图(SKILL 帮助记录聚合为能力节点) | — |
 | `AbilityController` | `/api/ability` | 能力图(SKILL 帮助记录聚合为能力节点) | — |
+| `ServiceRoleApplicationController` | `/api/service-role` | 统一服务角色申请(多角色合一 + 管理端审批) | — |
+| `GuideTeamController` | `/api/guide/team` | 规划师团队/邀请码/上下级 | — |
+| `GuideFamilyTaskController` | `/api/guide/families` | 规划师侧家庭任务管理 + 家庭解绑 | — |
 
 
 ---
 ---
 
 
@@ -1397,4 +1400,87 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 
 ---
 ---
 
 
-*文档最后更新:2026-09-29*
+### 4.44 统一服务角色申请(`/api/service-role/*`)
+
+多角色(规划师/营养师/管家/供应商/内容管理员/活动策划)统一申请入口,一次提交可含多个角色,管理端审批通过后回写 `users.roles` 并按角色初始化附属记录。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/service-role/apply` | 提交服务角色申请(`roles` 必填) |
+| `POST /api/service-role/my-apply` | 查询我的最新申请记录 |
+| `POST /api/service-role/upload-image` | 上传身份证/资质证书图片(multipart,字段名 `file`) |
+| `POST /api/service-role/admin/list` | 管理端:申请列表(`status` 查询参数,默认 `0`) |
+| `POST /api/service-role/admin/approve` | 管理端:批准(`{ id }`) |
+| `POST /api/service-role/admin/reject` | 管理端:驳回(`{ id, reason }`) |
+
+> **权限**:`admin/list`、`admin/approve`、`admin/reject` 三个端点校验 `@RequestAttribute("role")`,仅 `role=admin` 或 `role=operator` 可访问(与 `AdminHealthPlanController` 审核类端点口径一致),其他角色返回「无权限」。前端菜单由 `perm: 'audit:service-role'` 控制。
+
+**状态语义**(`service_role_applications.status`)
+
+| 值 | 含义 |
+|----|------|
+| `0` | 待审核(同一 userId 仅允许一条待审,重复提交返回「您已提交过申请,请等待审核」) |
+| `1` | 已通过 |
+| `2` | 已驳回(原因存 `reject_reason`) |
+
+**POST `/api/service-role/apply`** 请求体(`ServiceRoleApplyDTO`)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `roles` | List\<String\> | 申请角色,`@NotEmpty` 至少一个 |
+| `idCard` | String | 身份证号 |
+| `idCardImages` | List\<String\> | 身份证图片 URL 数组(落库转 JSON 字符串) |
+| `certificateImages` | List\<String\> | 资质证书图片 URL 数组 |
+| `province` / `city` / `district` / `street` | String | 四级地址 |
+| `addressDetail` | String | 详细地址 |
+
+**审批通过后的角色落地规则**(`ServiceRoleApplicationServiceImpl.approve`)
+
+| 申请角色 | 副作用 |
+|----------|--------|
+| `planner` | `guides` 新增一行(`status=active`)+ `users.roles` 追加 `teacher` |
+| `nutritionist` | `users.nutritionist_status=approved` + `roles` 追加 `nutritionist` |
+| `butler` | `users.butler_status=approved` + `butler_profiles` 新增一行 + `roles` 追加 `butler` |
+| `vendor` | `users.vendor_status=approved` + `roles` 追加 `supplier` |
+| `article_admin` | `users.article_admin_status=approved` + `roles` 追加 `knowledge_admin` |
+| `activity_provider` | `users.activity_provider_status=approved` + `roles` 追加 `activity_manager` |
+
+> **注意**:approve/reject 仅对 `status=0` 的记录生效,已处理返回「该申请已处理」。
+> `upload-image` 对图片类型文件先走 `wechatService.checkImage()` 内容安全检测,未通过直接拒绝。
+
+---
+
+### 4.45 规划师解除家庭绑定(`POST /api/guide/families/unbind`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/guide/families/unbind` | 规划师解除自己名下的家庭绑定(请求体 `{ id }`,即 `guide_families.id`) |
+
+- 角色校验:仅 `role=teacher` 可调用,否则返回 `Access denied`。
+- 归属校验:`GuideFamilyService.unbindByGuide()` 按 `id + guide_id + status='binding'` 定位,只能解除属于自己的绑定。
+- 副作用:将 `guide_families.status` 置为 `cancelled`。
+- 家长侧解绑为独立接口 `POST /api/bind/unbind`(按 familyId 定位,并通知规划师)。
+
+> **注意**:前端 `unbindFamily(id)` 需传 `guide_families.id`(绑定记录 ID),不是 `familyId`。
+
+---
+
+### 4.46 规划师方案审核(`/api/health/plan/pending-review/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/health/plan/pending-review/list` | 待审核方案列表(`teacher` 取当前登录人;`admin` 可传 `teacherId` 过滤,不传则返回该家庭全部) |
+| `POST /api/health/plan/pending-review/update` | 编辑方案内容(仅 `status` 为 `draft`/`pending_review` 允许) |
+| `POST /api/health/plan/pending-review/approve` | 审核通过并发布 |
+| `POST /api/health/plan/pending-review/reject` | 驳回 |
+
+- 角色校验:仅 `role=teacher` 或 `role=admin`,且必须携带 `familyId`,否则返回 `familyId不能为空`。
+- **未分配池规则**:`listPendingReviewPlans()` 在 `teacherId` 非空时按 `(teacher_id = ? OR teacher_id IS NULL)` 过滤 —— 规划师既能看到分配给自己的方案,也能看到本家庭内尚未指定规划师的方案(`health_plans.teacher_id` 为 `NULL`),避免用户未选规划师时保存的 `pending_review` 方案成为无人可见的孤儿记录。
+- 入选范围限定 `status IN ('draft','pending_review')`,按 `created_at` 倒序。
+
+> **注意**:`health_plans.teacher_id` 列由迁移 251 建立(`DatabaseInitializer`),`schema.sql` 已同步。
+> 前端 `getPendingReviewPlans()`(`utils/api.js`)目前尚无页面消费,规划师审核页未接通。
+
+---
+
+*文档最后更新:2026-10-04*