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