Răsfoiți Sursa

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

Xiaogang Liao 4 săptămâni în urmă
părinte
comite
42502d1e03
31 a modificat fișierele cu 896 adăugiri și 170 ștergeri
  1. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  2. 37 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java
  3. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  4. 71 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java
  5. 17 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java
  6. 4 1
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  7. 19 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  8. 3 1
      cfc-frontend/App.vue
  9. 5 41
      cfc-frontend/components/FamilyMemberStrip.vue
  10. 21 13
      cfc-frontend/components/report-blocks-renderer.vue
  11. 6 0
      cfc-frontend/pages.json
  12. 1 1
      cfc-frontend/pages/ai/chat.vue
  13. 172 0
      cfc-frontend/pages/butler/select.vue
  14. 75 7
      cfc-frontend/pages/health/report-detail.vue
  15. 2 1
      cfc-frontend/pages/login/login.vue
  16. 42 2
      cfc-frontend/pages/membership/index.vue
  17. 76 79
      cfc-frontend/pages/profile-extra/family-members.vue
  18. 15 1
      cfc-frontend/utils/api.js
  19. 8 1
      cfc-langgraph/app/graphs/health_coach_graph.py
  20. 1 1
      cfc-web/.last_build_commit
  21. 2 2
      cfc-web/package-lock.json
  22. 1 1
      cfc-web/package.json
  23. 48 0
      cfc-web/public/CHANGELOG-v1.0.md
  24. 49 1
      cfc-web/public/CHANGELOG.md
  25. 27 0
      cfc-web/src/api/butler.js
  26. 6 0
      cfc-web/src/router/index.js
  27. 87 0
      cfc-web/src/views/admin/ButlerAssignments.vue
  28. 24 1
      cfc-web/src/views/admin/review/ButlerAudit.vue
  29. 4 4
      docs/superpowers/PROJECT-OVERVIEW.md
  30. 11 2
      docs/superpowers/api/API_REFERENCE.md
  31. 30 8
      docs/superpowers/specs/2026-08-22-new-user-paid-plan.md

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -418,6 +418,21 @@ public class HealthReportController {
         return Result.success(detail);
         return Result.success(detail);
     }
     }
 
 
+    /**
+     * 获取报告 payload(编辑回填用,按需加载,减少首屏性能开销)
+     */
+    @Operation(summary = "获取报告payload(编辑回填)")
+    @PostMapping("/report/payload")
+    public Result<Map<String, Object>> getReportPayload(
+            @RequestBody Map<String, Object> params) {
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        if (reportId == null) {
+            return Result.error("reportId不能为空");
+        }
+        Map<String, Object> payload = healthReportService.getReportPayload(reportId);
+        return Result.success(payload);
+    }
+
     /**
     /**
      * 编辑已发布报告 — 修正 AI 解析错误的值
      * 编辑已发布报告 — 修正 AI 解析错误的值
      */
      */

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java

@@ -169,4 +169,41 @@ public class AdminButlerController {
         data.put("size", size);
         data.put("size", size);
         return Result.success(data);
         return Result.success(data);
     }
     }
+
+    @PostMapping("/assignments")
+    public Result<Map<String, Object>> assignments(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"finance".equals(role)) {
+            return Result.error("权限不足");
+        }
+        Integer pageNum = params.get("pageNum") != null ? Integer.valueOf(params.get("pageNum").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 10;
+        return butlerService.listAssignments(pageNum, pageSize);
+    }
+
+    @PostMapping("/unassign")
+    public Result<Void> unassign(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"finance".equals(role)) {
+            return Result.error("权限不足");
+        }
+        if (params.get("assignmentId") == null) {
+            return Result.error("缺少 assignmentId");
+        }
+        return butlerService.unassign(Long.valueOf(params.get("assignmentId").toString()));
+    }
+
+    @PostMapping("/toggle-accepting")
+    public Result<Void> toggleAccepting(@RequestBody Map<String, Object> params,
+                                         @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"finance".equals(role)) {
+            return Result.error("权限不足");
+        }
+        if (params.get("butlerUserId") == null || params.get("accepting") == null) {
+            return Result.error("缺少参数");
+        }
+        return butlerService.toggleAccepting(
+                Long.valueOf(params.get("butlerUserId").toString()),
+                Integer.valueOf(params.get("accepting").toString()));
+    }
 }
 }

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -431,6 +431,23 @@ public class AIChatController {
         if (memberId != null) {
         if (memberId != null) {
             inputs.put("child_id", memberId);
             inputs.put("child_id", memberId);
         }
         }
+        // 注入教练人格(对齐 /chat/send 的 mascot 范式;coach_id 用于 LangGraph prompt 路由)
+        com.etotem.cfc.entity.User hcUser = userService.getUserInfo(userId);
+        String hcMascotCode = hcUser != null ? hcUser.getMascot() : null;
+        if (hcMascotCode != null && !hcMascotCode.isEmpty()) {
+            com.etotem.cfc.enums.MascotEnum hcMascot = com.etotem.cfc.enums.MascotEnum.fromCode(hcMascotCode);
+            if (hcMascot != null) {
+                inputs.put("coach_id", hcMascot.getCode());
+                inputs.put("mascot_name", hcMascot.name());
+                inputs.put("mascot_gender", hcMascot.gender());
+                inputs.put("mascot_persona", hcMascot.persona());
+            }
+        } else {
+            inputs.put("coach_id", "");
+            inputs.put("mascot_name", "健康教练");
+            inputs.put("mascot_gender", "");
+            inputs.put("mascot_persona", "");
+        }
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
         Map<String, Object> result = new LinkedHashMap<>();
         Map<String, Object> result = new LinkedHashMap<>();

+ 71 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java

@@ -494,4 +494,75 @@ public class ButlerService {
         summary.put("description", bp != null ? bp.getDescription() : "");
         summary.put("description", bp != null ? bp.getDescription() : "");
         return summary;
         return summary;
     }
     }
+
+    // ==================== 管理端分配管理 ====================
+
+    /**
+     * 分配关系分页列表
+     */
+    public Result<Map<String, Object>> listAssignments(Integer pageNum, Integer pageSize) {
+        int pn = pageNum == null || pageNum < 1 ? 1 : pageNum;
+        int ps = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100);
+        Page<ButlerAssignment> page = butlerAssignmentMapper.selectPage(new Page<>(pn, ps),
+                new LambdaQueryWrapper<ButlerAssignment>()
+                        .orderByDesc(ButlerAssignment::getAssignedAt));
+
+        List<Map<String, Object>> rows = new java.util.ArrayList<>();
+        for (ButlerAssignment a : page.getRecords()) {
+            Map<String, Object> row = new HashMap<>();
+            row.put("id", a.getId());
+            row.put("familyId", a.getFamilyId());
+            row.put("butlerUserId", a.getButlerUserId());
+            row.put("level", a.getLevel());
+            row.put("assignedAt", a.getAssignedAt());
+            User bu = userMapper.selectById(a.getButlerUserId());
+            row.put("butlerNickname", bu != null ? bu.getNickname() : "");
+            rows.add(row);
+        }
+        Map<String, Object> data = new HashMap<>();
+        data.put("total", page.getTotal());
+        data.put("records", rows);
+        return Result.success(data);
+    }
+
+    /**
+     * 强制解绑(物理删除分配行 + 回退管家计数)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Result<Void> unassign(Long assignmentId) {
+        ButlerAssignment assignment = butlerAssignmentMapper.selectById(assignmentId);
+        if (assignment == null) {
+            return Result.error("分配记录不存在");
+        }
+        butlerAssignmentMapper.deleteById(assignmentId);
+
+        LambdaUpdateWrapper<ButlerProfile> decr = new LambdaUpdateWrapper<>();
+        decr.eq(ButlerProfile::getUserId, assignment.getButlerUserId())
+            .setSql("member_count = GREATEST(member_count - 1, 0)");
+        butlerProfileMapper.update(null, decr);
+
+        log.info("Admin unassigned assignmentId={}, family={}, butler={}",
+                assignmentId, assignment.getFamilyId(), assignment.getButlerUserId());
+        return Result.success(null);
+    }
+
+    /**
+     * 管家停接/恢复接单开关(不影响存量绑定)
+     */
+    public Result<Void> toggleAccepting(Long butlerUserId, Integer accepting) {
+        if (accepting == null || (accepting != 0 && accepting != 1)) {
+            return Result.error("accepting 取值只能为 0 或 1");
+        }
+        ButlerProfile profile = butlerProfileMapper.selectOne(
+                new LambdaQueryWrapper<ButlerProfile>()
+                        .eq(ButlerProfile::getUserId, butlerUserId)
+                        .last("LIMIT 1"));
+        if (profile == null) {
+            return Result.error("管家不存在");
+        }
+        profile.setAccepting(accepting);
+        profile.setUpdatedAt(new Date());
+        butlerProfileMapper.updateById(profile);
+        return Result.success(null);
+    }
 }
 }

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

@@ -83,12 +83,27 @@ public class FamilyInvitationService {
             throw new RuntimeException("家庭不存在");
             throw new RuntimeException("家庭不存在");
         }
         }
 
 
-        long activeCount = familyInvitationMapper.selectCount(
+        // 查询该用户所有 active 状态的邀请(仅统计未过期的,过期记录不再占用额度)
+        Date now = new Date();
+        List<FamilyInvitation> activeInvitations = familyInvitationMapper.selectList(
                 new LambdaQueryWrapper<FamilyInvitation>()
                 new LambdaQueryWrapper<FamilyInvitation>()
                         .eq(FamilyInvitation::getCreatedBy, userId)
                         .eq(FamilyInvitation::getCreatedBy, userId)
                         .eq(FamilyInvitation::getStatus, "active")
                         .eq(FamilyInvitation::getStatus, "active")
+                        .and(w -> w.isNull(FamilyInvitation::getExpiresAt)
+                                .or().gt(FamilyInvitation::getExpiresAt, now))
         );
         );
-        if (activeCount >= 10) {
+
+        // 优先复用同家庭、未过期、未用尽的邀请令牌(小程序分享场景下避免每次分享都新建记录导致额度耗尽)
+        for (FamilyInvitation inv : activeInvitations) {
+            boolean sameFamily = familyId != null && familyId.equals(inv.getFamilyId());
+            boolean notExhausted = inv.getMaxUseCount() == null || inv.getUsedCount() == null
+                    || inv.getUsedCount() < inv.getMaxUseCount();
+            if (sameFamily && notExhausted) {
+                return inv.getToken();
+            }
+        }
+
+        if (activeInvitations.size() >= 10) {
             throw new RuntimeException("您已生成过多邀请链接,请先吊销未使用的链接");
             throw new RuntimeException("您已生成过多邀请链接,请先吊销未使用的链接");
         }
         }
 
 

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -629,7 +629,9 @@ public class FamilyMemberService {
 
 
     /**
     /**
      * 获取家庭成员列表(含家庭信息),兼容旧接口 /api/family/user/family-members 的 Map 返回结构。
      * 获取家庭成员列表(含家庭信息),兼容旧接口 /api/family/user/family-members 的 Map 返回结构。
-     * 返回: { familyId, familyName, inviteCode, creatorId, parents[], children[] }
+     * 返回: { familyId, familyName, inviteCode, creatorId, parents[], children[], members[] }
+     * members 为富化的 FamilyMemberVO 列表(含 roleLabel/trustScore 等展示字段),
+     * 供前端单次调用 /api/family/member/list?includeFamily=true 同时获取成员与家庭信息,避免重复请求。
      */
      */
     public Map<String, Object> listMembersWithFamily(Long userId) {
     public Map<String, Object> listMembersWithFamily(Long userId) {
         Map<String, Object> result = new java.util.HashMap<>();
         Map<String, Object> result = new java.util.HashMap<>();
@@ -649,6 +651,7 @@ public class FamilyMemberService {
         result.put("familyName", family.getName());
         result.put("familyName", family.getName());
         result.put("inviteCode", family.getInviteCode());
         result.put("inviteCode", family.getInviteCode());
         result.put("creatorId", family.getCreatorId());
         result.put("creatorId", family.getCreatorId());
+        result.put("members", listMembers(userId));
         result.put("parents", userMapper.selectList(
         result.put("parents", userMapper.selectList(
                 new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId).eq(User::getRole, "parent")));
                 new LambdaQueryWrapper<User>().eq(User::getFamilyId, familyId).eq(User::getRole, "parent")));
         result.put("children", familyMemberMapper.selectList(
         result.put("children", familyMemberMapper.selectList(

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

@@ -500,6 +500,25 @@ public class HealthReportService {
         return detail;
         return detail;
     }
     }
 
 
+    /**
+     * 获取报告 payload(编辑回填用,按需加载)
+     */
+    public Map<String, Object> getReportPayload(Long reportId) {
+        Map<String, Object> result = new HashMap<>();
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report == null) {
+            return result;
+        }
+        if (report.getPayloadJson() != null && !report.getPayloadJson().isEmpty()) {
+            try {
+                result.put("payload", objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class));
+            } catch (Exception e) {
+                log.warn("解析payload_json失败 reportId={}: {}", reportId, e.getMessage());
+            }
+        }
+        return result;
+    }
+
     private int clampScore(Integer score) {
     private int clampScore(Integer score) {
         if (score == null) return 0;
         if (score == null) return 0;
         return Math.min(Math.max(score, 0), 100);
         return Math.min(Math.max(score, 0), 100);

+ 3 - 1
cfc-frontend/App.vue

@@ -119,9 +119,11 @@ export default {
       if (data.openid) {
       if (data.openid) {
         uni.setStorageSync('openid', data.openid)
         uni.setStorageSync('openid', data.openid)
       }
       }
+      // familyId 必须持久化:家庭在注册时已由后端创建,缺失会导致页面误判无家庭而重复创建
       uni.setStorageSync('userInfo', {
       uni.setStorageSync('userInfo', {
         nickname: data.nickname,
         nickname: data.nickname,
-        userId: data.userId
+        userId: data.userId,
+        familyId: data.familyId
       })
       })
       this.$store.commit('setToken', data.token)
       this.$store.commit('setToken', data.token)
       // 同步 familyId 到 store(hasFamily getter 依赖;auto-login 路径)
       // 同步 familyId 到 store(hasFamily getter 依赖;auto-login 路径)

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

@@ -117,26 +117,16 @@
         <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
         <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
       </view>
       </view>
     </view>
     </view>
-
-    <!-- 邀请成员分享海报弹窗 -->
-    <SharePoster
-      :show="showPoster"
-      :referralCode="inviteCode"
-      :qrCodeBase64="inviteQrCode"
-      :nickname="posterNickname"
-      :userAvatar="posterAvatar"
-      @close="showPoster = false" />
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
 import FamilyMemberCard from '@/components/FamilyMemberCard'
 import FamilyMemberCard from '@/components/FamilyMemberCard'
-import SharePoster from '@/components/SharePoster.vue'
-import { kickFamilyMemberById, createFamily, generateFamilyInviteQrCode, getPairwiseRelationships, getFamilyMemberList } from '@/utils/api'
+import { kickFamilyMemberById, createFamily, getPairwiseRelationships, getFamilyMemberList } from '@/utils/api'
 import { parseDate } from '@/utils/format.js'
 import { parseDate } from '@/utils/format.js'
 
 
 export default {
 export default {
-  components: { FamilyMemberCard, SharePoster },
+  components: { FamilyMemberCard },
   props: {
   props: {
     members: { type: Array, default: function() { return [] } },
     members: { type: Array, default: function() { return [] } },
     selectedMemberId: { type: Number, default: null },
     selectedMemberId: { type: Number, default: null },
@@ -149,12 +139,7 @@ export default {
       showActionMenu: false,
       showActionMenu: false,
       actionMember: null,
       actionMember: null,
       renderedMembers: [],
       renderedMembers: [],
-      creatingFamily: false,
-      showPoster: false,
-      inviteCode: '',
-      inviteQrCode: '',
-      posterNickname: '',
-      posterAvatar: ''
+      creatingFamily: false
     }
     }
   },
   },
   watch: {
   watch: {
@@ -350,31 +335,10 @@ export default {
       this.closeMenu()
       this.closeMenu()
       uni.navigateTo({ url: '/pages/family/add-member' })
       uni.navigateTo({ url: '/pages/family/add-member' })
     },
     },
-    // 邀请成员:弹窗展示分享海报(与 family-members.vue onInvite 一致的模式
+    // 邀请成员:跳转家庭成员管理页,使用小程序原生分享(转发邀请卡片给微信好友
     inviteMember: function() {
     inviteMember: function() {
-      var self = this
       this.closeMenu()
       this.closeMenu()
-      var userInfo = uni.getStorageSync('userInfo')
-      this.posterNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
-      this.posterAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
-      this.inviteCode = ''
-      this.inviteQrCode = ''
-      uni.showLoading({ title: '生成邀请...' })
-      generateFamilyInviteQrCode()
-        .then(function(res) {
-          uni.hideLoading()
-          if (res.code === 200 && res.data) {
-            self.inviteCode = res.data.inviteCode
-            self.inviteQrCode = res.data.qrCodeBase64
-            self.showPoster = true
-          } else {
-            uni.showToast({ title: res.message || '生成失败', icon: 'none' })
-          }
-        })
-        .catch(function() {
-          uni.hideLoading()
-          uni.showToast({ title: '生成失败', icon: 'none' })
-        })
+      uni.navigateTo({ url: '/pages/profile-extra/family-members' })
     },
     },
     loadPairwise: function(centerMemberId) {
     loadPairwise: function(centerMemberId) {
       var self = this
       var self = this

+ 21 - 13
cfc-frontend/components/report-blocks-renderer.vue

@@ -5,18 +5,18 @@
       <view v-if="block.type === 'score'" class="block score-block">
       <view v-if="block.type === 'score'" class="block score-block">
         <view class="block-title">{{ block.title }}</view>
         <view class="block-title">{{ block.title }}</view>
         <view class="score-grid">
         <view class="score-grid">
-<view class="score-item" v-for="(item, si) in block.items" :key="getScoreKey(si)">
-              <view class="score-circle" :style="'border-color:' + (item.color || '#4A9BD7')">
-                <text class="score-value">{{ item.value }}</text>
-              </view>
-              <text class="score-label">{{ item.label }}</text>
-              <view class="sub-items" v-if="item.subItems && item.subItems.length" style="margin-top:8rpx; display:flex; flex-direction:column; align-items:center;">
-                <view class="sub-item" v-for="(sub, ssi) in item.subItems" :key="getSubKey(ssi)" style="display:flex; flex-direction:row; align-items:center;">
-                  <text class="sub-label" style="font-size:20rpx; color:#666; margin-right:4rpx;">{{ sub.label }}:</text>
-                  <text class="sub-value" style="font-size:20rpx; color:#333;">{{ sub.value }}</text>
-                </view>
+          <view class="score-item" v-for="(item, si) in block.items" :key="getScoreKey(si)">
+            <view class="score-circle" :style="'border-color:' + (item.color || '#4A9BD7')">
+              <text class="score-value">{{ item.value }}</text>
+            </view>
+            <text class="score-label">{{ item.label }}</text>
+            <view class="sub-items" v-if="item.subItems && item.subItems.length" style="margin-top:8rpx; display:flex; flex-direction:column; align-items:center;">
+              <view class="sub-item" v-for="(sub, ssi) in item.subItems" :key="getSubKey(ssi)" style="display:flex; flex-direction:row; align-items:center;">
+                <text class="sub-label" style="font-size:20rpx; color:#666; margin-right:4rpx;">{{ sub.label }}:</text>
+                <text class="sub-value" style="font-size:20rpx; color:#333;">{{ sub.value }}</text>
               </view>
               </view>
             </view>
             </view>
+          </view>
         </view>
         </view>
       </view>
       </view>
 
 
@@ -28,6 +28,7 @@
           <text class="risk-name">{{ item.name }}</text>
           <text class="risk-name">{{ item.name }}</text>
           <text class="risk-value">风险值: {{ item.value || '--' }}</text>
           <text class="risk-value">风险值: {{ item.value || '--' }}</text>
           <text class="risk-badge" :class="'badge-' + riskCss(item.level)">{{ riskText(item.level) }}</text>
           <text class="risk-badge" :class="'badge-' + riskCss(item.level)">{{ riskText(item.level) }}</text>
+          <text class="ind-help-btn" @tap.stop="onHelp(item, 'indicator')">?</text>
         </view>
         </view>
       </view>
       </view>
 
 
@@ -41,19 +42,22 @@
           <text class="ind-name">{{ item.name }}</text>
           <text class="ind-name">{{ item.name }}</text>
           <text class="ind-value">{{ item.value }} {{ item.unit }}</text>
           <text class="ind-value">{{ item.value }} {{ item.unit }}</text>
           <text class="ind-status" :class="'status-' + statusCss(item.status)">{{ item.status }}</text>
           <text class="ind-status" :class="'status-' + statusCss(item.status)">{{ item.status }}</text>
+          <text class="ind-help-btn" @tap.stop="onHelp(item, 'indicator')">?</text>
         </view>
         </view>
       </view>
       </view>
 
 
-      <!-- list: 通用列表(columns 驱动) -->
+      <!-- list: 通用列表(columns 驱动,含 ? 按钮) -->
       <view v-else-if="block.type === 'list'" class="block list-block">
       <view v-else-if="block.type === 'list'" class="block list-block">
         <view class="block-title">{{ block.title }}</view>
         <view class="block-title">{{ block.title }}</view>
         <view class="list-head" v-if="block.columns">
         <view class="list-head" v-if="block.columns">
           <text class="list-cell head-cell" v-for="(colItem, ci) in block.columns" :key="getColumnKey(ci)"
           <text class="list-cell head-cell" v-for="(colItem, ci) in block.columns" :key="getColumnKey(ci)"
                 :style="'flex:' + (ci === 0 ? 2 : 1)">{{ colItem.label }}</text>
                 :style="'flex:' + (ci === 0 ? 2 : 1)">{{ colItem.label }}</text>
+          <text class="list-cell head-cell" style="flex:0 0 60rpx;">帮助</text>
         </view>
         </view>
         <view class="list-row" v-for="(item, li) in block.items" :key="getListItemKey(li)">
         <view class="list-row" v-for="(item, li) in block.items" :key="getListItemKey(li)">
           <text class="list-cell" :style="'flex:' + (ci === 0 ? 2 : 1)"
           <text class="list-cell" :style="'flex:' + (ci === 0 ? 2 : 1)"
                 v-for="(colItem, ci) in block.columns" :key="getListColumnKey(ci)">{{ item[colItem.key] || '--' }}</text>
                 v-for="(colItem, ci) in block.columns" :key="getListColumnKey(ci)">{{ item[colItem.key] || '--' }}</text>
+          <text class="list-cell ind-help-btn" style="flex:0 0 60rpx; text-align:center;" @tap.stop="onHelp(item, 'bacteria')">?</text>
         </view>
         </view>
       </view>
       </view>
 
 
@@ -82,7 +86,6 @@ export default {
     getBlockKey: function(bi) { return 'b' + bi },
     getBlockKey: function(bi) { return 'b' + bi },
     getSubKey: function(ssi) { return 'sub' + ssi },
     getSubKey: function(ssi) { return 'sub' + ssi },
     getScoreKey: function(si) { return 's' + si },
     getScoreKey: function(si) { return 's' + si },
-  getSubKey: function(ssi) { return 'sub' + ssi },
     getRiskKey: function(ri) { return 'r' + ri },
     getRiskKey: function(ri) { return 'r' + ri },
     getIndicatorKey: function(ii) { return 'i' + ii },
     getIndicatorKey: function(ii) { return 'i' + ii },
     getColumnKey: function(ci) { return 'c' + ci },
     getColumnKey: function(ci) { return 'c' + ci },
@@ -99,6 +102,9 @@ export default {
     statusCss: function(status) {
     statusCss: function(status) {
       var map = { '偏高': 'high', '偏低': 'low', '缺乏': 'low', '不足': 'low', '过多': 'high', '异常': 'high' }
       var map = { '偏高': 'high', '偏低': 'low', '缺乏': 'low', '不足': 'low', '过多': 'high', '异常': 'high' }
       return map[status] || 'normal'
       return map[status] || 'normal'
+    },
+    onHelp: function(item, blockType) {
+      this.$emit('help', item, blockType)
     }
     }
   }
   }
 }
 }
@@ -140,4 +146,6 @@ export default {
 /* text */
 /* text */
 .text-content { font-size: 26rpx; color: #555; line-height: 1.7; white-space: pre-wrap; }
 .text-content { font-size: 26rpx; color: #555; line-height: 1.7; white-space: pre-wrap; }
 .chart-placeholder { font-size: 24rpx; color: #ccc; }
 .chart-placeholder { font-size: 24rpx; color: #ccc; }
-</style>
+/* help button */
+.ind-help-btn { display: inline-flex; align-items: center; justify-content: center; width: 36rpx; height: 36rpx; border-radius: 50%; background: #E3F2FD; color: #1976D2; font-size: 22rpx; font-weight: 600; margin-left: 8rpx; flex-shrink: 0; }
+</style>

+ 6 - 0
cfc-frontend/pages.json

@@ -623,6 +623,12 @@
           "style": {
           "style": {
             "navigationBarTitleText": "管家入驻"
             "navigationBarTitleText": "管家入驻"
           }
           }
+        },
+        {
+          "path": "select",
+          "style": {
+            "navigationBarTitleText": "选择管家"
+          }
         }
         }
       ]
       ]
     },
     },

+ 1 - 1
cfc-frontend/pages/ai/chat.vue

@@ -38,7 +38,7 @@
       <view class="message-list-inner">
       <view class="message-list-inner">
         <view class="welcome-card" v-if="messages.length === 0 && !sending">
         <view class="welcome-card" v-if="messages.length === 0 && !sending">
           <view class="welcome-icon">{{ mascotIcon || (isNutrition ? '🥗' : (isHealthCoach ? '💡' : '🤖')) }}</view>
           <view class="welcome-icon">{{ mascotIcon || (isNutrition ? '🥗' : (isHealthCoach ? '💡' : '🤖')) }}</view>
-          <text class="welcome-title">{{ isNutrition ? '你好!我是营养助手' : (isHealthCoach ? '你好!我是健康教练' : (isButler ? '你好!我是健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>
+          <text class="welcome-title">{{ isNutrition ? '你好!我是营养助手' : (isHealthCoach ? ('你好!我是' + (mascotName || '健康教练')) : (isButler ? '你好!我是健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>
           <text class="welcome-desc">{{ welcomeSubtitle }}</text>
           <text class="welcome-desc">{{ welcomeSubtitle }}</text>
           <view class="welcome-suggestions">
           <view class="welcome-suggestions">
             <view class="welcome-suggestion" v-if="!isNutrition && !isHealthCoach" @click="quickQuestion('小明最近任务完成怎么样?')">
             <view class="welcome-suggestion" v-if="!isNutrition && !isHealthCoach" @click="quickQuestion('小明最近任务完成怎么样?')">

+ 172 - 0
cfc-frontend/pages/butler/select.vue

@@ -0,0 +1,172 @@
+<template>
+  <view class="select-page">
+    <!-- 顶部状态区 -->
+    <view class="status-card" v-if="pageState === 'bound'">
+      <view class="current-label">我的管家</view>
+      <view class="current-row">
+        <image class="current-avatar" :src="myButler.avatar || '/static/logo.png'" mode="aspectFill" />
+        <view class="current-info">
+          <text class="current-name">{{ myButler.nickname }}</text>
+          <text class="current-date">{{ formatDate(myButler.assignedAt) }} 绑定</text>
+        </view>
+      </view>
+      <view class="current-desc">{{ myButler.description }}</view>
+      <button class="btn-change" @click="scrollToList">更换管家</button>
+    </view>
+    <view class="upgrade-banner" v-if="pageState === 'locked'" @click="goUpgrade">
+      <text class="upgrade-text">久久一生(L2)会员专享,升级后可选择专属管家</text>
+      <text class="upgrade-arrow">›</text>
+    </view>
+
+    <!-- 管家列表 -->
+    <view class="list-section" id="butlerList">
+      <view class="section-title">可选管家</view>
+      <view class="butler-card" v-for="(item, idx) in butlers" :key="getItemKey(idx)"
+        :class="{ active: myButler && myButler.butlerUserId === item.butlerUserId }">
+        <image class="card-avatar" :src="item.avatar || '/static/logo.png'" mode="aspectFill" />
+        <view class="card-body">
+          <view class="card-top">
+            <text class="card-name">{{ item.nickname }}</text>
+            <text class="card-slots">剩余名额 {{ item.remainingSlots }}</text>
+          </view>
+          <text class="card-desc">{{ item.description || '专业家庭健康管家' }}</text>
+        </view>
+        <button class="btn-select"
+          :disabled="pageState === 'locked' || item.remainingSlots <= 0"
+          @click="onSelect(item)">
+          {{ myButler && myButler.butlerUserId === item.butlerUserId ? '当前' : '选择' }}
+        </button>
+      </view>
+      <view class="empty-tip" v-if="butlers.length === 0 && !loading">暂无可选管家</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getAvailableButlers, getMyButler, selectButler } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      loading: false,
+      pageState: 'browse', // browse=可浏览未绑定 / bound=已绑定 / locked=非L2
+      lockReason: '',
+      myButler: null,
+      butlers: [],
+      keySeq: 0
+    }
+  },
+  onLoad() {
+    this.initPage()
+  },
+  onShow() {
+    if (this.myButler || this.pageState === 'bound') {
+      this.refreshMyButler()
+    }
+  },
+  methods: {
+    getItemKey(idx) {
+      var item = this.butlers[idx]
+      return item && item.butlerUserId ? 'b_' + item.butlerUserId : 'idx_' + idx
+    },
+    formatDate(iso) {
+      if (!iso) return ''
+      return iso.substring(0, 19).replace('T', ' ').substring(0, 16)
+    },
+    initPage() {
+      var that = this
+      that.loading = true
+      Promise.all([getAvailableButlers(), getMyButler()])
+        .then(function (resArr) {
+          var listRes = resArr[0]
+          var myRes = resArr[1]
+          if (listRes && listRes.code === 200) {
+            that.butlers = listRes.data || []
+          }
+          if (myRes && myRes.code === 200 && myRes.data) {
+            that.myButler = myRes.data
+            that.pageState = 'bound'
+          }
+        })
+        .catch(function () {
+          uni.showToast({ title: '加载失败', icon: 'none' })
+        })
+        .then(function () { that.loading = false })
+    },
+    refreshMyButler() {
+      var that = this
+      getMyButler().then(function (res) {
+        if (res && res.code === 200) {
+          that.myButler = res.data
+          that.pageState = res.data ? 'bound' : 'browse'
+        }
+      })
+    },
+    markLocked(msg) {
+      this.pageState = 'locked'
+      this.lockReason = msg || ''
+    },
+    onSelect(item) {
+      var that = this
+      if (that.myButler && that.myButler.butlerUserId === item.butlerUserId) return
+      uni.showModal({
+        title: '确认选择',
+        content: '确定选择「' + item.nickname + '」作为您的专属管家?',
+        success: function (mr) {
+          if (!mr.confirm) return
+          selectButler(item.butlerUserId).then(function (res) {
+            if (res && res.code === 200) {
+              uni.showToast({ title: '绑定成功', icon: 'success' })
+              that.initPage()
+            } else if (res && res.message && res.message.indexOf('久久一生') !== -1) {
+              that.markLocked(res.message)
+              uni.showModal({
+                title: '会员权益',
+                content: res.message,
+                showCancel: false,
+                success: function () { that.goUpgrade() }
+              })
+            } else {
+              uni.showToast({ title: (res && res.message) || '绑定失败', icon: 'none' })
+            }
+          })
+        }
+      })
+    },
+    scrollToList() {
+      uni.pageScrollTo({ selector: '#butlerList', duration: 300 })
+    },
+    goUpgrade() {
+      uni.navigateTo({ url: '/pages/membership/upgrade' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.select-page { min-height: 100vh; background: #f5fafe; padding: 24rpx; box-sizing: border-box; display: flex; flex-direction: column; }
+.status-card { background: #fff; border-radius: 20rpx; padding: 32rpx; margin-bottom: 24rpx; }
+.current-label { font-size: 24rpx; color: #999; margin-bottom: 16rpx; }
+.current-row { display: flex; align-items: center; }
+.current-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; margin-right: 20rpx; }
+.current-info { display: flex; flex-direction: column; }
+.current-name { font-size: 34rpx; font-weight: bold; color: #333; }
+.current-date { font-size: 22rpx; color: #999; margin-top: 6rpx; }
+.current-desc { font-size: 26rpx; color: #666; margin-top: 20rpx; line-height: 1.6; }
+.btn-change { margin-top: 24rpx; background: #4a9bd7; color: #fff; font-size: 28rpx; border-radius: 40rpx; }
+.upgrade-banner { display: flex; justify-content: space-between; align-items: center; background: #fff7ed; border: 1rpx solid #ffb56b; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; }
+.upgrade-text { font-size: 26rpx; color: #d4770a; flex: 1; }
+.upgrade-arrow { font-size: 36rpx; color: #d4770a; margin-left: 12rpx; }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; margin: 8rpx 0 20rpx; }
+.butler-card { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 28rpx; margin-bottom: 20rpx; }
+.butler-card.active { border: 2rpx solid #4a9bd7; }
+.card-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; margin-right: 20rpx; flex-shrink: 0; }
+.card-body { flex: 1; min-width: 0; }
+.card-top { display: flex; align-items: center; justify-content: space-between; }
+.card-name { font-size: 30rpx; font-weight: bold; color: #333; }
+.card-slots { font-size: 22rpx; color: #10b981; }
+.card-desc { display: block; font-size: 24rpx; color: #888; margin-top: 8rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.btn-select { width: 120rpx; height: 60rpx; line-height: 60rpx; padding: 0; font-size: 26rpx; background: #4a9bd7; color: #fff; border-radius: 30rpx; flex-shrink: 0; margin-left: 16rpx; }
+.btn-select[disabled] { background: #ccc; color: #fff; }
+.empty-tip { text-align: center; color: #999; font-size: 26rpx; padding: 60rpx 0; }
+</style>

+ 75 - 7
cfc-frontend/pages/health/report-detail.vue

@@ -13,7 +13,7 @@
 
 
       <!-- view mode -->
       <!-- view mode -->
       <view v-if="pageMode==='view'">
       <view v-if="pageMode==='view'">
-        <report-blocks-renderer v-if="blocks && blocks.length > 0" :blocks="blocks" />
+        <report-blocks-renderer v-if="blocks && blocks.length > 0" :blocks="blocks" @help="onBlockHelp" />
         <view class="empty-hint" v-else>暂无报告内容</view>
         <view class="empty-hint" v-else>暂无报告内容</view>
         <view class="edit-btn" v-if="isGutFlora && pageMode==='view'">
         <view class="edit-btn" v-if="isGutFlora && pageMode==='view'">
           <view class="nav-edit" @tap="enterEditMode"><text>{{ backendBlocksExist ? '编辑' : '查看完整数据 ›' }}</text></view>
           <view class="nav-edit" @tap="enterEditMode"><text>{{ backendBlocksExist ? '编辑' : '查看完整数据 ›' }}</text></view>
@@ -391,7 +391,7 @@
 
 
 <script>
 <script>
 import ReportBlocksRenderer from '../../components/report-blocks-renderer.vue'
 import ReportBlocksRenderer from '../../components/report-blocks-renderer.vue'
-import { getReportDetail, getDanReportDetail, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember } from '../../utils/api.js'
+import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
 
 
 export default {
 export default {
@@ -447,7 +447,8 @@ export default {
       groupExpandList: [],
       groupExpandList: [],
       collapseState: { gutFlora: false, diseaseRisk: false, probiotic: false, taxonomy: false, pathogen: false },
       collapseState: { gutFlora: false, diseaseRisk: false, probiotic: false, taxonomy: false, pathogen: false },
       showHelpPopup: false,
       showHelpPopup: false,
-      helpData: {}
+      helpData: {},
+      _kbCache: {}
     }
     }
   },
   },
   computed: {
   computed: {
@@ -634,10 +635,23 @@ export default {
         pathogenDetection: []
         pathogenDetection: []
       }
       }
     },
     },
-    enterEditMode() {
-      this.pageMode = 'edit'
-      this.directEdit = false
-      this.showMemberPanel = false
+    enterEditMode: function() {
+      var self = this
+      self.pageMode = 'edit'
+      self.directEdit = false
+      self.showMemberPanel = false
+      // 按需加载 payload(编辑回填)
+      if (self.reportId && self.reportType !== 'dan') {
+        uni.showLoading({ title: '加载编辑数据...' })
+        getReportPayload(self.reportId).then(function(res) {
+          uni.hideLoading()
+          if (res.code === 200 && res.data && res.data.payload) {
+            self.payload = Object.assign({}, self.defaultPayload(), res.data.payload)
+          }
+        }).catch(function() {
+          uni.hideLoading()
+        })
+      }
     },
     },
     cancelEdit() {
     cancelEdit() {
       // 从列表 mode=edit 直入编辑模式时,取消等同返回上一页(报告卡片/报告列表)
       // 从列表 mode=edit 直入编辑模式时,取消等同返回上一页(报告卡片/报告列表)
@@ -817,6 +831,60 @@ export default {
       }
       }
       this.showHelpPopup = true
       this.showHelpPopup = true
     },
     },
+    /** blocks renderer ? 按钮点击 — 按需查询知识库(页面级缓存) */
+    onBlockHelp: function(item, blockType) {
+      var self = this
+      var name = item.name || ''
+      if (!name) return
+      var cacheKey = blockType + ':' + name
+      if (self._kbCache[cacheKey]) {
+        self.helpData = self._kbCache[cacheKey]
+        self.showHelpPopup = true
+        return
+      }
+      uni.showLoading({ title: '加载说明...' })
+      var queryFn = blockType === 'bacteria' ? queryBacteriaKnowledge : queryIndicatorKnowledge
+      queryFn(name).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200 && res.data) {
+          var v3 = res.data
+          var high = v3['偏高影响'] || ''
+          var low = v3['偏低影响'] || ''
+          var suggestion = v3['调整建议'] || ''
+          var desc = ''
+          if (high) desc += '偏高影响:' + high + '\n'
+          if (low) desc += '偏低影响:' + low
+          if (suggestion) desc += (desc ? '\n' : '') + '调整建议:' + suggestion
+          self.helpData = {
+            name: v3['名称'] || name,
+            value: item.value || '',
+            refRange: item.refRange || '',
+            status: item.status || 'normal',
+            description: desc || v3['说明'] || self.getDefaultDescription(name, item.status)
+          }
+        } else {
+          self.helpData = {
+            name: name,
+            value: item.value || '',
+            refRange: item.refRange || '',
+            status: item.status || 'normal',
+            description: self.getDefaultDescription(name, item.status)
+          }
+        }
+        self._kbCache[cacheKey] = self.helpData
+        self.showHelpPopup = true
+      }).catch(function() {
+        uni.hideLoading()
+        self.helpData = {
+          name: name,
+          value: item.value || '',
+          refRange: item.refRange || '',
+          status: item.status || 'normal',
+          description: self.getDefaultDescription(name, item.status)
+        }
+        self.showHelpPopup = true
+      })
+    },
     closeHelp() {
     closeHelp() {
       this.showHelpPopup = false
       this.showHelpPopup = false
     },
     },

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

@@ -147,7 +147,8 @@ export default {
       uni.setStorageSync('isSwitchedChild', false)
       uni.setStorageSync('isSwitchedChild', false)
       uni.setStorageSync('isSwitchedTeacher', false)
       uni.setStorageSync('isSwitchedTeacher', false)
       if (data.openid) uni.setStorageSync('openid', data.openid)
       if (data.openid) uni.setStorageSync('openid', data.openid)
-      uni.setStorageSync('userInfo', { nickname: data.nickname, userId: data.userId })
+      // familyId 必须持久化:家庭在注册时已由后端创建,缺失会导致页面误判无家庭而重复创建
+      uni.setStorageSync('userInfo', { nickname: data.nickname, userId: data.userId, familyId: data.familyId })
       if (data.phone) uni.setStorageSync('phone', data.phone)
       if (data.phone) uni.setStorageSync('phone', data.phone)
       if (data.teacherStatus) uni.setStorageSync('teacherStatus', data.teacherStatus)
       if (data.teacherStatus) uni.setStorageSync('teacherStatus', data.teacherStatus)
       if (data.teacherRejectReason) uni.setStorageSync('teacherRejectReason', data.teacherRejectReason)
       if (data.teacherRejectReason) uni.setStorageSync('teacherRejectReason', data.teacherRejectReason)

+ 42 - 2
cfc-frontend/pages/membership/index.vue

@@ -45,6 +45,16 @@
         </view>
         </view>
       </view>
       </view>
 
 
+      <!-- 我的管家入口 -->
+      <view class="butler-entry" @click="goButlerSelect">
+        <view class="butler-entry-icon">🤵</view>
+        <view class="butler-entry-main">
+          <text class="butler-entry-title">{{ butlerEntryTitle }}</text>
+          <text class="butler-entry-sub">{{ butlerEntrySub }}</text>
+        </view>
+        <text class="butler-entry-arrow">›</text>
+      </view>
+
       <!-- 权益对比表 -->
       <!-- 权益对比表 -->
       <view class="compare-section">
       <view class="compare-section">
         <text class="section-title">权益对比</text>
         <text class="section-title">权益对比</text>
@@ -68,7 +78,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getMyMembership } from '../../utils/api.js'
+import { getMyMembership, getMyButler } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
 
 
 export default {
 export default {
@@ -86,11 +96,22 @@ export default {
         { name: '专属活动', free: '部分参与', family: '全部参与', premium: '优先参与' },
         { name: '专属活动', free: '部分参与', family: '全部参与', premium: '优先参与' },
         { name: '成长报告', free: '月度', family: '每周+深度', premium: '每日+深度+专家解读' },
         { name: '成长报告', free: '月度', family: '每周+深度', premium: '每日+深度+专家解读' },
         { name: '任务配额', free: '每日5个', family: '不限量', premium: '不限量+优先审核' }
         { name: '任务配额', free: '每日5个', family: '不限量', premium: '不限量+优先审核' }
-      ]
+      ],
+      myButlerInfo: null,
+      isL2Active: false
+    }
+  },
+  computed: {
+    butlerEntryTitle() {
+      return this.myButlerInfo ? ('我的管家 · ' + this.myButlerInfo.nickname) : '我的管家'
+    },
+    butlerEntrySub() {
+      return this.myButlerInfo ? '点击查看或更换' : '久久一生会员可自助选择专属管家'
     }
     }
   },
   },
   onShow() {
   onShow() {
     this.loadData()
     this.loadData()
+    this.loadButlerEntry()
   },
   },
   methods: {
   methods: {
     loadData() {
     loadData() {
@@ -113,6 +134,17 @@ export default {
     goBenefits() {
     goBenefits() {
       uni.navigateTo({ url: '/pages/membership/benefits' })
       uni.navigateTo({ url: '/pages/membership/benefits' })
     },
     },
+    goButlerSelect() {
+      uni.navigateTo({ url: '/pages/butler/select' })
+    },
+    loadButlerEntry() {
+      var that = this
+      getMyButler().then(function (res) {
+        if (res && res.code === 200 && res.data) {
+          that.myButlerInfo = res.data
+        }
+      }).catch(function () {})
+    },
     formatDate(date) {
     formatDate(date) {
       if (!date) return ''
       if (!date) return ''
       var d = parseDate(date)
       var d = parseDate(date)
@@ -340,4 +372,12 @@ export default {
   color: #F97316;
   color: #F97316;
   font-weight: bold;
   font-weight: bold;
 }
 }
+
+/* ===== 我的管家入口 ===== */
+.butler-entry { display: flex; align-items: center; background: #fff; border-radius: 16rpx; padding: 28rpx; margin: 20rpx 0; }
+.butler-entry-icon { font-size: 48rpx; margin-right: 20rpx; }
+.butler-entry-main { flex: 1; display: flex; flex-direction: column; }
+.butler-entry-title { font-size: 30rpx; font-weight: bold; color: #333; }
+.butler-entry-sub { font-size: 24rpx; color: #999; margin-top: 6rpx; }
+.butler-entry-arrow { font-size: 40rpx; color: #ccc; }
 </style>
 </style>

+ 76 - 79
cfc-frontend/pages/profile-extra/family-members.vue

@@ -28,7 +28,7 @@
       <text class="empty-icon">👨‍👩‍👧‍👦</text>
       <text class="empty-icon">👨‍👩‍👧‍👦</text>
       <text class="empty-title">还没有家庭成员</text>
       <text class="empty-title">还没有家庭成员</text>
       <text class="empty-desc">邀请家人加入,一起记录成长</text>
       <text class="empty-desc">邀请家人加入,一起记录成长</text>
-      <button class="empty-invite-btn" @click="onInvite">📨 邀请家人</button>
+      <button class="empty-invite-btn" open-type="share">📨 邀请家人</button>
     </view>
     </view>
 
 
     <!-- ===== 成员详情弹窗 ===== -->
     <!-- ===== 成员详情弹窗 ===== -->
@@ -90,7 +90,7 @@
     <!-- ===== 底部操作栏 ===== -->
     <!-- ===== 底部操作栏 ===== -->
     <view class="bottom-bar">
     <view class="bottom-bar">
       <button class="bottom-btn btn-add" @click="onAddMember">+ 添加成员</button>
       <button class="bottom-btn btn-add" @click="onAddMember">+ 添加成员</button>
-      <button class="bottom-btn btn-invite" @click="onInvite">📨 邀请成员</button>
+      <button class="bottom-btn btn-invite" open-type="share">📨 邀请成员</button>
     </view>
     </view>
 
 
     <!-- ===== 加载状态 ===== -->
     <!-- ===== 加载状态 ===== -->
@@ -109,7 +109,6 @@ import {
   updateFamilyMember
   updateFamilyMember
 } from '../../utils/api.js'
 } from '../../utils/api.js'
 import store from '../../store/index.js'
 import store from '../../store/index.js'
-import { ensureFamily } from '../../utils/api.js'
 
 
 export default {
 export default {
   components: {},
   components: {},
@@ -128,7 +127,9 @@ export default {
       detailMember: null,
       detailMember: null,
       memberScores: {},
       memberScores: {},
       userNickname: '',
       userNickname: '',
-      userAvatar: ''
+      userAvatar: '',
+
+      inviteToken: ''
     }
     }
   },
   },
   computed: {
   computed: {
@@ -138,19 +139,6 @@ export default {
     detailMemberScores() {
     detailMemberScores() {
       if (!this.detailMember) return {}
       if (!this.detailMember) return {}
       return this.memberScores[this.detailMember.id] || {}
       return this.memberScores[this.detailMember.id] || {}
-    },
-    hasFamily: function() {
-      if (this.members && this.members.length > 0) return true
-      return this.$store.getters.hasFamily
-    }
-  },
-  onLoad(options) {
-    // 无家庭时自动创建
-    if (!this.hasFamily) {
-      var self = this
-      ensureFamily().then(function() {
-        self.loadMembers()
-      })
     }
     }
   },
   },
   onShow() {
   onShow() {
@@ -158,17 +146,57 @@ export default {
     this.userId = (userInfo && userInfo.id) || 0
     this.userId = (userInfo && userInfo.id) || 0
     this.userNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
     this.userNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
     this.userAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
     this.userAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
+    // 家庭在用户注册时已由后端创建(登录接口另有兜底),此处不再触发建家庭逻辑;
+    // 数据加载统一入口:单次 includeFamily 请求同时获取成员列表 + 家庭信息
     this.loadMembers()
     this.loadMembers()
+    // 预取邀请令牌(后端复用同家庭未用尽的有效令牌),保证点击分享时立即可用
+    this.prepareInviteToken()
+  },
+  // 页面级分享:转发邀请卡片,接收人点击直接进入加入家庭页(信息补充、家庭申请)
+  onShareAppMessage() {
+    var self = this
+    var title = '邀请你加入「' + (this.familyName || '我的家庭') + '」'
+    var buildResult = function(token) {
+      return {
+        title: title,
+        path: '/pages/invite/join?token=' + (token || ''),
+        success: function() {},
+        fail: function() {}
+      }
+    }
+    if (this.inviteToken) {
+      return buildResult(this.inviteToken)
+    }
+    // 令牌未就绪时异步获取(基础库 >=2.6.0 支持 promise,超时约 3 秒)
+    return {
+      title: title,
+      path: '/pages/index/index',
+      promise: new Promise(function(resolve) {
+        self.prepareInviteToken(function(token) {
+          resolve(token ? buildResult(token) : { path: '/pages/index/index' })
+        })
+      })
+    }
   },
   },
   methods: {
   methods: {
     async loadMembers() {
     async loadMembers() {
       this.loading = true
       this.loading = true
       try {
       try {
-        var res = await getFamilyMemberList()
+        var res = await getFamilyMemberList({ includeFamily: true })
         if (res.code === 200 && res.data) {
         if (res.code === 200 && res.data) {
-          this.members = res.data || []
-          this.buildScoreMap(res.data)
-          this.loadFamilyInfo()
+          var data = res.data
+          this.familyId = data.familyId
+          this.familyName = data.familyName
+          this.creatorId = data.creatorId
+          this.isAdmin = this.creatorId === this.userId
+          var arr = data.members
+          if (!(arr instanceof Array)) {
+            // 后端旧版本未返回 members 字段时的兼容:补一次无参请求
+            var legacy = await getFamilyMemberList()
+            arr = (legacy && legacy.code === 200 && legacy.data instanceof Array) ? legacy.data : []
+          }
+          this.members = arr || []
+          this.buildScoreMap(this.members)
         }
         }
       } catch (e) {
       } catch (e) {
         uni.showToast({ title: '加载失败', icon: 'none' })
         uni.showToast({ title: '加载失败', icon: 'none' })
@@ -190,26 +218,6 @@ export default {
       }
       }
       this.memberScores = map
       this.memberScores = map
     },
     },
-    async loadFamilyInfo() {
-      try {
-        var res = await getFamilyMemberList({ includeFamily: true })
-        if (res.code === 200 && res.data) {
-          var data = res.data
-          this.familyId = data.familyId
-          this.familyName = data.familyName
-          // inviteCode no longer needed
-          this.creatorId = data.creatorId
-          this.isAdmin = this.creatorId === this.userId
-        } else if (res.code === 5001 || (res.message && res.message.indexOf('未加入家庭') >= 0)) {
-          // 无家庭:自动创建
-          ensureFamily().then(function() {
-            self.loadMembers()
-          })
-        }
-      } catch (e) {
-        // 静默处理
-      }
-    },
     showMemberDetail(member) {
     showMemberDetail(member) {
       this.detailMember = member
       this.detailMember = member
       this.showMemberDetailModal = true
       this.showMemberDetailModal = true
@@ -268,46 +276,30 @@ export default {
         uni.showToast({ title: '操作失败', icon: 'none' })
         uni.showToast({ title: '操作失败', icon: 'none' })
       }
       }
     },
     },
-    async onInvite() {
-      uni.showLoading({ title: '生成邀请...' })
-      try {
-        var res = await generateFamilyInvite()
-        if (res.code === 200 && res.data) {
-          var token = res.data.token
-          uni.share({
-            provider: 'weixin',
-            scene: 'WXSceneSession',
-            type: 5,
-            title: '加入「' + (this.familyName || '我的家庭') + '」',
-            miniProgram: {
-              id: '',
-              path: 'pages/invite/join?token=' + token,
-              type: 0,
-              webUrl: 'https://cfc.etotem.com.cn/invite/join?token=' + token
-            },
-            success: function() {
-              uni.hideLoading()
-              uni.showToast({ title: '分享成功', icon: 'success' })
-            },
-            fail: function(err) {
-              uni.hideLoading()
-              if (err.errMsg.indexOf('cancel') >= 0) return
-              uni.setClipboardData({
-                data: 'https://cfc.etotem.com.cn/invite/join?token=' + token,
-                success: function() {
-                  uni.showToast({ title: '链接已复制', icon: 'success' })
-                }
-              })
-            }
-          })
-        } else {
-          uni.showToast({ title: res.message || '生成失败', icon: 'none' })
-          uni.hideLoading()
+    // 生成/复用邀请令牌(小程序原生分享卡片路径使用),完成后回调 callback(token)
+    prepareInviteToken(callback) {
+      var self = this
+      var done = function(token) {
+        if (typeof callback === 'function') {
+          callback(token)
         }
         }
-      } catch (e) {
-        uni.showToast({ title: '生成失败', icon: 'none' })
-        uni.hideLoading()
       }
       }
+      if (this.inviteToken) {
+        done(this.inviteToken)
+        return
+      }
+      generateFamilyInvite().then(function(res) {
+        if (res && res.code === 200 && res.data && res.data.token) {
+          self.inviteToken = res.data.token
+          done(self.inviteToken)
+        } else {
+          uni.showToast({ title: (res && res.message) || '生成邀请失败', icon: 'none' })
+          done('')
+        }
+      }).catch(function() {
+        uni.showToast({ title: '生成邀请失败', icon: 'none' })
+        done('')
+      })
     },
     },
     onEditRelation(member) {
     onEditRelation(member) {
       this.showMemberDetailModal = false
       this.showMemberDetailModal = false
@@ -481,6 +473,11 @@ export default {
   text-align: center;
   text-align: center;
   border: none;
   border: none;
 }
 }
+/* 微信小程序 button 默认边框来自 ::after 伪元素,分享按钮同样需要重置 */
+.empty-invite-btn::after,
+.bottom-btn::after {
+  border: none;
+}
 .bottom-btn:active {
 .bottom-btn:active {
   opacity: 0.85;
   opacity: 0.85;
 }
 }

+ 15 - 1
cfc-frontend/utils/api.js

@@ -250,6 +250,8 @@ export const createFamily = (name) => {
 }
 }
 
 
 // 确保家庭已存在:无家庭则自动创建(默认名)
 // 确保家庭已存在:无家庭则自动创建(默认名)
+// 注意:家庭在用户注册时已由后端创建,此函数仅作静默兜底,成功时不弹提示
+// (此前弹『家庭创建成功』toast 会误导用户,已移除;错误仍会提示)
 export const ensureFamily = () => {
 export const ensureFamily = () => {
 	var ui = uni.getStorageSync('userInfo') || {}
 	var ui = uni.getStorageSync('userInfo') || {}
 	if (ui.familyId) return Promise.resolve({ alreadyExists: true, familyId: ui.familyId })
 	if (ui.familyId) return Promise.resolve({ alreadyExists: true, familyId: ui.familyId })
@@ -259,7 +261,6 @@ export const ensureFamily = () => {
 			ui.familyId = res.data
 			ui.familyId = res.data
 			uni.setStorageSync('userInfo', ui)
 			uni.setStorageSync('userInfo', ui)
 			try { getApp().$store.commit('setFamilyId', res.data) } catch (e) {}
 			try { getApp().$store.commit('setFamilyId', res.data) } catch (e) {}
-			uni.showToast({ title: '家庭创建成功', icon: 'success' })
 			return { created: true, familyId: res.data }
 			return { created: true, familyId: res.data }
 		}
 		}
 		throw new Error((res && res.message) || '创建失败')
 		throw new Error((res && res.message) || '创建失败')
@@ -1955,6 +1956,8 @@ export const getFamilyReports = () => request('/api/health/reports/family', 'POS
 export const getReportDetail = (reportId) => request('/api/health/report/detail', 'POST', { reportId })
 export const getReportDetail = (reportId) => request('/api/health/report/detail', 'POST', { reportId })
 // DAN 报告详情(含 blocks)
 // DAN 报告详情(含 blocks)
 export const getDanReportDetail = (id) => request('/api/dan-report/' + id, 'POST', {})
 export const getDanReportDetail = (id) => request('/api/dan-report/' + id, 'POST', {})
+// 报告 payload(编辑回填按需加载)
+export const getReportPayload = (reportId) => request('/api/health/report/payload', 'POST', { reportId })
 
 
 export const editHealthReport = (reportId, payload, subjectId) => request('/api/health/report/edit', 'POST', { reportId, payload, subjectId })
 export const editHealthReport = (reportId, payload, subjectId) => request('/api/health/report/edit', 'POST', { reportId, payload, subjectId })
 
 
@@ -2711,3 +2714,14 @@ export function getChallengeProgress(memberId) {
     })
     })
   })
   })
 }
 }
+
+// ── 管家自助选择 ──
+export function getAvailableButlers() {
+  return request('/api/butler/available-list', 'POST', {})
+}
+export function getMyButler() {
+  return request('/api/butler/my-butler', 'POST', {})
+}
+export function selectButler(butlerUserId) {
+  return request('/api/butler/select', 'POST', { butlerUserId })
+}

+ 8 - 1
cfc-langgraph/app/graphs/health_coach_graph.py

@@ -86,7 +86,14 @@ def create_health_coach_graph():
 
 
     async def generate_answer(state: HealthCoachState) -> dict:
     async def generate_answer(state: HealthCoachState) -> dict:
         """带健康知识检索的 LLM 生成"""
         """带健康知识检索的 LLM 生成"""
-        messages = [SystemMessage(content=await get_prompt("health_coach") or DEFAULT_COACH_PROMPT)]
+        # 人格路由:context.coach_id (xibao/fubao) -> 专属prompt,三级回退保证可用性
+        _ctx = state.get("context") or {}
+        _coach_id = _ctx.get("coach_id")
+        if _coach_id not in ("xibao", "fubao"):
+            _coach_id = None
+        _persona_prompt = await get_prompt(f"health_coach_{_coach_id}") if _coach_id else None
+        _base_prompt = await get_prompt("health_coach")
+        messages = [SystemMessage(content=_persona_prompt or _base_prompt or DEFAULT_COACH_PROMPT)]
 
 
         # 家庭上下文
         # 家庭上下文
         ctx = state.get("context") or {}
         ctx = state.get("context") or {}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-8521180d9ceebdefc7e1ec36e373f54f55d6d055
+fa032844341a83c63d0b2cda673432153a4ff5b8

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

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1206",
+  "version": "1.0.1209",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1206",
+      "version": "1.0.1209",
       "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.1207",
+  "version": "1.0.1210",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,54 @@
 
 
 ---
 ---
 
 
+## v1.0.1210 (2026-08-23)
+
+### 文档
+- 登记管家自选与健康教练接口文档
+
+### 新功能
+- 报告详情优化-新增payload按需加载接口,blocks渲染器增加知识库?查询
+
+
+## v1.0.1209 (2026-08-23)
+
+### 文档
+- 更新健康启航计划CF奖励设计——每日4任务×225CF+里程碑7200CF
+
+
+## v1.0.1208 (2026-08-23)
+
+### 新功能
+- 管理端分配管理与停接开关页面
+- 健康教练欢迎语个性化与我的管家入口
+- 小程序管家选择页与接口封装
+- 健康教练graph按coach_id路由人格prompt
+- 健康教练对话注入教练人格标识
+- 管理端分配管理与停接开关接口
+- 邀请令牌复用同家庭有效令牌,组件邀请入口改跳成员管理页
+- includeFamily响应增加members字段,支持单次调用同时获取成员列表与家庭信息
+
+### 其他
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- ensureFamily改为静默兜底,移除误导性的家庭创建成功提示
+- 家庭成员编辑页合并为单次member/list调用,移除onLoad前端建家庭逻辑
+- 登录成功时将familyId持久化到userInfo缓存,避免页面误判无家庭而重复创建
+
+
 ## v1.0.1207 (2026-08-23)
 ## v1.0.1207 (2026-08-23)
 
 
 ### 新功能
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1207
+> 当前版本: v1.0.1210
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,54 @@
 
 
 ---
 ---
 
 
+## v1.0.1210 (2026-08-23)
+
+### 文档
+- 登记管家自选与健康教练接口文档
+
+### 新功能
+- 报告详情优化-新增payload按需加载接口,blocks渲染器增加知识库?查询
+
+
+## v1.0.1209 (2026-08-23)
+
+### 文档
+- 更新健康启航计划CF奖励设计——每日4任务×225CF+里程碑7200CF
+
+
+## v1.0.1208 (2026-08-23)
+
+### 新功能
+- 管理端分配管理与停接开关页面
+- 健康教练欢迎语个性化与我的管家入口
+- 小程序管家选择页与接口封装
+- 健康教练graph按coach_id路由人格prompt
+- 健康教练对话注入教练人格标识
+- 管理端分配管理与停接开关接口
+- 邀请令牌复用同家庭有效令牌,组件邀请入口改跳成员管理页
+- includeFamily响应增加members字段,支持单次调用同时获取成员列表与家庭信息
+
+### 其他
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- ensureFamily改为静默兜底,移除误导性的家庭创建成功提示
+- 家庭成员编辑页合并为单次member/list调用,移除onLoad前端建家庭逻辑
+- 登录成功时将familyId持久化到userInfo缓存,避免页面误判无家庭而重复创建
+
+
 ## v1.0.1207 (2026-08-23)
 ## v1.0.1207 (2026-08-23)
 
 
 ### 新功能
 ### 新功能

+ 27 - 0
cfc-web/src/api/butler.js

@@ -44,3 +44,30 @@ export function assignMemberToButler(data) {
     data: data
     data: data
   })
   })
 }
 }
+
+// 分配关系列表
+export function getButlerAssignments(params) {
+  return request({
+    url: '/api/admin/butler/assignments',
+    method: 'post',
+    data: params
+  })
+}
+
+// 强制解绑
+export function unassignButler(data) {
+  return request({
+    url: '/api/admin/butler/unassign',
+    method: 'post',
+    data: data
+  })
+}
+
+// 停接开关
+export function toggleButlerAccepting(data) {
+  return request({
+    url: '/api/admin/butler/toggle-accepting',
+    method: 'post',
+    data: data
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -161,6 +161,12 @@ const routes = [
         component: () => import('@/views/admin/ReviewCenter.vue'),
         component: () => import('@/views/admin/ReviewCenter.vue'),
         meta: { title: '审核中心', perm: 'audit' }
         meta: { title: '审核中心', perm: 'audit' }
       },
       },
+      {
+        path: 'butler-assignments',
+        name: 'ButlerAssignments',
+        component: () => import('@/views/admin/ButlerAssignments.vue'),
+        meta: { title: '管家分配管理', perm: 'audit' }
+      },
       {
       {
         path: 'operation-logs',
         path: 'operation-logs',
         name: 'OperationLogs',
         name: 'OperationLogs',

+ 87 - 0
cfc-web/src/views/admin/ButlerAssignments.vue

@@ -0,0 +1,87 @@
+<template>
+  <div class="app-container">
+    <el-card shadow="never">
+      <div slot="header">
+        <span>管家分配管理</span>
+      </div>
+      <el-table v-loading="loading" :data="records" border stripe>
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="familyId" label="家庭ID" width="100" />
+        <el-table-column prop="butlerNickname" label="管家" min-width="140" />
+        <el-table-column prop="butlerUserId" label="管家用户ID" width="110" />
+        <el-table-column prop="level" label="订阅级别" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.level === 'L2' ? 'warning' : 'info'" size="small">{{ row.level }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="绑定时间" width="170">
+          <template slot-scope="{ row }">{{ formatTime(row.assignedAt) }}</template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="danger" @click="onUnassign(row)">强制解绑</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-pagination
+        style="margin-top: 16px; text-align: right;"
+        layout="total, prev, pager, next"
+        :total="total"
+        :page-size="pageSize"
+        :current-page.sync="pageNum"
+        @current-change="loadData"
+      />
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { getButlerAssignments, unassignButler } from '@/api/butler'
+
+export default {
+  name: 'ButlerAssignments',
+  data() {
+    return {
+      loading: false,
+      records: [],
+      total: 0,
+      pageNum: 1,
+      pageSize: 10
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    formatTime(iso) {
+      if (!iso) return '-'
+      return iso.substring(0, 19).replace('T', ' ')
+    },
+    loadData() {
+      this.loading = true
+      getButlerAssignments({ pageNum: this.pageNum, pageSize: this.pageSize })
+        .then(res => {
+          const data = res.data || {}
+          this.records = data.records || []
+          this.total = data.total || 0
+        })
+        .finally(() => { this.loading = false })
+    },
+    onUnassign(row) {
+      this.$confirm(
+        `确定解除家庭 ${row.familyId} 与管家「${row.butlerNickname}」的绑定?`,
+        '强制解绑',
+        { type: 'warning' }
+      ).then(() => unassignButler({ assignmentId: row.id }))
+        .then(res => {
+          if (res.code === 200) {
+            this.$message.success('已解绑')
+            this.loadData()
+          } else {
+            this.$message.error(res.message || '操作失败')
+          }
+        }).catch(() => {})
+    }
+  }
+}
+</script>

+ 24 - 1
cfc-web/src/views/admin/review/ButlerAudit.vue

@@ -30,6 +30,15 @@
           <span v-else class="no-op">—</span>
           <span v-else class="no-op">—</span>
         </template>
         </template>
       </el-table-column>
       </el-table-column>
+      <el-table-column label="接单状态" width="110">
+        <template slot-scope="{ row }">
+          <el-switch
+            :value="row.accepting !== 0"
+            active-color="#13ce66"
+            @change="onToggleAccepting(row)" />
+          <div v-if="row.accepting === 0" style="font-size: 12px; color: #f56c6c;">已停接</div>
+        </template>
+      </el-table-column>
     </el-table>
     </el-table>
 
 
     <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
     <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="400px">
@@ -43,7 +52,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getButlerList, reviewButler } from '@/api/butler'
+import { getButlerList, reviewButler, toggleButlerAccepting } from '@/api/butler'
 import { searchUsers } from '@/api/admin'
 import { searchUsers } from '@/api/admin'
 
 
 export default {
 export default {
@@ -63,6 +72,20 @@ export default {
   methods: {
   methods: {
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusType(s) { return { pending: 'warning', approved: 'success', rejected: 'danger' }[s] || 'info' },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
     statusLabel(s) { return { pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s },
+    async onToggleAccepting(row) {
+      const next = row.accepting === 0 ? 1 : 0
+      try {
+        const res = await toggleButlerAccepting({ butlerUserId: row.userId, accepting: next })
+        if (res.code === 200) {
+          this.$set(row, 'accepting', next)
+          this.$message.success(next === 1 ? '已恢复接单' : '已停接新单')
+        } else {
+          this.$message.error(res.message || '操作失败')
+        }
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+    },
     async loadData() {
     async loadData() {
       this.loading = true
       this.loading = true
       try {
       try {

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

@@ -2,7 +2,7 @@
 
 
 **文档版本:** v2.9
 **文档版本:** v2.9
 **日期:** 2026-08-23
 **日期:** 2026-08-23
-**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间五维启动器,已提交)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(🔄 实施中,11 Tasks)**
+**状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间五维启动器,已提交)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)**
 **维护:** 所有需求变更需更新本文档
 **维护:** 所有需求变更需更新本文档
 
 
 ---
 ---
@@ -210,7 +210,7 @@
 | 订阅+推广核心实施(P0) | `plans/2026-07-09-family-membership-core.md` |
 | 订阅+推广核心实施(P0) | `plans/2026-07-09-family-membership-core.md` |
 | AI健康管家+推荐引擎(P1) | `plans/2026-07-22-comprehensive-implementation.md`(Phase 2 Wave 3) |
 | AI健康管家+推荐引擎(P1) | `plans/2026-07-22-comprehensive-implementation.md`(Phase 2 Wave 3) |
 | **LIFETIME 终身会员(方案A:等级扩展+实时聚合+管理员审核)** | `specs/2026-08-07-xaf-partner-ppt-redesign-design.md` + `plans/2026-08-09-lifetime-membership.md` |
 | **LIFETIME 终身会员(方案A:等级扩展+实时聚合+管理员审核)** | `specs/2026-08-07-xaf-partner-ppt-redesign-design.md` + `plans/2026-08-09-lifetime-membership.md` |
-| **AI健康教练人格分化与管家自助选择**(L2 自助绑管/换绑 + 浠宝/福宝人格话术 + 管理端分配管理与停接开关) 🔄 实施中 | `specs/2026-08-23-coach-butler-design.md` + `plans/2026-08-23-coach-butler.md` |
+| **AI健康教练人格分化与管家自助选择**(L2 自助绑管/换绑 + 浠宝/福宝人格话术 + 管理端分配管理与停接开关) ✅ 已完成 | `specs/2026-08-23-coach-butler-design.md` + `plans/2026-08-23-coach-butler.md` |
 
 
 ---
 ---
 
 
@@ -358,7 +358,7 @@
 | `2026-08-18-homepage-health-funnel-design.md` | ✅ 已实施(四段式漏斗:了解区自测卡/发现区沙盘+上传报告/行动区聚合/内容区降级;未登录态零改动) | 首页重构 — 主动健康漏斗(了解→发现→改变) |
 | `2026-08-18-homepage-health-funnel-design.md` | ✅ 已实施(四段式漏斗:了解区自测卡/发现区沙盘+上传报告/行动区聚合/内容区降级;未登录态零改动) | 首页重构 — 主动健康漏斗(了解→发现→改变) |
 | `2026-08-13-health-status-survey-design.md` | 🔄 v2 设计已定稿(2026-08-18;v1 已实施;v2 疾病史两源预置清单+确诊时间+服药时长) | 当前状态调研 — 健康现状档案 |
 | `2026-08-13-health-status-survey-design.md` | 🔄 v2 设计已定稿(2026-08-18;v1 已实施;v2 疾病史两源预置清单+确诊时间+服药时长) | 当前状态调研 — 健康现状档案 |
 | `2026-08-18-health-status-survey-v2.md` | 🟢 已实施(单文件改 health-status-form.vue;8 组 54 项预置清单分类多选+确诊时间;用药年月+时长自动计算;后端零变更;v1 旧数据兼容) | 当前状态调研 — 健康现状档案 v2 |
 | `2026-08-18-health-status-survey-v2.md` | 🟢 已实施(单文件改 health-status-form.vue;8 组 54 项预置清单分类多选+确诊时间;用药年月+时长自动计算;后端零变更;v1 旧数据兼容) | 当前状态调研 — 健康现状档案 v2 |
-| `2026-08-23-coach-butler-design.md` | 🔄 实施中 | AI健康教练人格分化(浠宝/福宝话术路由)与 L2 家庭管家自助选择设计 |
+| `2026-08-23-coach-butler-design.md` | ✅ 已实施 | AI健康教练人格分化(浠宝/福宝话术路由)与 L2 家庭管家自助选择设计 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
 
 
 ### 实施计划(plans/)
 ### 实施计划(plans/)
@@ -430,7 +430,7 @@
 | `2026-08-10-unlock-gates-implementation.md` | 🟡 待实施 | 通关解锁功能实施计划(三关递进:自检→个人沙盘/邀请→全家沙盘/创建→微行动;沙盘个人全家切换;8种判定+奖励发放;增值关卡购物/会员/报告;管理端关卡配置) |
 | `2026-08-10-unlock-gates-implementation.md` | 🟡 待实施 | 通关解锁功能实施计划(三关递进:自检→个人沙盘/邀请→全家沙盘/创建→微行动;沙盘个人全家切换;8种判定+奖励发放;增值关卡购物/会员/报告;管理端关卡配置) |
 | `2026-08-15-daily-task-overview.md` | 🟡 待实施 | 今日任务首页卡片实施计划(仅小程序接入 + 1个只读聚合接口 `/api/daily-task/overview`,聚合打卡/任务/阅读活动/测评报告四类,点击跳转+返回自动刷新;设计稿:2026-08-15-daily-task-overview-design.md) |
 | `2026-08-15-daily-task-overview.md` | 🟡 待实施 | 今日任务首页卡片实施计划(仅小程序接入 + 1个只读聚合接口 `/api/daily-task/overview`,聚合打卡/任务/阅读活动/测评报告四类,点击跳转+返回自动刷新;设计稿:2026-08-15-daily-task-overview-design.md) |
 | `2026-08-15-sku-price-cents-and-spec-selector.md` | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | SKU 规格价格统一分单位 + 详情页规格按钮修复实施计划 |
 | `2026-08-15-sku-price-cents-and-spec-selector.md` | ✅ 已实施(7 Tasks:实体/DTO/订单/购物车/迁移/管理端/小程序,5 commits) | SKU 规格价格统一分单位 + 详情页规格按钮修复实施计划 |
-| `2026-08-23-coach-butler.md` | 🔄 实施中(11 Tasks:DB迁移/实体Mapper/Service/控制器×2/AI注入/LangGraph路由/小程序/Web管理端/文档验收) | AI健康教练人格分化与管家自助选择实施计划(设计稿:2026-08-23-coach-butler-design.md) |
+| `2026-08-23-coach-butler.md` | ✅ 已完成(11 Tasks:DB迁移/实体Mapper/Service/控制器×2/AI注入/LangGraph路由/小程序/Web管理端/文档验收) | AI健康教练人格分化与管家自助选择实施计划(设计稿:2026-08-23-coach-butler-design.md) |
 
 
 ### 计划与设计文档(specs/)
 ### 计划与设计文档(specs/)
 
 

+ 11 - 2
docs/superpowers/api/API_REFERENCE.md

@@ -169,7 +169,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/family/invite/pending-requests` | 待审批列表 | — |
 | `POST /api/family/invite/pending-requests` | 待审批列表 | — |
 | `POST /api/family/invite/my-request` | 我的申请 | — |
 | `POST /api/family/invite/my-request` | 我的申请 | — |
 | `POST /api/family/invite/cancel-request` | 取消申请 | — |
 | `POST /api/family/invite/cancel-request` | 取消申请 | — |
-| `POST /api/family/invite/generate` | 生成邀请令牌 | — |
+| `POST /api/family/invite/generate` | 生成邀请令牌(复用同家庭未过期未用尽的有效令牌;仅未过期记录计入 10 条额度) | — |
 | `POST /api/family/invite/validate` | 验证邀请令牌 | — |
 | `POST /api/family/invite/validate` | 验证邀请令牌 | — |
 | `POST /api/family/invite/accept` | 接受邀请 | — |
 | `POST /api/family/invite/accept` | 接受邀请 | — |
 | `POST /api/family/invite/check-family` | 检查家庭状态 | — |
 | `POST /api/family/invite/check-family` | 检查家庭状态 | — |
@@ -323,7 +323,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/ai/chat/messages` | 消息列表 |
 | `POST /api/ai/chat/messages` | 消息列表 |
 | `POST /api/ai/chat/conversations/{id}/delete` | 删除对话 |
 | `POST /api/ai/chat/conversations/{id}/delete` | 删除对话 |
 | `POST /api/ai/nutrition/send` | 营养对话 |
 | `POST /api/ai/nutrition/send` | 营养对话 |
-| `POST /api/ai/health-coach/send` | 健康教练 |
+| `POST /api/ai/health-coach/send` | 健康教练(请求体可选字段 `coach_id` 由服务端自动注入,客户端无需传;LangGraph 按 xibao/fubao 三级回退路由话术) |
 | `POST /api/ai/butler/send` | 管家对话 |
 | `POST /api/ai/butler/send` | 管家对话 |
 | `POST /api/ai/context` | 上下文管理 |
 | `POST /api/ai/context` | 上下文管理 |
 | `POST /api/butler/sessions/create` | 创建会话 |
 | `POST /api/butler/sessions/create` | 创建会话 |
@@ -394,6 +394,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `/api/admin/assessment/*` | 测评材料管理 |
 | `/api/admin/assessment/*` | 测评材料管理 |
 | `/api/admin/bazi-config/*` | 八字配置 |
 | `/api/admin/bazi-config/*` | 八字配置 |
 | `/api/admin/blood-type-config/*` | 血型配置 |
 | `/api/admin/blood-type-config/*` | 血型配置 |
+| `/api/admin/butler/*` | 管家分配管理(分配列表/强制解绑/停接开关) |
 | `/api/admin/commission/*` | 佣金管理 |
 | `/api/admin/commission/*` | 佣金管理 |
 | `/api/admin/dimension/*` | 维度配置 |
 | `/api/admin/dimension/*` | 维度配置 |
 | `/api/admin/dimension-config/*` | 维度权重 |
 | `/api/admin/dimension-config/*` | 维度权重 |
@@ -633,6 +634,14 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 6. mvn clean compile 验证
 6. mvn clean compile 验证
 ```
 ```
 
 
+### 4.30 家庭管家选择(`/api/butler/*`)
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/butler/available-list` | 可选管家列表(浏览开放,脱敏;仅 L2 有效订阅家庭可绑定) |
+| `POST /api/butler/my-butler` | 我家当前管家绑定信息(含管家昵称/等级/分配时间) |
+| `POST /api/butler/select` | 绑定/更换管家(校验 L2 订阅、管家容量与接单状态;换绑自动解绑旧关系) |
+
 ---
 ---
 
 
 ## 六、待清理的废弃接口
 ## 六、待清理的废弃接口

+ 30 - 8
docs/superpowers/specs/2026-08-22-new-user-paid-plan.md

@@ -96,13 +96,13 @@ async function checkAccess() {
 
 
 ### 3.1 奖励结构(复利累积模式)
 ### 3.1 奖励结构(复利累积模式)
 
 
-| 周期 | 固定任务CF | 个性化任务CF | 连续打卡加成 | 周累计CF |
-|------|-----------|-------------|-------------|---------|
-| 第1周 | 100 CF | 60 CF | +20 CF/天(最多+140) | 300 CF |
-| 第2周 | 100 CF | 60 CF | +20 CF/天(最多+140) | 300 CF |
-| 第3周 | 100 CF | 60 CF | +20 CF/天(最多+140) | 300 CF |
-| **3周合计** | **300 CF** | **180 CF** | **最多+420 CF** | **最多900 CF/周,总计3000 CF(基础)+ 额外任务CF** |
-| **终极全勤** | — | — | — | **最高19,900 CF** |
+| 周期 | 每日任务 | 打卡加成 | 周累计CF |
+|------|---------|---------|---------|
+| 第1周 | 4个任务 ×250 CF + 1个个性化 ×175 CF = 1175 CF | +30 CF/天 | ~8,500 CF |
+| 第2周 | 同上 | +30 CF/天 | ~8,500 CF |
+| 第3周 | 同上 | +30 CF/天 | ~8,500 CF |
+| **里程碑** | 首次/3份/5份/8份报告 + 挑战完成 + 全勤 | — | ~8,200 CF |
+| **3周总计** | — | — | **≈19,900 CF** |
 
 
 **CF值与金额换算:**
 **CF值与金额换算:**
 - 1 CF值 = 0.01元(即100 CF = 1元)
 - 1 CF值 = 0.01元(即100 CF = 1元)
@@ -113,7 +113,29 @@ async function checkAccess() {
 - "完成任务就能拿回199元" → 7天体验期内极强打卡动力
 - "完成任务就能拿回199元" → 7天体验期内极强打卡动力
 - "全勤=免费体验" → 制造"不完成任务就亏了"的损失厌恶
 - "全勤=免费体验" → 制造"不完成任务就亏了"的损失厌恶
 - 续费等此时已有199元CF在账户,年费1999元实付1800元
 - 续费等此时已有199元CF在账户,年费1999元实付1800元
-- 可变奖励机制(打卡天数×20)让用户每天都有"再赚一点"的期待
+- 可变奖励机制(打卡天数×30)让用户每天都有"再赚一点"的期待
+
+**每日任务结构(4个):**
+| 任务类型 | 数量 | CF值/个 | 说明 |
+|---------|------|---------|------|
+| 固定任务 | 3个 | 250 CF | 上传报告/健康打卡/完成挑战 |
+| 个性化任务 | 1个 | 175 CF | 根据用户画像推荐(如早睡、注意力训练) |
+| 打卡加成 | 每日 | 30 CF | 连续打卡额外奖励 |
+
+**里程碑奖励(一次性):**
+| 里程碑 | CF值 |
+|--------|------|
+| 首次报告上传 | 300 CF |
+| 累计上传3份报告 | 500 CF |
+| 累计上传5份报告 | 800 CF |
+| 累计上传8份报告 | 1000 CF |
+| 完成3个挑战 | 500 CF |
+| 完成5个挑战 | 800 CF |
+| 连续7天打卡 | 800 CF |
+| 连续14天打卡 | 1000 CF |
+| 连续21天打卡 | 1500 CF |
+| 完成所有个性化任务 | 1000 CF |
+| **里程碑合计** | **8200 CF** |
 
 
 ### 3.2 CF值用途
 ### 3.2 CF值用途