# 弹窗引导页配置化管理 实现计划 > **面向 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`(`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 { } ``` 同理创建 `GuidePopupStepMapper`、`GuidePopupTriggerMapper`、`GuidePopupLogMapper`,分别 `extends BaseMapper` / `BaseMapper` / `BaseMapper`。 - [ ] **步骤 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> listForUser(Long userId, String role) { // 1. 查所有启用弹窗 QueryWrapper popupQuery = new QueryWrapper<>(); popupQuery.eq("status", 1).orderByAsc("sort_order"); List popups = guidePopupMapper.selectList(popupQuery); if (popups == null || popups.isEmpty()) { return new ArrayList<>(); } List> result = new ArrayList<>(); for (GuidePopup popup : popups) { // 2. 匹配触发条件 if (!matchesTrigger(popup.getId(), role, null, null)) { continue; } // 3. 排除已消费记录(button_clicked=false 表示仅关闭未点击,也视为已消费不再弹) QueryWrapper logQuery = new QueryWrapper<>(); logQuery.eq("user_id", userId).eq("popup_id", popup.getId()); if (guidePopupLogMapper.selectCount(logQuery) > 0) { continue; } // 4. 组装返回 Map item = buildPopupVo(popup); result.add(item); } return result; } /** * 上报触发行为,返回匹配的弹窗(供前端决定是否展示) */ public List> trigger(Long userId, String role, String action, String targetPage) { List> result = new ArrayList<>(); QueryWrapper popupQuery = new QueryWrapper<>(); popupQuery.eq("status", 1); List popups = guidePopupMapper.selectList(popupQuery); if (popups == null) return result; for (GuidePopup popup : popups) { if (!matchesTrigger(popup.getId(), role, action, targetPage)) { continue; } QueryWrapper 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 triggerQuery = new QueryWrapper<>(); triggerQuery.eq("popup_id", popupId); List 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 buildPopupVo(GuidePopup popup) { Map 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 stepQuery = new QueryWrapper<>(); stepQuery.eq("popup_id", popup.getId()).orderByAsc("step_order"); List steps = guidePopupStepMapper.selectList(stepQuery); List> stepList = new ArrayList<>(); if (steps != null) { for (GuidePopupStep s : steps) { Map 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(@RequestAttribute("userId") Long userId, @RequestAttribute("role") String role) { return Result.success(guidePopupService.listForUser(userId, role)); } @PostMapping("/trigger") public Result>> trigger(@RequestAttribute("userId") Long userId, @RequestAttribute("role") String role, @RequestBody Map 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 consume(@RequestAttribute("userId") Long userId, @RequestAttribute("role") String role, @RequestBody Map 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 list(String slug, Integer status) { QueryWrapper 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 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 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 stepDel = new QueryWrapper<>(); stepDel.eq("popup_id", id); guidePopupStepMapper.delete(stepDel); QueryWrapper 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 stepDel = new QueryWrapper<>(); stepDel.eq("popup_id", id); guidePopupStepMapper.delete(stepDel); QueryWrapper trigDel = new QueryWrapper<>(); trigDel.eq("popup_id", id); guidePopupTriggerMapper.delete(trigDel); QueryWrapper logDel = new QueryWrapper<>(); logDel.eq("popup_id", id); guidePopupLogMapper.delete(logDel); return guidePopupMapper.deleteById(id) > 0; } public Map stats(Long popupId) { Map 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> 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 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(@RequestBody(required = false) Map 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 create(@RequestBody Map body) { return Result.success(adminGuidePopupService.create(body)); } @PostMapping("/update") public Result update(@RequestBody Map 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 status(@RequestBody Map 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 delete(@RequestBody Map 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> stats(@RequestBody(required = false) Map 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 ``` - [ ] **步骤 3:语法校验** 运行:`awk '/ ``` > 注意:`listGuidePopup` 接口返回 `Result>`,其中 `steps`/`triggers` 不在 `GuidePopup` 实体字段内。若编辑页需要预填步骤/触发条件,需后端 `list` 接口额外返回。**为控制本次范围,编辑预填仅回填 `GuidePopup` 基础字段,steps/triggers 需后端 `list` 或 `detail` 接口扩展——本计划在任务 3 的 `list` 返回基础字段,前端编辑时 steps/triggers 为空需重新录入。**(已在任务 3 注释中说明,如需完整回填可后续扩展 `detail` 接口。) - [ ] **步骤 3:创建 GuidePopupStats.vue** ```vue ``` - [ ] **步骤 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 逐任务执行(每个任务调度新子代理 + 任务间审查)。