Browse Source

Merge remote changes and add seven-dimension cognitive assessment

E2E Test Bot 1 month ago
parent
commit
7136bd4230

+ 22 - 26
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11134,6 +11134,28 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         // 迁移323: dan_assessment_results 添加 language_score 列(游戏化认知测评:语言表达维度)
         ensureColumn("dan_assessment_results", "language_score", "INT COMMENT '语言表达得分'");
         log.info("迁移323: 已确保 language_score 列存在于 dan_assessment_results");
+
+        // 迁移324-325: octopus_effect_record 添加 happened_at 列 + article_reading_records 回填与补列
+        try {
+            jdbcTemplate.execute("ALTER TABLE octopus_effect_record ADD COLUMN happened_at DATETIME COMMENT '成效发生时间'");
+            log.info("迁移324: octopus_effect_record.happened_at 列已添加");
+        } catch (Exception e) {
+            log.warn("迁移324: octopus_effect_record.happened_at 列已存在或添加失败: {}", e.getMessage());
+        }
+
+        try {
+            int updated = jdbcTemplate.update(
+                    "UPDATE article_reading_records r " +
+                    "LEFT JOIN tasks t ON r.task_id = t.id AND t.source_type = 'article' " +
+                    "SET r.article_id = t.source_id " +
+                    "WHERE r.article_id IS NULL AND t.id IS NOT NULL AND t.source_id IS NOT NULL"
+            );
+            log.info("迁移325: 已回填 article_reading_records.article_id {} 条记录", updated);
+        } catch (Exception e) {
+            log.warn("迁移325: 回填 article_reading_records.article_id 失败: {}", e.getMessage());
+        }
+
+        ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
     }
 
     /**
@@ -11210,32 +11232,6 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移312: 写入 gut_flora 指纹规则失败: {}", e.getMessage());
         }
 
-        // 迁移313: octopus_effect_record 添加 happened_at 列(成效发生时间)
-        try {
-            jdbcTemplate.execute("ALTER TABLE octopus_effect_record ADD COLUMN happened_at DATETIME COMMENT '成效发生时间'");
-            log.info("迁移313: octopus_effect_record.happened_at 列已添加");
-        } catch (Exception e) {
-            // 列已存在,忽略
-            log.warn("迁移313: octopus_effect_record.happened_at 列已存在或添加失败: {}", e.getMessage());
-        }
-
-        // 迁移314: 回填 article_reading_records 表中 article_id 为 NULL 的老记录
-        // 通过 task 表关联恢复:record.task_id → task.source_type='article' → task.source_id
-        try {
-            int updated = jdbcTemplate.update(
-                    "UPDATE article_reading_records r " +
-                    "LEFT JOIN tasks t ON r.task_id = t.id AND t.source_type = 'article' " +
-                    "SET r.article_id = t.source_id " +
-                    "WHERE r.article_id IS NULL AND t.id IS NOT NULL AND t.source_id IS NOT NULL"
-            );
-            log.info("迁移314: 已回填 article_reading_records.article_id {} 条记录", updated);
-        } catch (Exception e) {
-            log.warn("迁移314: 回填 article_reading_records.article_id 失败: {}", e.getMessage());
-        }
-
-        // 迁移315: article_reading_records 添加 reading_completed 列(阅读达标后标记,用于推荐过滤)
-        ensureColumn("article_reading_records", "reading_completed", "TINYINT DEFAULT 0 COMMENT '是否已阅读达标(达到最低时长阈值)'");
-
         // 迁移316: 创建 recommendation_rules 表(统一推荐规则配置)
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS recommendation_rules (" +

+ 20 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java

@@ -16,8 +16,11 @@ public class DietRecommendationController {
 
     @PostMapping("/today")
     public Result<Map> getTodayRecommendation(
-            @RequestAttribute("familyId") Long familyId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
             @RequestBody(required = false) Map<String, String> request) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         String date = (request != null) ? request.get("date") : null;
         if (date == null || date.isEmpty()) {
             return Result.error("date不能为空");
@@ -27,30 +30,42 @@ public class DietRecommendationController {
 
     @PostMapping("/generate")
     public Result<Map> generateRecommendation(
-            @RequestAttribute("familyId") Long familyId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
             @RequestBody Map<String, String> request) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         return Result.success(dietRecommendationService.generateRecommendation(familyId, request));
     }
 
     @PostMapping("/update")
     public Result<Void> updateRecommendation(
-            @RequestAttribute("familyId") Long familyId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
             @RequestBody Map<String, Object> request) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         dietRecommendationService.updateRecommendation(familyId, request);
         return Result.success(null);
     }
 
     @PostMapping("/regenerate")
     public Result<Map> regenerateRecommendation(
-            @RequestAttribute("familyId") Long familyId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
             @RequestBody Map<String, Object> request) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         return Result.success(dietRecommendationService.regenerateRecommendation(familyId, request));
     }
 
     @PostMapping("/complete")
     public Result<Void> completeRecommendation(
-            @RequestAttribute("familyId") Long familyId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId,
             @RequestBody Map<String, Object> request) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         dietRecommendationService.completeRecommendation(familyId, request);
         return Result.success(null);
     }

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -179,8 +179,8 @@ public class AiGateway {
                                       Map<String, Object> inputs) {
         if (!enabled || isCircuitOpen()) return null;
 
-        // BASIC 配额检查(仅 BASIC 用户)
-        if (userId != null) {
+        // BASIC 配额检查(仅对真实用户生效;userId=0 为系统级后台任务,如文章自动分类/打标,不受配额限制)
+        if (userId != null && userId != 0L) {
             com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeQuota(userId, "AI_CHAT");
             if (!quotaResult.isSuccess()) {
                 log.info("AI对话配额不足: userId={}, toast={}", userId, quotaResult.getToastMsg());

+ 178 - 60
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -210,7 +210,7 @@ public class FamilyMemberService {
             membershipService.syncMembershipForNewMember(memberUserId, familyId);
         }
 
-        return toFamilyMemberVO(member);
+        return toFamilyMemberVO(member, userId);
     }
 
     /**
@@ -250,7 +250,7 @@ public class FamilyMemberService {
             vo.setSource("family_member");
             vo.setUserId(fm.getUserId());
             vo.setIsAdmin(isAdmin);
-            vo.setNickname(fm.getNickname());
+            vo.setNickname(resolveMemberNickname(fm));
             vo.setPhone(fm.getPhone());
             vo.setAvatar(fm.getAvatar());
             vo.setGender(fm.getGender());
@@ -291,19 +291,13 @@ public class FamilyMemberService {
             vo.setIntimacyScore(fm.getIntimacyScore());
             vo.setCommunicationScore(fm.getCommunicationScore());
             vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
+            // 设置 isSwitchable:同家庭非自己成员均可切换视角(含被邀请的能登录成员)
+            vo.setIsSwitchable(!(viewerMember != null && viewerMember.getId().equals(fm.getId())));
             vo.setLastInteractionAt(fm.getLastInteractionAt());
             vo.setStreakDays(fm.getStreakDays());
             // 兼容旧前端字段
             vo.setMemberId(fm.getId());
-            vo.setName(fm.getNickname());
-            // 设置 isSwitchable:仅无 openid 的不可登录成员可被切换
-            if (fm.getUserId() != null) {
-                User memberUser = userMapper.selectById(fm.getUserId());
-                String openid = memberUser != null ? memberUser.getOpenid() : null;
-                vo.setIsSwitchable(openid == null || openid.isEmpty());
-            } else {
-                vo.setIsSwitchable(true);
-            }
+            vo.setName(resolveMemberNickname(fm));
             result.add(vo);
         }
 
@@ -343,6 +337,7 @@ public class FamilyMemberService {
             vo.setGeneration(0);
             vo.setIsAdmin(isAdmin);
             vo.setIsSelf(p.getId().equals(userId));
+            vo.setIsSwitchable(!p.getId().equals(userId)); // 非自己即可切换(含 source='parent' 段)
             vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);
             vo.setIsSpouse(false);
             vo.setTotalPoints(p.getTotalPoints());
@@ -364,19 +359,42 @@ public class FamilyMemberService {
         // 从 family_members 查找
         FamilyMember member = familyMemberMapper.selectById(memberId);
         if (member != null && member.getFamilyId().equals(user.getFamilyId())) {
-            // 【重构】成员切换校验:仅不可登录成员可被切换
-            User memberUser = member.getUserId() != null ? userMapper.selectById(member.getUserId()) : null;
-            String openid = memberUser != null ? memberUser.getOpenid() : null;
-            boolean canLogin = openid != null && !openid.isEmpty();
-            if (canLogin) {
-                throw new RuntimeException("该成员可通过微信登录,不能切换为TA的身份");
+            // 不能切换到自己
+            if (member.getUserId() != null && member.getUserId().equals(userId)) {
+                throw new RuntimeException("不能切换到自己");
+            }
+            String effectiveRole = computeEffectiveRole(member);
+            // 直接登录的孩子端不能切换到家长端
+            if ("child".equals(user.getRole()) && "parent".equals(effectiveRole)) {
+                throw new RuntimeException("孩子账号不能切换到家长视角");
             }
 
             SwitchMemberVO vo = new SwitchMemberVO();
             vo.setMemberId(member.getId());
             vo.setSource("family_member");
-            vo.setEffectiveRole(computeEffectiveRole(member));
-            vo.setNickname(member.getNickname());
+            vo.setEffectiveRole(effectiveRole);
+            vo.setNickname(resolveMemberNickname(member));
+            vo.setIsSwitchable(true);
+            return vo;
+        }
+
+        // 兼容 source='parent':无 family_members 记录的 users 表家长成员(memberId 即 userId)
+        Long familyId = user.getFamilyId();
+        User targetParent = userMapper.selectById(memberId);
+        if (targetParent != null && familyId.equals(targetParent.getFamilyId())) {
+            // 不能切换到自己
+            if (targetParent.getId().equals(userId)) {
+                throw new RuntimeException("不能切换到自己");
+            }
+            // 直接登录的孩子端不能切换到家长端
+            if ("child".equals(user.getRole())) {
+                throw new RuntimeException("孩子账号不能切换到家长视角");
+            }
+            SwitchMemberVO vo = new SwitchMemberVO();
+            vo.setMemberId(targetParent.getId());
+            vo.setSource("parent");
+            vo.setEffectiveRole("parent");
+            vo.setNickname(targetParent.getNickname());
             vo.setIsSwitchable(true);
             return vo;
         }
@@ -408,18 +426,34 @@ public class FamilyMemberService {
             throw new RuntimeException("只有家庭管理员才能踢出成员");
         }
 
-        // 从 family_members 踢出
+        Long familyId = operator.getFamilyId();
+
+        // 从 family_members 查找
         FamilyMember member = familyMemberMapper.selectById(memberId);
-        if (member == null || !member.getFamilyId().equals(operator.getFamilyId())) {
-            throw new RuntimeException("成员不存在");
-        }
-        // 不能踢出家庭创建者
-        if (member.getUserId() != null && Long.valueOf(member.getUserId()).equals(family.getCreatorId())) {
-            throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+        String memberNickname = null;
+        if (member != null && familyId.equals(member.getFamilyId())) {
+            // 不能踢出家庭创建者
+            if (member.getUserId() != null && Long.valueOf(member.getUserId()).equals(family.getCreatorId())) {
+                throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+            }
+            memberNickname = member.getNickname();
+        } else {
+            // 兼容 source='parent':无 family_members 记录的 users 表家长成员(memberId 即 userId)
+            User targetUser = userMapper.selectById(memberId);
+            member = null;
+            if (targetUser != null && familyId.equals(targetUser.getFamilyId())) {
+                // 不能踢出家庭创建者
+                if (targetUser.getId().equals(family.getCreatorId())) {
+                    throw new RuntimeException("不能踢出家庭创建者,请先转让管理员权限");
+                }
+                memberNickname = targetUser.getNickname();
+            } else {
+                throw new RuntimeException("成员不存在");
+            }
         }
 
-        Long oldFamilyId = member.getFamilyId();
-        Long targetUserId = member.getUserId();
+        Long oldFamilyId = familyId;
+        Long targetUserId = (member != null) ? member.getUserId() : memberId;
 
         // 收回该家庭发放给该成员的人口券
         try {
@@ -428,8 +462,10 @@ public class FamilyMemberService {
             log.warn("收回人口券异常: familyId={}, memberId={}", oldFamilyId, memberId, e);
         }
 
-        // 清理关系记录
-        familyRelationshipService.deleteRelationsForMember(memberId);
+        // 清理关系记录(source='parent' 的 users 表成员无关系记录,跳过)
+        if (member != null) {
+            familyRelationshipService.deleteRelationsForMember(memberId);
+        }
 
         if (targetUserId != null) {
             User targetUser = userMapper.selectById(targetUserId);
@@ -441,19 +477,23 @@ public class FamilyMemberService {
                     if (ownFamilyId == null) {
                         ownFamilyId = createFamilyForUser(targetUser);
                     }
-                    migrateMemberAssets(member, oldFamilyId, ownFamilyId);
-                    member.setFamilyId(ownFamilyId);
-                    member.setStatus("active");
-                    member.setUpdatedAt(new Date());
-                    familyMemberMapper.updateById(member);
+                    if (member != null) {
+                        migrateMemberAssets(member, oldFamilyId, ownFamilyId);
+                        member.setFamilyId(ownFamilyId);
+                        member.setStatus("active");
+                        member.setUpdatedAt(new Date());
+                        familyMemberMapper.updateById(member);
+                    }
                     targetUser.setFamilyId(ownFamilyId);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
                 } else {
-                    // 非微信用户:进入回收箱,资产保留在原家庭记录上
-                    member.setStatus("recycled");
-                    member.setUpdatedAt(new Date());
-                    familyMemberMapper.updateById(member);
+                    // 非微信用户:解除家庭关联,资产保留在原家庭记录上
+                    if (member != null) {
+                        member.setStatus("recycled");
+                        member.setUpdatedAt(new Date());
+                        familyMemberMapper.updateById(member);
+                    }
                     targetUser.setFamilyId(null);
                     targetUser.setUpdatedAt(new Date());
                     userMapper.updateById(targetUser);
@@ -464,7 +504,7 @@ public class FamilyMemberService {
             familyMemberMapper.deleteById(memberId);
         }
 
-        logMemberAction(oldFamilyId, memberId, "kick", member.getNickname(), null, userId);
+        logMemberAction(oldFamilyId, memberId, "kick", memberNickname, null, userId);
 
         // 方案B:成员被踢出后,重新核算原家庭会员身份
         membershipService.reconcileFamilyMembership(oldFamilyId);
@@ -779,9 +819,13 @@ public class FamilyMemberService {
             throw new RuntimeException("用户未加入家庭");
         }
         FamilyMember member = familyMemberMapper.selectById(memberId);
-        if (member == null || !member.getFamilyId().equals(user.getFamilyId())) {
+        if (member != null && !member.getFamilyId().equals(user.getFamilyId())) {
             throw new RuntimeException("成员不存在或不属于您的家庭");
         }
+        // 家长账号(users 表成员)在 family_members 表无记录,走家长更新分支
+        if (member == null) {
+            return updateParentMember(user, memberId, dto);
+        }
 
         // 检查编辑权限:管理员可编辑所有成员;普通用户只能编辑无关联账号的成员
         boolean isAdmin = false;
@@ -826,17 +870,101 @@ public class FamilyMemberService {
         member.setUpdatedAt(new Date());
         familyMemberMapper.updateById(member);
         logMemberAction(member.getFamilyId(), member.getId(), "update", oldNickname, member.getNickname(), userId);
-        return toFamilyMemberVO(member);
+        return toFamilyMemberVO(member, userId);
+    }
+
+    /**
+     * 更新家长账号(users 表成员)的基本资料。
+     * 家长账号只存在于 users 表(family_id + role=parent),无 family_members 记录,
+     * 因此编辑昵称/性别/生日/头像/手机号直接写 users 表;辈分关系仅 family_members 成员支持。
+     */
+    private FamilyMemberVO updateParentMember(User operator, Long memberId, AddFamilyMemberDTO dto) {
+        Family family = familyMapper.selectById(operator.getFamilyId());
+        boolean isAdmin = family != null && Long.valueOf(operator.getId()).equals(family.getCreatorId());
+        if (!isAdmin) {
+            throw new RuntimeException("仅家庭创建者可编辑该成员");
+        }
+        User parent = userMapper.selectById(memberId);
+        if (parent == null
+                || parent.getFamilyId() == null
+                || !parent.getFamilyId().equals(operator.getFamilyId())
+                || !"parent".equals(parent.getRole())) {
+            throw new RuntimeException("成员不存在或不属于您的家庭");
+        }
+        String oldNickname = parent.getNickname();
+        if (dto.getNickname() != null && !dto.getNickname().trim().isEmpty()) {
+            parent.setNickname(dto.getNickname().trim());
+        }
+        if (dto.getPhone() != null) {
+            parent.setPhone(dto.getPhone());
+        }
+        if (dto.getAvatar() != null) {
+            parent.setAvatar(dto.getAvatar());
+        }
+        if (dto.getGender() != null) {
+            parent.setGender(dto.getGender());
+        }
+        if (dto.getBirthday() != null) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+                parent.setBirthday(sdf.parse(dto.getBirthday()));
+            } catch (java.text.ParseException e) {
+                throw new IllegalArgumentException("出生日期格式错误,应为yyyy-MM-dd");
+            }
+        }
+        parent.setUpdatedAt(new Date());
+        userMapper.updateById(parent);
+        logMemberAction(parent.getFamilyId(), parent.getId(), "update", oldNickname, parent.getNickname(), operator.getId());
+        // 返回与成员列表一致的 VO
+        FamilyMemberVO vo = new FamilyMemberVO();
+        vo.setId(parent.getId());
+        vo.setMemberId(parent.getId());
+        vo.setSource("parent");
+        vo.setMemberType("parent");
+        vo.setUserId(parent.getId());
+        vo.setNickname(parent.getNickname());
+        vo.setName(parent.getNickname());
+        vo.setAvatar(parent.getAvatar());
+        vo.setPhone(parent.getPhone());
+        vo.setGender(parent.getGender());
+        vo.setEffectiveRole("parent");
+        vo.setRoleLabel(parent.getFamilyRole() != null ? parent.getFamilyRole() : "家长");
+        if (parent.getBirthday() != null) {
+            vo.setAge(calculateAge(parent.getBirthday()));
+        }
+        vo.setGeneration(0);
+        vo.setIsAdmin(true);
+        vo.setIsSelf(parent.getId().equals(operator.getId()));
+        vo.setIsSwitchable(!parent.getId().equals(operator.getId()));
+        vo.setShowToFamily(parent.getShowToFamily() != null ? parent.getShowToFamily() : 1);
+        vo.setIsSpouse(false);
+        vo.setTotalPoints(parent.getTotalPoints());
+        return vo;
     }
 
     // ===== 内部方法 =====
 
-    private FamilyMemberVO toFamilyMemberVO(FamilyMember member) {
+    /**
+     * 解析成员显示名:优先关联 users 表的 nickname,其次 family_members.nickname。
+     * 同一人可能同时存在于两表(创建者/受邀登录成员),getVisibleMembers 家长段显示的正是 users.nickname,
+     * 此处统一口径,避免「我的家庭」与成员管理页名称不一致。
+     */
+    private String resolveMemberNickname(FamilyMember fm) {
+        if (fm.getUserId() != null) {
+            User u = userMapper.selectById(fm.getUserId());
+            if (u != null && u.getNickname() != null && !u.getNickname().trim().isEmpty()) {
+                return u.getNickname();
+            }
+        }
+        return fm.getNickname();
+    }
+
+    private FamilyMemberVO toFamilyMemberVO(FamilyMember member, Long operatorUserId) {
         FamilyMemberVO vo = new FamilyMemberVO();
         vo.setId(member.getId());
         vo.setSource("family_member");
         vo.setUserId(member.getUserId());
-        vo.setNickname(member.getNickname());
+        vo.setNickname(resolveMemberNickname(member));
         vo.setPhone(member.getPhone());
         vo.setAvatar(member.getAvatar());
         vo.setGender(member.getGender());
@@ -848,14 +976,10 @@ public class FamilyMemberService {
         if (member.getBirthday() != null) {
             vo.setAge(calculateAge(member.getBirthday()));
         }
-        // 设置 isSwitchable:仅无 openid 的不可登录成员可被切换
-        if (member.getUserId() != null) {
-            User memberUser = userMapper.selectById(member.getUserId());
-            String openid = memberUser != null ? memberUser.getOpenid() : null;
-            vo.setIsSwitchable(openid == null || openid.isEmpty());
-        } else {
-            vo.setIsSwitchable(true);
-        }
+        // 设置 isSwitchable:非操作者本人即可切换视角(含可登录成员),与 listMembers 口径一致
+        boolean switchableIsSelf = operatorUserId != null && member.getUserId() != null
+                && operatorUserId.equals(member.getUserId());
+        vo.setIsSwitchable(!switchableIsSelf);
         return vo;
     }
 
@@ -1076,8 +1200,8 @@ public class FamilyMemberService {
 
     /**
      * 获取家庭成员列表(含家庭信息),兼容旧接口 /api/family/user/family-members 的 Map 返回结构。
-     * 返回: { familyId, familyName, inviteCode, creatorId, parents[], children[], members[] }
-     * members 为富化的 FamilyMemberVO 列表(含 roleLabel/trustScore 等展示字段),
+     * 返回: { familyId, familyName, inviteCode, creatorId, members[] }
+     * members 为富化的 FamilyMemberVO 列表(含 roleLabel/trustScore 等展示字段),已含家长段与成员段全量,
      * 供前端单次调用 /api/family/member/list?includeFamily=true 同时获取成员与家庭信息,避免重复请求。
      */
     public Map<String, Object> listMembersWithFamily(Long userId) {
@@ -1099,12 +1223,6 @@ public class FamilyMemberService {
         result.put("inviteCode", family.getInviteCode());
         result.put("creatorId", family.getCreatorId());
         result.put("members", listMembers(userId));
-        result.put("parents", userMapper.selectList(
-                new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId).eq(User::getRole, "parent")));
-        result.put("children", familyMemberMapper.selectList(
-                new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getFamilyId, familyId)
-                        .ne(FamilyMember::getStatus, "recycled")));
         return result;
     }
 }

+ 1 - 1
cfc-backend/src/main/resources/application.yml

@@ -115,7 +115,7 @@ python:
   base-url: https://ai.etotem.com.cn
   timeout-ms: 15000
   # LLM 推理可达 30-60s(画像/问卷更长),read 超时必须覆盖推理耗时,connect 超时才用 timeout-ms
-  read-timeout-ms: 120000
+  read-timeout-ms: 300000   # menu/generate 等长推理任务可达 120s,5 分钟兜底
   circuit-breaker:
     failure-threshold: 3
     reset-timeout-ms: 30000

+ 4 - 5
cfc-frontend/components/FamilyMemberStrip.vue

@@ -169,16 +169,15 @@ export default {
   },
   computed: {
     canSwitch: function() {
-      // 仅不可登录成员(isSwitchable=true)且来源为 family_member 时可切换视角;
-      // 可登录成员(已通过微信登录)不能被切换使用
-      return this.actionMember && this.actionMember.isSwitchable && this.actionMember.source === 'family_member'
+      // 非自己的成员可切换视角(isSwitchable 由后端统一计算,含 family_member 与 parent 两种来源)
+      return this.actionMember && this.actionMember.isSwitchable
     },
     canEdit: function() {
       return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
     },
     canRemove: function() {
-      // 仅家庭成员记录可移除(kick 接口只查 family_members 表),且不能移除自己
-      return this.isParent && this.actionMember && !this.actionMember.isSelf && this.actionMember.source === 'family_member'
+      // 家长可移除非自己的成员(kick 接口已支持 family_member 与 parent 两种来源)
+      return this.isParent && this.actionMember && !this.actionMember.isSelf
     },
     // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
     actionScores: function() {

+ 48 - 3
cfc-frontend/components/OctopusDiagram.vue

@@ -41,9 +41,9 @@
   <!-- 空状态 -->
   <view class="octopus-empty" v-else-if="effects && effects.list && effects.list.length === 0">
     <text class="empty-icon">🧭</text>
-    <text class="empty-text">暂无成效记录</text>
-      <text class="empty-hint">记录你的成交成效,画出章鱼图</text>
-    <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 记录成效</text>
+    <text class="empty-text">暂无感恩记录</text>
+      <text class="empty-hint">记录你的感恩时刻,画出章鱼图</text>
+    <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 感恩记录</text>
   </view>
   <!-- 加载状态 -->
   <view class="octopus-loading" v-else>
@@ -205,6 +205,7 @@ export default {
       var w = this.canvasWidth
       var h = this.canvasHeight
       ctx.clearRect(0, 0, w, h)
+      this._drawOctopusBackground(ctx)
       this._drawCenterNode(ctx)
       this._drawEdges(ctx)
       for (var i = 0; i < this.effectNodes.length; i++) {
@@ -215,6 +216,50 @@ export default {
       }
       this._drawLabels(ctx)
     },
+    /** 绘制章鱼背景:头部 + 8 条波浪触手(低透明度,衬托节点) */
+    _drawOctopusBackground: function(ctx) {
+      var cx = this.canvasWidth / 2
+      var cy = this.canvasHeight / 2
+      var minDim = Math.min(this.canvasWidth, this.canvasHeight)
+      var headR = minDim * 0.12
+      // 触手:8 条波浪曲线向外延伸
+      for (var i = 0; i < 8; i++) {
+        var angle = (i / 8) * Math.PI * 2 - Math.PI / 2
+        var len = minDim * 0.36
+        var sx = cx + Math.cos(angle) * headR
+        var sy = cy + Math.sin(angle) * headR
+        var ex = cx + Math.cos(angle) * (headR + len)
+        var ey = cy + Math.sin(angle) * (headR + len)
+        // 控制点:垂直偏移制造波浪
+        var perp = angle + Math.PI / 2
+        var mid = headR + len * 0.5
+        var wave = Math.sin(i * 2.2) * len * 0.16
+        var mx = cx + Math.cos(angle) * mid + Math.cos(perp) * wave
+        var my = cy + Math.sin(angle) * mid + Math.sin(perp) * wave
+        ctx.beginPath()
+        ctx.moveTo(sx, sy)
+        ctx.quadraticCurveTo(mx, my, ex, ey)
+        ctx.strokeStyle = 'rgba(16, 185, 129, 0.12)'
+        ctx.lineWidth = 6
+        ctx.lineCap = 'round'
+        ctx.stroke()
+      }
+      // 头部(半透明圆)
+      ctx.beginPath()
+      ctx.arc(cx, cy, headR, 0, Math.PI * 2)
+      ctx.fillStyle = 'rgba(16, 185, 129, 0.08)'
+      ctx.fill()
+      // 眼睛
+      var eyeOff = headR * 0.35
+      var eyeR = Math.max(3, headR * 0.09)
+      ctx.beginPath()
+      ctx.arc(cx - eyeOff, cy - headR * 0.12, eyeR, 0, Math.PI * 2)
+      ctx.fillStyle = 'rgba(16, 185, 129, 0.22)'
+      ctx.fill()
+      ctx.beginPath()
+      ctx.arc(cx + eyeOff, cy - headR * 0.12, eyeR, 0, Math.PI * 2)
+      ctx.fill()
+    },
     _drawCenterNode: function(ctx) {
       var cx = this.canvasWidth / 2
       var cy = this.canvasHeight / 2

+ 1 - 1
cfc-frontend/components/ProfileHeader.vue

@@ -94,7 +94,7 @@ export default {
           return
         }
       }
-      // 优先显示可切换成员(不可登录成员),过滤掉可通过微信登录的成员
+      // 优先显示可切换成员(isSwitchable 由后端计算:非本人即可切换,含可登录成员)
       var switchable = this.children.filter(function(m) { return m.isSwitchable })
       if (switchable.length === 0) {
         uni.showToast({ title: '暂无可切换成员', icon: 'none' })

+ 7 - 1
cfc-frontend/pages.json

@@ -1280,7 +1280,13 @@
         {
           "path": "octopus-add-effect",
           "style": {
-            "navigationBarTitleText": "记录成效"
+            "navigationBarTitleText": "感恩记录"
+          }
+        },
+        {
+          "path": "octopus-effects-list",
+          "style": {
+            "navigationBarTitleText": "感恩记录"
           }
         },
         {

+ 148 - 13
cfc-frontend/pages/action-detail/index.vue

@@ -53,7 +53,10 @@
     <!-- 登录后:家庭关系(行维度核心) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <text class="section-title">家庭关系</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('家庭关系', '了解每位家人的关系质量,改善沟通方式。点击家人节点,查看关系详情。')">?</text>
+          <text class="section-title">家庭关系</text>
+        </view>
         <text class="section-sub">了解每位家人的关系质量,改善沟通方式</text>
         <text class="section-sub">点击家人节点,查看关系详情</text>
       </view>
@@ -69,11 +72,14 @@
     <!-- 登录后:章鱼图(关键人拓展) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <view>
-          <text class="section-title">🐙 章鱼图</text>
-          <text class="section-sub">从成效出发,挖掘关键人,画出拓展路径</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('章鱼图', '从成效出发,挖掘关键人,画出拓展路径。记录感恩时刻,建立有温度的人脉网络。')">?</text>
+          <view>
+            <text class="section-title">🐙 章鱼图</text>
+            <text class="section-sub">从成效出发,挖掘关键人,画出拓展路径</text>
+          </view>
         </view>
-        <text class="section-link" @tap="goOctopusAddEffect">+ 记录成效</text>
+        <text class="section-link section-link-octopus" @tap="goOctopusEffectsList">+ 感恩记录</text>
       </view>
       <OctopusDiagram
         ref="octopusDiagram"
@@ -90,11 +96,14 @@
     <!-- 登录后:珍珠图(我有什么) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <view>
-          <text class="section-title">🫧 珍珠图</text>
-          <text class="section-sub">把可调用的资源串成珍珠项链——盘点我有什么</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('珍珠图', '把可调用的资源串成珍珠项链——盘点我有什么。登记人脉、资源、信息缺口,画出一条清晰的成长珍珠链。')">?</text>
+          <view>
+            <text class="section-title">🫧 珍珠图</text>
+            <text class="section-sub">把可调用的资源串成珍珠项链——盘点我有什么</text>
+          </view>
         </view>
-        <text class="section-link" @tap="goPearlAddResource">+ 登记</text>
+        <text class="section-link section-link-pearl" @tap="goPearlAddResource">+ 登记</text>
       </view>
       <PearlDiagram
         ref="pearlDiagram"
@@ -109,10 +118,14 @@
     <!-- 登录后:能力图(谁会什么) -->
     <view class="section" v-if="isLoggedIn">
       <view class="section-header">
-        <view>
-          <text class="section-title">💡 能力图</text>
-          <text class="section-sub">身边藏着什么技能——谁有能力帮到你</text>
+        <view class="section-title-wrap">
+          <text class="help-icon" @tap="showHelp('能力图', '身边藏着什么技能——谁有能力帮到你。记录技能类帮助后,这里会画出大家的能力图谱。')">?</text>
+          <view>
+            <text class="section-title">💡 能力图</text>
+            <text class="section-sub">身边藏着什么技能——谁有能力帮到你</text>
+          </view>
         </view>
+        <text class="section-link section-link-ability" @tap="goInteractionLog">+ 记录帮助</text>
       </view>
       <AbilityDiagram
         ref="abilityDiagram"
@@ -177,6 +190,17 @@
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <FloatingAvatar />
+
+    <!-- 帮助说明弹窗 -->
+    <view class="help-mask" v-if="helpPopup.visible" @tap="closeHelp">
+      <view class="help-dialog" @tap.stop>
+        <view class="help-dialog-header">
+          <text class="help-dialog-title">{{ helpPopup.title }}</text>
+          <text class="help-dialog-close" @tap="closeHelp">✕</text>
+        </view>
+        <text class="help-dialog-content">{{ helpPopup.content }}</text>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -226,6 +250,8 @@ export default {
       octopusExpansions: [],
       octopusKeyPersons: {},
       overdueMap: null,
+      // 帮助弹窗
+      helpPopup: { visible: false, title: '', content: '' },
       funcList: [
         { icon: '\u{1F4AC}', label: '记录互动', needLogin: true, page: '/pages/action-detail/interaction-log' },
         { icon: '\u{1F4DD}', label: '关系问卷', needLogin: true, page: '/pages/action-detail/relationship-questionnaire' },
@@ -559,6 +585,10 @@ export default {
     goPearlAddResource: function() {
       uni.navigateTo({ url: '/pages/action-detail/pearl-add-resource' })
     },
+    /** 能力图记录帮助入口(跳转互动记录,记录技能类帮助) */
+    goInteractionLog: function() {
+      uni.navigateTo({ url: '/pages/action-detail/interaction-log' })
+    },
     /** 删除珍珠图资源 */
     onDeletePearlResource: function(item) {
       if (!item || !item.id) return
@@ -613,6 +643,10 @@ export default {
     goOctopusAddEffect: function() {
       uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' })
     },
+    /** 跳转感恩记录维护列表 */
+    goOctopusEffectsList: function() {
+      uni.navigateTo({ url: '/pages/action-detail/octopus-effects-list' })
+    },
     /** 点击成效节点 */
     onEffectClick: function(effect) {
       var self = this
@@ -631,6 +665,13 @@ export default {
     onReanalyze: function() {
       this.loadOctopusData()
     },
+    /** 显示帮助弹窗 */
+    showHelp: function(title, content) {
+      this.helpPopup = { visible: true, title: title, content: content }
+    },
+    closeHelp: function() {
+      this.helpPopup.visible = false
+    },
     onTaskClick: function(task) {
       uni.navigateTo({ url: '/pages/tasks/tasks' })
     },
@@ -717,6 +758,30 @@ export default {
   justify-content: space-between;
   margin-bottom: 16rpx;
 }
+.section-title-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 8rpx;
+}
+.help-icon {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  border: 2rpx solid #CBD5E1;
+  color: #94A3B8;
+  font-size: 22rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  transition: all 0.2s;
+}
+.help-icon:active {
+  border-color: #F97316;
+  color: #F97316;
+  background: #FFF7ED;
+}
 .section-title {
   font-size: 32rpx;
   font-weight: bold;
@@ -726,8 +791,78 @@ export default {
   font-size: 22rpx;
   color: #999;
 }
+.section-link {
+  display: inline-flex;
+  align-items: center;
+  padding: 10rpx 28rpx;
+  border-radius: 40rpx;
+  font-size: 24rpx;
+  font-weight: 500;
+  white-space: nowrap;
+  transition: opacity 0.2s;
+}
+.section-link:active {
+  opacity: 0.7;
+}
+.section-link-octopus {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.25);
+}
+.section-link-pearl {
+  background: linear-gradient(135deg, #0EA5E9, #38BDF8);
+  color: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(14,165,233,0.25);
+}
+.section-link-ability {
+  background: linear-gradient(135deg, #10B981, #34D399);
+  color: #fff;
+  box-shadow: 0 4rpx 16rpx rgba(16,185,129,0.25);
+}
 
-/* ===== 功能入口 ===== */
+/* 帮助说明弹窗 */
+.help-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.4);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.help-dialog {
+  background: #fff;
+  border-radius: 20rpx;
+  width: 90%;
+  max-width: 500rpx;
+  max-height: 80vh;
+  overflow: hidden;
+}
+.help-dialog-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx 24rpx;
+  border-bottom: 1rpx solid #E5E7EB;
+}
+.help-dialog-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+.help-dialog-close {
+  font-size: 32rpx;
+  color: #94A3B8;
+  cursor: pointer;
+}
+.help-dialog-content {
+  padding: 24rpx;
+  font-size: 26rpx;
+  color: #666;
+  line-height: 1.6;
+}
 .func-section {
   margin: 20rpx 30rpx 0;
 }

+ 1 - 0
cfc-frontend/pages/action-detail/interaction-log.vue

@@ -470,6 +470,7 @@ export default {
   gap: 16rpx;
 }
 .type-item {
+  box-sizing: border-box;
   width: calc(25% - 12rpx);
   padding: 20rpx 0;
   text-align: center;

+ 12 - 3
cfc-frontend/pages/action-detail/octopus-add-effect.vue

@@ -1,7 +1,7 @@
 <template>
   <view class="container">
     <view class="page-header">
-      <text class="page-title">记录成效</text>
+      <text class="page-title">感恩记录</text>
     </view>
 
     <!-- 成交金额 -->
@@ -57,7 +57,7 @@
 
     <!-- 提交按钮 -->
     <view class="submit-bar">
-      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存成效</button>
+      <button class="btn-submit" @click="onSubmit" :disabled="!canSubmit">保存感恩记录</button>
     </view>
   </view>
 </template>
@@ -80,7 +80,8 @@ export default {
       happenedTime: h + ':' + min,
       keyPersons: [],
       selectedMap: {},   // kpId -> true
-      roleMap: {}        // kpId -> role (1引荐/2决策/3其他)
+      roleMap: {},        // kpId -> role (1引荐/2决策/3其他)
+      _onLoadFired: false
     }
   },
   computed: {
@@ -90,6 +91,14 @@ export default {
   },
   onLoad() {
     this.loadKeyPersons()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadKeyPersons()
+    }
   },
   methods: {
     async loadKeyPersons() {

+ 224 - 0
cfc-frontend/pages/action-detail/octopus-effects-list.vue

@@ -0,0 +1,224 @@
+<template>
+  <view class="container">
+    <view class="page-header">
+      <text class="page-title">感恩记录</text>
+      <text class="page-sub">管理你的感恩时刻与成效记录</text>
+    </view>
+
+    <!-- 记录列表 -->
+    <view class="record-list" v-if="records && records.length > 0">
+      <view class="record-item" v-for="r in records" :key="r.id">
+        <view class="record-main">
+          <view class="record-amount">{{ formatAmount(r.effectAmount) }} 元</view>
+          <view class="record-tags">
+            <text class="tag" v-for="t in (r.effectTypeList || [])" :key="t">{{ t }}</text>
+          </view>
+        </view>
+        <view class="record-date">{{ formatTime(r.createdAt) }}</view>
+        <view class="record-desc" v-if="r.effectDesc">{{ r.effectDesc }}</view>
+        <view class="record-actions">
+          <text class="action-btn-del" @tap="onDelete(r.id)">删除</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-else>
+      <text class="empty-icon">🙏</text>
+      <text class="empty-text">暂无感恩记录</text>
+      <text class="empty-hint">记录你的感恩时刻,画出章鱼图</text>
+    </view>
+
+    <!-- 添加按钮 -->
+    <view class="fab" @tap="goAddEffect">
+      <text class="fab-icon">+</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getOctopusEffects, deleteOctopusEffect } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      records: [],
+      _onLoadFired: false
+    }
+  },
+  onLoad() {
+    this.loadRecords()
+    this._onLoadFired = true
+  },
+  onShow() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else {
+      this.loadRecords()
+    }
+  },
+  methods: {
+    async loadRecords() {
+      try {
+        var res = await getOctopusEffects()
+        if (res.code === 200 && res.data && res.data.list) {
+          this.records = res.data.list
+        } else {
+          this.records = []
+        }
+      } catch (e) { /* 静默 */ }
+    },
+    formatAmount: function(val) {
+      if (val == null) return '0'
+      return String(val)
+    },
+    formatTime: function(ts) {
+      if (!ts) return ''
+      return String(ts).substring(0, 10)
+    },
+    onDelete: function(id) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确定删除这条感恩记录吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          deleteOctopusEffect({ effectId: id }).then(function(r) {
+            if (r.code === 200) {
+              uni.showToast({ title: '已删除', icon: 'success' })
+              self.loadRecords()
+            } else {
+              uni.showToast({ title: r.message || '删除失败', icon: 'none' })
+            }
+          }).catch(function(e) {
+            uni.showToast({ title: '删除失败', icon: 'none' })
+          })
+        }
+      })
+    },
+    goAddEffect: function() {
+      uni.navigateTo({ url: '/pages/action-detail/octopus-add-effect' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  padding: 24rpx;
+  padding-bottom: 120rpx;
+}
+.page-header {
+  padding: 20rpx 0 30rpx;
+}
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+}
+.page-sub {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.record-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+.record-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+  position: relative;
+}
+.record-main {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.record-amount {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+.record-tags {
+  display: flex;
+  gap: 8rpx;
+}
+.tag {
+  font-size: 20rpx;
+  padding: 4rpx 12rpx;
+  background: #FFF7ED;
+  color: #F97316;
+  border-radius: 8rpx;
+}
+.record-date {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-top: 12rpx;
+}
+.record-desc {
+  font-size: 26rpx;
+  color: #666;
+  margin-top: 8rpx;
+}
+.record-actions {
+  position: absolute;
+  top: 24rpx;
+  right: 24rpx;
+}
+.action-btn-del {
+  font-size: 24rpx;
+  color: #CBD5E1;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.action-btn-del:active {
+  color: #EF4444;
+  background: #FEF2F2;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 80rpx 20rpx;
+}
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 16rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 8rpx;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #999;
+}
+.fab {
+  position: fixed;
+  bottom: 80rpx;
+  right: 40rpx;
+  width: 100rpx;
+  height: 100rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  box-shadow: 0 4rpx 20rpx rgba(249,115,22,0.35);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.fab-icon {
+  font-size: 48rpx;
+  color: #fff;
+  font-weight: 300;
+}
+</style>

+ 21 - 10
cfc-frontend/pages/profile-extra/family-members.vue

@@ -35,8 +35,8 @@
 <view class="member-card" v-for="m in filteredMembers" :key="m.id" @click="showMemberDetail(m)">
         <image class="member-avatar" :src="m.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
         <view class="member-info">
-          <text class="member-name">{{ m.nickname || '未设置' }}</text>
-          <text class="member-role-tag">{{ m.relativeLabel || m.roleLabel || (m.effectiveRole === 'parent' ? '家长' : '孩子') }}</text>
+          <text class="member-name">{{ m.nickname || m.name || '成员' }}</text>
+          <text class="member-role-tag">{{ m.relationship || m.relativeLabel || m.roleLabel || '成员' }}</text>
         </view>
         <text class="member-arrow">›</text>
       </view>
@@ -56,17 +56,21 @@
         <view class="detail-header">
           <image class="detail-avatar" :src="detailMember && (detailMember.avatar || '/static/default-avatar.png')" mode="aspectFill"></image>
           <view class="detail-info">
-            <text class="detail-name">{{ detailMember && detailMember.nickname || '' }}</text>
-            <text class="detail-relation">{{ detailMember && (detailMember.relativeLabel || detailMember.roleLabel || '') }}</text>
+            <text class="detail-name">{{ detailMember && (detailMember.nickname || detailMember.name || '成员') || '' }}</text>
+            <text class="detail-relation">{{ detailMember && (detailMember.relationship || detailMember.relativeLabel || detailMember.roleLabel || '成员') }}</text>
           </view>
           <text class="detail-close" @click="closeMemberDetail">×</text>
         </view>
 
-        <!-- 详情:性别/年龄/手机号 -->
+        <!-- 详情:性别/年龄/手机号/角色/辈分/积分/来源 -->
         <view class="detail-meta">
           <text class="detail-meta-item" v-if="detailMember && detailMember.gender">性别: {{ detailMember.gender === 'male' ? '男' : '女' }}</text>
           <text class="detail-meta-item" v-if="detailMember && detailMember.age">{{ detailMember.age }}岁</text>
           <text class="detail-meta-item" v-if="detailMember && detailMember.phone">{{ detailMember.phone }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.roleLabel">角色: {{ detailMember.roleLabel }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.generation !== null && detailMember.generation !== undefined && generationLabel(detailMember.generation)">辈分: {{ generationLabel(detailMember.generation) }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.totalPoints !== null && detailMember.totalPoints !== undefined">积分: {{ detailMember.totalPoints }}</text>
+          <text class="detail-meta-item" v-if="detailMember && detailMember.source">类型: {{ detailMember.source === 'parent' ? '家长账号' : '家庭成员' }}</text>
         </view>
 
         <!-- 三维评分展示 -->
@@ -97,11 +101,11 @@
 
         <!-- 操作按钮 -->
         <view class="detail-actions">
-          <button class="detail-action-btn action-switch" @click="onSwitchMember(detailMember)" v-if="!(detailMember.source === 'parent' && detailMember.userId === userId)">切换身份</button>
+          <button class="detail-action-btn action-switch" @click="onSwitchMember(detailMember)" v-if="detailMember && detailMember.isSwitchable">切换身份</button>
           <button class="detail-action-btn action-edit" @click="onEditRelation(detailMember)" v-if="canEditMember(detailMember)">编辑资料</button>
           <button class="detail-action-btn action-questionnaire" @click="doQuestionnaire(detailMember)">关系问卷</button>
           <button class="detail-action-btn action-interaction" @click="recordInteraction(detailMember)">记录互动</button>
-          <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember.source === 'family_member'">删除成员</button>
+          <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember && !detailMember.isSelf">删除成员</button>
         </view>
       </view>
     </view>
@@ -166,7 +170,7 @@ export default {
       var self = this
       return (this.members || []).filter(function(m) {
         var isSelf = m.isSelf === true
-        if (!isSelf && m.userId && m.userId === self.userId) {
+        if (!isSelf && m.userId && String(m.userId) === String(self.userId)) {
           isSelf = true
         }
         return !isSelf
@@ -277,7 +281,8 @@ export default {
           this.familyId = data.familyId
           this.familyName = data.familyName
           this.creatorId = data.creatorId
-          this.isAdmin = this.creatorId === this.userId
+          // creatorId 来自后端 JSON(数字),userId 来自 storage(可能为字符串),用 String 归一避免 === 恒 false 导致管理员按钮不显示
+          this.isAdmin = String(this.creatorId) === String(this.userId)
           var arr = data.members
           if (!(arr instanceof Array)) {
             // 后端旧版本未返回 members 字段时的兼容:补一次无参请求
@@ -313,7 +318,8 @@ export default {
     },
     canEditMember(member) {
       if (!this.isAdmin) return false
-      return member.source === 'family_member'
+      // 家长账号(users 表成员)与手写家庭成员均可编辑资料(后端 updateMember/updateParentMember 分别处理)
+      return member.source === 'family_member' || member.source === 'parent'
     },
     onAddMember() {
       uni.navigateTo({ url: '/pages/family/add-member' })
@@ -518,6 +524,11 @@ export default {
       var map = { 3: 'great_grandparent', 2: 'grandparent', 1: 'parent', 0: 'peer', '-1': 'child', '-2': 'grandchild', '-3': 'great_grandchild' }
       return map[gen] || ''
     },
+    // 辈分数值 → 中文标签(弹窗展示用,与 utils/generationLevel.js GENERATION_LEVELS 对应)
+    generationLabel(gen) {
+      var map = { 3: '曾祖辈', 2: '祖辈', 1: '父辈', 0: '同辈', '-1': '子辈', '-2': '孙辈', '-3': '曾孙辈' }
+      return map[gen] || ''
+    },
     onEditRelation(member) {
       this.showMemberDetailModal = false
       var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')

+ 2 - 2
cfc-frontend/store/index.js

@@ -195,11 +195,11 @@ const store = new Vuex.Store({
       commit('switchToChild', memberId)
       return true
     },
-    // 切换到家庭成员前检查(仅不可登录成员可切换)
+    // 切换到家庭成员前检查(isSwitchable 由后端统一计算:非本人即可切换,含可登录成员)
     switchToMemberWithCheck({ commit }, payload) {
       var member = payload.member || {}
       if (member.isSwitchable === false) {
-        uni.showToast({ title: '该成员可通过微信登录,不能切换为TA的身份', icon: 'none' })
+        uni.showToast({ title: '该成员暂不可切换', icon: 'none' })
         return false
       }
       commit('switchToMember', payload)

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

@@ -404,7 +404,7 @@ export const ensureFamily = () => {
 }
 
 // 统一家庭成员列表接口(唯一入口,替代 getFamilyMembers / getVisibleFamilyMembers / listFamilyMembers)
-// params: { visibleOnly: true } 仅返回 show_to_family=1 成员; { includeFamily: true } 附带 familyId/familyName/inviteCode/creatorId
+// params: { visibleOnly: true } 仅返回 show_to_family=1 成员; { includeFamily: true } 返回 {familyId/familyName/inviteCode/creatorId/members}(members 已含家长段与成员段全量)
 export const getFamilyMemberList = (params) => {
 	return request('/api/family/member/list', 'POST', params || {})
 }
@@ -2225,11 +2225,14 @@ export const parseReportPreview = (filePath, memberId, type) => {
 // 阶段2:确认落库(可选传修改后的payload和subjectId)
 
 // 阶段0:仅上传报告文件(不解析),返回 draftId
-export const uploadReportOnly = (filePath, type) => {
+export const uploadReportOnly = (filePath, type, memberId) => {
   return new Promise((resolve, reject) => {
     const token = uni.getStorageSync('token')
+    var query = ''
+    if (type) query += '?type=' + type
+    if (memberId) query += (query ? '&' : '?') + 'memberId=' + memberId
     uni.uploadFile({
-      url: BASE_URL + '/api/health/report/upload-only' + (type ? '?type=' + type : ''),
+      url: BASE_URL + '/api/health/report/upload-only' + query,
       filePath: filePath,
       name: 'file',
       header: {

+ 4 - 2
cfc-langgraph/app/api/meal.py

@@ -30,7 +30,7 @@ MENU_GENERATE_TEMPLATE = """请为{participant_count}人生成{date}的一日三
 {constraints_text}
 
 请返回 JSON 格式:
-{{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "烹饪方法", "nutrition": {{"calories": 200, "protein": 10, "carbs": 30, "fat": 5}}, "notes": "备注"}}]}}]}}
+{{"meals": [{{"type": "breakfast", "name": "早餐", "dishes": [{{"name": "菜品名", "ingredients": [{{"name": "食材", "grams": 100}}], "cooking_method": "做法一句话", "nutrition": {{"calories": 200}}}}]}}]}}
 
 要求:
 1. 早/午/晚各至少1-2道菜
@@ -141,10 +141,12 @@ async def generate_menu(req: MenuGenerateRequest):
             )
 
         llm = ChatOpenAI(
-            model=settings.llm_model,
+            # agnes-2.5-flash:非推理模型,菜单生成 11s(deepseek 推理型会占满 token 导致 content 为空)
+            model="agnes-2.5-flash",
             api_key=settings.llm_api_key,
             base_url=settings.llm_base_url,
             temperature=0.7,
+            max_tokens=3000,
         )
 
         response = llm.invoke([

+ 2 - 2
cfc-langgraph/docker-compose.yml

@@ -21,7 +21,7 @@ services:
       retries: 3
 
   langgraph-svc:
-    image: cfc-langgraph-langgraph-svc:v9
+    image: cfc-langgraph-langgraph-svc:v10.3
     build:
       context: .
       dockerfile: Dockerfile
@@ -44,7 +44,7 @@ services:
       - cfc-net
     restart: unless-stopped
     # 内存硬限制:防止 ChromaDB/搜索任务异常膨胀吃满宿主机内存导致整机 OOM
-    mem_limit: 4g
+    mem_limit: 8g
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:9000/health', timeout=5).status==200 else 1)"]
       interval: 30s

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-589e38d6edf82f17749d06486f3363879198a2ae
+601bcf083fa21f1f95fd20a641629d9bc4e8f282

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,92 @@
 
 ---
 
+## v1.0.1446 (2026-09-19)
+
+### 新功能
+- 允许切换到可登录成员视角,统一 isSwitchable 口径
+
+### 其他
+- - switchToMember 成员段返回 nickname 改用 resolveMemberNickname(可登录成员显示 users 表昵称)
+- - 前端 store 切换守卫文案与 ProfileHeader 注释同步,移除'可通过微信登录不能切换'的旧提示
+- 
+
+
+## v1.0.1445 (2026-09-19)
+
+### Bug 修复
+- 家庭成员管理页 isAdmin 类型比较修复,管理员删除/编辑按钮恢复显示
+
+### 其他
+- - 列表过滤自身成员同样按 String 归一(m.userId === userId 类型敏感)
+- 
+
+
+## v1.0.1444 (2026-09-19)
+
+### Bug 修复
+- includeFamily 接口只返回 members 并统一成员显示名来源
+
+### 其他
+- - resolveMemberNickname 改为优先 users 表昵称:同一人同时存在于两表时,与 getVisibleMembers 家长段口径一致,避免「我的家庭」与成员管理页名称不一致
+- - utils/api.js 同步更新接口注释
+- 
+
+
+## v1.0.1443 (2026-09-19)
+
+### Bug 修复
+- 饮食推荐控制器 familyId 改 required=false,无家庭时返回 noFamily 而非抛 ServletRequestBindingException
+
+
+## v1.0.1442 (2026-09-19)
+
+### Bug 修复
+- 菜单生成切换非推理模型(agnes-2.5-flash)并加max_tokens=3000,耗时从60-96s降至~13s;容器内存限制6g→8g解决ChromaDB启动OOM
+
+
+## v1.0.1440 (2026-09-19)
+
+### 新功能
+- 家庭成员管理支持家长账号编辑/移除/切换及名称一致性修复
+
+### 其他
+- - updateMember 新增 updateParentMember 分支: 家长账号可编辑昵称/性别/生日/头像/手机号
+- - 新增 resolveMemberNickname: family_members.nickname 为空时回退 users 表真实昵称
+- - family-members.vue: 名称兜底链与我的家庭页一致, canEditMember 放开 parent 来源
+- - FamilyMemberStrip.vue: canSwitch/canRemove 放开 source 限制
+- - interaction-log.vue: .type-item 加 box-sizing 修复四列折行
+- 
+
+
+## v1.0.1439 (2026-09-19)
+
+### Bug 修复
+- 迁移323-325移至runSystemMigrations,修复因早返回导致永不执行的happened_at/article_id问题
+
+
+## v1.0.1438 (2026-09-19)
+
+### 新功能
+- 行页面整体美化
+
+### 其他
+- 2. 章鱼图添加入口从'记录成效'改为'感恩记录',并跳转至新建的维护列表页
+- 3. 三个图添加入口按钮统一美化(渐变圆角 + 彩色阴影)
+- 4. 章鱼图 Canvas 背景添加低透明度章鱼触手+头部装饰
+- 5. 新增 octopus-effects-list.vue:感恩记录维护页(列表展示/删除/FAB 添加)
+- 6. 修复 octopus-add-effect.vue 关键人'能添加但不能选'bug:补 onShow 刷新
+- - 新增:pages/action-detail/octopus-effects-list.vue
+- - 修改:OctopusDiagram.vue / pages.json / index.vue / octopus-add-effect.vue
+- 
+
+
+## v1.0.1437 (2026-09-19)
+
+### Bug 修复
+- selectedMembers 规整为标准结构-兼容 memberPicker 仅返回 id/nickname 的情况
+
+
 ## v1.0.1436 (2026-09-19)
 
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1436
+> 当前版本: v1.0.1446
 
 ## 历史版本
 
@@ -8,6 +8,92 @@
 
 ---
 
+## v1.0.1446 (2026-09-19)
+
+### 新功能
+- 允许切换到可登录成员视角,统一 isSwitchable 口径
+
+### 其他
+- - switchToMember 成员段返回 nickname 改用 resolveMemberNickname(可登录成员显示 users 表昵称)
+- - 前端 store 切换守卫文案与 ProfileHeader 注释同步,移除'可通过微信登录不能切换'的旧提示
+- 
+
+
+## v1.0.1445 (2026-09-19)
+
+### Bug 修复
+- 家庭成员管理页 isAdmin 类型比较修复,管理员删除/编辑按钮恢复显示
+
+### 其他
+- - 列表过滤自身成员同样按 String 归一(m.userId === userId 类型敏感)
+- 
+
+
+## v1.0.1444 (2026-09-19)
+
+### Bug 修复
+- includeFamily 接口只返回 members 并统一成员显示名来源
+
+### 其他
+- - resolveMemberNickname 改为优先 users 表昵称:同一人同时存在于两表时,与 getVisibleMembers 家长段口径一致,避免「我的家庭」与成员管理页名称不一致
+- - utils/api.js 同步更新接口注释
+- 
+
+
+## v1.0.1443 (2026-09-19)
+
+### Bug 修复
+- 饮食推荐控制器 familyId 改 required=false,无家庭时返回 noFamily 而非抛 ServletRequestBindingException
+
+
+## v1.0.1442 (2026-09-19)
+
+### Bug 修复
+- 菜单生成切换非推理模型(agnes-2.5-flash)并加max_tokens=3000,耗时从60-96s降至~13s;容器内存限制6g→8g解决ChromaDB启动OOM
+
+
+## v1.0.1440 (2026-09-19)
+
+### 新功能
+- 家庭成员管理支持家长账号编辑/移除/切换及名称一致性修复
+
+### 其他
+- - updateMember 新增 updateParentMember 分支: 家长账号可编辑昵称/性别/生日/头像/手机号
+- - 新增 resolveMemberNickname: family_members.nickname 为空时回退 users 表真实昵称
+- - family-members.vue: 名称兜底链与我的家庭页一致, canEditMember 放开 parent 来源
+- - FamilyMemberStrip.vue: canSwitch/canRemove 放开 source 限制
+- - interaction-log.vue: .type-item 加 box-sizing 修复四列折行
+- 
+
+
+## v1.0.1439 (2026-09-19)
+
+### Bug 修复
+- 迁移323-325移至runSystemMigrations,修复因早返回导致永不执行的happened_at/article_id问题
+
+
+## v1.0.1438 (2026-09-19)
+
+### 新功能
+- 行页面整体美化
+
+### 其他
+- 2. 章鱼图添加入口从'记录成效'改为'感恩记录',并跳转至新建的维护列表页
+- 3. 三个图添加入口按钮统一美化(渐变圆角 + 彩色阴影)
+- 4. 章鱼图 Canvas 背景添加低透明度章鱼触手+头部装饰
+- 5. 新增 octopus-effects-list.vue:感恩记录维护页(列表展示/删除/FAB 添加)
+- 6. 修复 octopus-add-effect.vue 关键人'能添加但不能选'bug:补 onShow 刷新
+- - 新增:pages/action-detail/octopus-effects-list.vue
+- - 修改:OctopusDiagram.vue / pages.json / index.vue / octopus-add-effect.vue
+- 
+
+
+## v1.0.1437 (2026-09-19)
+
+### Bug 修复
+- selectedMembers 规整为标准结构-兼容 memberPicker 仅返回 id/nickname 的情况
+
+
 ## v1.0.1436 (2026-09-19)
 
 ### 新功能