面向 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@Resource,字段名匹配 Bean NameResult<T>(success(T)/success(msg,data)/error(msg)/error(code,msg))@RequestAttribute("userId") Long userId、@RequestAttribute("role") String roleDatabaseInitializer.runMigrations(),幂等(try-catch 包裹),同步 schema.sql?.、禁 :key 表达式、Vue 2 Options API| 文件 | 职责 |
|---|---|
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 表建表语句 |
| 文件 | 职责 |
|---|---|
components/ConfigurableGuide.vue |
配置驱动弹窗组件(单步/多步) |
utils/api.js |
新增 getGuidePopupList/reportGuidePopupTrigger/consumeGuidePopup 封装 |
pages/index-home/index.vue |
接入 ConfigurableGuide |
pages/home-pages/child-index.vue |
接入 ConfigurableGuide |
| 文件 | 职责 |
|---|---|
src/views/admin/GuidePopupConfig.vue |
弹窗配置列表 + 编辑抽屉 |
src/views/admin/GuidePopupStats.vue |
数据看板 |
src/api/guidePopup.js |
管理端 API 封装 |
src/router/index.js |
注册两个路由 |
文件:
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.javacfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupStep.javacfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupTrigger.javacfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupLog.javacfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupStepMapper.javacfc-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());
}
追加到 schema.sql 末尾(用 grep -n "guide_popup" schema.sql 确认未存在后追加)。SQL 与迁移中 CREATE TABLE 完全一致(去掉 log.info 等 Java 包裹,保留纯 SQL)。
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;
}
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>。
运行: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"
文件:
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;
}
}
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));
}
}
运行: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 均无重复。
运行: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 三接口"
文件:
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);
}
}
}
}
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));
}
}
运行:grep -rn "class AdminGuidePopup" cfc-backend/src/main/java/
预期:仅 1 处(AdminGuidePopupController + AdminGuidePopupService 均唯一,不与其他包重名)。
运行: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 六接口"
文件:
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 })
}
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>
运行: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 封装"
文件:
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" />
注意:
popupprop 传对象需保证非空,否则组件required: true会告警。用guidePopup仅在有值时showGuidePopup=true,且组件内部用v-if="show"包裹,但 prop 校验在挂载时即执行。若担心 null 告警,可将 prop 改为非 required 并加default: () => ({})。本计划采用:guidePopup初始为 null,组件popupprop 改为{ type: Object, default: null },isMulti等计算属性已用this.popup &&兜底,安全。
在 pages/home-pages/child-index.vue 做同样接入,import 路径为 ../../components/ConfigurableGuide.vue,复用现有生命周期(该页 onShow/mounted 已 guard,追加到加载链)。
运行:node scripts/audit-duplicate-api-calls.js(在 cfc-frontend)
预期:退出码 0,无新增 ERROR(/api/guide/popup/list、/api/guide/popup/consume 各自只封装一次、每页只调用一次)。
运行:对两个 .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 接入配置驱动弹窗"
文件:
cfc-web/src/api/guidePopup.jscfc-web/src/views/admin/GuidePopupConfig.vuecfc-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确认封装风格,再写本文件。
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' }
}
运行: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): 管理端配置页 + 数据看板 + 路由"
文件:
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());
}
在 docs/superpowers/api/API_REFERENCE.md 新增一节记录 9 个接口(3 用户端 + 6 管理端),格式参照现有条目。
将 2026-09-22-guide-popup-config-design.md 状态从「✅ 已确认」改为「✅ 已实施」,并补充实现计划文件名。
运行: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. 规格覆盖度:
2. 占位符扫描: 无 TODO/待定;管理端 guidePopup.js 封装风格已注明「执行时先读 cfc-web/src/api/guide.js 确认」,因为请求实例封装方式需以现有代码为准,这是唯一需执行时核对的点。
3. 类型一致性:
iconType/isIntro/btnText/btnAction/themeColor/sortOrder/slug 在实体、VO 组装、前端 props、管理端 form 中命名一致(camelCase)。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 逐任务执行(每个任务调度新子代理 + 任务间审查)。