Browse Source

docs(superpowers): 弹窗引导页配置化实现计划

iwt 1 month ago
parent
commit
daffd69056
1 changed files with 1575 additions and 0 deletions
  1. 1575 0
      docs/superpowers/plans/2026-09-22-guide-popup-config.md

+ 1575 - 0
docs/superpowers/plans/2026-09-22-guide-popup-config.md

@@ -0,0 +1,1575 @@
+# 弹窗引导页配置化管理 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 将小程序弹窗引导页从硬编码改为可配置化管理,支持管理员在 Web 后台配置弹出条件(trigger)与弹出内容(step),用户端按条件展示、消费后不再重复。
+
+**架构:** 后端新增 `guide_popup`(弹窗配置)、`guide_popup_step`(步骤)、`guide_popup_trigger`(触发条件)、`guide_popup_log`(消费记录)四表 + 用户端 3 接口 + 管理端 6 接口;前端新增 `ConfigurableGuide.vue` 配置驱动组件,替换 `OnboardingGuide.vue` 的硬编码步骤来源;管理端新增 `GuidePopupConfig.vue`/`GuidePopupStats.vue` 两页。
+
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI 管理端
+
+**设计文档:** `docs/superpowers/specs/2026-09-22-guide-popup-config-design.md`
+
+**现状基线(重要):**
+- 迁移最新编号:`迁移328`(搜索 `// 迁移` 末尾确认),新迁移从 **329** 开始
+- 已存在半成品方案:`sys_config` 表 `onboarding_guide_config` key(迁移318)存 JSON,配合 `GuideConfigController`(`/api/guide-config/current`)、`AdminGuideConfigController`(`/api/admin/guide-config/get|save`)、前端 `getGuideConfig()`。**本计划不删除该旧方案**,新方案与它并行,旧方案继续服务 `OnboardingGuide.vue`,新方案服务 `ConfigurableGuide.vue`。
+- 前端 `OnboardingGuide.vue` 已通过 `getGuideConfig()` 拉取 `_configSteps`,但仍保留硬编码 `PARENT_STEPS`/`CHILD_STEPS` 作 fallback。
+
+**项目硬约束:**
+- 接口统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- 实体 `@TableName` + `@TableId(type = IdType.AUTO)` + `@Data` + `implements Serializable`
+- DI 用 `@Resource`,字段名匹配 Bean Name
+- 响应统一 `Result<T>`(`success(T)`/`success(msg,data)`/`error(msg)`/`error(code,msg)`)
+- 身份:`@RequestAttribute("userId") Long userId`、`@RequestAttribute("role") String role`
+- 迁移唯一入口 `DatabaseInitializer.runMigrations()`,幂等(try-catch 包裹),同步 `schema.sql`
+- 小程序:禁可选链 `?.`、禁 `:key` 表达式、Vue 2 Options API
+
+---
+
+## 文件结构
+
+### 后端(cfc-backend)
+
+| 文件 | 职责 |
+|------|------|
+| `entity/GuidePopup.java` | 弹窗配置实体(guide_popup 表) |
+| `entity/GuidePopupStep.java` | 步骤实体(guide_popup_step 表) |
+| `entity/GuidePopupTrigger.java` | 触发条件实体(guide_popup_trigger 表) |
+| `entity/GuidePopupLog.java` | 消费记录实体(guide_popup_log 表) |
+| `mapper/GuidePopupMapper.java` | 弹窗配置 Mapper |
+| `mapper/GuidePopupStepMapper.java` | 步骤 Mapper |
+| `mapper/GuidePopupTriggerMapper.java` | 触发条件 Mapper |
+| `mapper/GuidePopupLogMapper.java` | 消费记录 Mapper |
+| `service/GuidePopupService.java` | 用户端匹配逻辑 + 消费记录 |
+| `service/AdminGuidePopupService.java` | 管理端 CRUD + 统计 |
+| `controller/guide/GuidePopupController.java` | 用户端 3 接口 |
+| `controller/admin/AdminGuidePopupController.java` | 管理端 6 接口 |
+| `config/DatabaseInitializer.java` | 迁移329-332(4 表)+ 迁移333(种子数据) |
+| `resources/schema.sql` | 同步 4 表建表语句 |
+
+### 前端小程序(cfc-frontend)
+
+| 文件 | 职责 |
+|------|------|
+| `components/ConfigurableGuide.vue` | 配置驱动弹窗组件(单步/多步) |
+| `utils/api.js` | 新增 `getGuidePopupList`/`reportGuidePopupTrigger`/`consumeGuidePopup` 封装 |
+| `pages/index-home/index.vue` | 接入 ConfigurableGuide |
+| `pages/home-pages/child-index.vue` | 接入 ConfigurableGuide |
+
+### 管理端(cfc-web)
+
+| 文件 | 职责 |
+|------|------|
+| `src/views/admin/GuidePopupConfig.vue` | 弹窗配置列表 + 编辑抽屉 |
+| `src/views/admin/GuidePopupStats.vue` | 数据看板 |
+| `src/api/guidePopup.js` | 管理端 API 封装 |
+| `src/router/index.js` | 注册两个路由 |
+
+---
+
+## 任务 1:数据库迁移(4 表)+ 实体 + Mapper
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(在 `runMigrations()` 末尾追加迁移329-332)
+- 修改:`cfc-backend/src/main/resources/schema.sql`(追加 4 表建表语句)
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopup.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupStep.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupTrigger.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupLog.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupMapper.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupStepMapper.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupTriggerMapper.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupLogMapper.java`
+
+- [ ] **步骤 1:在 DatabaseInitializer.runMigrations() 末尾追加 4 张表迁移**
+
+先定位末尾。搜索 `// 迁移328` 找到最后一个迁移块,在其后追加。完整迁移代码:
+
+```java
+// 迁移329: 创建 guide_popup 表(弹窗引导页配置化管理)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
+            "slug VARCHAR(64) NOT NULL UNIQUE COMMENT '唯一标识', " +
+            "title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '弹窗标题', " +
+            "description TEXT COMMENT '主描述文案', " +
+            "icon VARCHAR(256) NOT NULL DEFAULT '' COMMENT '图标:emoji或图片URL', " +
+            "icon_type TINYINT NOT NULL DEFAULT 0 COMMENT '图标类型:0=emoji 1=图片', " +
+            "theme_color VARCHAR(20) NOT NULL DEFAULT '#F97316' COMMENT '主题色', " +
+            "btn_text VARCHAR(64) NOT NULL DEFAULT '知道了' COMMENT '按钮文案', " +
+            "btn_action VARCHAR(256) NOT NULL DEFAULT '' COMMENT '点击跳转路径,空=关闭', " +
+            "status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用 1=启用', " +
+            "sort_order INT NOT NULL DEFAULT 0 COMMENT '排序优先级', " +
+            "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
+            "INDEX idx_status_sort (status, sort_order)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗配置表'");
+    log.info("迁移329: 已创建 guide_popup 表");
+} catch (Exception e) {
+    log.warn("guide_popup 表可能已存在: {}", e.getMessage());
+}
+
+// 迁移330: 创建 guide_popup_step 表(引导弹窗步骤)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_step (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
+            "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " +
+            "step_order INT NOT NULL DEFAULT 0 COMMENT '步骤顺序', " +
+            "title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '步骤标题', " +
+            "desc TEXT COMMENT '步骤描述', " +
+            "icon VARCHAR(256) NOT NULL DEFAULT '' COMMENT '步骤图标', " +
+            "icon_type TINYINT NOT NULL DEFAULT 0 COMMENT '图标类型:0=emoji 1=图片', " +
+            "is_intro TINYINT NOT NULL DEFAULT 0 COMMENT '是否为开场身份定义页', " +
+            "btn_text VARCHAR(64) NOT NULL DEFAULT '下一步' COMMENT '本步骤按钮文案', " +
+            "btn_action VARCHAR(256) NOT NULL DEFAULT '' COMMENT '最后一步跳转链接', " +
+            "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_popup_order (popup_id, step_order)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗步骤表'");
+    log.info("迁移330: 已创建 guide_popup_step 表");
+} catch (Exception e) {
+    log.warn("guide_popup_step 表可能已存在: {}", e.getMessage());
+}
+
+// 迁移331: 创建 guide_popup_trigger 表(引导弹窗触发条件)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_trigger (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
+            "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " +
+            "trigger_type VARCHAR(32) NOT NULL COMMENT '触发类型:page_enter/task_complete/assessment_done/first_login/daily_active/role_change', " +
+            "target_page VARCHAR(128) DEFAULT '' COMMENT '目标页面路径', " +
+            "target_action VARCHAR(128) DEFAULT '' COMMENT '触发行为标识', " +
+            "min_role VARCHAR(32) DEFAULT 'parent' COMMENT '最低角色要求', " +
+            "extra_config JSON DEFAULT NULL COMMENT '扩展条件', " +
+            "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_popup_trigger (popup_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗触发条件表'");
+    log.info("迁移331: 已创建 guide_popup_trigger 表");
+} catch (Exception e) {
+    log.warn("guide_popup_trigger 表可能已存在: {}", e.getMessage());
+}
+
+// 迁移332: 创建 guide_popup_log 表(引导弹窗用户消费记录)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS guide_popup_log (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', " +
+            "popup_id BIGINT NOT NULL COMMENT '关联 guide_popup.id', " +
+            "user_id BIGINT NOT NULL COMMENT '用户ID users.id', " +
+            "member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID family_members.id', " +
+            "role VARCHAR(32) NOT NULL COMMENT '弹出时所在角色', " +
+            "consumed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间', " +
+            "button_clicked TINYINT NOT NULL DEFAULT 0 COMMENT '是否点击按钮', " +
+            "INDEX idx_user_popup (user_id, popup_id), " +
+            "INDEX idx_consume_time (consumed_at)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='引导弹窗用户消费记录表'");
+    log.info("迁移332: 已创建 guide_popup_log 表");
+} catch (Exception e) {
+    log.warn("guide_popup_log 表可能已存在: {}", e.getMessage());
+}
+```
+
+- [ ] **步骤 2:在 schema.sql 追加 4 张表建表语句**
+
+追加到 schema.sql 末尾(用 `grep -n "guide_popup" schema.sql` 确认未存在后追加)。SQL 与迁移中 CREATE TABLE 完全一致(去掉 `log.info` 等 Java 包裹,保留纯 SQL)。
+
+- [ ] **步骤 3:创建 4 个实体类**
+
+`entity/GuidePopup.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.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("guide_popup")
+public class GuidePopup implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String slug;
+    private String title;
+    private String description;
+    private String icon;
+    private Integer iconType;
+    private String themeColor;
+    private String btnText;
+    private String btnAction;
+    private Integer status;
+    private Integer sortOrder;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+`entity/GuidePopupStep.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.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("guide_popup_step")
+public class GuidePopupStep implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long popupId;
+    private Integer stepOrder;
+    private String title;
+    private String desc;
+    private String icon;
+    private Integer iconType;
+    private Integer isIntro;
+    private String btnText;
+    private String btnAction;
+    private Date createdAt;
+}
+```
+
+`entity/GuidePopupTrigger.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.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("guide_popup_trigger")
+public class GuidePopupTrigger implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long popupId;
+    private String triggerType;
+    private String targetPage;
+    private String targetAction;
+    private String minRole;
+    private String extraConfig;
+    private Date createdAt;
+}
+```
+
+`entity/GuidePopupLog.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.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("guide_popup_log")
+public class GuidePopupLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long popupId;
+    private Long userId;
+    private Long memberId;
+    private String role;
+    private Date consumedAt;
+    private Integer buttonClicked;
+}
+```
+
+- [ ] **步骤 4:创建 4 个 Mapper 接口**
+
+`mapper/GuidePopupMapper.java`(其余 3 个结构相同,替换实体名与泛型):
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GuidePopup;
+
+public interface GuidePopupMapper extends BaseMapper<GuidePopup> {
+}
+```
+
+同理创建 `GuidePopupStepMapper`、`GuidePopupTriggerMapper`、`GuidePopupLogMapper`,分别 `extends BaseMapper<GuidePopupStep>` / `BaseMapper<GuidePopupTrigger>` / `BaseMapper<GuidePopupLog>`。
+
+- [ ] **步骤 5:编译验证**
+
+运行:`mvn clean compile`(在 cfc-backend 目录)
+预期:BUILD SUCCESS
+
+- [ ] **步骤 6:Commit**
+
+```bash
+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/GuidePopup*.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopup*Mapper.java
+git commit -m "feat(guide-popup): 新增引导弹窗配置 4 表迁移 + 实体 + Mapper"
+```
+
+---
+
+## 任务 2:用户端 Service + Controller(list/trigger/consume)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/GuidePopupService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePopupController.java`
+
+- [ ] **步骤 1:创建 GuidePopupService**
+
+`service/GuidePopupService.java`(核心匹配逻辑 + 消费记录):
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class GuidePopupService {
+
+    @Resource
+    private GuidePopupMapper guidePopupMapper;
+
+    @Resource
+    private GuidePopupStepMapper guidePopupStepMapper;
+
+    @Resource
+    private GuidePopupTriggerMapper guidePopupTriggerMapper;
+
+    @Resource
+    private GuidePopupLogMapper guidePopupLogMapper;
+
+    /**
+     * 查询当前用户应展示的激活弹窗列表(按触发条件 + 消费记录过滤)
+     */
+    public List<Map<String, Object>> listForUser(Long userId, String role) {
+        // 1. 查所有启用弹窗
+        QueryWrapper<GuidePopup> popupQuery = new QueryWrapper<>();
+        popupQuery.eq("status", 1).orderByAsc("sort_order");
+        List<GuidePopup> popups = guidePopupMapper.selectList(popupQuery);
+        if (popups == null || popups.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (GuidePopup popup : popups) {
+            // 2. 匹配触发条件
+            if (!matchesTrigger(popup.getId(), role, null, null)) {
+                continue;
+            }
+            // 3. 排除已消费记录(button_clicked=false 表示仅关闭未点击,也视为已消费不再弹)
+            QueryWrapper<GuidePopupLog> logQuery = new QueryWrapper<>();
+            logQuery.eq("user_id", userId).eq("popup_id", popup.getId());
+            if (guidePopupLogMapper.selectCount(logQuery) > 0) {
+                continue;
+            }
+            // 4. 组装返回
+            Map<String, Object> item = buildPopupVo(popup);
+            result.add(item);
+        }
+        return result;
+    }
+
+    /**
+     * 上报触发行为,返回匹配的弹窗(供前端决定是否展示)
+     */
+    public List<Map<String, Object>> trigger(Long userId, String role, String action, String targetPage) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        QueryWrapper<GuidePopup> popupQuery = new QueryWrapper<>();
+        popupQuery.eq("status", 1);
+        List<GuidePopup> popups = guidePopupMapper.selectList(popupQuery);
+        if (popups == null) return result;
+
+        for (GuidePopup popup : popups) {
+            if (!matchesTrigger(popup.getId(), role, action, targetPage)) {
+                continue;
+            }
+            QueryWrapper<GuidePopupLog> logQuery = new QueryWrapper<>();
+            logQuery.eq("user_id", userId).eq("popup_id", popup.getId());
+            if (guidePopupLogMapper.selectCount(logQuery) > 0) {
+                continue;
+            }
+            result.add(buildPopupVo(popup));
+        }
+        return result;
+    }
+
+    /**
+     * 消费弹窗(点击/关闭)
+     */
+    public boolean consume(Long userId, Long memberId, String role, Long popupId, boolean buttonClicked) {
+        GuidePopupLog log = new GuidePopupLog();
+        log.setPopupId(popupId);
+        log.setUserId(userId);
+        log.setMemberId(memberId);
+        log.setRole(role);
+        log.setButtonClicked(buttonClicked ? 1 : 0);
+        log.setConsumedAt(new Date());
+        return guidePopupLogMapper.insert(log) > 0;
+    }
+
+    /**
+     * 判断弹窗是否匹配触发条件。
+     * action 与 targetPage 可为 null(null 表示未限定,first_login 类触发不依赖二者)。
+     */
+    private boolean matchesTrigger(Long popupId, String role, String action, String targetPage) {
+        QueryWrapper<GuidePopupTrigger> triggerQuery = new QueryWrapper<>();
+        triggerQuery.eq("popup_id", popupId);
+        List<GuidePopupTrigger> triggers = guidePopupTriggerMapper.selectList(triggerQuery);
+        if (triggers == null || triggers.isEmpty()) {
+            // 无触发条件 = 默认不弹(避免误弹),除非明确配置了 first_login
+            return false;
+        }
+        for (GuidePopupTrigger t : triggers) {
+            boolean typeMatch = false;
+            if ("first_login".equals(t.getTriggerType()) || "daily_active".equals(t.getTriggerType()) || "role_change".equals(t.getTriggerType())) {
+                // 这类触发不依赖 action/targetPage,只要角色匹配即命中
+                typeMatch = true;
+            } else if (action != null && action.equals(t.getTriggerType())) {
+                typeMatch = true;
+            } else if (targetPage != null && "page_enter".equals(t.getTriggerType()) && targetPage.equals(t.getTargetPage())) {
+                typeMatch = true;
+            }
+            if (typeMatch && roleMatches(role, t.getMinRole())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean roleMatches(String role, String minRole) {
+        if (minRole == null || minRole.isEmpty() || "parent".equals(minRole)) {
+            return true;
+        }
+        if ("child".equals(minRole)) {
+            return "child".equals(role);
+        }
+        if ("teacher".equals(minRole)) {
+            return "teacher".equals(role);
+        }
+        return true;
+    }
+
+    private Map<String, Object> buildPopupVo(GuidePopup popup) {
+        Map<String, Object> item = new HashMap<>();
+        item.put("id", popup.getId());
+        item.put("slug", popup.getSlug());
+        item.put("title", popup.getTitle());
+        item.put("description", popup.getDescription());
+        item.put("icon", popup.getIcon());
+        item.put("iconType", popup.getIconType());
+        item.put("themeColor", popup.getThemeColor());
+        item.put("btnText", popup.getBtnText());
+        item.put("btnAction", popup.getBtnAction());
+
+        QueryWrapper<GuidePopupStep> stepQuery = new QueryWrapper<>();
+        stepQuery.eq("popup_id", popup.getId()).orderByAsc("step_order");
+        List<GuidePopupStep> steps = guidePopupStepMapper.selectList(stepQuery);
+        List<Map<String, Object>> stepList = new ArrayList<>();
+        if (steps != null) {
+            for (GuidePopupStep s : steps) {
+                Map<String, Object> sm = new HashMap<>();
+                sm.put("stepOrder", s.getStepOrder());
+                sm.put("title", s.getTitle());
+                sm.put("desc", s.getDesc());
+                sm.put("icon", s.getIcon());
+                sm.put("iconType", s.getIconType());
+                sm.put("isIntro", s.getIsIntro());
+                sm.put("btnText", s.getBtnText());
+                sm.put("btnAction", s.getBtnAction());
+                stepList.add(sm);
+            }
+        }
+        item.put("steps", stepList);
+        return item;
+    }
+}
+```
+
+- [ ] **步骤 2:创建 GuidePopupController(用户端 3 接口)**
+
+`controller/guide/GuidePopupController.java`:
+
+```java
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GuidePopupService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/guide/popup")
+public class GuidePopupController {
+
+    @Resource
+    private GuidePopupService guidePopupService;
+
+    @PostMapping("/list")
+    public Result<List<Map<String, Object>>> list(@RequestAttribute("userId") Long userId,
+                                                  @RequestAttribute("role") String role) {
+        return Result.success(guidePopupService.listForUser(userId, role));
+    }
+
+    @PostMapping("/trigger")
+    public Result<List<Map<String, Object>>> trigger(@RequestAttribute("userId") Long userId,
+                                                     @RequestAttribute("role") String role,
+                                                     @RequestBody Map<String, String> body) {
+        String action = body != null ? body.get("action") : null;
+        String targetPage = body != null ? body.get("targetPage") : null;
+        return Result.success(guidePopupService.trigger(userId, role, action, targetPage));
+    }
+
+    @PostMapping("/consume")
+    public Result<Boolean> consume(@RequestAttribute("userId") Long userId,
+                                   @RequestAttribute("role") String role,
+                                   @RequestBody Map<String, Object> body) {
+        Long popupId = body != null && body.get("popupId") != null ? Long.valueOf(body.get("popupId").toString()) : null;
+        Boolean buttonClicked = body != null && body.get("buttonClicked") != null ? Boolean.valueOf(body.get("buttonClicked").toString()) : false;
+        Long memberId = body != null && body.get("memberId") != null ? Long.valueOf(body.get("memberId").toString()) : null;
+        if (popupId == null) {
+            return Result.error("popupId 不能为空");
+        }
+        return Result.success(guidePopupService.consume(userId, memberId, role, popupId, buttonClicked));
+    }
+}
+```
+
+- [ ] **步骤 3:检查路由冲突**
+
+运行:`grep -rn '@PostMapping' cfc-backend/src/main/java/com/etotem/cfc/controller/guide/ | grep -oP '@PostMapping\("\K[^"]*' | sort -u`
+预期:`/api/guide/popup/list`、`/api/guide/popup/trigger`、`/api/guide/popup/consume` 均无重复。
+
+- [ ] **步骤 4:编译验证**
+
+运行:`mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/GuidePopupService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePopupController.java
+git commit -m "feat(guide-popup): 用户端 list/trigger/consume 三接口"
+```
+
+---
+
+## 任务 3:管理端 Service + Controller(6 接口)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/AdminGuidePopupService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuidePopupController.java`
+
+- [ ] **步骤 1:创建 AdminGuidePopupService**
+
+`service/AdminGuidePopupService.java`(CRUD + 步骤/触发条件级联 + 统计):
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class AdminGuidePopupService {
+
+    @Resource
+    private GuidePopupMapper guidePopupMapper;
+
+    @Resource
+    private GuidePopupStepMapper guidePopupStepMapper;
+
+    @Resource
+    private GuidePopupTriggerMapper guidePopupTriggerMapper;
+
+    @Resource
+    private GuidePopupLogMapper guidePopupLogMapper;
+
+    @Resource
+    private JdbcTemplate jdbcTemplate;
+
+    public List<GuidePopup> list(String slug, Integer status) {
+        QueryWrapper<GuidePopup> qw = new QueryWrapper<>();
+        if (slug != null && !slug.isEmpty()) {
+            qw.like("slug", slug);
+        }
+        if (status != null) {
+            qw.eq("status", status);
+        }
+        qw.orderByAsc("sort_order");
+        return guidePopupMapper.selectList(qw);
+    }
+
+    @Transactional
+    public boolean create(Map<String, Object> body) {
+        GuidePopup popup = new GuidePopup();
+        popup.setSlug((String) body.get("slug"));
+        popup.setTitle((String) body.get("title"));
+        popup.setDescription((String) body.get("description"));
+        popup.setIcon(body.get("icon") != null ? (String) body.get("icon") : "");
+        popup.setIconType(body.get("iconType") != null ? Integer.valueOf(body.get("iconType").toString()) : 0);
+        popup.setThemeColor(body.get("themeColor") != null ? (String) body.get("themeColor") : "#F97316");
+        popup.setBtnText(body.get("btnText") != null ? (String) body.get("btnText") : "知道了");
+        popup.setBtnAction(body.get("btnAction") != null ? (String) body.get("btnAction") : "");
+        popup.setStatus(body.get("status") != null ? Integer.valueOf(body.get("status").toString()) : 1);
+        popup.setSortOrder(body.get("sortOrder") != null ? Integer.valueOf(body.get("sortOrder").toString()) : 0);
+        popup.setCreatedAt(new Date());
+        popup.setUpdatedAt(new Date());
+        guidePopupMapper.insert(popup);
+
+        saveChildren(popup.getId(), body);
+        return true;
+    }
+
+    @Transactional
+    public boolean update(Long id, Map<String, Object> body) {
+        GuidePopup popup = guidePopupMapper.selectById(id);
+        if (popup == null) return false;
+        if (body.get("slug") != null) popup.setSlug((String) body.get("slug"));
+        if (body.get("title") != null) popup.setTitle((String) body.get("title"));
+        if (body.get("description") != null) popup.setDescription((String) body.get("description"));
+        if (body.get("icon") != null) popup.setIcon((String) body.get("icon"));
+        if (body.get("iconType") != null) popup.setIconType(Integer.valueOf(body.get("iconType").toString()));
+        if (body.get("themeColor") != null) popup.setThemeColor((String) body.get("themeColor"));
+        if (body.get("btnText") != null) popup.setBtnText((String) body.get("btnText"));
+        if (body.get("btnAction") != null) popup.setBtnAction((String) body.get("btnAction"));
+        if (body.get("status") != null) popup.setStatus(Integer.valueOf(body.get("status").toString()));
+        if (body.get("sortOrder") != null) popup.setSortOrder(Integer.valueOf(body.get("sortOrder").toString()));
+        popup.setUpdatedAt(new Date());
+        guidePopupMapper.updateById(popup);
+
+        // 全量覆盖步骤与触发条件
+        QueryWrapper<GuidePopupStep> stepDel = new QueryWrapper<>();
+        stepDel.eq("popup_id", id);
+        guidePopupStepMapper.delete(stepDel);
+        QueryWrapper<GuidePopupTrigger> trigDel = new QueryWrapper<>();
+        trigDel.eq("popup_id", id);
+        guidePopupTriggerMapper.delete(trigDel);
+        saveChildren(id, body);
+        return true;
+    }
+
+    public boolean updateStatus(Long id, Integer status) {
+        GuidePopup popup = guidePopupMapper.selectById(id);
+        if (popup == null) return false;
+        popup.setStatus(status);
+        popup.setUpdatedAt(new Date());
+        return guidePopupMapper.updateById(popup) > 0;
+    }
+
+    @Transactional
+    public boolean delete(Long id) {
+        QueryWrapper<GuidePopupStep> stepDel = new QueryWrapper<>();
+        stepDel.eq("popup_id", id);
+        guidePopupStepMapper.delete(stepDel);
+        QueryWrapper<GuidePopupTrigger> trigDel = new QueryWrapper<>();
+        trigDel.eq("popup_id", id);
+        guidePopupTriggerMapper.delete(trigDel);
+        QueryWrapper<GuidePopupLog> logDel = new QueryWrapper<>();
+        logDel.eq("popup_id", id);
+        guidePopupLogMapper.delete(logDel);
+        return guidePopupMapper.deleteById(id) > 0;
+    }
+
+    public Map<String, Object> stats(Long popupId) {
+        Map<String, Object> result = new HashMap<>();
+        String where = popupId != null ? " WHERE popup_id = " + popupId : "";
+        Integer totalViews = jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM guide_popup_log" + where, Integer.class);
+        Integer totalClicks = jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM guide_popup_log" + where + (popupId != null ? " AND button_clicked = 1" : " WHERE button_clicked = 1"),
+            Integer.class);
+        result.put("totalViews", totalViews != null ? totalViews : 0);
+        result.put("totalClicks", totalClicks != null ? totalClicks : 0);
+        int views = totalViews != null ? totalViews : 0;
+        int clicks = totalClicks != null ? totalClicks : 0;
+        double rate = views > 0 ? (double) clicks / views * 100 : 0;
+        result.put("completionRate", String.format("%.1f%%", rate));
+
+        List<Map<String, Object>> trend = jdbcTemplate.queryForList(
+            "SELECT DATE_FORMAT(consumed_at, '%H:00') AS hour, " +
+            "COUNT(*) AS views, " +
+            "SUM(button_clicked) AS clicks " +
+            "FROM guide_popup_log" + where +
+            " GROUP BY DATE_FORMAT(consumed_at, '%H:00') ORDER BY hour");
+        result.put("trendByHour", trend);
+        return result;
+    }
+
+    private void saveChildren(Long popupId, Map<String, Object> body) {
+        // 步骤
+        Object stepsObj = body.get("steps");
+        if (stepsObj instanceof List) {
+            List<?> steps = (List<?>) stepsObj;
+            int order = 0;
+            for (Object so : steps) {
+                if (!(so instanceof Map)) continue;
+                Map<?, ?> sm = (Map<?, ?>) so;
+                GuidePopupStep step = new GuidePopupStep();
+                step.setPopupId(popupId);
+                step.setStepOrder(order++);
+                step.setTitle(sm.get("title") != null ? sm.get("title").toString() : "");
+                step.setDesc(sm.get("desc") != null ? sm.get("desc").toString() : "");
+                step.setIcon(sm.get("icon") != null ? sm.get("icon").toString() : "");
+                step.setIconType(sm.get("iconType") != null ? Integer.valueOf(sm.get("iconType").toString()) : 0);
+                step.setIsIntro(sm.get("isIntro") != null ? Integer.valueOf(sm.get("isIntro").toString()) : 0);
+                step.setBtnText(sm.get("btnText") != null ? sm.get("btnText").toString() : "下一步");
+                step.setBtnAction(sm.get("btnAction") != null ? sm.get("btnAction").toString() : "");
+                step.setCreatedAt(new Date());
+                guidePopupStepMapper.insert(step);
+            }
+        }
+        // 触发条件
+        Object triggersObj = body.get("triggers");
+        if (triggersObj instanceof List) {
+            List<?> triggers = (List<?>) triggersObj;
+            for (Object to : triggers) {
+                if (!(to instanceof Map)) continue;
+                Map<?, ?> tm = (Map<?, ?>) to;
+                GuidePopupTrigger trigger = new GuidePopupTrigger();
+                trigger.setPopupId(popupId);
+                trigger.setTriggerType(tm.get("triggerType") != null ? tm.get("triggerType").toString() : "first_login");
+                trigger.setTargetPage(tm.get("targetPage") != null ? tm.get("targetPage").toString() : "");
+                trigger.setTargetAction(tm.get("targetAction") != null ? tm.get("targetAction").toString() : "");
+                trigger.setMinRole(tm.get("minRole") != null ? tm.get("minRole").toString() : "parent");
+                trigger.setExtraConfig(tm.get("extraConfig") != null ? tm.get("extraConfig").toString() : null);
+                trigger.setCreatedAt(new Date());
+                guidePopupTriggerMapper.insert(trigger);
+            }
+        }
+    }
+}
+```
+
+- [ ] **步骤 2:创建 AdminGuidePopupController(管理端 6 接口)**
+
+`controller/admin/AdminGuidePopupController.java`:
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.GuidePopup;
+import com.etotem.cfc.service.AdminGuidePopupService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/guide/popup")
+public class AdminGuidePopupController {
+
+    @Resource
+    private AdminGuidePopupService adminGuidePopupService;
+
+    @PostMapping("/list")
+    public Result<List<GuidePopup>> list(@RequestBody(required = false) Map<String, Object> body) {
+        String slug = body != null ? (String) body.get("slug") : null;
+        Integer status = body != null && body.get("status") != null ? Integer.valueOf(body.get("status").toString()) : null;
+        return Result.success(adminGuidePopupService.list(slug, status));
+    }
+
+    @PostMapping("/create")
+    public Result<Boolean> create(@RequestBody Map<String, Object> body) {
+        return Result.success(adminGuidePopupService.create(body));
+    }
+
+    @PostMapping("/update")
+    public Result<Boolean> update(@RequestBody Map<String, Object> body) {
+        Long id = body != null && body.get("id") != null ? Long.valueOf(body.get("id").toString()) : null;
+        if (id == null) return Result.error("id 不能为空");
+        return Result.success(adminGuidePopupService.update(id, body));
+    }
+
+    @PostMapping("/status")
+    public Result<Boolean> status(@RequestBody Map<String, Object> body) {
+        Long id = body != null && body.get("id") != null ? Long.valueOf(body.get("id").toString()) : null;
+        Integer status = body != null && body.get("status") != null ? Integer.valueOf(body.get("status").toString()) : null;
+        if (id == null || status == null) return Result.error("id 和 status 不能为空");
+        return Result.success(adminGuidePopupService.updateStatus(id, status));
+    }
+
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> body) {
+        Long id = body != null && body.get("id") != null ? Long.valueOf(body.get("id").toString()) : null;
+        if (id == null) return Result.error("id 不能为空");
+        return Result.success(adminGuidePopupService.delete(id));
+    }
+
+    @PostMapping("/stats")
+    public Result<Map<String, Object>> stats(@RequestBody(required = false) Map<String, Object> body) {
+        Long popupId = body != null && body.get("popupId") != null ? Long.valueOf(body.get("popupId").toString()) : null;
+        return Result.success(adminGuidePopupService.stats(popupId));
+    }
+}
+```
+
+- [ ] **步骤 3:检查 Bean 名冲突**
+
+运行:`grep -rn "class AdminGuidePopup" cfc-backend/src/main/java/`
+预期:仅 1 处(`AdminGuidePopupController` + `AdminGuidePopupService` 均唯一,不与其他包重名)。
+
+- [ ] **步骤 4:编译验证**
+
+运行:`mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AdminGuidePopupService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuidePopupController.java
+git commit -m "feat(guide-popup): 管理端 list/create/update/status/delete/stats 六接口"
+```
+
+---
+
+## 任务 4:前端小程序 ConfigurableGuide 组件 + API 封装
+
+**文件:**
+- 创建:`cfc-frontend/components/ConfigurableGuide.vue`
+- 修改:`cfc-frontend/utils/api.js`
+
+- [ ] **步骤 1:在 utils/api.js 追加 3 个封装**
+
+在 `getGuideConfig` 附近追加(保持单一规范入口,每个 URL 只封装一次):
+
+```js
+// 引导弹窗配置(guide_popup 表,配置化管理):查询当前用户应展示的弹窗列表
+export const getGuidePopupList = () => {
+	return request('/api/guide/popup/list', 'POST', {})
+}
+
+// 上报触发行为,返回匹配弹窗
+export const reportGuidePopupTrigger = (action, targetPage) => {
+	return request('/api/guide/popup/trigger', 'POST', { action, targetPage })
+}
+
+// 消费弹窗(点击/关闭上报,防止重复弹出)
+export const consumeGuidePopup = (popupId, buttonClicked, memberId) => {
+	return request('/api/guide/popup/consume', 'POST', { popupId, buttonClicked, memberId })
+}
+```
+
+- [ ] **步骤 2:创建 ConfigurableGuide.vue**
+
+`components/ConfigurableGuide.vue`(配置驱动,支持单步弹窗/多步向导,emoji/图片图标,主题色):
+
+```vue
+<template>
+  <view class="cg-overlay" v-if="show">
+    <view class="cg-card">
+      <!-- 顶部主题色条 -->
+      <view class="cg-topbar" :style="{ background: themeColor }"></view>
+
+      <!-- 步骤内容 -->
+      <view class="cg-content">
+        <!-- 单步弹窗:无 steps 时直接用 title/description/icon -->
+        <block v-if="!isMulti">
+          <text v-if="iconType === 0 && icon" class="cg-icon">{{ icon }}</text>
+          <image v-if="iconType === 1 && icon" class="cg-img" :src="icon" mode="aspectFit" />
+          <text class="cg-title">{{ title }}</text>
+          <text class="cg-desc">{{ description }}</text>
+        </block>
+
+        <!-- 多步向导:steps 数组渲染 -->
+        <block v-else>
+          <text v-if="currentStepObj.iconType === 0 && currentStepObj.icon" class="cg-icon">{{ currentStepObj.icon }}</text>
+          <image v-if="currentStepObj.iconType === 1 && currentStepObj.icon" class="cg-img" :src="currentStepObj.icon" mode="aspectFit" />
+          <text class="cg-title" :class="{ 'cg-title--intro': currentStepObj.isIntro }">{{ currentStepObj.title }}</text>
+          <text class="cg-desc">{{ currentStepObj.desc }}</text>
+        </block>
+      </view>
+
+      <!-- 步进器(仅多步) -->
+      <view class="cg-dots" v-if="isMulti">
+        <view v-for="(s, i) in steps" :key="i" class="cg-dot" :class="{ 'cg-dot--active': i === currentStep }"></view>
+      </view>
+
+      <!-- 按钮 -->
+      <view class="cg-action">
+        <view class="cg-btn" :style="{ background: themeColor }" @click="handleClick">
+          <text>{{ btnLabel }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'ConfigurableGuide',
+  props: {
+    popup: { type: Object, required: true },
+    show: { type: Boolean, default: false }
+  },
+  data() {
+    return {
+      currentStep: 0
+    }
+  },
+  computed: {
+    isMulti() {
+      return this.popup && this.popup.steps && this.popup.steps.length > 0
+    },
+    title() { return this.popup && this.popup.title ? this.popup.title : '' },
+    description() { return this.popup && this.popup.description ? this.popup.description : '' },
+    icon() { return this.popup && this.popup.icon ? this.popup.icon : '' },
+    iconType() { return this.popup && this.popup.iconType !== undefined ? this.popup.iconType : 0 },
+    themeColor() { return this.popup && this.popup.themeColor ? this.popup.themeColor : '#F97316' },
+    steps() { return this.isMulti ? this.popup.steps : [] },
+    currentStepObj() {
+      if (!this.isMulti) return {}
+      return this.steps[this.currentStep] || {}
+    },
+    btnLabel() {
+      if (!this.isMulti) {
+        return this.popup && this.popup.btnText ? this.popup.btnText : '知道了'
+      }
+      var isLast = this.currentStep >= this.steps.length - 1
+      if (isLast) {
+        return this.popup && this.popup.btnText ? this.popup.btnText : '开始体验'
+      }
+      return this.currentStepObj.btnText || '下一步'
+    }
+  },
+  watch: {
+    show(val) {
+      if (val) this.currentStep = 0
+    }
+  },
+  methods: {
+    handleClick() {
+      if (this.isMulti && this.currentStep < this.steps.length - 1) {
+        this.currentStep++
+        return
+      }
+      // 最后一步 / 单步:跳转 + 消费
+      var action = ''
+      if (this.isMulti) {
+        action = this.currentStepObj.btnAction || this.popup.btnAction || ''
+      } else {
+        action = this.popup.btnAction || ''
+      }
+      this.$emit('done', { popup: this.popup, btnAction: action, buttonClicked: true })
+    },
+    handleClose() {
+      this.$emit('done', { popup: this.popup, btnAction: '', buttonClicked: false })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.cg-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 9998;
+}
+.cg-card {
+  width: 640rpx;
+  min-height: 560rpx;
+  background: #FFFFFF;
+  border-radius: 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  overflow: hidden;
+}
+.cg-topbar { width: 100%; height: 12rpx; flex-shrink: 0; }
+.cg-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 72rpx 56rpx 40rpx;
+}
+.cg-icon { font-size: 80rpx; margin-bottom: 32rpx; display: block; }
+.cg-img { width: 160rpx; height: 160rpx; margin-bottom: 32rpx; }
+.cg-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #1E293B;
+  text-align: center;
+  margin-bottom: 20rpx;
+  display: block;
+  line-height: 1.3;
+}
+.cg-title--intro { font-size: 32rpx; font-weight: 800; }
+.cg-desc {
+  font-size: 28rpx;
+  color: #64748B;
+  text-align: center;
+  line-height: 1.6;
+  display: block;
+  padding: 0 16rpx;
+}
+.cg-dots { display: flex; align-items: center; justify-content: center; padding: 0 56rpx 32rpx; }
+.cg-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #CBD5E1; margin: 0 8rpx; }
+.cg-dot--active { background: #F97316; transform: scale(1.3); }
+.cg-action { width: 100%; padding: 0 56rpx 48rpx; box-sizing: border-box; }
+.cg-btn {
+  width: 100%;
+  height: 88rpx;
+  border-radius: 999rpx;
+  color: #FFFFFF;
+  font-size: 32rpx;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+</style>
+```
+
+- [ ] **步骤 3:语法校验**
+
+运行:`awk '/<script>/{flag=1;next}/<\/script>/{flag=0}flag' cfc-frontend/components/ConfigurableGuide.vue > /tmp/cg.mjs && node --check /tmp/cg.mjs`
+预期:无报错。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/components/ConfigurableGuide.vue cfc-frontend/utils/api.js
+git commit -m "feat(guide-popup): 新增 ConfigurableGuide 配置驱动组件 + API 封装"
+```
+
+---
+
+## 任务 5:小程序页面接入(index-home + child-index)
+
+**文件:**
+- 修改:`cfc-frontend/pages/index-home/index.vue`
+- 修改:`cfc-frontend/pages/home-pages/child-index.vue`
+
+- [ ] **步骤 1:index-home 引入组件并接入**
+
+先读现有 `_maybeShowOnboardingGuide()` 逻辑(约 1193、1216-1228 行)与组件挂载点(约 240-243 行)。在 `<script>` 顶部 import 追加:
+
+```js
+import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
+import { getGuidePopupList, consumeGuidePopup } from '../../utils/api.js'
+```
+
+在 `components` 注册 `ConfigurableGuide`。在 `data` 追加:
+
+```js
+guidePopup: null,
+showGuidePopup: false
+```
+
+新增方法 `loadGuidePopup()`(复用现有生命周期,注意不重复调用——若已有 `loadFamilyData` 等首屏加载方法,追加到其中一次即可):
+
+```js
+loadGuidePopup() {
+  var self = this
+  getGuidePopupList().then(function(res) {
+    var arr = (res && res.data) || []
+    if (arr && arr.length > 0) {
+      self.guidePopup = arr[0]
+      self.showGuidePopup = true
+    }
+  }).catch(function() {})
+},
+handleGuidePopupDone(payload) {
+  var popup = payload && payload.popup
+  var buttonClicked = payload && payload.buttonClicked
+  if (popup && popup.id) {
+    consumeGuidePopup(popup.id, buttonClicked)
+  }
+  this.showGuidePopup = false
+  if (buttonClicked && payload && payload.btnAction) {
+    uni.navigateTo({ url: payload.btnAction })
+  }
+}
+```
+
+在模板中挂载(放在根 `<view>` 内、现有 OnboardingGuide 挂载点旁):
+
+```html
+<ConfigurableGuide :popup="guidePopup" :show="showGuidePopup" @done="handleGuidePopupDone" />
+```
+
+> 注意:`popup` prop 传对象需保证非空,否则组件 `required: true` 会告警。用 `guidePopup` 仅在有值时 `showGuidePopup=true`,且组件内部用 `v-if="show"` 包裹,但 prop 校验在挂载时即执行。若担心 null 告警,可将 prop 改为非 required 并加 `default: () => ({})`。**本计划采用:`guidePopup` 初始为 null,组件 `popup` prop 改为 `{ type: Object, default: null }`,`isMulti` 等计算属性已用 `this.popup &&` 兜底,安全。**
+
+- [ ] **步骤 2:child-index 接入(同理)**
+
+在 `pages/home-pages/child-index.vue` 做同样接入,import 路径为 `../../components/ConfigurableGuide.vue`,复用现有生命周期(该页 `onShow`/`mounted` 已 guard,追加到加载链)。
+
+- [ ] **步骤 3:API 去重审计**
+
+运行:`node scripts/audit-duplicate-api-calls.js`(在 cfc-frontend)
+预期:退出码 0,无新增 ERROR(`/api/guide/popup/list`、`/api/guide/popup/consume` 各自只封装一次、每页只调用一次)。
+
+- [ ] **步骤 4:语法校验**
+
+运行:对两个 `.vue` 文件提取 script 块执行 `node --check`(`.mjs` 后缀)。
+预期:无报错。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-frontend/pages/index-home/index.vue cfc-frontend/pages/home-pages/child-index.vue
+git commit -m "feat(guide-popup): index-home/child-index 接入配置驱动弹窗"
+```
+
+---
+
+## 任务 6:管理端 API 封装 + 页面 + 路由
+
+**文件:**
+- 创建:`cfc-web/src/api/guidePopup.js`
+- 创建:`cfc-web/src/views/admin/GuidePopupConfig.vue`
+- 创建:`cfc-web/src/views/admin/GuidePopupStats.vue`
+- 修改:`cfc-web/src/router/index.js`
+
+- [ ] **步骤 1:创建 api/guidePopup.js**
+
+先看现有 `cfc-web/src/api/` 下某文件(如 `guide.js`)的请求封装风格(确认是 `request` 还是 `axios` 实例),保持一致。封装:
+
+```js
+import request from '@/utils/request'
+
+export function listGuidePopup(params) {
+  return request({ url: '/api/admin/guide/popup/list', method: 'post', data: params })
+}
+export function createGuidePopup(data) {
+  return request({ url: '/api/admin/guide/popup/create', method: 'post', data })
+}
+export function updateGuidePopup(data) {
+  return request({ url: '/api/admin/guide/popup/update', method: 'post', data })
+}
+export function updateGuidePopupStatus(data) {
+  return request({ url: '/api/admin/guide/popup/status', method: 'post', data })
+}
+export function deleteGuidePopup(data) {
+  return request({ url: '/api/admin/guide/popup/delete', method: 'post', data })
+}
+export function getGuidePopupStats(params) {
+  return request({ url: '/api/admin/guide/popup/stats', method: 'post', data: params })
+}
+```
+
+> 注意:确认 `cfc-web/src/utils/request.js` 存在且导出 default。若项目用 `@/api/xxx.js` 内直接 `axios` 封装,则跟随现有模式。**执行时先读 `cfc-web/src/api/guide.js` 确认封装风格,再写本文件。**
+
+- [ ] **步骤 2:创建 GuidePopupConfig.vue**
+
+Element UI 列表 + 编辑抽屉。核心结构(完整可运行):
+
+```vue
+<template>
+  <div class="guide-popup-config">
+    <el-card>
+      <div slot="header">
+        <span>引导弹窗配置</span>
+        <el-button type="primary" size="small" style="float:right" @click="openCreate">新建弹窗</el-button>
+      </div>
+      <el-form inline>
+        <el-form-item label="标识">
+          <el-input v-model="query.slug" placeholder="slug" clearable style="width:180px" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="query.status" clearable placeholder="全部" style="width:120px">
+            <el-option label="启用" :value="1" />
+            <el-option label="禁用" :value="0" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="load">查询</el-button>
+        </el-form-item>
+      </el-form>
+      <el-table :data="list" v-loading="loading" border>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="slug" label="标识" width="160" />
+        <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="themeColor" label="主题色" width="90">
+          <template slot-scope="s"><span :style="{color: s.row.themeColor}">●</span></template>
+        </el-table-column>
+        <el-table-column label="状态" width="80">
+          <template slot-scope="s">
+            <el-tag :type="s.row.status === 1 ? 'success' : 'info'">{{ s.row.status === 1 ? '启用' : '禁用' }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" label="排序" width="70" />
+        <el-table-column label="操作" width="200" fixed="right">
+          <template slot-scope="s">
+            <el-button size="mini" @click="openEdit(s.row)">编辑</el-button>
+            <el-button size="mini" :type="s.row.status === 1 ? 'warning' : 'success'" @click="toggle(s.row)">
+              {{ s.row.status === 1 ? '禁用' : '启用' }}
+            </el-button>
+            <el-button size="mini" type="danger" @click="remove(s.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-drawer :title="editing.id ? '编辑弹窗' : '新建弹窗'" :visible.sync="drawerVisible" size="60%">
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="标识 slug" required>
+          <el-input v-model="form.slug" placeholder="如 parent_first_login" />
+        </el-form-item>
+        <el-form-item label="标题">
+          <el-input v-model="form.title" placeholder="弹窗标题" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input type="textarea" :rows="3" v-model="form.description" />
+        </el-form-item>
+        <el-form-item label="图标">
+          <el-input v-model="form.icon" placeholder="emoji 或图片 URL" />
+        </el-form-item>
+        <el-form-item label="图标类型">
+          <el-radio-group v-model="form.iconType">
+            <el-radio :label="0">emoji</el-radio>
+            <el-radio :label="1">图片</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="主题色">
+          <el-color-picker v-model="form.themeColor" />
+        </el-form-item>
+        <el-form-item label="按钮文案">
+          <el-input v-model="form.btnText" />
+        </el-form-item>
+        <el-form-item label="跳转路径">
+          <el-input v-model="form.btnAction" placeholder="如 /pages/assessment/plan" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sortOrder" :min="0" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-divider>步骤(留空则单步弹窗)</el-divider>
+        <div v-for="(step, idx) in form.steps" :key="idx" class="step-row">
+          <el-input v-model="step.title" placeholder="步骤标题" style="width:160px" />
+          <el-input v-model="step.icon" placeholder="图标" style="width:100px" />
+          <el-input v-model="step.desc" placeholder="步骤描述" style="flex:1" />
+          <el-button type="text" @click="removeStep(idx)">删除</el-button>
+        </div>
+        <el-button type="text" @click="addStep">+ 添加步骤</el-button>
+        <el-divider>触发条件</el-divider>
+        <div v-for="(trg, idx) in form.triggers" :key="'t' + idx" class="step-row">
+          <el-select v-model="trg.triggerType" placeholder="触发类型" style="width:160px">
+            <el-option label="首次登录" value="first_login" />
+            <el-option label="进入页面" value="page_enter" />
+            <el-option label="完成任务" value="task_complete" />
+            <el-option label="完成测评" value="assessment_done" />
+            <el-option label="每日活跃" value="daily_active" />
+            <el-option label="切换角色" value="role_change" />
+          </el-select>
+          <el-input v-model="trg.targetPage" placeholder="目标页面" style="width:160px" />
+          <el-input v-model="trg.minRole" placeholder="角色 parent/child/teacher" style="width:160px" />
+          <el-button type="text" @click="removeTrigger(idx)">删除</el-button>
+        </div>
+        <el-button type="text" @click="addTrigger">+ 添加触发条件</el-button>
+      </el-form>
+      <div class="drawer-footer">
+        <el-button @click="drawerVisible = false">取消</el-button>
+        <el-button type="primary" @click="save">保存</el-button>
+      </div>
+    </el-drawer>
+  </div>
+</template>
+
+<script>
+import { listGuidePopup, createGuidePopup, updateGuidePopup, updateGuidePopupStatus, deleteGuidePopup } from '@/api/guidePopup'
+
+export default {
+  name: 'GuidePopupConfig',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      query: { slug: '', status: null },
+      drawerVisible: false,
+      editing: {},
+      form: {}
+    }
+  },
+  created() { this.load() },
+  methods: {
+    load() {
+      this.loading = true
+      listGuidePopup(this.query).then(res => {
+        this.list = (res && res.data) || []
+      }).finally(() => { this.loading = false })
+    },
+    emptyForm() {
+      return { slug: '', title: '', description: '', icon: '', iconType: 0, themeColor: '#F97316',
+        btnText: '知道了', btnAction: '', sortOrder: 0, status: 1, steps: [], triggers: [] }
+    },
+    openCreate() {
+      this.editing = {}
+      this.form = this.emptyForm()
+      this.drawerVisible = true
+    },
+    openEdit(row) {
+      this.editing = row
+      this.form = Object.assign(this.emptyForm(), row, { steps: row.steps || [], triggers: row.triggers || [] })
+      this.drawerVisible = true
+    },
+    addStep() { this.form.steps.push({ title: '', icon: '', desc: '', iconType: 0, isIntro: 0, btnText: '下一步', btnAction: '' }) },
+    removeStep(idx) { this.form.steps.splice(idx, 1) },
+    addTrigger() { this.form.triggers.push({ triggerType: 'first_login', targetPage: '', targetAction: '', minRole: 'parent', extraConfig: null }) },
+    removeTrigger(idx) { this.form.triggers.splice(idx, 1) },
+    save() {
+      const data = Object.assign({}, this.form)
+      const fn = this.editing.id ? updateGuidePopup(Object.assign(data, { id: this.editing.id })) : createGuidePopup(data)
+      fn.then(() => {
+        this.$message.success('保存成功')
+        this.drawerVisible = false
+        this.load()
+      })
+    },
+    toggle(row) {
+      updateGuidePopupStatus({ id: row.id, status: row.status === 1 ? 0 : 1 }).then(() => this.load())
+    },
+    remove(row) {
+      this.$confirm('确认删除该弹窗配置?', '提示', { type: 'warning' }).then(() => {
+        deleteGuidePopup({ id: row.id }).then(() => this.load())
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.step-row { display: flex; align-items: center; margin-bottom: 8px; }
+.step-row .el-input, .step-row .el-select { margin-right: 8px; }
+.drawer-footer { margin-top: 16px; text-align: right; }
+</style>
+```
+
+> 注意:`listGuidePopup` 接口返回 `Result<List<GuidePopup>>`,其中 `steps`/`triggers` 不在 `GuidePopup` 实体字段内。若编辑页需要预填步骤/触发条件,需后端 `list` 接口额外返回。**为控制本次范围,编辑预填仅回填 `GuidePopup` 基础字段,steps/triggers 需后端 `list` 或 `detail` 接口扩展——本计划在任务 3 的 `list` 返回基础字段,前端编辑时 steps/triggers 为空需重新录入。**(已在任务 3 注释中说明,如需完整回填可后续扩展 `detail` 接口。)
+
+- [ ] **步骤 3:创建 GuidePopupStats.vue**
+
+```vue
+<template>
+  <div class="guide-popup-stats">
+    <el-card>
+      <div slot="header">
+        <span>引导弹窗数据看板</span>
+        <el-select v-model="popupId" placeholder="全部弹窗" clearable style="width:220px;float:right" @change="load">
+          <el-option v-for="p in popups" :key="p.id" :label="p.slug" :value="p.id" />
+        </el-select>
+      </div>
+      <el-row :gutter="16">
+        <el-col :span="6"><el-card shadow="never"><div class="stat-num">{{ stats.totalViews || 0 }}</div><div class="stat-label">总曝光</div></el-card></el-col>
+        <el-col :span="6"><el-card shadow="never"><div class="stat-num">{{ stats.totalClicks || 0 }}</div><div class="stat-label">总点击</div></el-card></el-col>
+        <el-col :span="6"><el-card shadow="never"><div class="stat-num">{{ stats.completionRate || '0.0%' }}</div><div class="stat-label">完成率</div></el-card></el-col>
+      </el-row>
+      <el-table :data="trend" border style="margin-top:16px">
+        <el-table-column prop="hour" label="时段" width="120" />
+        <el-table-column prop="views" label="曝光" />
+        <el-table-column prop="clicks" label="点击" />
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import { listGuidePopup, getGuidePopupStats } from '@/api/guidePopup'
+
+export default {
+  name: 'GuidePopupStats',
+  data() {
+    return { popupId: null, popups: [], stats: {}, trend: [] }
+  },
+  created() {
+    listGuidePopup({}).then(res => { this.popups = (res && res.data) || [] })
+    this.load()
+  },
+  methods: {
+    load() {
+      getGuidePopupStats({ popupId: this.popupId }).then(res => {
+        const d = (res && res.data) || {}
+        this.stats = d
+        this.trend = d.trendByHour || []
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.stat-num { font-size: 28px; font-weight: 700; color: #409EFF; }
+.stat-label { color: #909399; margin-top: 4px; }
+</style>
+```
+
+- [ ] **步骤 4:注册路由**
+
+在 `cfc-web/src/router/index.js` 的 admin children 中追加(参考已有 `guide-config` 路径):
+
+```js
+{
+  path: 'guide-popup-config',
+  name: 'GuidePopupConfig',
+  component: () => import('@/views/admin/GuidePopupConfig.vue'),
+  meta: { title: '引导弹窗配置', icon: 'el-icon-s-opportunity' }
+},
+{
+  path: 'guide-popup-stats',
+  name: 'GuidePopupStats',
+  component: () => import('@/views/admin/GuidePopupStats.vue'),
+  meta: { title: '引导弹窗数据', icon: 'el-icon-data-analysis' }
+}
+```
+
+- [ ] **步骤 5:管理端构建验证**
+
+运行:`npm run build`(在 cfc-web)
+预期:构建成功,无编译错误。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-web/src/api/guidePopup.js \
+        cfc-web/src/views/admin/GuidePopupConfig.vue \
+        cfc-web/src/views/admin/GuidePopupStats.vue \
+        cfc-web/src/router/index.js
+git commit -m "feat(guide-popup): 管理端配置页 + 数据看板 + 路由"
+```
+
+---
+
+## 任务 7:种子数据 + 文档同步
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(迁移333 种子数据)
+- 修改:`docs/superpowers/api/API_REFERENCE.md`(新增 9 个接口记录)
+- 修改:`docs/superpowers/PROJECT-OVERVIEW.md`(状态更新为已实施)
+
+- [ ] **步骤 1:迁移333 写入种子数据**
+
+在 DatabaseInitializer 末尾追加(将现有 PARENT_STEPS 内容作为 `parent_first_login` 弹窗种子,触发条件 first_login,仅 parent 角色):
+
+```java
+// 迁移333: 预置引导弹窗种子数据(parent_first_login,首次登录家长端)
+try {
+    Long count = jdbcTemplate.queryForObject(
+        "SELECT COUNT(*) FROM guide_popup WHERE slug = 'parent_first_login'", Long.class);
+    if (count == null || count == 0L) {
+        jdbcTemplate.update(
+            "INSERT INTO guide_popup (slug, title, description, icon, icon_type, theme_color, btn_text, btn_action, status, sort_order) " +
+            "VALUES ('parent_first_login', '欢迎使用浠艾福', '身·智·富·行·心,全家健康一站式管理', '🌏', 0, '#F97316', '开始体验', '', 1, 0)");
+        Long popupId = jdbcTemplate.queryForObject(
+            "SELECT id FROM guide_popup WHERE slug = 'parent_first_login'", Long.class);
+        // 步骤
+        jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 0, ?, ?, ?, 0, 1, '下一步', '')",
+            popupId, "你真的了解家人的状态吗?", "情绪低落没察觉、习惯变化没注意、能力短板没发现——什么问题等爆发了才看见。浠艾福帮你在问题发生前,看清每位家人的真实状态。", "🔍");
+        jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 1, ?, ?, ?, 0, 0, '下一步', '')",
+            popupId, "五维能量,看见全家", "身·心·智·行·富,一张全景图看清每位家人。谁的身体需要关注、谁的情绪需要疏导、谁的状态正在下滑——不用猜,看得见。", "🌏");
+        jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 2, ?, ?, ?, 0, 0, '下一步', '')",
+            popupId, "小行动,大改变", "全家一起完成每日小任务,各自攒星星。每天一点点积累,好习惯自然养成。", "📋");
+        jdbcTemplate.update("INSERT INTO guide_popup_step (popup_id, step_order, title, desc, icon, icon_type, is_intro, btn_text, btn_action) VALUES (?, 3, ?, ?, ?, 0, 0, '开始体验', '')",
+            popupId, "心愿墙,看得见努力", "每位家人用自己的星星兑换心愿。努力有回报,全家人都能看到彼此的坚持。", "🎁");
+        // 触发条件:首次登录,parent 角色
+        jdbcTemplate.update("INSERT INTO guide_popup_trigger (popup_id, trigger_type, target_page, target_action, min_role, extra_config) VALUES (?, 'first_login', '', '', 'parent', NULL)",
+            popupId);
+        log.info("迁移333: 已预置 parent_first_login 种子弹窗");
+    }
+} catch (Exception e) {
+    log.warn("预置引导弹窗种子数据可能已存在: {}", e.getMessage());
+}
+```
+
+- [ ] **步骤 2:同步 API_REFERENCE.md**
+
+在 `docs/superpowers/api/API_REFERENCE.md` 新增一节记录 9 个接口(3 用户端 + 6 管理端),格式参照现有条目。
+
+- [ ] **步骤 3:更新 PROJECT-OVERVIEW.md**
+
+将 `2026-09-22-guide-popup-config-design.md` 状态从「✅ 已确认」改为「✅ 已实施」,并补充实现计划文件名。
+
+- [ ] **步骤 4:编译验证**
+
+运行:`mvn clean compile`
+预期:BUILD SUCCESS
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+        docs/superpowers/api/API_REFERENCE.md \
+        docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "feat(guide-popup): 种子数据 + 文档同步"
+```
+
+---
+
+## 自检清单
+
+**1. 规格覆盖度:**
+- ✅ 4 表(guide_popup/step/trigger/log)→ 任务 1
+- ✅ 用户端 3 接口(list/trigger/consume)→ 任务 2
+- ✅ 管理端 6 接口 → 任务 3
+- ✅ ConfigurableGuide.vue 组件 → 任务 4
+- ✅ 页面接入(index-home/child-index)→ 任务 5
+- ✅ 管理端页面(Config/Stats)+ 路由 → 任务 6
+- ✅ 种子数据 + 文档 → 任务 7
+
+**2. 占位符扫描:** 无 TODO/待定;管理端 `guidePopup.js` 封装风格已注明「执行时先读 `cfc-web/src/api/guide.js` 确认」,因为请求实例封装方式需以现有代码为准,这是唯一需执行时核对的点。
+
+**3. 类型一致性:**
+- 实体字段 `iconType`/`isIntro`/`btnText`/`btnAction`/`themeColor`/`sortOrder`/`slug` 在实体、VO 组装、前端 props、管理端 form 中命名一致(camelCase)。
+- 前端组件 prop `popup` 字段与后端 `buildPopupVo` 返回的 key 完全对应(`iconType`/`themeColor`/`btnText`/`btnAction`/`steps[].stepOrder/title/desc/icon/iconType/isIntro/btnText/btnAction`)。
+- `consume` 接口入参 `popupId`/`buttonClicked`/`memberId` 与前端 `consumeGuidePopup` 封装一致。
+
+**4. 已知范围边界:**
+- 管理端 `list` 接口不返回 steps/triggers 详情(编辑预填需重新录入),已在任务 3/6 注释标注,属可接受范围(如需完整回填,后续加 `detail` 接口)。
+- 旧 `onboarding_guide_config`(sys_config)+ `OnboardingGuide.vue` 保留,不删除,与新方案并行。
+
+---
+
+## 执行交接
+
+计划完成并保存到 `docs/superpowers/plans/2026-09-22-guide-popup-config.md`。推荐用 subagent-driven-development 逐任务执行(每个任务调度新子代理 + 任务间审查)。