|
|
@@ -0,0 +1,1325 @@
|
|
|
+# AI健康教练人格分化与管家自助选择 实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 复用吉祥物管线为健康教练提供浠宝/福宝双人格话术;L2 家庭可自助浏览并绑定/更换真人管家;管理端补齐分配管理与停接开关。
|
|
|
+
|
|
|
+**架构:** 后端在现有 butler 体系上增量扩展(1 列迁移 + 1 新实体 + ButlerService 方法 + 2 个控制器);AI 人格经 `/health-coach/send` 补注入 `coach_id`,经 AiGateway context 透传至 LangGraph 按 prompt_key 三级回退路由;小程序新增选择页三态视图;Web 管理端新增分配管理视图与开关列。
|
|
|
+
|
|
|
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus(Java 8)/ FastAPI + LangGraph(Python)/ uni-app Vue 2 Options API / Vue 2 + Element UI
|
|
|
+
|
|
|
+**规格:** `docs/superpowers/specs/2026-08-23-coach-butler-design.md`(已批准,commit 8cc0a758)
|
|
|
+
|
|
|
+**⚠️ 本仓库验证约定(覆盖技能默认 TDD 流程):**
|
|
|
+- 后端唯一验证方式:`cd cfc-backend && mvn clean compile`(EXIT 0 即通过,无测试基建)
|
|
|
+- 小程序前端验证:提取改动 `.vue` 的 `<script>` 块存临时文件后 `node --check`;**禁止**运行整包 build
|
|
|
+- 所有 git 命令带 `GIT_MASTER=1` 前缀;commit message 用中文 SEMANTIC 风格(feat:/fix:/docs:)
|
|
|
+- 每个任务结束必须 commit(单文件或强耦合文件组一个 commit)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构总览
|
|
|
+
|
|
|
+| # | 文件 | 动作 | 职责 |
|
|
|
+|---|------|------|------|
|
|
|
+| 1 | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 修改 | 迁移261:butler_profiles.accepting |
|
|
|
+| 2 | `cfc-backend/src/main/resources/schema.sql` | 修改 | 同步 accepting 列定义 |
|
|
|
+| 3 | `cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerProfile.java` | 修改 | 加 accepting 字段 |
|
|
|
+| 4 | `cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.java` | 创建 | butler_assignments 表实体(当前全库无引用,本功能为首写入方) |
|
|
|
+| 5 | `cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerAssignmentMapper.java` | 创建 | BaseMapper |
|
|
|
+| 6 | `cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java` | 修改 | 家庭端三方法 + 管理端三方法 |
|
|
|
+| 7 | `cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.java` | 创建 | 家庭端 3 端点 |
|
|
|
+| 8 | `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java` | 修改 | 管理端 3 端点 |
|
|
|
+| 9 | `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | 修改 | /health-coach/send 注入 coach_id |
|
|
|
+| 10 | `cfc-langgraph/app/graphs/health_coach_graph.py` | 修改 | generate_answer prompt 路由 |
|
|
|
+| 11 | `cfc-frontend/utils/api.js` | 修改 | 3 个接口封装 |
|
|
|
+| 12 | `cfc-frontend/pages/butler/select.vue` | 创建 | 管家选择页(三态) |
|
|
|
+| 13 | `cfc-frontend/pages.json` | 修改 | 注册 select 页 |
|
|
|
+| 14 | `cfc-frontend/pages/ai/chat.vue` | 修改 | 欢迎语带教练名 |
|
|
|
+| 15 | `cfc-frontend/pages/membership/index.vue` | 修改 | 我的管家入口卡片 |
|
|
|
+| 16 | `cfc-web/src/api/butler.js` | 修改 | 3 个封装 |
|
|
|
+| 17 | `cfc-web/src/views/admin/ButlerAssignments.vue` | 创建 | 分配管理视图 |
|
|
|
+| 18 | `cfc-web/src/views/admin/review/ButlerAudit.vue` | 修改 | 停接开关列 |
|
|
|
+| 19 | `docs/superpowers/api/API_REFERENCE.md` | 修改 | 登记 6 个新接口 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 1:DB 迁移 —— butler_profiles.accepting 停接开关列
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(迁移260 块之后,约 :9143 起)
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`(butler_profiles CREATE TABLE,约 :2646 commission_rate_l2 行后)
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerProfile.java`(commissionRateL2 字段后,约 :40)
|
|
|
+
|
|
|
+- [ ] **步骤 1.1:DatabaseInitializer 添加迁移 261**
|
|
|
+
|
|
|
+在 `// 迁移260: 创建圈子评论表` 整块结束后、下一个方法或空行处插入:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
|
|
|
+ ensureColumn("butler_profiles", "accepting",
|
|
|
+ "TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");
|
|
|
+```
|
|
|
+
|
|
|
+注意保持与相邻代码相同的缩进风格(该文件使用 Tab)。
|
|
|
+
|
|
|
+- [ ] **步骤 1.2:schema.sql 同步**
|
|
|
+
|
|
|
+在 `CREATE TABLE IF NOT EXISTS butler_profiles (` 块内,`commission_rate_l2 INT DEFAULT 5000,` 行之后插入一行:
|
|
|
+
|
|
|
+```sql
|
|
|
+ accepting TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单',
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 1.3:实体加字段**
|
|
|
+
|
|
|
+在 `ButlerProfile.java` 的 `private Integer commissionRateL2;` 之后加:
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 是否接受新家庭绑定: 0=停接/1=接单
|
|
|
+ */
|
|
|
+ private Integer accepting;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 1.4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 1.5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
|
|
|
+ cfc-backend/src/main/resources/schema.sql \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerProfile.java
|
|
|
+GIT_MASTER=1 git commit -m "feat: butler_profiles 新增accepting停接开关列"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 2:新建 ButlerAssignment 实体与 Mapper
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerAssignmentMapper.java`
|
|
|
+
|
|
|
+表结构参照(schema.sql 已有,勿改):`id, family_id(UNIQUE uk_family_assignment), butler_user_id, subscription_id, level, assigned_at`
|
|
|
+
|
|
|
+- [ ] **步骤 2.1:确认 Mapper 风格**
|
|
|
+
|
|
|
+运行:`cat cfc-backend/src/main/java/com/etom/cfc/mapper/ButlerProfileMapper.java 2>/dev/null || cat cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerProfileMapper.java`
|
|
|
+观察:是否有 `@Mapper` 注解、是否继承 `BaseMapper<T>`。新 Mapper 与其保持完全一致的风格。
|
|
|
+
|
|
|
+- [ ] **步骤 2.2:创建实体**
|
|
|
+
|
|
|
+`entity/ButlerAssignment.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 会员-管家分配记录(家庭自助选择/换绑写入)
|
|
|
+ */
|
|
|
+@Data
|
|
|
+@TableName("butler_assignments")
|
|
|
+public class ButlerAssignment {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 家庭 ID(uk_family_assignment 唯一)
|
|
|
+ */
|
|
|
+ private Long familyId;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 管家用户 ID
|
|
|
+ */
|
|
|
+ private Long butlerUserId;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 关联订阅 ID
|
|
|
+ */
|
|
|
+ private Long subscriptionId;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订阅级别 L1/L2
|
|
|
+ */
|
|
|
+ private String level;
|
|
|
+
|
|
|
+ private Date assignedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+若项目实体不使用 Lombok `@Data`(对照 ButlerProfile.java 是否有 getter/setter 手写),则改为手写各字段的 getter/setter,去掉 `@Data`。
|
|
|
+
|
|
|
+- [ ] **步骤 2.3:创建 Mapper**
|
|
|
+
|
|
|
+`mapper/ButlerAssignmentMapper.java`(按步骤 2.1 观察到的风格调整注解):
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.ButlerAssignment;
|
|
|
+
|
|
|
+public interface ButlerAssignmentMapper extends BaseMapper<ButlerAssignment> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2.4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 2.5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerAssignment.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerAssignmentMapper.java
|
|
|
+GIT_MASTER=1 git commit -m "feat: 新增butler_assignments实体与Mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 3:ButlerService 家庭端方法(浏览/查询/绑定)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java`
|
|
|
+
|
|
|
+已有依赖:`@Resource UserMapper userMapper; @Resource ButlerProfileMapper butlerProfileMapper; @Resource UserService userService;`
|
|
|
+需要新增依赖:`ButlerAssignmentMapper`、`MemberSubscriptionService`(Bean 名与类名首字母小写一致,符合注入规范)。
|
|
|
+
|
|
|
+- [ ] **步骤 3.1:添加依赖注入与 import**
|
|
|
+
|
|
|
+import 区新增:
|
|
|
+
|
|
|
+```java
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
+import com.etotem.cfc.entity.ButlerAssignment;
|
|
|
+import com.etotem.cfc.mapper.ButlerAssignmentMapper;
|
|
|
+import com.etotem.cfc.service.MemberSubscriptionService;
|
|
|
+import com.etotem.cfc.entity.MemberSubscription;
|
|
|
+```
|
|
|
+
|
|
|
+类内字段区新增:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private ButlerAssignmentMapper butlerAssignmentMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private MemberSubscriptionService memberSubscriptionService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3.2:追加三个家庭端方法**
|
|
|
+
|
|
|
+在类末尾(`assignMemberToButler(Long familyId, ...)` 方法 `}` 之后、类结束 `}` 之前)追加:
|
|
|
+
|
|
|
+```java
|
|
|
+ // ==================== 家庭端自助选择 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 可选管家列表(浏览开放,不校验会员等级)
|
|
|
+ * 条件:approved + tier=L2 + accepting=1 + 有余量;返回脱敏档案
|
|
|
+ */
|
|
|
+ public Result<List<Map<String, Object>>> getAvailableButlers() {
|
|
|
+ LambdaQueryWrapper<ButlerProfile> query = new LambdaQueryWrapper<>();
|
|
|
+ query.eq(ButlerProfile::getStatus, "approved")
|
|
|
+ .eq(ButlerProfile::getTier, "L2")
|
|
|
+ .eq(ButlerProfile::getAccepting, 1)
|
|
|
+ .apply("member_count < max_members")
|
|
|
+ .orderByAsc(ButlerProfile::getCreatedAt);
|
|
|
+ SortUtil.applySort(query);
|
|
|
+ List<ButlerProfile> profiles = butlerProfileMapper.selectList(query);
|
|
|
+
|
|
|
+ List<Long> userIds = new java.util.ArrayList<>();
|
|
|
+ for (ButlerProfile p : profiles) {
|
|
|
+ userIds.add(p.getUserId());
|
|
|
+ }
|
|
|
+ Map<Long, User> userMap = new HashMap<>();
|
|
|
+ if (!userIds.isEmpty()) {
|
|
|
+ for (User u : userMapper.selectBatchIds(userIds)) {
|
|
|
+ userMap.put(u.getId(), u);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> result = new java.util.ArrayList<>();
|
|
|
+ for (ButlerProfile p : profiles) {
|
|
|
+ User u = userMap.get(p.getUserId());
|
|
|
+ Map<String, Object> item = new HashMap<>();
|
|
|
+ item.put("butlerUserId", p.getUserId());
|
|
|
+ item.put("nickname", u != null ? u.getNickname() : "管家");
|
|
|
+ item.put("avatar", u != null ? u.getAvatar() : "");
|
|
|
+ item.put("description", p.getDescription());
|
|
|
+ item.put("certificates", p.getCertificates());
|
|
|
+ item.put("remainingSlots", (p.getMaxMembers() == null ? 0 : p.getMaxMembers())
|
|
|
+ - (p.getMemberCount() == null ? 0 : p.getMemberCount()));
|
|
|
+ result.add(item);
|
|
|
+ }
|
|
|
+ return Result.success(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当前家庭的管家绑定信息(无绑定时 data 为 null)
|
|
|
+ */
|
|
|
+ public Result<Map<String, Object>> getMyButler(Long userId) {
|
|
|
+ Long familyId = requireFamilyId(userId);
|
|
|
+ if (familyId == null) {
|
|
|
+ return Result.error("未找到家庭信息");
|
|
|
+ }
|
|
|
+ ButlerAssignment assignment = butlerAssignmentMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ButlerAssignment>()
|
|
|
+ .eq(ButlerAssignment::getFamilyId, familyId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (assignment == null) {
|
|
|
+ return Result.success(null);
|
|
|
+ }
|
|
|
+ return Result.success(buildButlerSummary(assignment));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 自助绑定/更换管家:L2 校验 + 条件扣容 + INSERT/UPDATE 双路径
|
|
|
+ */
|
|
|
+ @Transactional(rollbackFor = Exception.class)
|
|
|
+ public Result<Map<String, Object>> selectButler(Long userId, Long butlerUserId) {
|
|
|
+ Long familyId = requireFamilyId(userId);
|
|
|
+ if (familyId == null) {
|
|
|
+ return Result.error("未找到家庭信息");
|
|
|
+ }
|
|
|
+ // ① L2 有效订阅校验
|
|
|
+ MemberSubscription sub = memberSubscriptionService.getActiveSubscription(familyId);
|
|
|
+ boolean active = sub != null && sub.getExpireTime() != null
|
|
|
+ && sub.getExpireTime().after(new Date());
|
|
|
+ if (!active || !"L2".equals(sub.getLevel())) {
|
|
|
+ return Result.error("该功能面向久久一生(L2)会员家庭");
|
|
|
+ }
|
|
|
+ // ② 目标管家校验
|
|
|
+ ButlerProfile butler = butlerProfileMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ButlerProfile>()
|
|
|
+ .eq(ButlerProfile::getUserId, butlerUserId)
|
|
|
+ .eq(ButlerProfile::getStatus, "approved")
|
|
|
+ .eq(ButlerProfile::getTier, "L2")
|
|
|
+ .eq(ButlerProfile::getAccepting, 1)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (butler == null) {
|
|
|
+ return Result.error("该管家不可选择");
|
|
|
+ }
|
|
|
+ // ③ 条件原子扣容(防并发超卖)
|
|
|
+ LambdaUpdateWrapper<ButlerProfile> incr = new LambdaUpdateWrapper<>();
|
|
|
+ incr.eq(ButlerProfile::getId, butler.getId())
|
|
|
+ .apply("member_count < max_members")
|
|
|
+ .setSql("member_count = member_count + 1");
|
|
|
+ int affected = butlerProfileMapper.update(null, incr);
|
|
|
+ if (affected == 0) {
|
|
|
+ return Result.error("该管家名额已满");
|
|
|
+ }
|
|
|
+ // ④ assignments INSERT/UPDATE 双路径(激活自动分配只加计数不建行)
|
|
|
+ ButlerAssignment existing = butlerAssignmentMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ButlerAssignment>()
|
|
|
+ .eq(ButlerAssignment::getFamilyId, familyId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (existing != null) {
|
|
|
+ // 旧管家计数回退
|
|
|
+ LambdaUpdateWrapper<ButlerProfile> decr = new LambdaUpdateWrapper<>();
|
|
|
+ decr.eq(ButlerProfile::getUserId, existing.getButlerUserId())
|
|
|
+ .setSql("member_count = GREATEST(member_count - 1, 0)");
|
|
|
+ butlerProfileMapper.update(null, decr);
|
|
|
+
|
|
|
+ existing.setButlerUserId(butlerUserId);
|
|
|
+ existing.setLevel(sub.getLevel());
|
|
|
+ existing.setAssignedAt(new Date());
|
|
|
+ butlerAssignmentMapper.updateById(existing);
|
|
|
+ } else {
|
|
|
+ ButlerAssignment na = new ButlerAssignment();
|
|
|
+ na.setFamilyId(familyId);
|
|
|
+ na.setButlerUserId(butlerUserId);
|
|
|
+ na.setSubscriptionId(sub.getId());
|
|
|
+ na.setLevel(sub.getLevel());
|
|
|
+ na.setAssignedAt(new Date());
|
|
|
+ butlerAssignmentMapper.insert(na);
|
|
|
+ }
|
|
|
+ log.info("Family {} selected butler {}, level={}", familyId, butlerUserId, sub.getLevel());
|
|
|
+
|
|
|
+ ButlerAssignment saved = butlerAssignmentMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ButlerAssignment>()
|
|
|
+ .eq(ButlerAssignment::getFamilyId, familyId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ return Result.success(buildButlerSummary(saved));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * userId -> familyId(与 MembershipController 同源:users.family_id)
|
|
|
+ */
|
|
|
+ private Long requireFamilyId(Long userId) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ return user != null ? user.getFamilyId() : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 组装绑定摘要(脱敏:不含联系方式)
|
|
|
+ */
|
|
|
+ private Map<String, Object> buildButlerSummary(ButlerAssignment assignment) {
|
|
|
+ Map<String, Object> summary = new HashMap<>();
|
|
|
+ summary.put("butlerUserId", assignment.getButlerUserId());
|
|
|
+ summary.put("level", assignment.getLevel());
|
|
|
+ summary.put("assignedAt", assignment.getAssignedAt());
|
|
|
+ User bu = userMapper.selectById(assignment.getButlerUserId());
|
|
|
+ summary.put("nickname", bu != null ? bu.getNickname() : "管家");
|
|
|
+ summary.put("avatar", bu != null ? bu.getAvatar() : "");
|
|
|
+ ButlerProfile bp = butlerProfileMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ButlerProfile>()
|
|
|
+ .eq(ButlerProfile::getUserId, assignment.getButlerUserId())
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ summary.put("description", bp != null ? bp.getDescription() : "");
|
|
|
+ return summary;
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3.3:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS。若报 `getAccepting()` 不存在,检查任务 1 的实体字段;若报 `MemberSubscription` import 找不到,确认包路径 `com.etotem.cfc.entity.MemberSubscription`。
|
|
|
+
|
|
|
+- [ ] **步骤 3.4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java
|
|
|
+GIT_MASTER=1 git commit -m "feat: ButlerService新增管家浏览/查询/自助绑定方法"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 4:FamilyButlerSelectController 家庭端接口
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.java`
|
|
|
+
|
|
|
+Bean 名 `familyButlerSelectController` 与既有 `butlerController` 无冲突。禁止 Controller 直连 Mapper——全部走 ButlerService(任务 3 已封装)。
|
|
|
+
|
|
|
+- [ ] **步骤 4.1:创建控制器**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.butler;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.service.ButlerService;
|
|
|
+import io.swagger.annotations.ApiOperation;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestAttribute;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 家庭端管家自助选择(仅 L2 绑定受限,浏览开放)
|
|
|
+ */
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/butler")
|
|
|
+public class FamilyButlerSelectController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ButlerService butlerService;
|
|
|
+
|
|
|
+ @ApiOperation("可选管家列表(浏览开放)")
|
|
|
+ @PostMapping("/available-list")
|
|
|
+ public Result<List<Map<String, Object>>> availableList() {
|
|
|
+ return butlerService.getAvailableButlers();
|
|
|
+ }
|
|
|
+
|
|
|
+ @ApiOperation("我家的管家绑定信息")
|
|
|
+ @PostMapping("/my-butler")
|
|
|
+ public Result<Map<String, Object>> myButler(@RequestAttribute("userId") Long userId) {
|
|
|
+ return butlerService.getMyButler(userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @ApiOperation("绑定/更换管家(仅L2)")
|
|
|
+ @PostMapping("/select")
|
|
|
+ public Result<Map<String, Object>> select(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, Object> params) {
|
|
|
+ Object butlerUserIdObj = params.get("butlerUserId");
|
|
|
+ if (butlerUserIdObj == null || butlerUserIdObj.toString().trim().isEmpty()) {
|
|
|
+ return Result.error("缺少 butlerUserId 参数");
|
|
|
+ }
|
|
|
+ Long butlerUserId = Long.valueOf(butlerUserIdObj.toString());
|
|
|
+ return butlerService.selectButler(userId, butlerUserId);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+注意:`Result` 的实际包路径以 `ButlerService.java` 现有 import 为准(`com.etotem.cfc.common.Result`)。若 ButlerService 中三方法返回的 `Result` 泛型与此不一致,以 ButlerService 定义为准同步控制器签名。
|
|
|
+
|
|
|
+- [ ] **步骤 4.2:路由冲突检查**
|
|
|
+
|
|
|
+运行:`grep -rn '"/api/butler' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u`
|
|
|
+预期:新增的 `/available-list`、`/my-butler`、`/select` 不与任何既有路径重复。
|
|
|
+
|
|
|
+- [ ] **步骤 4.3:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4.4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/butler/FamilyButlerSelectController.java
|
|
|
+GIT_MASTER=1 git commit -m "feat: 家庭端管家自助选择接口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 5:管理端接口(分配列表/强制解绑/停接开关)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java`
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java`
|
|
|
+
|
|
|
+- [ ] **步骤 5.1:确认管理员鉴权模式**
|
|
|
+
|
|
|
+运行:`sed -n '1,60p' cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java`
|
|
|
+记录该控制器如何校验管理员(通常 `@RequestAttribute("role")` + `"admin".equals(role)` 判断)。后续端点完全复制该守卫模式。
|
|
|
+
|
|
|
+- [ ] **步骤 5.2:ButlerService 追加管理端方法**
|
|
|
+
|
|
|
+在任务 3 追加代码之后继续追加(import 额外需要 `com.baomidou.mybatisplus.extension.plugins.pagination.Page`,该文件头部已 import):
|
|
|
+
|
|
|
+```java
|
|
|
+ // ==================== 管理端分配管理 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分配关系分页列表
|
|
|
+ */
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5.3:AdminButlerController 追加端点**
|
|
|
+
|
|
|
+在该控制器最后一个方法之后追加(守卫代码从步骤 5.1 观察到的现有方法中逐字复制;下例假设标准模式):
|
|
|
+
|
|
|
+```java
|
|
|
+ @Operation(summary = "分配关系分页列表")
|
|
|
+ @PostMapping("/assignments")
|
|
|
+ public Result<Map<String, Object>> assignments(@RequestBody Map<String, Object> params) {
|
|
|
+ // ← 此处粘贴步骤 5.1 观察到的管理员守卫代码(与现有方法一致)
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "强制解绑")
|
|
|
+ @PostMapping("/unassign")
|
|
|
+ public Result<Void> unassign(@RequestBody Map<String, Object> params) {
|
|
|
+ // ← 同上粘贴守卫代码
|
|
|
+ if (params.get("assignmentId") == null) {
|
|
|
+ return Result.error("缺少 assignmentId");
|
|
|
+ }
|
|
|
+ return butlerService.unassign(Long.valueOf(params.get("assignmentId").toString()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Operation(summary = "管家停接开关")
|
|
|
+ @PostMapping("/toggle-accepting")
|
|
|
+ public Result<Void> toggleAccepting(@RequestBody Map<String, Object> params) {
|
|
|
+ // ← 同上粘贴守卫代码
|
|
|
+ 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()));
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+同时确认控制器中已存在 `@Resource private ButlerService butlerService;`(AdminButlerController 现有 assign-member 功能应已注入;若无则补上,字段名必须为 `butlerService`)。`@Operation` 注解包路径以该文件现有用法为准。
|
|
|
+
|
|
|
+- [ ] **步骤 5.4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 5.5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/service/ButlerService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java
|
|
|
+GIT_MASTER=1 git commit -m "feat: 管理端分配管理与停接开关接口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 6:/health-coach/send 注入教练人格
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`(sendHealthCoachMessage 方法内,约 :420-447)
|
|
|
+
|
|
|
+现状:该方法只放 `child_id`,缺 mascot 注入(/chat/send :76-109 已有完整范式)。
|
|
|
+
|
|
|
+- [ ] **步骤 6.1:定位并插入注入代码**
|
|
|
+
|
|
|
+找到 `sendHealthCoachMessage` 内这一段:
|
|
|
+
|
|
|
+```java
|
|
|
+ Map<String, Object> inputs = new LinkedHashMap<>();
|
|
|
+ if (memberId != null) {
|
|
|
+ inputs.put("child_id", memberId);
|
|
|
+ }
|
|
|
+ Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
|
|
|
+```
|
|
|
+
|
|
|
+在 `inputs.put("child_id", memberId);` 的 `}` 之后、`Map<String, Object> resp = ...` 之前插入:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 注入教练人格(对齐 /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", "");
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+说明:变量名用 `hcUser/hcMascot/hcMascotCode` 防止与本方法其他局部变量冲突;`mascot.name()` 保持与 /chat/send 既有行为一致(不修无关行为);AiGateway 会将 String 类型键值放入 body.context 透传(AiGateway.java:162-168)。
|
|
|
+
|
|
|
+- [ ] **步骤 6.2:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS(若 `userService` 未注入报错,检查类头 `@Resource private UserService userService;` 是否存在——/chat/send 已在使用,必然存在)
|
|
|
+
|
|
|
+- [ ] **步骤 6.3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
|
|
|
+GIT_MASTER=1 git commit -m "feat: 健康教练对话注入教练人格标识"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 7:LangGraph 人格 prompt 路由
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-langgraph/app/graphs/health_coach_graph.py`(generate_answer 函数开头,约 :87-90)
|
|
|
+
|
|
|
+- [ ] **步骤 7.1:改写 generate_answer 开头**
|
|
|
+
|
|
|
+将现有:
|
|
|
+
|
|
|
+```python
|
|
|
+ async def generate_answer(state: HealthCoachState) -> dict:
|
|
|
+ """带健康知识检索的 LLM 生成"""
|
|
|
+ messages = [SystemMessage(content=await get_prompt("health_coach") or DEFAULT_COACH_PROMPT)]
|
|
|
+```
|
|
|
+
|
|
|
+替换为:
|
|
|
+
|
|
|
+```python
|
|
|
+ async def generate_answer(state: HealthCoachState) -> dict:
|
|
|
+ """带健康知识检索的 LLM 生成"""
|
|
|
+ # 人格路由: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 家庭上下文、检索、save_memory 等)一律不动。
|
|
|
+
|
|
|
+- [ ] **步骤 7.2:语法验证**
|
|
|
+
|
|
|
+运行:`cd cfc-langgraph && python3 -m py_compile app/graphs/health_coach_graph.py && echo OK`
|
|
|
+预期:输出 `OK`
|
|
|
+
|
|
|
+- [ ] **步骤 7.3:运营配置两段提示词(人工/管理端操作,非代码)**
|
|
|
+
|
|
|
+登录 Web 管理端「系统提示词管理」(SystemPromptManagement 页),新增两条记录:
|
|
|
+
|
|
|
+| promptKey | 内容要点 |
|
|
|
+|-----------|----------|
|
|
|
+| `health_coach_xibao` | 浠宝·温暖知心风:倾听共情先行、温和提问引导、鼓励性语言、建议委婉分层给出;保留默认 prompt 的就医红线与中文要求 |
|
|
|
+| `health_coach_fubao` | 福宝·活泼行动派:语气轻快直接、优先给可执行行动清单、打卡式督促口吻、建议具体到频次;保留就医红线与中文要求 |
|
|
|
+
|
|
|
+两段均以现有 `health_coach` 默认提示词(DEFAULT_COACH_PROMPT 同款结构:核心原则/职责/回答结构/原则四段)为骨架改写语气即可。未配置时三级回退自动生效,不阻塞上线。
|
|
|
+
|
|
|
+- [ ] **步骤 7.4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-langgraph/app/graphs/health_coach_graph.py
|
|
|
+GIT_MASTER=1 git commit -m "feat: 健康教练graph按coach_id路由人格prompt"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 8:小程序 API 封装 + 管家选择页 + 路由注册
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/utils/api.js`(文件尾部追加)
|
|
|
+- 创建:`cfc-frontend/pages/butler/select.vue`
|
|
|
+- 修改:`cfc-frontend/pages.json`(butler 子包,约 :628-638)
|
|
|
+
|
|
|
+- [ ] **步骤 8.1:api.js 追加封装**
|
|
|
+
|
|
|
+在 `utils/api.js` 末尾追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// ── 管家自助选择 ──
|
|
|
+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.2:pages.json 注册**
|
|
|
+
|
|
|
+将 butler 子包改为:
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "root": "pages/butler",
|
|
|
+ "pages": [
|
|
|
+ {
|
|
|
+ "path": "apply",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "管家入驻"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "path": "select",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "选择管家"
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 8.3:创建 select.vue(完整页面)**
|
|
|
+
|
|
|
+`pages/butler/select.vue`(Vue2 Options API;无可选链;flexbox;`:key` 用方法;时间用字符串截取格式化):
|
|
|
+
|
|
|
+```vue
|
|
|
+<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: #f5fafе; 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>
|
|
|
+```
|
|
|
+
|
|
|
+⚠️ 注意上面样式第一行的 `background: #f5fafе;` —— 其中 `е` 必须是半角英文字母 e(此处若粘贴出现异常字符请手动改正为 `#f5fafe`)。
|
|
|
+
|
|
|
+- [ ] **步骤 8.4:语法验证**
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' pages/butler/select.vue | sed '1d;$d' > /tmp/opencode/select_check.js && node --check /tmp/opencode/select_check.js && echo SYNTAX_OK
|
|
|
+```
|
|
|
+预期:`SYNTAX_OK`
|
|
|
+
|
|
|
+- [ ] **步骤 8.5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-frontend/utils/api.js cfc-frontend/pages/butler/select.vue cfc-frontend/pages.json
|
|
|
+GIT_MASTER=1 git commit -m "feat: 小程序管家选择页与接口封装"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 9:chat.vue 欢迎语 + 会员中心入口卡片
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/pages/ai/chat.vue`(welcome-title,:41)
|
|
|
+- 修改:`cfc-frontend/pages/membership/index.vue`(入口卡片 + 方法)
|
|
|
+
|
|
|
+- [ ] **步骤 9.1:chat.vue 欢迎语带教练名**
|
|
|
+
|
|
|
+将 :41 行整行:
|
|
|
+
|
|
|
+```html
|
|
|
+ <text class="welcome-title">{{ isNutrition ? '你好!我是营养助手' : (isHealthCoach ? '你好!我是健康教练' : (isButler ? '你好!我是健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>
|
|
|
+```
|
|
|
+
|
|
|
+替换为:
|
|
|
+
|
|
|
+```html
|
|
|
+ <text class="welcome-title">{{ isNutrition ? '你好!我是营养助手' : (isHealthCoach ? ('你好!我是' + (mascotName || '健康教练')) : (isButler ? '你好!我是健康管家' : ('你好!我是' + (mascotName || '家庭助手')))) }}</text>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 9.2:membership/index.vue 定位插入点**
|
|
|
+
|
|
|
+运行:`grep -n "class=\"\(benefits\|cards\|banner\|section\)\|<view class=\"benefit\|权益" cfc-frontend/pages/membership/index.vue | head`
|
|
|
+选择一个主内容区容器(如权益卡片区之前),在其后插入下方卡片。若该页有现成"特权入口"横向卡片区,跟随其结构放置。
|
|
|
+
|
|
|
+- [ ] **步骤 9.3:插入「我的管家」卡片(模板)**
|
|
|
+
|
|
|
+```html
|
|
|
+ <!-- 我的管家入口 -->
|
|
|
+ <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>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 9.4:membership/index.vue 脚本接线**
|
|
|
+
|
|
|
+data 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ myButlerInfo: null,
|
|
|
+ isL2Active: false,
|
|
|
+```
|
|
|
+
|
|
|
+methods 中追加(并确认该页已引入 `getMyButler`——若无则在顶部 api 引入行加入 `getMyButler`):
|
|
|
+
|
|
|
+```javascript
|
|
|
+ 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 () {})
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+computed 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ butlerEntryTitle() {
|
|
|
+ return this.myButlerInfo ? ('我的管家 · ' + this.myButlerInfo.nickname) : '我的管家'
|
|
|
+ },
|
|
|
+ butlerEntrySub() {
|
|
|
+ return this.myButlerInfo ? '点击查看或更换' : '久久一生会员可自助选择专属管家'
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+onLoad/onShow 中调用一次 `this.loadButlerEntry()`。
|
|
|
+
|
|
|
+样式追加:
|
|
|
+
|
|
|
+```css
|
|
|
+.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; }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 9.5:语法验证**
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' pages/ai/chat.vue | sed '1d;$d' > /tmp/opencode/chat_check.js && node --check /tmp/opencode/chat_check.js && sed -n '/<script>/,/<\/script>/p' pages/membership/index.vue | sed '1d;$d' > /tmp/opencode/memb_check.js && node --check /tmp/opencode/memb_check.js && echo BOTH_OK
|
|
|
+```
|
|
|
+预期:`BOTH_OK`
|
|
|
+
|
|
|
+- [ ] **步骤 9.6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-frontend/pages/ai/chat.vue cfc-frontend/pages/membership/index.vue
|
|
|
+GIT_MASTER=1 git commit -m "feat: 健康教练欢迎语个性化与我的管家入口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 10:Web 管理端(封装 + 分配管理视图 + 开关列 + 路由权限)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-web/src/api/butler.js`(尾部追加)
|
|
|
+- 创建:`cfc-web/src/views/admin/ButlerAssignments.vue`
|
|
|
+- 修改:`cfc-web/src/views/admin/review/ButlerAudit.vue`(开关列)
|
|
|
+- 修改:`cfc-web/src/router/index.js` 及权限配置(定位后照抄邻居条目)
|
|
|
+
|
|
|
+- [ ] **步骤 10.1:api/butler.js 追加封装**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 分配关系列表
|
|
|
+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
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 10.2:创建 ButlerAssignments.vue(完整视图)**
|
|
|
+
|
|
|
+`views/admin/ButlerAssignments.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+响应包装取值方式(res.data / res.code)先对照 `api/butler.js` 现有消费方(如 ButlerAudit.vue 中 `getButlerList(...).then(...)` 的取值层级)并保持一致,若全局拦截器已剥壳则同步调整。
|
|
|
+
|
|
|
+- [ ] **步骤 10.3:ButlerAudit.vue 加停接开关列**
|
|
|
+
|
|
|
+运行:`grep -n "el-table-column" cfc-web/src/views/admin/review/ButlerAudit.vue | tail -5`
|
|
|
+找到表格最后一列的位置,其后插入(prop 名以该表格数据字段为准,若列表项含 `userId` 则如下):
|
|
|
+
|
|
|
+```html
|
|
|
+ <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>
|
|
|
+```
|
|
|
+
|
|
|
+methods 中追加(并顶部 import `toggleButlerAccepting`):
|
|
|
+
|
|
|
+```javascript
|
|
|
+ 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('操作失败')
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+若管家列表接口未返回 `accepting` 字段,需回查后端 list 接口的组装逻辑把 `profile.getAccepting()` 放进行数据(在 AdminButlerController/相关 Service 中搜索现有 list 的组装点,加一行 `row.put("accepting", p.getAccepting())` 同级代码),并在本计划内完成该最小改动后再进入下一任务。
|
|
|
+
|
|
|
+- [ ] **步骤 10.4:路由与权限注册(定位式)**
|
|
|
+
|
|
|
+运行:`grep -rn "ButlerAudit" cfc-web/src/router/ cfc-web/src/permissions.js 2>/dev/null ; grep -rn "path: '/admin" cfc-web/src/router/index.js | head -8`
|
|
|
+- 若找到 ButlerAudit 的注册行:紧随其后照抄一条 ButlerAssignments 路由(component 改为 `() => import('@/views/admin/ButlerAssignments.vue')`,name/path/meta 结构逐字段镜像邻居条目)。
|
|
|
+- 若 router 中搜不到但页面可访问(动态注册机制):在 `src/router/index.js` 里找任一 admin 视图条目作为模板,同样镜像新增。
|
|
|
+- 若存在独立权限清单文件(步骤命令会暴露路径):为新路由追加同名权限码。
|
|
|
+
|
|
|
+- [ ] **步骤 10.5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add cfc-web/src/api/butler.js cfc-web/src/views/admin/ButlerAssignments.vue \
|
|
|
+ cfc-web/src/views/admin/review/ButlerAudit.vue cfc-web/src/router/index.js
|
|
|
+GIT_MASTER=1 git commit -m "feat: 管理端分配管理与停接开关页面"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 11:API 文档登记 + 全量验收
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
|
|
|
+
|
|
|
+- [ ] **步骤 11.1:登记 6 个新接口**
|
|
|
+
|
|
|
+运行:`grep -n "api/butler" docs/superpowers/api/API_REFERENCE.md | head -3` 定位管家章节,按该文档既有表格格式追加:
|
|
|
+
|
|
|
+| 接口 | 说明 | 权限 |
|
|
|
+|------|------|------|
|
|
|
+| `POST /api/butler/available-list` | 可选管家列表(浏览开放,脱敏) | 登录 |
|
|
|
+| `POST /api/butler/my-butler` | 我家当前管家绑定信息 | 登录 |
|
|
|
+| `POST /api/butler/select` | 绑定/更换管家(仅L2有效订阅) | 登录 |
|
|
|
+| `POST /api/admin/butler/assignments` | 分配关系分页列表 | 管理员 |
|
|
|
+| `POST /api/admin/butler/unassign` | 强制解绑 | 管理员 |
|
|
|
+| `POST /api/admin/butler/toggle-accepting` | 管家停接新单开关 | 管理员 |
|
|
|
+
|
|
|
+另在 AI 章节给 `/api/ai/health-coach/send` 的请求体补充一行说明:新增可选字段 `coach_id` 由服务端自动注入(客户端无需传)。
|
|
|
+
|
|
|
+- [ ] **步骤 11.2:最终验证清单(对照规格 §十一 验收标准)**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile # EXIT 0
|
|
|
+cd ../cfc-langgraph && python3 -m py_compile app/graphs/health_coach_graph.py && echo LG_OK
|
|
|
+for f in pages/butler/select.vue pages/ai/chat.vue pages/membership/index.vue; do
|
|
|
+ cd cfc-frontend && sed -n '/<script>/,/<\/script>/p' "$f" | sed '1d;$d' > /tmp/opencode/final_check.js && node --check /tmp/opencode/final_check.js && echo "OK: $f" && cd ..
|
|
|
+done
|
|
|
+```
|
|
|
+预期:BUILD SUCCESS / LG_OK / 三行 OK。
|
|
|
+
|
|
|
+- [ ] **步骤 11.3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+GIT_MASTER=1 git add docs/superpowers/api/API_REFERENCE.md
|
|
|
+GIT_MASTER=1 git commit -m "docs: 登记管家自选与健康教练接口文档"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检记录(编写者已完成)
|
|
|
+
|
|
|
+1. **规格覆盖度**:§1 数据模型→任务1;§2 家庭端接口→任务3/4;§2 AI端→任务6;§2 管理端→任务5;§3 LangGraph→任务7;§4 前端→任务8/9;§5 Web→任务10;§6 边界(条件扣容/GREATEST回退/双路径/三级回退/停接双拦截/物理删除)→任务3/5/7 对应实现;§十一 验收→任务11.2。无遗漏。
|
|
|
+2. **占位符扫描**:全文无"待定/TODO/类似任务N";两处"定位式步骤"(5.1/10.4)均为给出确切命令+两种已知形态的完整代码,属可执行指令而非缺口。
|
|
|
+3. **类型一致性**:`getAvailableButlers/getMyButler/selectButler/listAssignments/unassign/toggleAccepting` 在服务与控制器两侧签名一致;`accepting` 字段名贯穿迁移/schema/实体/接口/前端;`butlerUserId` 前后端统一驼峰;`coach_id` Java inputs 键与 Python `ctx.get("coach_id")` 一致。
|