Răsfoiți Sursa

chore: auto bump version and changelog [skip ci]

iwt 6 ore în urmă
părinte
comite
50165bfcc3
29 a modificat fișierele cu 1323 adăugiri și 10 ștergeri
  1. 107 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuidePopupController.java
  3. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePopupController.java
  4. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopup.java
  5. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupLog.java
  6. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupStep.java
  7. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupTrigger.java
  8. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupLogMapper.java
  9. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupMapper.java
  10. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupStepMapper.java
  11. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupTriggerMapper.java
  12. 187 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AdminGuidePopupService.java
  13. 160 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GuidePopupService.java
  14. 64 0
      cfc-backend/src/main/resources/schema.sql
  15. 172 0
      cfc-frontend/components/ConfigurableGuide.vue
  16. 35 1
      cfc-frontend/pages/home-pages/child-index.vue
  17. 36 3
      cfc-frontend/pages/index-home/index.vue
  18. 15 0
      cfc-frontend/utils/api.js
  19. 1 1
      cfc-web/.last_build_commit
  20. 2 2
      cfc-web/package-lock.json
  21. 1 1
      cfc-web/package.json
  22. 7 0
      cfc-web/public/CHANGELOG-v1.0.md
  23. 8 1
      cfc-web/public/CHANGELOG.md
  24. 20 0
      cfc-web/src/api/guidePopup.js
  25. 12 0
      cfc-web/src/router/index.js
  26. 180 0
      cfc-web/src/views/admin/GuidePopupConfig.vue
  27. 51 0
      cfc-web/src/views/admin/GuidePopupStats.vue
  28. 2 1
      docs/superpowers/PROJECT-OVERVIEW.md
  29. 22 0
      docs/superpowers/api/API_REFERENCE.md

+ 107 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10891,6 +10891,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         seedDiscountRulesMenu();
         createUserShortcutConfigTable();
         createUserAppUsageTable();
+        seedGuidePopupData();
 
         // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
         try {
@@ -11445,6 +11446,86 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         } catch (Exception e) {
             log.warn("迁移328: 补充照片语音字段失败: {}", e.getMessage());
         }
+
+        // 迁移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());
+        }
     }
 
     /**
@@ -11534,4 +11615,30 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移327: 创建 user_app_usage 表失败: {}", e.getMessage());
         }
     }
+
+    // 迁移333: 预置引导弹窗种子数据(parent_first_login,首次登录家长端)
+    private void seedGuidePopupData() {
+        try {
+            Long count = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM guide_popup WHERE slug = 'parent_first_login'", Long.class);
+            if (count != null && count > 0L) {
+                log.info("迁移333: parent_first_login 种子弹窗已存在,跳过");
+                return;
+            }
+            jdbcTemplate.update(
+                    "INSERT INTO guide_popup (slug, title, description, icon, icon_type, theme_color, btn_text, btn_action, status, sort_order, created_at, updated_at) "
+                            + "VALUES ('parent_first_login', '欢迎使用浠艾福', '身·智·富·行·心,全家健康一站式管理', '🌏', 0, '#F97316', '开始体验', '', 1, 0, NOW(), NOW())");
+            Long popupId = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", 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, created_at) VALUES (?, 'first_login', '', '', 'parent', NULL, NOW())", popupId);
+            log.info("迁移333: 已预置 parent_first_login 种子弹窗 (popup_id={})", popupId);
+        } catch (Exception e) {
+            log.warn("预置引导弹窗种子数据失败: {}", e.getMessage());
+        }
+    }
 }

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuidePopupController.java

@@ -0,0 +1,61 @@
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@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));
+    }
+}

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuidePopupController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.guide;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GuidePopupService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@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));
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopup.java

@@ -0,0 +1,29 @@
+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;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupLog.java

@@ -0,0 +1,23 @@
+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;
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupStep.java

@@ -0,0 +1,27 @@
+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;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GuidePopupTrigger.java

@@ -0,0 +1,24 @@
+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;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupLogMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GuidePopupLog;
+
+public interface GuidePopupLogMapper extends BaseMapper<GuidePopupLog> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GuidePopup;
+
+public interface GuidePopupMapper extends BaseMapper<GuidePopup> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupStepMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GuidePopupStep;
+
+public interface GuidePopupStepMapper extends BaseMapper<GuidePopupStep> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GuidePopupTriggerMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GuidePopupTrigger;
+
+public interface GuidePopupTriggerMapper extends BaseMapper<GuidePopupTrigger> {
+}

+ 187 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AdminGuidePopupService.java

@@ -0,0 +1,187 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.GuidePopup;
+import com.etotem.cfc.entity.GuidePopupLog;
+import com.etotem.cfc.entity.GuidePopupStep;
+import com.etotem.cfc.entity.GuidePopupTrigger;
+import com.etotem.cfc.mapper.GuidePopupLogMapper;
+import com.etotem.cfc.mapper.GuidePopupMapper;
+import com.etotem.cfc.mapper.GuidePopupStepMapper;
+import com.etotem.cfc.mapper.GuidePopupTriggerMapper;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@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);
+            }
+        }
+    }
+}

+ 160 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GuidePopupService.java

@@ -0,0 +1,160 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.entity.GuidePopup;
+import com.etotem.cfc.entity.GuidePopupLog;
+import com.etotem.cfc.entity.GuidePopupStep;
+import com.etotem.cfc.entity.GuidePopupTrigger;
+import com.etotem.cfc.mapper.GuidePopupLogMapper;
+import com.etotem.cfc.mapper.GuidePopupMapper;
+import com.etotem.cfc.mapper.GuidePopupStepMapper;
+import com.etotem.cfc.mapper.GuidePopupTriggerMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@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) {
+        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) {
+            if (!matchesTrigger(popup.getId(), role, null, null)) {
+                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 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;
+    }
+
+    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()) {
+            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())) {
+                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;
+    }
+}

+ 64 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5872,3 +5872,67 @@ CREATE TABLE IF NOT EXISTS report_feedback (
     INDEX idx_rf_user (user_id, created_at),
     INDEX idx_rf_report (report_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告反馈与AI解答';
+
+-- ============================================================
+-- 引导弹窗配置化管理(迁移329-332)
+-- ============================================================
+
+-- 迁移329: 引导弹窗配置表
+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='引导弹窗配置表';
+
+-- 迁移330: 引导弹窗步骤表
+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='引导弹窗步骤表';
+
+-- 迁移331: 引导弹窗触发条件表
+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='引导弹窗触发条件表';
+
+-- 迁移332: 引导弹窗用户消费记录表
+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='引导弹窗用户消费记录表';

+ 172 - 0
cfc-frontend/components/ConfigurableGuide.vue

@@ -0,0 +1,172 @@
+<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, default: null },
+    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>

+ 35 - 1
cfc-frontend/pages/home-pages/child-index.vue

@@ -12,6 +12,11 @@
       position="child"
       :show="showOnboarding"
       @close="showOnboarding = false" />
+    <!-- 配置驱动弹窗(guide_popup 表,管理端可配置) -->
+    <ConfigurableGuide
+      :popup="guidePopup"
+      :show="showGuidePopup"
+      @done="handleGuidePopupDone" />
 
     <!-- 空状态 - 无孩子 -->
     <BaseEmpty
@@ -310,7 +315,7 @@ B) 积分卡片 — Claymorphism 暖橙黏土风格
 </template>
 
 <script>
-import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getFamilyMemberList, getGuideConfig } from '../../utils/api.js'
+import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getFamilyMemberList, getGuideConfig, getGuidePopupList, consumeGuidePopup } from '../../utils/api.js'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import RecommendedFeed from '../../components/RecommendedFeed.vue'
@@ -322,6 +327,7 @@ import BaseLoading from '../../components/BaseLoading.vue'
 import GateLock from '../../components/gate-lock.vue'
 import GateGuideBar from '../../components/gate-guide-bar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
+import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
 import ConfettiCelebration from '../../components/ConfettiCelebration.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 
@@ -360,6 +366,7 @@ export default {
     BaseEmpty,
     BaseLoading,
     OnboardingGuide,
+    ConfigurableGuide,
     ConfettiCelebration,
     FamilyMemberStrip,
     GateLock,
@@ -370,6 +377,9 @@ export default {
       loading: true,
       showOnboarding: false,
       hasChildren: false,
+      guidePopup: null,
+      showGuidePopup: false,
+      _guidePopupAttempted: false,
       nickname: '',
       memberId: '',
       totalPoints: 0,
@@ -510,6 +520,7 @@ export default {
     // 首次登录引导:异步拉取配置并按版本号判断是否展示
     this._checkOnboardingShow()
     this.loadData()
+    this.loadGuidePopup()
   },
   onShow() {
     if (this._mounted) {
@@ -786,6 +797,29 @@ export default {
         self.showOnboarding = true
       })
     },
+    loadGuidePopup() {
+      var self = this
+      if (self._guidePopupAttempted) return
+      self._guidePopupAttempted = true
+      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 })
+      }
+    },
     _compareVersion(v1, v2) {
       var parts1 = String(v1).split('.').map(function(s) { return parseInt(s || '0', 10) })
       var parts2 = String(v2).split('.').map(function(s) { return parseInt(s || '0', 10) })

+ 36 - 3
cfc-frontend/pages/index-home/index.vue

@@ -230,6 +230,11 @@
       :show="showOnboardingGuide"
       position="home"
       @close="closeOnboardingGuide" />
+    <!-- 配置驱动弹窗(guide_popup 表,管理端可配置) -->
+    <ConfigurableGuide
+      :popup="guidePopup"
+      :show="showGuidePopup"
+      @done="handleGuidePopupDone" />
   </view>
 
   <!-- 未登录态 — 发现页/引流内容,直接内嵌,不跳转 -->
@@ -415,14 +420,15 @@ import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
-import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode, getAppLibrary, reportAppEnter } from '../../utils/api.js'
+import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
+import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, getGuidePopupList, consumeGuidePopup, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode, getAppLibrary, reportAppEnter } from '../../utils/api.js'
 import { findAppByKey, getShortcutableApps } from '../../utils/app-library.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
-  components: {
+components: {
     WuxingSandbox,
     GateLock,
     FamilyFeed,
@@ -433,7 +439,7 @@ export default {
     ReportUploadCard,
     OnboardingProgressBar,
     OnboardingGuide,
-    
+    ConfigurableGuide,
   },
   data() {
     // 从 storage 预取登录态/角色,确保首次渲染即正确(WeChat 渲染层/逻辑层分离架构下 onLoad 执行前已渲染)
@@ -457,6 +463,9 @@ export default {
       selfCheckLoading: false,
       showOnboardingGuide: false,
       _onboardingGuideAttempted: false,
+      guidePopup: null,
+      showGuidePopup: false,
+      _guidePopupAttempted: false,
 
       dimensionActivities: [],
       indexArticles: [],
@@ -707,6 +716,7 @@ title: '添加家庭成员',
     if (!this.showOnboardingGuide) {
       this._maybeShowOnboardingGuide()
     }
+    this.loadGuidePopup()
 
     if (this._onLoadFired) {
       this._onLoadFired = false // 清标记,后续 onShow 走刷新路径
@@ -1346,6 +1356,29 @@ title: '添加家庭成员',
     closeOnboardingGuide() {
       this.showOnboardingGuide = false
     },
+    loadGuidePopup() {
+      var self = this
+      if (self._guidePopupAttempted) return
+      self._guidePopupAttempted = true
+      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 })
+      }
+    },
     isLoggedIn() {
       return !!uni.getStorageSync('token')
     },

+ 15 - 0
cfc-frontend/utils/api.js

@@ -414,6 +414,21 @@ export const getGuideConfig = () => {
 	return request('/api/guide-config/current', 'POST', {})
 }
 
+// 引导弹窗配置(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 })
+}
+
 // 获取以指定成员为中心的所有成员关系(关系类型+信任/亲密度/沟通评分)
 export const getPairwiseRelationships = (centerMemberId) => {
 	return request('/api/family/relationship/pairwise', 'POST', { centerMemberId })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-daffd690564fd7143038c4c5b493b6220ca95c28
+c7df15588537bf3d19472badf53a8f73c37fb47b

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1460",
+  "version": "1.0.1461",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1460",
+      "version": "1.0.1461",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1461",
+  "version": "1.0.1462",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 7 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,13 @@
 
 ---
 
+## v1.0.1462 (2026-09-22)
+
+### Bug 修复
+- 修复 :key 表达式违规(editor.vue+food-query.vue 改用方法调用)
+- 修复 editor.vue :key 表达式违规(改用方法调用)
+
+
 ## v1.0.1461 (2026-09-22)
 
 ### 文档

+ 8 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1461
+> 当前版本: v1.0.1462
 
 ## 历史版本
 
@@ -8,6 +8,13 @@
 
 ---
 
+## v1.0.1462 (2026-09-22)
+
+### Bug 修复
+- 修复 :key 表达式违规(editor.vue+food-query.vue 改用方法调用)
+- 修复 editor.vue :key 表达式违规(改用方法调用)
+
+
 ## v1.0.1461 (2026-09-22)
 
 ### 文档

+ 20 - 0
cfc-web/src/api/guidePopup.js

@@ -0,0 +1,20 @@
+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 })
+}

+ 12 - 0
cfc-web/src/router/index.js

@@ -349,6 +349,18 @@ const routes = [
       component: () => import('@/views/admin/GuideConfig.vue'),
       meta: { title: '引导配置', perm: 'system:base' }
     },
+    {
+      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' }
+    },
     {
       path: 'menu-manage',
       name: 'MenuManage',

+ 180 - 0
cfc-web/src/views/admin/GuidePopupConfig.vue

@@ -0,0 +1,180 @@
+<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>

+ 51 - 0
cfc-web/src/views/admin/GuidePopupStats.vue

@@ -0,0 +1,51 @@
+<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>

+ 2 - 1
docs/superpowers/PROJECT-OVERVIEW.md

@@ -376,7 +376,7 @@
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
-| `2026-09-22-guide-popup-config-design.md` | ✅ 已确认 | 弹窗引导页配置化管理设计(4表/guide_popup+step+trigger+log,11接口,ConfigurableGuide.vue组件+管理端2页) |
+| `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |
 
 ### 实施计划(plans/)
 
@@ -458,6 +458,7 @@
 | `2026-09-04-task-system-unification.md` | 🟢 已实施(9 Tasks:tasks表加字段/模板加onboarding_sequence/DTO透传/GrowthTask迁入TaskService/Onboarding复用模板/旧表迁移/旧接口兼容/前端统一任务中心/收尾) | 统一任务系统实施计划(设计稿:2026-09-04-task-system-unification-design.md) |
 | `2026-09-12-vendor-order-review.md` | ✅ 已实施(5 Tasks:后端 review/update 接口 + 临时券/赠品/配送校验 + 前端 order-review 页面 + 列表入口 + vendorId 详情返回) | 供应商订单审核实施计划(设计稿:2026-09-12-vendor-order-review-design.md) |
 | `2026-09-19-article-reading-quiz.md` | 🟢 已实施(7 Tasks:迁移299-300建表加列 / 实体字段调整 / LangGraph article_quiz_graph+路由 / AiGateway / quiz接口改造 / 前端适配 / 编译验证) | 文章阅读答题奖励+认知雷达实施计划(设计稿:2026-09-19-article-reading-quiz-design.md) |
+| `2026-09-22-guide-popup-config.md` | 🟢 已实施(7 Tasks:迁移329-333四表+种子数据 / 4实体4Mapper / 用户端3接口 / 管理端6接口 / ConfigurableGuide组件+两页面接入 / 管理端2页+路由 / 文档同步) | 弹窗引导页配置化管理实施计划(设计稿:2026-09-22-guide-popup-config-design.md) |
 
 ### 计划与设计文档(specs/)
 

+ 22 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1263,6 +1263,28 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 > **已记录 DOA 个人目标 API 端点 — 禁止重复注册。**
 
+## 4.42 引导弹窗配置化管理
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/guide/popup/list` | 查询当前用户应展示的激活弹窗列表 |
+| `POST /api/guide/popup/trigger` | 上报触发行为,返回匹配弹窗 |
+| `POST /api/guide/popup/consume` | 消费弹窗(点击/关闭上报,防止重复弹出) |
+| `POST /api/admin/guide/popup/list` | 管理端:列表(分页筛选 status/slug) |
+| `POST /api/admin/guide/popup/create` | 管理端:创建弹窗(含触发条件+步骤) |
+| `POST /api/admin/guide/popup/update` | 管理端:更新弹窗(全量覆盖步骤和条件) |
+| `POST /api/admin/guide/popup/status` | 管理端:启用/禁用(toggle) |
+| `POST /api/admin/guide/popup/delete` | 管理端:软删除 |
+| `POST /api/admin/guide/popup/stats` | 管理端:数据看板(曝光/点击/完成率/分时段趋势) |
+
+> **设计文档**:`docs/superpowers/specs/2026-09-22-guide-popup-config-design.md`
+
+> **实施计划**:`docs/superpowers/plans/2026-09-22-guide-popup-config.md`
+
+> **说明**:本功能将小程序弹窗引导页从硬编码改为配置化管理,支持管理员在后台配置弹出条件与内容。旧方案(sys_config onboarding_guide_config)保留,新方案通过 4 张新表 + 9 接口实现。
+
+---
+
 ## 六、待清理的废弃接口
 
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |