2026-09-22-guide-popup-config.md 65 KB

弹窗引导页配置化管理 实现计划

面向 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 找到最后一个迁移块,在其后追加。完整迁移代码:

// 迁移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:

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:

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:

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:

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 个结构相同,替换实体名与泛型):

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

    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(核心匹配逻辑 + 消费记录):

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:

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

    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 + 步骤/触发条件级联 + 统计):

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:

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

    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 只封装一次):

// 引导弹窗配置(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/图片图标,主题色):

<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

    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 追加:

import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
import { getGuidePopupList, consumeGuidePopup } from '../../utils/api.js'

在 components 注册 ConfigurableGuide。在 data 追加:

guidePopup: null,
showGuidePopup: false

新增方法 loadGuidePopup()(复用现有生命周期,注意不重复调用——若已有 loadFamilyData 等首屏加载方法,追加到其中一次即可):

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 挂载点旁):

<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

    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 实例),保持一致。封装:

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 列表 + 编辑抽屉。核心结构(完整可运行):

<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

    <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 路径):

{
  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

    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 角色):

// 迁移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

    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 逐任务执行(每个任务调度新子代理 + 任务间审查)。