|
|
@@ -0,0 +1,1959 @@
|
|
|
+# DOA 家庭打卡(个人目标-周计划-周复盘)实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 在家庭挑战模块中新增并列的「个人目标 DOA」体系:每人每阶段定 1-3 个领域目标(自选 2/4/8/12 周周期),每周填写行动宣告计划(A表),周末填写复盘五问(B表),家庭成员互相可见、能登录用户各自管理、不能登录成员由能登录用户代管。
|
|
|
+
|
|
|
+**架构:** 全新 3 张表(`doa_stage` / `doa_goal` / `doa_week`)+ 新 `DoaService` + `DoaController`(`/api/doa/*`),与现有 `family_challenge` 完全解耦。前端在 `pages/health/challenge-manage.vue` 顶部加「共同挑战/个人目标」双 tab,新增 4 个页面(总览/创建/详情/周编辑)。
|
|
|
+
|
|
|
+**技术栈:** Spring Boot 2.7.18 + MyBatis-Plus(Java 8)、uni-app Vue 2 小程序(Options API)、MySQL 8。
|
|
|
+
|
|
|
+**规格文档:** `docs/superpowers/specs/2026-09-12-doa-family-goal-design.md`
|
|
|
+**数据库迁移:** 迁移 314(最新编号 313),`DatabaseInitializer.runMigrations()` + `schema.sql` 同步
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+**后端(全部新建,除标注修改):**
|
|
|
+| 文件 | 职责 |
|
|
|
+|---|---|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java` | 阶段实体(doa_stage 表) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.java` | 目标实体(doa_goal 表) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.java` | 周记录实体(doa_week 表) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.java` | 阶段 Mapper |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java` | 目标 Mapper |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java` | 周记录 Mapper |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java` | 业务逻辑(创建/查询/计划/复盘/代管校验) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java` | REST 接口(/api/doa/*) |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` **修改** | 迁移 314 建三表 |
|
|
|
+| `cfc-backend/src/main/resources/schema.sql` **修改** | 同步三表 CREATE TABLE |
|
|
|
+
|
|
|
+**前端:**
|
|
|
+| 文件 | 职责 |
|
|
|
+|---|---|
|
|
|
+| `cfc-frontend/utils/api.js` **修改** | 新增 7 个 doa 接口封装 |
|
|
|
+| `cfc-frontend/pages.json` **修改** | 注册 4 个 doa 页面 |
|
|
|
+| `cfc-frontend/pages/health/challenge-manage.vue` **修改** | 顶部加「共同挑战/个人目标」tab |
|
|
|
+| `cfc-frontend/pages/health/doa-index.vue` | DOA 家庭总览 |
|
|
|
+| `cfc-frontend/pages/health/doa-stage-create.vue` | 创建阶段(选成员/周期/目标) |
|
|
|
+| `cfc-frontend/pages/health/doa-stage-detail.vue` | 阶段详情(目标+周时间线) |
|
|
|
+| `cfc-frontend/pages/health/doa-week-edit.vue` | 周计划/复盘编辑 |
|
|
|
+
|
|
|
+**文档:**
|
|
|
+| 文件 | 职责 |
|
|
|
+|---|---|
|
|
|
+| `docs/superpowers/api/API_REFERENCE.md` **修改** | 登记 /api/doa/* 接口 |
|
|
|
+| `AGENTS.md` / `cfc-backend/AGENTS.md` **修改** | 记录「孩子=不能登录用户」规范 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 1:数据库迁移 314(建三表)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(文件末尾 `}` 前,`seedGutFloraFingerprints()` 方法之后)
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`(文件末尾追加)
|
|
|
+
|
|
|
+- [ ] **步骤 1:在 DatabaseInitializer 末尾添加迁移 314**
|
|
|
+
|
|
|
+在 `runMigrations()` 方法末尾(`seedGutFloraFingerprints();` 调用之后、方法闭合 `}` 之前)追加以下代码:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 迁移314: 创建 DOA 个人目标-周计划-周复盘三表(家庭打卡个人目标模块,2026-09-12)
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_stage (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "family_id BIGINT NOT NULL COMMENT '家庭ID', " +
|
|
|
+ "member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)', " +
|
|
|
+ "title VARCHAR(200) COMMENT '阶段主题(可选)', " +
|
|
|
+ "start_date DATE NOT NULL COMMENT '阶段开始日期', " +
|
|
|
+ "end_date DATE NOT NULL COMMENT '阶段结束日期', " +
|
|
|
+ "duration_weeks TINYINT NOT NULL COMMENT '周期周数:2/4/8/12', " +
|
|
|
+ "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " +
|
|
|
+ "created_by BIGINT NOT NULL COMMENT '创建人userId(代管时为代管人)', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
|
|
|
+ "INDEX idx_ds_family_member (family_id, member_id), " +
|
|
|
+ "INDEX idx_ds_status (status) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段'");
|
|
|
+ log.info("迁移314: 已创建 doa_stage 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移314: 创建 doa_stage 表失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_goal (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " +
|
|
|
+ "domain VARCHAR(20) NOT NULL COMMENT '领域:health/wealth/family/social/help', " +
|
|
|
+ "content VARCHAR(500) NOT NULL COMMENT '量化目标内容', " +
|
|
|
+ "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/achieved/abandoned', " +
|
|
|
+ "sort_order TINYINT DEFAULT 0 COMMENT '排序', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
|
|
|
+ "INDEX idx_dg_stage (stage_id) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段目标'");
|
|
|
+ log.info("迁移314: 已创建 doa_goal 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移314: 创建 doa_goal 表失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_week (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " +
|
|
|
+ "week_no TINYINT NOT NULL COMMENT '第几周(1起)', " +
|
|
|
+ "week_start DATE NOT NULL COMMENT '本周开始(周一)', " +
|
|
|
+ "week_end DATE NOT NULL COMMENT '本周结束(周日)', " +
|
|
|
+ "plan TEXT COMMENT '周行动宣告计划(A表)', " +
|
|
|
+ "achieved TEXT COMMENT '复盘:本周达成了什么', " +
|
|
|
+ "experience TEXT COMMENT '复盘:有什么体验', " +
|
|
|
+ "worked TEXT COMMENT '复盘:行得通的', " +
|
|
|
+ "improve TEXT COMMENT '复盘:要提升/行不通的', " +
|
|
|
+ "next_challenge TEXT COMMENT '复盘:下周挑战自己什么', " +
|
|
|
+ "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/planned/reviewed', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
|
|
|
+ "UNIQUE KEY uk_dw_stage_week (stage_id, week_no), " +
|
|
|
+ "INDEX idx_dw_stage (stage_id) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录'");
|
|
|
+ log.info("迁移314: 已创建 doa_week 表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("迁移314: 创建 doa_week 表失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:同步 schema.sql**
|
|
|
+
|
|
|
+在 `cfc-backend/src/main/resources/schema.sql` 文件末尾追加:
|
|
|
+
|
|
|
+```sql
|
|
|
+-- ============================================================
|
|
|
+-- DOA 个人目标-周计划-周复盘(迁移314,家庭打卡个人目标模块)
|
|
|
+-- ============================================================
|
|
|
+CREATE TABLE IF NOT EXISTS doa_stage (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ family_id BIGINT NOT NULL COMMENT '家庭ID',
|
|
|
+ member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)',
|
|
|
+ title VARCHAR(200) COMMENT '阶段主题(可选)',
|
|
|
+ start_date DATE NOT NULL COMMENT '阶段开始日期',
|
|
|
+ end_date DATE NOT NULL COMMENT '阶段结束日期',
|
|
|
+ duration_weeks TINYINT NOT NULL COMMENT '周期周数:2/4/8/12',
|
|
|
+ status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled',
|
|
|
+ created_by BIGINT NOT NULL COMMENT '创建人userId(代管时为代管人)',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_ds_family_member (family_id, member_id),
|
|
|
+ INDEX idx_ds_status (status)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段';
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS doa_goal (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ stage_id BIGINT NOT NULL COMMENT '所属阶段ID',
|
|
|
+ domain VARCHAR(20) NOT NULL COMMENT '领域:health/wealth/family/social/help',
|
|
|
+ content VARCHAR(500) NOT NULL COMMENT '量化目标内容',
|
|
|
+ status VARCHAR(20) DEFAULT 'active' COMMENT 'active/achieved/abandoned',
|
|
|
+ sort_order TINYINT DEFAULT 0 COMMENT '排序',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_dg_stage (stage_id)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段目标';
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS doa_week (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ stage_id BIGINT NOT NULL COMMENT '所属阶段ID',
|
|
|
+ week_no TINYINT NOT NULL COMMENT '第几周(1起)',
|
|
|
+ week_start DATE NOT NULL COMMENT '本周开始(周一)',
|
|
|
+ week_end DATE NOT NULL COMMENT '本周结束(周日)',
|
|
|
+ plan TEXT COMMENT '周行动宣告计划(A表)',
|
|
|
+ achieved TEXT COMMENT '复盘:本周达成了什么',
|
|
|
+ experience TEXT COMMENT '复盘:有什么体验',
|
|
|
+ worked TEXT COMMENT '复盘:行得通的',
|
|
|
+ improve TEXT COMMENT '复盘:要提升/行不通的',
|
|
|
+ next_challenge TEXT COMMENT '复盘:下周挑战自己什么',
|
|
|
+ status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/planned/reviewed',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
+ UNIQUE KEY uk_dw_stage_week (stage_id, week_no),
|
|
|
+ INDEX idx_dw_stage (stage_id)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录';
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS(迁移代码是幂等的 try-catch,无需运行时验证)
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
|
|
|
+git commit -m "feat(doa): 迁移314创建DOA三表(doa_stage/doa_goal/doa_week)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 2:后端 Entity + Mapper(3 实体 3 Mapper)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 DoaStage 实体**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableField;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("doa_stage")
|
|
|
+public class DoaStage implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private Long familyId;
|
|
|
+
|
|
|
+ private Long memberId;
|
|
|
+
|
|
|
+ private String title;
|
|
|
+
|
|
|
+ private Date startDate;
|
|
|
+
|
|
|
+ private Date endDate;
|
|
|
+
|
|
|
+ private Integer durationWeeks;
|
|
|
+
|
|
|
+ private String status;
|
|
|
+
|
|
|
+ private Long createdBy;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ private Date updatedAt;
|
|
|
+
|
|
|
+ /** 非表字段:目标列表 */
|
|
|
+ @TableField(exist = false)
|
|
|
+ private List<DoaGoal> goals;
|
|
|
+
|
|
|
+ /** 非表字段:周记录列表 */
|
|
|
+ @TableField(exist = false)
|
|
|
+ private List<DoaWeek> weeks;
|
|
|
+
|
|
|
+ /** 非表字段:目标成员昵称 */
|
|
|
+ @TableField(exist = false)
|
|
|
+ private String memberNickname;
|
|
|
+
|
|
|
+ /** 非表字段:已完成周数(复盘过的周) */
|
|
|
+ @TableField(exist = false)
|
|
|
+ private Integer reviewedWeeks;
|
|
|
+
|
|
|
+ /** 非表字段:周总数 */
|
|
|
+ @TableField(exist = false)
|
|
|
+ private Integer totalWeeks;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:创建 DoaGoal 实体**
|
|
|
+
|
|
|
+```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("doa_goal")
|
|
|
+public class DoaGoal implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private Long stageId;
|
|
|
+
|
|
|
+ private String domain;
|
|
|
+
|
|
|
+ private String content;
|
|
|
+
|
|
|
+ private String status;
|
|
|
+
|
|
|
+ private Integer sortOrder;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:创建 DoaWeek 实体**
|
|
|
+
|
|
|
+```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("doa_week")
|
|
|
+public class DoaWeek implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private Long stageId;
|
|
|
+
|
|
|
+ private Integer weekNo;
|
|
|
+
|
|
|
+ private Date weekStart;
|
|
|
+
|
|
|
+ private Date weekEnd;
|
|
|
+
|
|
|
+ private String plan;
|
|
|
+
|
|
|
+ private String achieved;
|
|
|
+
|
|
|
+ private String experience;
|
|
|
+
|
|
|
+ private String worked;
|
|
|
+
|
|
|
+ private String improve;
|
|
|
+
|
|
|
+ private String nextChallenge;
|
|
|
+
|
|
|
+ private String status;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:创建 3 个 Mapper**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.DoaStage;
|
|
|
+
|
|
|
+public interface DoaStageMapper extends BaseMapper<DoaStage> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.DoaGoal;
|
|
|
+
|
|
|
+public interface DoaGoalMapper extends BaseMapper<DoaGoal> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.DoaWeek;
|
|
|
+
|
|
|
+public interface DoaWeekMapper extends BaseMapper<DoaWeek> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java cfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.java cfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.java cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java
|
|
|
+git commit -m "feat(doa): 新增DoaStage/DoaGoal/DoaWeek实体与Mapper"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 3:DoaService(核心业务逻辑)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java`
|
|
|
+
|
|
|
+**依赖注入的 Mapper/Service:** `DoaStageMapper`、`DoaGoalMapper`、`DoaWeekMapper`、`FamilyMemberMapper`(校验家庭归属与代管权限)
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 DoaService(完整代码)**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.DoaGoal;
|
|
|
+import com.etotem.cfc.entity.DoaStage;
|
|
|
+import com.etotem.cfc.entity.DoaWeek;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.mapper.DoaGoalMapper;
|
|
|
+import com.etotem.cfc.mapper.DoaStageMapper;
|
|
|
+import com.etotem.cfc.mapper.DoaWeekMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.util.SortUtil;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class DoaService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private DoaStageMapper doaStageMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private DoaGoalMapper doaGoalMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private DoaWeekMapper doaWeekMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ private static final Set<String> VALID_DOMAINS = new HashSet<>(Arrays.asList(
|
|
|
+ "health", "wealth", "family", "social", "help"));
|
|
|
+
|
|
|
+ /** 周起始:周一 */
|
|
|
+ private static Date weekStart(Date date) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(date);
|
|
|
+ cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY);
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 0);
|
|
|
+ cal.set(Calendar.MINUTE, 0);
|
|
|
+ cal.set(Calendar.SECOND, 0);
|
|
|
+ cal.set(Calendar.MILLISECOND, 0);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Date weekEnd(Date weekStart) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(weekStart);
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, 6);
|
|
|
+ cal.set(Calendar.HOUR_OF_DAY, 23);
|
|
|
+ cal.set(Calendar.MINUTE, 59);
|
|
|
+ cal.set(Calendar.SECOND, 59);
|
|
|
+ cal.set(Calendar.MILLISECOND, 999);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建阶段:校验归属 + 周期 + 目标数量,写 stage + goals,预生成 N 条 week。
|
|
|
+ *
|
|
|
+ * @param memberId 目标成员ID;null 表示当前登录成员自己
|
|
|
+ * @param creatorUserId 当前登录 userId
|
|
|
+ * @param familyId 当前家庭
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public Result<Long> createStage(Long memberId, Long creatorUserId, Long familyId,
|
|
|
+ String title, Integer durationWeeks, List<Map<String, Object>> goals) {
|
|
|
+ if (creatorUserId == null || familyId == null) {
|
|
|
+ return Result.error("未登录或未加入家庭");
|
|
|
+ }
|
|
|
+ if (durationWeeks == null || (durationWeeks != 2 && durationWeeks != 4 && durationWeeks != 8 && durationWeeks != 12)) {
|
|
|
+ return Result.error("durationWeeks必须是2/4/8/12");
|
|
|
+ }
|
|
|
+ if (goals == null || goals.isEmpty() || goals.size() > 3) {
|
|
|
+ return Result.error("每阶段需设置1-3个目标");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 目标成员:默认当前登录成员自己
|
|
|
+ if (memberId == null) {
|
|
|
+ memberId = resolveMemberIdByUserId(creatorUserId, familyId);
|
|
|
+ if (memberId == null) {
|
|
|
+ return Result.error("未找到当前成员身份");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 校验目标成员属于当前家庭
|
|
|
+ FamilyMember targetMember = familyMemberMapper.selectById(memberId);
|
|
|
+ if (targetMember == null || !familyId.equals(targetMember.getFamilyId())) {
|
|
|
+ return Result.error("目标成员不属于当前家庭");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 代管校验:memberId != 当前成员 时,目标成员必须是不能登录成员(userId 为空)
|
|
|
+ Long selfMemberId = resolveMemberIdByUserId(creatorUserId, familyId);
|
|
|
+ if (selfMemberId != null && !selfMemberId.equals(memberId)) {
|
|
|
+ if (targetMember.getUserId() != null) {
|
|
|
+ return Result.error("只能代管不能登录成员的目标");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 校验目标领域与去重
|
|
|
+ Set<String> domains = new HashSet<>();
|
|
|
+ for (Map<String, Object> g : goals) {
|
|
|
+ String domain = g.get("domain") != null ? g.get("domain").toString() : "";
|
|
|
+ String content = g.get("content") != null ? g.get("content").toString().trim() : "";
|
|
|
+ if (!VALID_DOMAINS.contains(domain)) {
|
|
|
+ return Result.error("目标领域无效: " + domain);
|
|
|
+ }
|
|
|
+ if (content.isEmpty()) {
|
|
|
+ return Result.error("目标内容不能为空");
|
|
|
+ }
|
|
|
+ if (!domains.add(domain)) {
|
|
|
+ return Result.error("同一阶段目标领域不能重复: " + domain);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 日期计算:start=今天,end=start + durationWeeks*7 - 1
|
|
|
+ Date startDate = new Date();
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(startDate);
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, durationWeeks * 7 - 1);
|
|
|
+ Date endDate = cal.getTime();
|
|
|
+
|
|
|
+ // 写 stage
|
|
|
+ DoaStage stage = new DoaStage();
|
|
|
+ stage.setFamilyId(familyId);
|
|
|
+ stage.setMemberId(memberId);
|
|
|
+ stage.setTitle(title);
|
|
|
+ stage.setStartDate(startDate);
|
|
|
+ stage.setEndDate(endDate);
|
|
|
+ stage.setDurationWeeks(durationWeeks);
|
|
|
+ stage.setStatus("active");
|
|
|
+ stage.setCreatedBy(creatorUserId);
|
|
|
+ stage.setCreatedAt(new Date());
|
|
|
+ doaStageMapper.insert(stage);
|
|
|
+
|
|
|
+ // 写 goals
|
|
|
+ int order = 0;
|
|
|
+ for (Map<String, Object> g : goals) {
|
|
|
+ DoaGoal goal = new DoaGoal();
|
|
|
+ goal.setStageId(stage.getId());
|
|
|
+ goal.setDomain(g.get("domain").toString());
|
|
|
+ goal.setContent(g.get("content").toString().trim());
|
|
|
+ goal.setStatus("active");
|
|
|
+ goal.setSortOrder(order++);
|
|
|
+ goal.setCreatedAt(new Date());
|
|
|
+ doaGoalMapper.insert(goal);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 预生成 N 条 week(周一为一周起点,首周从 start 所在周的周一起算)
|
|
|
+ Date firstWeekStart = weekStart(stage.getStartDate());
|
|
|
+ for (int i = 1; i <= durationWeeks; i++) {
|
|
|
+ DoaWeek week = new DoaWeek();
|
|
|
+ week.setStageId(stage.getId());
|
|
|
+ week.setWeekNo(i);
|
|
|
+ Date ws = addDays(firstWeekStart, (i - 1) * 7);
|
|
|
+ week.setWeekStart(ws);
|
|
|
+ week.setWeekEnd(weekEnd(ws));
|
|
|
+ week.setStatus("pending");
|
|
|
+ week.setCreatedAt(new Date());
|
|
|
+ doaWeekMapper.insert(week);
|
|
|
+ }
|
|
|
+
|
|
|
+ log.info("DOA创建阶段: stageId={}, memberId={}, weeks={}, goals={}",
|
|
|
+ stage.getId(), memberId, durationWeeks, goals.size());
|
|
|
+ return Result.success(stage.getId());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 家庭 DOA 总览:各成员当前 active 阶段 */
|
|
|
+ public List<Map<String, Object>> getFamilyOverview(Long familyId) {
|
|
|
+ List<Map<String, Object>> result = new ArrayList<>();
|
|
|
+ if (familyId == null) return result;
|
|
|
+
|
|
|
+ LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getFamilyId, familyId);
|
|
|
+ SortUtil.applySort(mw);
|
|
|
+ List<FamilyMember> members = familyMemberMapper.selectList(mw);
|
|
|
+
|
|
|
+ for (FamilyMember m : members) {
|
|
|
+ LambdaQueryWrapper<DoaStage> sw = new LambdaQueryWrapper<DoaStage>()
|
|
|
+ .eq(DoaStage::getFamilyId, familyId)
|
|
|
+ .eq(DoaStage::getMemberId, m.getId())
|
|
|
+ .eq(DoaStage::getStatus, "active")
|
|
|
+ .orderByDesc(DoaStage::getCreatedAt);
|
|
|
+ SortUtil.applySort(sw);
|
|
|
+ List<DoaStage> stages = doaStageMapper.selectList(sw);
|
|
|
+
|
|
|
+ Map<String, Object> memberMap = new HashMap<>();
|
|
|
+ memberMap.put("memberId", m.getId());
|
|
|
+ memberMap.put("nickname", m.getNickname());
|
|
|
+ memberMap.put("memberType", m.getUserId() == null ? "local" : "login");
|
|
|
+
|
|
|
+ if (!stages.isEmpty()) {
|
|
|
+ DoaStage current = stages.get(0);
|
|
|
+ fillStageDetail(current);
|
|
|
+ memberMap.put("currentStage", current);
|
|
|
+ }
|
|
|
+ result.add(memberMap);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 阶段详情(stage + goals + weeks) */
|
|
|
+ public Result<DoaStage> getStageDetail(Long stageId, Long familyId) {
|
|
|
+ if (stageId == null) return Result.error("stageId不能为空");
|
|
|
+ DoaStage stage = doaStageMapper.selectById(stageId);
|
|
|
+ if (stage == null) return Result.error("阶段不存在");
|
|
|
+ if (!familyId.equals(stage.getFamilyId())) return Result.error("无权查看该阶段");
|
|
|
+
|
|
|
+ fillStageDetail(stage);
|
|
|
+
|
|
|
+ // 成员昵称
|
|
|
+ FamilyMember m = familyMemberMapper.selectById(stage.getMemberId());
|
|
|
+ stage.setMemberNickname(m != null ? m.getNickname() : "成员" + stage.getMemberId());
|
|
|
+ return Result.success(stage);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 填充 stage 的 goals + weeks(含复盘周数统计) */
|
|
|
+ private void fillStageDetail(DoaStage stage) {
|
|
|
+ LambdaQueryWrapper<DoaGoal> gw = new LambdaQueryWrapper<DoaGoal>()
|
|
|
+ .eq(DoaGoal::getStageId, stage.getId())
|
|
|
+ .orderByAsc(DoaGoal::getSortOrder);
|
|
|
+ SortUtil.applySort(gw);
|
|
|
+ stage.setGoals(doaGoalMapper.selectList(gw));
|
|
|
+
|
|
|
+ LambdaQueryWrapper<DoaWeek> ww = new LambdaQueryWrapper<DoaWeek>()
|
|
|
+ .eq(DoaWeek::getStageId, stage.getId())
|
|
|
+ .orderByAsc(DoaWeek::getWeekNo);
|
|
|
+ SortUtil.applySort(ww);
|
|
|
+ List<DoaWeek> weeks = doaWeekMapper.selectList(ww);
|
|
|
+ stage.setWeeks(weeks);
|
|
|
+ stage.setTotalWeeks(weeks.size());
|
|
|
+
|
|
|
+ int reviewed = 0;
|
|
|
+ for (DoaWeek w : weeks) {
|
|
|
+ if ("reviewed".equals(w.getStatus())) reviewed++;
|
|
|
+ }
|
|
|
+ stage.setReviewedWeeks(reviewed);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 取消阶段 */
|
|
|
+ @Transactional
|
|
|
+ public Result<Void> cancelStage(Long stageId, Long userId, Long familyId) {
|
|
|
+ DoaStage stage = doaStageMapper.selectById(stageId);
|
|
|
+ if (stage == null) return Result.error("阶段不存在");
|
|
|
+ if (!familyId.equals(stage.getFamilyId())) return Result.error("无权操作该阶段");
|
|
|
+ if (!"active".equals(stage.getStatus())) return Result.error("仅active阶段可取消");
|
|
|
+ // 权限:本人 或 代管不能登录成员
|
|
|
+ Long selfMemberId = resolveMemberIdByUserId(userId, familyId);
|
|
|
+ boolean isSelf = selfMemberId != null && selfMemberId.equals(stage.getMemberId());
|
|
|
+ FamilyMember target = familyMemberMapper.selectById(stage.getMemberId());
|
|
|
+ boolean canManageLocal = target != null && target.getUserId() == null;
|
|
|
+ if (!isSelf && !canManageLocal) {
|
|
|
+ return Result.error("无权取消该阶段");
|
|
|
+ }
|
|
|
+ stage.setStatus("cancelled");
|
|
|
+ doaStageMapper.updateById(stage);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 提交周计划(A表) */
|
|
|
+ @Transactional
|
|
|
+ public Result<Void> submitWeekPlan(Long weekId, String plan, Long userId, Long familyId) {
|
|
|
+ DoaWeek week = doaWeekMapper.selectById(weekId);
|
|
|
+ if (week == null) return Result.error("周记录不存在");
|
|
|
+ DoaStage stage = doaStageMapper.selectById(week.getStageId());
|
|
|
+ if (stage == null || !familyId.equals(stage.getFamilyId())) return Result.error("无权操作");
|
|
|
+ if (!checkStageMemberPermission(stage, userId, familyId)) return Result.error("无权操作该阶段");
|
|
|
+ if ("reviewed".equals(week.getStatus())) return Result.error("已复盘不可修改计划");
|
|
|
+ week.setPlan(plan);
|
|
|
+ if ("pending".equals(week.getStatus())) week.setStatus("planned");
|
|
|
+ doaWeekMapper.updateById(week);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 提交周复盘(B表五问) */
|
|
|
+ @Transactional
|
|
|
+ public Result<Void> submitWeekReview(Long weekId, Map<String, String> review, Long userId, Long familyId) {
|
|
|
+ DoaWeek week = doaWeekMapper.selectById(weekId);
|
|
|
+ if (week == null) return Result.error("周记录不存在");
|
|
|
+ DoaStage stage = doaStageMapper.selectById(week.getStageId());
|
|
|
+ if (stage == null || !familyId.equals(stage.getFamilyId())) return Result.error("无权操作");
|
|
|
+ if (!checkStageMemberPermission(stage, userId, familyId)) return Result.error("无权操作该阶段");
|
|
|
+ if ("reviewed".equals(week.getStatus())) return Result.error("本周已复盘,不可重复提交");
|
|
|
+ week.setAchieved(review.get("achieved"));
|
|
|
+ week.setExperience(review.get("experience"));
|
|
|
+ week.setWorked(review.get("worked"));
|
|
|
+ week.setImprove(review.get("improve"));
|
|
|
+ week.setNextChallenge(review.get("nextChallenge"));
|
|
|
+ week.setStatus("reviewed");
|
|
|
+ doaWeekMapper.updateById(week);
|
|
|
+ return Result.success();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 周记录列表(按阶段查,含周序号) */
|
|
|
+ public List<DoaWeek> listWeeks(Long stageId, Long familyId) {
|
|
|
+ DoaStage stage = doaStageMapper.selectById(stageId);
|
|
|
+ if (stage == null || !familyId.equals(stage.getFamilyId())) return new ArrayList<>();
|
|
|
+ LambdaQueryWrapper<DoaWeek> ww = new LambdaQueryWrapper<DoaWeek>()
|
|
|
+ .eq(DoaWeek::getStageId, stageId)
|
|
|
+ .orderByAsc(DoaWeek::getWeekNo);
|
|
|
+ SortUtil.applySort(ww);
|
|
|
+ return doaWeekMapper.selectList(ww);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 校验当前用户对该阶段的编辑权限(本人 或 代管不能登录成员) */
|
|
|
+ private boolean checkStageMemberPermission(DoaStage stage, Long userId, Long familyId) {
|
|
|
+ Long selfMemberId = resolveMemberIdByUserId(userId, familyId);
|
|
|
+ if (selfMemberId != null && selfMemberId.equals(stage.getMemberId())) return true;
|
|
|
+ FamilyMember target = familyMemberMapper.selectById(stage.getMemberId());
|
|
|
+ return target != null && target.getUserId() == null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** userId → 家庭内成员ID */
|
|
|
+ private Long resolveMemberIdByUserId(Long userId, Long familyId) {
|
|
|
+ if (userId == null) return null;
|
|
|
+ LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getUserId, userId)
|
|
|
+ .eq(FamilyMember::getFamilyId, familyId);
|
|
|
+ SortUtil.applySort(mw);
|
|
|
+ List<FamilyMember> members = familyMemberMapper.selectList(mw);
|
|
|
+ return members.isEmpty() ? null : members.get(0).getId();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Date addDays(Date date, int days) {
|
|
|
+ Calendar cal = Calendar.getInstance();
|
|
|
+ cal.setTime(date);
|
|
|
+ cal.add(Calendar.DAY_OF_YEAR, days);
|
|
|
+ return cal.getTime();
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** 若 `Result` 类没有 `noFamily` 之外所需的静态方法(如 `error(String)`),先阅读 `cfc-backend/src/main/java/com/etotem/cfc/common/Result.java` 确认方法签名。上述代码假设 `Result.error(String)` 与 `Result.success(T)` 存在(与 `FamilyChallengeService` 用法一致)。`SortUtil.applySort(wrapper)` 是项目通用排序工具,与 `FamilyChallengeService` 用法一致。
|
|
|
+
|
|
|
+- [ ] **步骤 2:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS(若有 Result 方法名不一致的编译错误,按 Result.java 实际签名调整)
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java
|
|
|
+git commit -m "feat(doa): 新增DoaService(创建阶段/家庭总览/详情/周计划/周复盘)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 4:DoaController(REST 接口)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 DoaController(完整代码)**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.DoaStage;
|
|
|
+import com.etotem.cfc.entity.DoaWeek;
|
|
|
+import com.etotem.cfc.service.DoaService;
|
|
|
+import com.etotem.cfc.util.ParamUtils;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/doa")
|
|
|
+public class DoaController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private DoaService doaService;
|
|
|
+
|
|
|
+ @PostMapping("/stage/create")
|
|
|
+ public Result<Long> createStage(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long memberId = params.get("memberId") != null
|
|
|
+ ? Long.valueOf(params.get("memberId").toString()) : null;
|
|
|
+ String title = params.get("title") != null ? params.get("title").toString() : null;
|
|
|
+ Integer durationWeeks = params.get("durationWeeks") != null
|
|
|
+ ? Integer.parseInt(params.get("durationWeeks").toString()) : null;
|
|
|
+ List<Map<String, Object>> goals = params.get("goals") instanceof List
|
|
|
+ ? (List<Map<String, Object>>) params.get("goals") : null;
|
|
|
+ return doaService.createStage(memberId, userId, familyId, title, durationWeeks, goals);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/overview")
|
|
|
+ public Result<List<Map<String, Object>>> overview(@RequestAttribute(value = "familyId", required = false) Long familyId) {
|
|
|
+ if (familyId == null) {
|
|
|
+ return Result.noFamily("请先创建或加入家庭");
|
|
|
+ }
|
|
|
+ return Result.success(doaService.getFamilyOverview(familyId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/stage/detail")
|
|
|
+ public Result<DoaStage> stageDetail(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId) {
|
|
|
+ Long stageId = ParamUtils.getLong(params.get("stageId"));
|
|
|
+ return doaService.getStageDetail(stageId, familyId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/stage/cancel")
|
|
|
+ public Result<Void> cancelStage(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long stageId = ParamUtils.getLong(params.get("stageId"));
|
|
|
+ return doaService.cancelStage(stageId, userId, familyId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/week/plan")
|
|
|
+ public Result<Void> submitPlan(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long weekId = ParamUtils.getLong(params.get("weekId"));
|
|
|
+ String plan = params.get("plan") != null ? params.get("plan").toString() : "";
|
|
|
+ return doaService.submitWeekPlan(weekId, plan, userId, familyId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/week/review")
|
|
|
+ public Result<Void> submitReview(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
+ @RequestAttribute(value = "userId", required = false) Long userId) {
|
|
|
+ Long weekId = ParamUtils.getLong(params.get("weekId"));
|
|
|
+ Map<String, String> review = new java.util.HashMap<>();
|
|
|
+ review.put("achieved", params.get("achieved") != null ? params.get("achieved").toString() : null);
|
|
|
+ review.put("experience", params.get("experience") != null ? params.get("experience").toString() : null);
|
|
|
+ review.put("worked", params.get("worked") != null ? params.get("worked").toString() : null);
|
|
|
+ review.put("improve", params.get("improve") != null ? params.get("improve").toString() : null);
|
|
|
+ review.put("nextChallenge", params.get("nextChallenge") != null ? params.get("nextChallenge").toString() : null);
|
|
|
+ return doaService.submitWeekReview(weekId, review, userId, familyId);
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostMapping("/week/list")
|
|
|
+ public Result<List<DoaWeek>> weekList(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId) {
|
|
|
+ Long stageId = ParamUtils.getLong(params.get("stageId"));
|
|
|
+ return Result.success(doaService.listWeeks(stageId, familyId));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** `ParamUtils.getLong` 是项目已有工具(`FamilyChallengeController` 中已使用)。`Result.noFamily(String)` 也是已有方法。若编译报错,按实际签名调整。
|
|
|
+
|
|
|
+- [ ] **步骤 2:路由重复检查**
|
|
|
+
|
|
|
+运行:`grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep 'doa'`
|
|
|
+预期:仅显示 `/doa/stage/create`、`/doa/overview`、`/doa/stage/detail`、`/doa/stage/cancel`、`/doa/week/plan`、`/doa/week/review`、`/doa/week/list`,无重复
|
|
|
+
|
|
|
+- [ ] **步骤 3:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java
|
|
|
+git commit -m "feat(doa): 新增DoaController(/api/doa 七接口)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 5:前端 API 封装 + 页面注册 + 入口 tab
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/utils/api.js`(在家庭挑战接口块之后追加)
|
|
|
+- 修改:`cfc-frontend/pages.json`(`pages/health` 分包内追加 4 页)
|
|
|
+- 修改:`cfc-frontend/pages/health/challenge-manage.vue`(顶部加 tab)
|
|
|
+
|
|
|
+- [ ] **步骤 1:api.js 追加 DOA 接口封装**
|
|
|
+
|
|
|
+在 `cfc-frontend/utils/api.js` 中 `export const sendLike` 之后追加:
|
|
|
+
|
|
|
+```js
|
|
|
+// ===== DOA 个人目标(家庭打卡个人目标模块) =====
|
|
|
+export const createDoaStage = (data) => request('/api/doa/stage/create', 'POST', data)
|
|
|
+export const getDoaOverview = () => request('/api/doa/overview', 'POST', {})
|
|
|
+export const getDoaStageDetail = (stageId) => request('/api/doa/stage/detail', 'POST', { stageId: stageId })
|
|
|
+export const cancelDoaStage = (stageId) => request('/api/doa/stage/cancel', 'POST', { stageId: stageId })
|
|
|
+export const submitDoaWeekPlan = (weekId, plan) => request('/api/doa/week/plan', 'POST', { weekId: weekId, plan: plan })
|
|
|
+export const submitDoaWeekReview = (data) => request('/api/doa/week/review', 'POST', data)
|
|
|
+export const getDoaWeekList = (stageId) => request('/api/doa/week/list', 'POST', { stageId: stageId })
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:pages.json 注册 4 个页面**
|
|
|
+
|
|
|
+在 `cfc-frontend/pages.json` 的 `"root": "pages/health"` 分包 `pages` 数组中,`challenge-manage` 条目之后追加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "doa-index",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "个人目标"
|
|
|
+ }
|
|
|
+},
|
|
|
+{
|
|
|
+ "path": "doa-stage-create",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "创建阶段"
|
|
|
+ }
|
|
|
+},
|
|
|
+{
|
|
|
+ "path": "doa-stage-detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "阶段详情"
|
|
|
+ }
|
|
|
+},
|
|
|
+{
|
|
|
+ "path": "doa-week-edit",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "周计划与复盘"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:challenge-manage.vue 顶部加 tab**
|
|
|
+
|
|
|
+修改 `cfc-frontend/pages/health/challenge-manage.vue`:
|
|
|
+
|
|
|
+1. `<template>` 中 `<view v-if="!loading" class="content">` 之前插入:
|
|
|
+
|
|
|
+```html
|
|
|
+<!-- 顶部双 Tab:共同挑战 / 个人目标 -->
|
|
|
+<view class="page-tabs">
|
|
|
+ <view class="page-tab active-tab" @click="goToDoa">🎯 共同挑战</view>
|
|
|
+ <view class="page-tab" @click="goToDoaIndex">📋 个人目标</view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+2. `<script>` `methods` 中新增:
|
|
|
+
|
|
|
+```js
|
|
|
+goToDoa() {
|
|
|
+ // 当前即共同挑战页
|
|
|
+},
|
|
|
+goToDoaIndex() {
|
|
|
+ uni.navigateTo({ url: '/pages/health/doa-index' })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+3. `<style scoped>` 中新增:
|
|
|
+
|
|
|
+```css
|
|
|
+.page-tabs {
|
|
|
+ display: flex;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 2rpx solid #E2E8F0;
|
|
|
+}
|
|
|
+.page-tab {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ padding: 20rpx 0;
|
|
|
+ font-size: 28rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #64748B;
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+.active-tab {
|
|
|
+ background: #10B981;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** 若 `challenge-manage.vue` 已有 `page-tabs`/`page-tab` 类名,改用新类名 `doa-tabs`/`doa-tab` 避免冲突。
|
|
|
+
|
|
|
+- [ ] **步骤 4:语法校验**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && node -e "const s=require('fs').readFileSync('utils/api.js','utf8'); new Function(s)"`
|
|
|
+预期:无报错(仅校验语法,不打包;打包必须用 HBuilderX)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js cfc-frontend/pages.json cfc-frontend/pages/health/challenge-manage.vue
|
|
|
+git commit -m "feat(doa): 前端API封装+页面注册+挑战页双tab入口"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 6:doa-index.vue(家庭总览页)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/health/doa-index.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 doa-index.vue(完整代码)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="page">
|
|
|
+ <view v-if="loading" class="loading-box">加载中...</view>
|
|
|
+
|
|
|
+ <view v-else class="content">
|
|
|
+ <!-- 创建按钮 -->
|
|
|
+ <view class="action-bar">
|
|
|
+ <button class="btn-new" @click="goCreate">+ 创建阶段</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 家庭成员 DOA 卡片 -->
|
|
|
+ <view v-for="m in members" :key="getMemberKey(m)" class="member-card">
|
|
|
+ <view class="member-header">
|
|
|
+ <text class="member-name">{{ m.nickname }}</text>
|
|
|
+ <text v-if="m.memberType === 'local'" class="member-tag">未登录成员</text>
|
|
|
+ <text class="member-status">{{ m.currentStage ? '进行中' : '未开始' }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 无阶段 -->
|
|
|
+ <view v-if="!m.currentStage" class="member-empty">
|
|
|
+ <text class="empty-hint">还没有阶段目标</text>
|
|
|
+ <text v-if="canManage(m)" class="empty-link" @click="goCreateFor(m.memberId)">为他创建</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 有阶段 -->
|
|
|
+ <view v-else class="stage-box" @click="goDetail(m.currentStage.id)">
|
|
|
+ <text class="stage-title">{{ m.currentStage.title || '阶段目标' }}</text>
|
|
|
+ <text class="stage-dates">{{ formatDate(m.currentStage.startDate) }} ~ {{ formatDate(m.currentStage.endDate) }}</text>
|
|
|
+
|
|
|
+ <!-- 目标领域标签 -->
|
|
|
+ <view class="goal-row">
|
|
|
+ <text v-for="g in m.currentStage.goals" :key="g.id" class="goal-tag" :class="'domain-' + g.domain">
|
|
|
+ {{ domainName(g.domain) }} · {{ g.content }}
|
|
|
+ </text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 周进度 -->
|
|
|
+ <view class="week-progress">
|
|
|
+ <text class="week-text">复盘 {{ m.currentStage.reviewedWeeks || 0 }}/{{ m.currentStage.totalWeeks || 0 }} 周</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 空状态 -->
|
|
|
+ <view v-if="members.length === 0" class="empty">
|
|
|
+ <text class="empty-icon">🎯</text>
|
|
|
+ <text class="empty-desc">创建你的第一个阶段目标,开启 DOA 之旅!</text>
|
|
|
+ <button class="btn-empty" @click="goCreate">立即创建</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getDoaOverview, getFamilyMemberList } from '../../utils/api.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: true,
|
|
|
+ members: [],
|
|
|
+ selfMemberId: 0
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad() {
|
|
|
+ this.selfMemberId = uni.getStorageSync('currentMemberId') || 0
|
|
|
+ this.loadData()
|
|
|
+ this._onLoadFired = true
|
|
|
+ },
|
|
|
+ onShow() {
|
|
|
+ if (this._onLoadFired) {
|
|
|
+ this._onLoadFired = false
|
|
|
+ } else {
|
|
|
+ this.loadData()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ getMemberKey(m) {
|
|
|
+ return m.memberId
|
|
|
+ },
|
|
|
+ async loadData() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ var res = await getDoaOverview()
|
|
|
+ this.members = res.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.members = []
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ domainName(d) {
|
|
|
+ var map = { health: '健康', wealth: '事业', family: '家庭关系', social: '社会关系', help: '帮助他人' }
|
|
|
+ return map[d] || d
|
|
|
+ },
|
|
|
+ formatDate(dateStr) {
|
|
|
+ if (!dateStr) return ''
|
|
|
+ return String(dateStr).slice(0, 10)
|
|
|
+ },
|
|
|
+ canManage(m) {
|
|
|
+ // 自己 或 未登录成员 → 可代管
|
|
|
+ return m.memberId === this.selfMemberId || m.memberType === 'local'
|
|
|
+ },
|
|
|
+ goCreate() {
|
|
|
+ uni.navigateTo({ url: '/pages/health/doa-stage-create' })
|
|
|
+ },
|
|
|
+ goCreateFor(memberId) {
|
|
|
+ uni.navigateTo({ url: '/pages/health/doa-stage-create?memberId=' + memberId })
|
|
|
+ },
|
|
|
+ goDetail(stageId) {
|
|
|
+ uni.navigateTo({ url: '/pages/health/doa-stage-detail?stageId=' + stageId })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
|
|
|
+.loading-box { text-align: center; padding: 100rpx 0; color: #94A3B8; font-size: 28rpx; }
|
|
|
+.action-bar { display: flex; justify-content: flex-end; margin-bottom: 20rpx; }
|
|
|
+.btn-new {
|
|
|
+ background: linear-gradient(135deg, #10B981, #34D399);
|
|
|
+ color: #fff; font-size: 26rpx; font-weight: 600;
|
|
|
+ padding: 14rpx 32rpx; border-radius: 40rpx; border: none;
|
|
|
+}
|
|
|
+.member-card {
|
|
|
+ background: #fff; border-radius: 16rpx; padding: 24rpx;
|
|
|
+ margin-bottom: 16rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
|
|
|
+}
|
|
|
+.member-header { display: flex; align-items: center; margin-bottom: 12rpx; }
|
|
|
+.member-name { font-size: 30rpx; font-weight: 700; color: #1E293B; flex: 1; }
|
|
|
+.member-tag { font-size: 20rpx; color: #B45309; background: #FEF3C7; padding: 4rpx 12rpx; border-radius: 20rpx; margin-right: 12rpx; }
|
|
|
+.member-status { font-size: 22rpx; color: #16A34A; }
|
|
|
+.member-empty { padding: 20rpx 0; text-align: center; }
|
|
|
+.empty-hint { font-size: 24rpx; color: #94A3B8; display: block; }
|
|
|
+.empty-link { font-size: 24rpx; color: #10B981; margin-top: 8rpx; display: inline-block; }
|
|
|
+.stage-box { background: #F8FAFC; border-radius: 12rpx; padding: 20rpx; }
|
|
|
+.stage-title { font-size: 28rpx; font-weight: 700; color: #1E293B; display: block; }
|
|
|
+.stage-dates { font-size: 22rpx; color: #64748B; margin-top: 4rpx; display: block; }
|
|
|
+.goal-row { margin-top: 12rpx; }
|
|
|
+.goal-tag {
|
|
|
+ font-size: 22rpx; color: #475569; background: #fff;
|
|
|
+ padding: 8rpx 16rpx; border-radius: 10rpx; margin: 6rpx 8rpx 0 0;
|
|
|
+ display: inline-block; border: 2rpx solid #E2E8F0;
|
|
|
+}
|
|
|
+.domain-health { border-color: #FF8C42 !important; color: #C2410C !important; }
|
|
|
+.domain-wealth { border-color: #F59E0B !important; color: #B45309 !important; }
|
|
|
+.domain-family { border-color: #10B981 !important; color: #047857 !important; }
|
|
|
+.domain-social { border-color: #6366F1 !important; color: #4338CA !important; }
|
|
|
+.domain-help { border-color: #FF6B9D !important; color: #BE185D !important; }
|
|
|
+.week-progress { margin-top: 12rpx; }
|
|
|
+.week-text { font-size: 22rpx; color: #10B981; font-weight: 600; }
|
|
|
+.empty { display: flex; flex-direction: column; align-items: center; padding: 100rpx 0; }
|
|
|
+.empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
|
|
|
+.empty-desc { font-size: 26rpx; color: #94A3B8; margin-bottom: 32rpx; }
|
|
|
+.btn-empty {
|
|
|
+ background: #10B981; color: #fff; font-size: 28rpx;
|
|
|
+ font-weight: 600; padding: 16rpx 48rpx; border-radius: 40rpx; border: none;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+> **小程序限制检查:** 无可选链、无 CSS Grid、`:key` 使用方法调用 `getMemberKey(m)`(`m.memberId` 简单值,也可以用 `:key="m.memberId"`,此处方法调用同样合规)、无 `new Date(string)`。
|
|
|
+
|
|
|
+- [ ] **步骤 2:语法校验**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && node -e "const fs=require('fs');const s=fs.readFileSync('pages/health/doa-index.vue','utf8');const m=s.match(/<script>([\s\S]*?)<\/script>/);new Function(m[1].replace(/import[^;]+;/g,'').replace(/export default/,'var x='))"`
|
|
|
+预期:无报错
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/doa-index.vue
|
|
|
+git commit -m "feat(doa): 新增doa-index家庭总览页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 7:doa-stage-create.vue(创建阶段页)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/health/doa-stage-create.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 doa-stage-create.vue(完整代码)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="page">
|
|
|
+ <!-- 目标成员(代管时显示,仅未登录成员可选) -->
|
|
|
+ <view class="field" v-if="!isSelfOnly">
|
|
|
+ <text class="field-label">目标成员</text>
|
|
|
+ <view class="member-grid">
|
|
|
+ <view
|
|
|
+ v-for="m in members"
|
|
|
+ :key="m.id"
|
|
|
+ class="member-item"
|
|
|
+ :class="{ active: form.memberId === m.id }"
|
|
|
+ @click="selectMember(m)"
|
|
|
+ >
|
|
|
+ <text>{{ m.nickname }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 阶段主题 -->
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">阶段主题(可选)</text>
|
|
|
+ <input class="field-input" v-model="form.title" placeholder="如:暑期习惯养成" maxlength="50" />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 周期选择 -->
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">阶段周期 <text class="required">*</text></text>
|
|
|
+ <view class="radio-group">
|
|
|
+ <view class="radio-btn" :class="{ active: form.durationWeeks === 2 }" @click="form.durationWeeks = 2">2周</view>
|
|
|
+ <view class="radio-btn" :class="{ active: form.durationWeeks === 4 }" @click="form.durationWeeks = 4">4周</view>
|
|
|
+ <view class="radio-btn" :class="{ active: form.durationWeeks === 8 }" @click="form.durationWeeks = 8">8周</view>
|
|
|
+ <view class="radio-btn" :class="{ active: form.durationWeeks === 12 }" @click="form.durationWeeks = 12">12周</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 目标列表(1-3条) -->
|
|
|
+ <view class="field">
|
|
|
+ <text class="field-label">阶段目标(1-3 个,不同领域)</text>
|
|
|
+ <view v-for="(goal, idx) in form.goals" :key="getGoalKey(goal, idx)" class="goal-editor">
|
|
|
+ <view class="goal-domain-row">
|
|
|
+ <text class="goal-label">目标 {{ idx + 1 }} 领域</text>
|
|
|
+ <text class="goal-remove" @click="removeGoal(idx)">✕</text>
|
|
|
+ </view>
|
|
|
+ <view class="radio-group">
|
|
|
+ <view
|
|
|
+ v-for="d in domains"
|
|
|
+ :key="d.value"
|
|
|
+ class="radio-btn small"
|
|
|
+ :class="{ active: goal.domain === d.value }"
|
|
|
+ @click="goal.domain = d.value"
|
|
|
+ >{{ d.label }}</view>
|
|
|
+ </view>
|
|
|
+ <input class="field-input" v-model="goal.content" placeholder="量化目标,如:体重降到 172 以下" maxlength="100" />
|
|
|
+ </view>
|
|
|
+ <view class="add-goal" v-if="form.goals.length < 3" @click="addGoal">+ 添加目标</view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <button class="btn-submit" @click="submit">创建阶段</button>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { createDoaStage, getFamilyMemberList } from '../../utils/api.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ members: [],
|
|
|
+ selfMemberId: 0,
|
|
|
+ targetMemberId: 0,
|
|
|
+ domains: [
|
|
|
+ { value: 'health', label: '健康' },
|
|
|
+ { value: 'wealth', label: '事业' },
|
|
|
+ { value: 'family', label: '家庭关系' },
|
|
|
+ { value: 'social', label: '社会关系' },
|
|
|
+ { value: 'help', label: '帮助他人' }
|
|
|
+ ],
|
|
|
+ form: {
|
|
|
+ memberId: 0,
|
|
|
+ title: '',
|
|
|
+ durationWeeks: 4,
|
|
|
+ goals: [{ domain: 'health', content: '' }]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ isSelfOnly() {
|
|
|
+ // 只为自己创建时(未传入 memberId 且无可代管成员)
|
|
|
+ return this.targetMemberId === 0 && !this.hasLocalMembers
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.selfMemberId = uni.getStorageSync('currentMemberId') || 0
|
|
|
+ if (options.memberId) {
|
|
|
+ this.targetMemberId = parseInt(options.memberId, 10)
|
|
|
+ this.form.memberId = parseInt(options.memberId, 10)
|
|
|
+ }
|
|
|
+ this.loadMembers()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ getGoalKey(goal, idx) {
|
|
|
+ return goal.domain + '_' + idx
|
|
|
+ },
|
|
|
+ async loadMembers() {
|
|
|
+ try {
|
|
|
+ var res = await getFamilyMemberList({ visibleOnly: true })
|
|
|
+ var list = res.data || []
|
|
|
+ this.members = list
|
|
|
+ this.hasLocalMembers = list.some(function(m) { return !m.userId })
|
|
|
+ // 若从总览"为他创建"进入且目标成员未登录 → 直接锁定
|
|
|
+ if (this.targetMemberId > 0) {
|
|
|
+ this.form.memberId = this.targetMemberId
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ this.members = []
|
|
|
+ this.hasLocalMembers = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ selectMember(m) {
|
|
|
+ this.form.memberId = m.id
|
|
|
+ },
|
|
|
+ addGoal() {
|
|
|
+ if (this.form.goals.length >= 3) return
|
|
|
+ this.form.goals.push({ domain: 'health', content: '' })
|
|
|
+ },
|
|
|
+ removeGoal(idx) {
|
|
|
+ if (this.form.goals.length <= 1) return
|
|
|
+ this.form.goals.splice(idx, 1)
|
|
|
+ },
|
|
|
+ async submit() {
|
|
|
+ // 目标成员校验
|
|
|
+ var memberId = this.form.memberId || this.selfMemberId
|
|
|
+ // 目标校验
|
|
|
+ var validGoals = []
|
|
|
+ var usedDomains = {}
|
|
|
+ var hasError = false
|
|
|
+ for (var i = 0; i < this.form.goals.length; i++) {
|
|
|
+ var g = this.form.goals[i]
|
|
|
+ if (!g.content.trim()) {
|
|
|
+ uni.showToast({ title: '请填写目标 ' + (i + 1) + ' 的内容', icon: 'none' })
|
|
|
+ hasError = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ if (usedDomains[g.domain]) {
|
|
|
+ uni.showToast({ title: '目标领域不能重复', icon: 'none' })
|
|
|
+ hasError = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ usedDomains[g.domain] = true
|
|
|
+ validGoals.push({ domain: g.domain, content: g.content.trim() })
|
|
|
+ }
|
|
|
+ if (hasError) return
|
|
|
+ if (validGoals.length === 0) {
|
|
|
+ uni.showToast({ title: '请至少填写1个目标', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ var data = {
|
|
|
+ title: this.form.title.trim(),
|
|
|
+ durationWeeks: this.form.durationWeeks,
|
|
|
+ goals: validGoals
|
|
|
+ }
|
|
|
+ if (memberId && memberId !== this.selfMemberId) {
|
|
|
+ data.memberId = memberId
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ var res = await createDoaStage(data)
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '创建成功', icon: 'success' })
|
|
|
+ setTimeout(function() {
|
|
|
+ uni.navigateBack()
|
|
|
+ }, 600)
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '创建失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '创建失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
|
|
|
+.field { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
|
|
+.field-label { font-size: 26rpx; color: #475569; font-weight: 600; margin-bottom: 12rpx; display: block; }
|
|
|
+.required { color: #EF4444; }
|
|
|
+.field-input {
|
|
|
+ background: #F8FAFC; border: 1rpx solid #E2E8F0; border-radius: 12rpx;
|
|
|
+ padding: 16rpx 18rpx; font-size: 28rpx; color: #1E293B; margin-top: 12rpx;
|
|
|
+}
|
|
|
+.member-grid { display: flex; flex-wrap: wrap; gap: 12rpx; }
|
|
|
+.member-item {
|
|
|
+ padding: 10rpx 22rpx; border-radius: 24rpx; font-size: 24rpx;
|
|
|
+ color: #475569; background: #F1F5F9; border: 2rpx solid transparent;
|
|
|
+}
|
|
|
+.member-item.active { background: #DCFCE7; color: #16A34A; border-color: #10B981; }
|
|
|
+.radio-group { display: flex; gap: 12rpx; margin-top: 12rpx; flex-wrap: wrap; }
|
|
|
+.radio-btn {
|
|
|
+ flex: 1; text-align: center; padding: 12rpx 0; border-radius: 12rpx;
|
|
|
+ font-size: 26rpx; color: #475569; background: #F1F5F9;
|
|
|
+}
|
|
|
+.radio-btn.small { flex: none; padding: 8rpx 18rpx; font-size: 22rpx; }
|
|
|
+.radio-btn.active { background: #10B981; color: #fff; font-weight: 600; }
|
|
|
+.goal-editor { background: #F8FAFC; border-radius: 12rpx; padding: 16rpx; margin-top: 12rpx; }
|
|
|
+.goal-domain-row { display: flex; justify-content: space-between; align-items: center; }
|
|
|
+.goal-label { font-size: 24rpx; color: #475569; font-weight: 600; }
|
|
|
+.goal-remove { font-size: 28rpx; color: #EF4444; padding: 4rpx 12rpx; }
|
|
|
+.add-goal {
|
|
|
+ margin-top: 16rpx; text-align: center; padding: 16rpx 0;
|
|
|
+ border: 2rpx dashed #10B981; border-radius: 12rpx;
|
|
|
+ color: #10B981; font-size: 26rpx; font-weight: 600;
|
|
|
+}
|
|
|
+.btn-submit {
|
|
|
+ background: linear-gradient(135deg, #10B981, #34D399);
|
|
|
+ color: #fff; font-size: 30rpx; font-weight: 600;
|
|
|
+ border-radius: 42rpx; border: none; height: 84rpx; line-height: 84rpx;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** `hasLocalMembers` 在 data 中未初始化会导致 undefined,已在 `loadMembers` 中赋值;`isSelfOnly` 计算属性引用它,初始为 undefined 时表现为 false,符合"展示成员选择"的兜底行为。若希望无成员时不显示选择区,可在 `data()` 中初始化 `hasLocalMembers: false`。
|
|
|
+
|
|
|
+- [ ] **步骤 2:语法校验**
|
|
|
+
|
|
|
+运行:同任务 6 步骤 2 的 node 校验方法(提取 script 块 + `new Function`)
|
|
|
+预期:无报错
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/doa-stage-create.vue
|
|
|
+git commit -m "feat(doa): 新增doa-stage-create创建阶段页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 8:doa-stage-detail.vue(阶段详情页)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/health/doa-stage-detail.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 doa-stage-detail.vue(完整代码)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="page">
|
|
|
+ <view v-if="loading" class="loading-box">加载中...</view>
|
|
|
+
|
|
|
+ <view v-else-if="stage" class="content">
|
|
|
+ <!-- 阶段头部 -->
|
|
|
+ <view class="stage-header">
|
|
|
+ <text class="stage-title">{{ stage.title || '阶段目标' }}</text>
|
|
|
+ <text class="stage-dates">{{ formatDate(stage.startDate) }} ~ {{ formatDate(stage.endDate) }}</text>
|
|
|
+ <view class="progress-row">
|
|
|
+ <text class="progress-text">复盘进度 {{ stage.reviewedWeeks || 0 }}/{{ stage.totalWeeks || 0 }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 目标列表 -->
|
|
|
+ <view class="section">
|
|
|
+ <text class="section-title">🎯 阶段目标</text>
|
|
|
+ <view v-for="g in stage.goals" :key="g.id" class="goal-item">
|
|
|
+ <text class="goal-tag" :class="'domain-' + g.domain">{{ domainName(g.domain) }}</text>
|
|
|
+ <text class="goal-content">{{ g.content }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 周时间线 -->
|
|
|
+ <view class="section">
|
|
|
+ <text class="section-title">📅 每周计划与复盘</text>
|
|
|
+ <view v-for="w in stage.weeks" :key="getWeekKey(w)" class="week-card">
|
|
|
+ <view class="week-header" @click="toggleWeek(w.id)">
|
|
|
+ <text class="week-no">第 {{ w.weekNo }} 周</text>
|
|
|
+ <text class="week-dates">{{ formatDate(w.weekStart) }} ~ {{ formatDate(w.weekEnd) }}</text>
|
|
|
+ <text class="week-status" :class="'ws-' + w.status">{{ weekStatusText(w.status) }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="expandedWeekId === w.id" class="week-body">
|
|
|
+ <!-- 计划 -->
|
|
|
+ <view class="week-block">
|
|
|
+ <text class="block-title">📋 本周计划</text>
|
|
|
+ <text class="block-text">{{ w.plan || '(未填写)' }}</text>
|
|
|
+ </view>
|
|
|
+ <!-- 复盘 -->
|
|
|
+ <view class="week-block" v-if="w.status === 'reviewed'">
|
|
|
+ <text class="block-title">✅ 复盘总结</text>
|
|
|
+ <view class="review-item">
|
|
|
+ <text class="review-label">达成:</text>
|
|
|
+ <text class="review-text">{{ w.achieved || '—' }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="review-item">
|
|
|
+ <text class="review-label">体验:</text>
|
|
|
+ <text class="review-text">{{ w.experience || '—' }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="review-item">
|
|
|
+ <text class="review-label">行得通:</text>
|
|
|
+ <text class="review-text worked">{{ w.worked || '—' }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="review-item">
|
|
|
+ <text class="review-label">要提升:</text>
|
|
|
+ <text class="review-text improve">{{ w.improve || '—' }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="review-item">
|
|
|
+ <text class="review-label">下周挑战:</text>
|
|
|
+ <text class="review-text">{{ w.nextChallenge || '—' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 编辑按钮(本人或代管) -->
|
|
|
+ <view v-if="canEdit" class="week-actions">
|
|
|
+ <button class="btn-edit" @click="editWeek(w)">编辑{{ w.status === 'reviewed' ? '' : '计划' }}</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 取消阶段 -->
|
|
|
+ <button v-if="canEdit && stage.status === 'active'" class="btn-cancel" @click="cancelStage">取消阶段</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getDoaStageDetail, cancelDoaStage } from '../../utils/api.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ loading: true,
|
|
|
+ stage: null,
|
|
|
+ stageId: 0,
|
|
|
+ selfMemberId: 0,
|
|
|
+ expandedWeekId: 0,
|
|
|
+ canEdit: false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.stageId = parseInt(options.stageId || '0', 10)
|
|
|
+ this.selfMemberId = uni.getStorageSync('currentMemberId') || 0
|
|
|
+ this.loadDetail()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ getWeekKey(w) {
|
|
|
+ return w.id
|
|
|
+ },
|
|
|
+ async loadDetail() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ var res = await getDoaStageDetail(this.stageId)
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.stage = res.data
|
|
|
+ // 编辑权限:自己 或 未登录成员(memberNickname 存在且非本人 → 后端已校验,前端保守判断)
|
|
|
+ this.canEdit = this.stage.memberId === this.selfMemberId || !this.stage.memberUserId
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '加载失败', icon: 'none' })
|
|
|
+ setTimeout(function() { uni.navigateBack() }, 1000)
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '加载失败', icon: 'none' })
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ domainName(d) {
|
|
|
+ var map = { health: '健康', wealth: '事业', family: '家庭关系', social: '社会关系', help: '帮助他人' }
|
|
|
+ return map[d] || d
|
|
|
+ },
|
|
|
+ formatDate(dateStr) {
|
|
|
+ if (!dateStr) return ''
|
|
|
+ return String(dateStr).slice(0, 10)
|
|
|
+ },
|
|
|
+ weekStatusText(s) {
|
|
|
+ if (s === 'reviewed') return '已复盘'
|
|
|
+ if (s === 'planned') return '已计划'
|
|
|
+ return '待计划'
|
|
|
+ },
|
|
|
+ toggleWeek(id) {
|
|
|
+ this.expandedWeekId = this.expandedWeekId === id ? 0 : id
|
|
|
+ },
|
|
|
+ editWeek(w) {
|
|
|
+ uni.navigateTo({ url: '/pages/health/doa-week-edit?weekId=' + w.id + '&stageId=' + this.stageId })
|
|
|
+ },
|
|
|
+ cancelStage() {
|
|
|
+ var self = this
|
|
|
+ uni.showModal({
|
|
|
+ title: '确认取消',
|
|
|
+ content: '确定要取消这个阶段吗?周记录将保留但不可再编辑。',
|
|
|
+ success: function(res) {
|
|
|
+ if (res.confirm) {
|
|
|
+ cancelDoaStage(self.stageId).then(function(r) {
|
|
|
+ if (r.code === 200) {
|
|
|
+ uni.showToast({ title: '已取消', icon: 'success' })
|
|
|
+ setTimeout(function() { uni.navigateBack() }, 600)
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: r.message || '取消失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
|
|
|
+.loading-box { text-align: center; padding: 100rpx 0; color: #94A3B8; font-size: 28rpx; }
|
|
|
+.stage-header {
|
|
|
+ background: linear-gradient(135deg, #10B981, #34D399);
|
|
|
+ border-radius: 20rpx; padding: 32rpx; margin-bottom: 20rpx; color: #fff;
|
|
|
+}
|
|
|
+.stage-title { font-size: 34rpx; font-weight: 700; display: block; }
|
|
|
+.stage-dates { font-size: 24rpx; opacity: 0.9; margin-top: 8rpx; display: block; }
|
|
|
+.progress-row { margin-top: 16rpx; }
|
|
|
+.progress-text { font-size: 24rpx; font-weight: 600; }
|
|
|
+.section { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
|
|
+.section-title { font-size: 28rpx; font-weight: 700; color: #1E293B; display: block; margin-bottom: 16rpx; }
|
|
|
+.goal-item { display: flex; align-items: center; margin-bottom: 12rpx; }
|
|
|
+.goal-tag {
|
|
|
+ font-size: 20rpx; padding: 6rpx 14rpx; border-radius: 8rpx; margin-right: 12rpx;
|
|
|
+ border: 2rpx solid #E2E8F0; color: #475569; flex-shrink: 0;
|
|
|
+}
|
|
|
+.domain-health { border-color: #FF8C42 !important; color: #C2410C !important; }
|
|
|
+.domain-wealth { border-color: #F59E0B !important; color: #B45309 !important; }
|
|
|
+.domain-family { border-color: #10B981 !important; color: #047857 !important; }
|
|
|
+.domain-social { border-color: #6366F1 !important; color: #4338CA !important; }
|
|
|
+.domain-help { border-color: #FF6B9D !important; color: #BE185D !important; }
|
|
|
+.goal-content { font-size: 26rpx; color: #334155; flex: 1; }
|
|
|
+.week-card { background: #F8FAFC; border-radius: 12rpx; padding: 20rpx; margin-bottom: 12rpx; }
|
|
|
+.week-header { display: flex; align-items: center; }
|
|
|
+.week-no { font-size: 26rpx; font-weight: 700; color: #1E293B; flex: 1; }
|
|
|
+.week-dates { font-size: 20rpx; color: #94A3B8; margin-right: 12rpx; }
|
|
|
+.week-status { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
|
|
|
+.ws-pending { background: #F1F5F9; color: #64748B; }
|
|
|
+.ws-planned { background: #DBEAFE; color: #1D4ED8; }
|
|
|
+.ws-reviewed { background: #DCFCE7; color: #16A34A; }
|
|
|
+.week-body { margin-top: 16rpx; border-top: 2rpx solid #E2E8F0; padding-top: 16rpx; }
|
|
|
+.week-block { margin-bottom: 16rpx; }
|
|
|
+.block-title { font-size: 24rpx; font-weight: 700; color: #475569; display: block; margin-bottom: 8rpx; }
|
|
|
+.block-text { font-size: 24rpx; color: #334155; white-space: pre-wrap; display: block; }
|
|
|
+.review-item { margin-bottom: 8rpx; }
|
|
|
+.review-label { font-size: 22rpx; color: #64748B; }
|
|
|
+.review-text { font-size: 22rpx; color: #334155; }
|
|
|
+.review-text.worked { color: #16A34A; }
|
|
|
+.review-text.improve { color: #DC2626; }
|
|
|
+.week-actions { margin-top: 12rpx; }
|
|
|
+.btn-edit {
|
|
|
+ background: #10B981; color: #fff; font-size: 24rpx; font-weight: 600;
|
|
|
+ padding: 12rpx 32rpx; border-radius: 32rpx; border: none; width: auto;
|
|
|
+}
|
|
|
+.btn-cancel {
|
|
|
+ background: #fff; color: #EF4444; font-size: 26rpx; font-weight: 600;
|
|
|
+ border: 2rpx solid #FCA5A5; border-radius: 42rpx; height: 80rpx; line-height: 80rpx;
|
|
|
+ margin-top: 8rpx;
|
|
|
+}
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** 后端 `getStageDetail` 返回的 `DoaStage` 实体不含 `memberUserId` 字段——前端 `canEdit` 判断用 `!this.stage.memberUserId` 会永远为 true。**修正方案**:后端 `DoaService.getStageDetail` 中已通过 `targetMember.getUserId()` 拿到该值,应在返回前设置到 VO。由于实体没有该字段,最简做法是在 `getStageDetail` 返回的 `DoaStage` 增加 `@TableField(exist = false) private Long memberUserId;` 并在 service 填充。**实施时必须同步任务 2/3:** 在 `DoaStage.java` 加 `memberUserId` 非表字段,在 `getStageDetail()` 与 `getFamilyOverview()` 中 `stage.setMemberUserId(m.getUserId())`。
|
|
|
+
|
|
|
+- [ ] **步骤 2:同步修改 DoaStage 实体与 DoaService**
|
|
|
+
|
|
|
+在 `DoaStage.java` 追加非表字段:
|
|
|
+
|
|
|
+```java
|
|
|
+ /** 非表字段:目标成员的 userId(null=不能登录成员,可代管) */
|
|
|
+ @TableField(exist = false)
|
|
|
+ private Long memberUserId;
|
|
|
+```
|
|
|
+
|
|
|
+在 `DoaService.getStageDetail()` 中(已有 `FamilyMember m = familyMemberMapper.selectById(stage.getMemberId());` 处):
|
|
|
+
|
|
|
+```java
|
|
|
+ stage.setMemberNickname(m != null ? m.getNickname() : "成员" + stage.getMemberId());
|
|
|
+ stage.setMemberUserId(m != null ? m.getUserId() : null);
|
|
|
+```
|
|
|
+
|
|
|
+在 `DoaService.getFamilyOverview()` 中(遍历成员循环内):
|
|
|
+
|
|
|
+```java
|
|
|
+ memberMap.put("memberType", m.getUserId() == null ? "local" : "login");
|
|
|
+ // currentStage 填充时同时设置 memberUserId(fillStageDetail 内无法访问 member,需在外层设置)
|
|
|
+```
|
|
|
+
|
|
|
+`getFamilyOverview` 中 `currentStage` 的填充需补一行(在 `fillStageDetail(current)` 之后):
|
|
|
+
|
|
|
+```java
|
|
|
+ current.setMemberUserId(m.getUserId());
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java cfc-frontend/pages/health/doa-stage-detail.vue
|
|
|
+git commit -m "feat(doa): 新增doa-stage-detail详情页+实体memberUserId字段"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 9:doa-week-edit.vue(周计划/复盘编辑页)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 创建:`cfc-frontend/pages/health/doa-week-edit.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:创建 doa-week-edit.vue(完整代码)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <view class="page">
|
|
|
+ <view v-if="!week" class="loading-box">加载中...</view>
|
|
|
+
|
|
|
+ <view v-else class="content">
|
|
|
+ <text class="page-hint">第 {{ week.weekNo }} 周 · {{ formatDate(week.weekStart) }} ~ {{ formatDate(week.weekEnd) }}</text>
|
|
|
+
|
|
|
+ <!-- A表:周行动宣告计划 -->
|
|
|
+ <view class="section">
|
|
|
+ <text class="section-title">📋 本周行动计划(A表)</text>
|
|
|
+ <text class="section-desc">写下本周要做的具体行动,每行一个(参考:明确人事时地)</text>
|
|
|
+ <textarea class="field-textarea" v-model="form.plan" placeholder="1、完成站桩15分钟 2、准备早餐、拍照记录 3、..."></textarea>
|
|
|
+ <button class="btn-save" @click="savePlan">保存计划</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- B表:周复盘五问 -->
|
|
|
+ <view class="section" v-if="week.status === 'reviewed' || week.status === 'planned' || week.status === 'pending'">
|
|
|
+ <text class="section-title">✅ 本周复盘(B表)</text>
|
|
|
+ <view class="review-field">
|
|
|
+ <text class="review-label">本周达成了什么?</text>
|
|
|
+ <textarea class="field-textarea" v-model="form.achieved" placeholder="如:体重降到 171.5"></textarea>
|
|
|
+ </view>
|
|
|
+ <view class="review-field">
|
|
|
+ <text class="review-label">有什么体验?</text>
|
|
|
+ <textarea class="field-textarea" v-model="form.experience" placeholder="如:愉悦、疲惫"></textarea>
|
|
|
+ </view>
|
|
|
+ <view class="review-field">
|
|
|
+ <text class="review-label">行得通的是什么?</text>
|
|
|
+ <textarea class="field-textarea" v-model="form.worked" placeholder="总结有效的方法"></textarea>
|
|
|
+ </view>
|
|
|
+ <view class="review-field">
|
|
|
+ <text class="review-label">要提升 / 行不通的是什么?</text>
|
|
|
+ <textarea class="field-textarea" v-model="form.improve" placeholder="总结待改进之处"></textarea>
|
|
|
+ </view>
|
|
|
+ <view class="review-field">
|
|
|
+ <text class="review-label">下一周要挑战自己什么?</text>
|
|
|
+ <textarea class="field-textarea" v-model="form.nextChallenge" placeholder="写下下周的新挑战"></textarea>
|
|
|
+ </view>
|
|
|
+ <button class="btn-save review-btn" @click="saveReview">提交复盘</button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import { getDoaStageDetail, submitDoaWeekPlan, submitDoaWeekReview } from '../../utils/api.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ weekId: 0,
|
|
|
+ stageId: 0,
|
|
|
+ week: null,
|
|
|
+ form: {
|
|
|
+ plan: '',
|
|
|
+ achieved: '',
|
|
|
+ experience: '',
|
|
|
+ worked: '',
|
|
|
+ improve: '',
|
|
|
+ nextChallenge: ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(options) {
|
|
|
+ this.weekId = parseInt(options.weekId || '0', 10)
|
|
|
+ this.stageId = parseInt(options.stageId || '0', 10)
|
|
|
+ this.loadWeek()
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ formatDate(dateStr) {
|
|
|
+ if (!dateStr) return ''
|
|
|
+ return String(dateStr).slice(0, 10)
|
|
|
+ },
|
|
|
+ async loadWeek() {
|
|
|
+ try {
|
|
|
+ var res = await getDoaStageDetail(this.stageId)
|
|
|
+ if (res.code === 200 && res.data && res.data.weeks) {
|
|
|
+ for (var i = 0; i < res.data.weeks.length; i++) {
|
|
|
+ if (res.data.weeks[i].id === this.weekId) {
|
|
|
+ this.week = res.data.weeks[i]
|
|
|
+ this.form.plan = this.week.plan || ''
|
|
|
+ this.form.achieved = this.week.achieved || ''
|
|
|
+ this.form.experience = this.week.experience || ''
|
|
|
+ this.form.worked = this.week.worked || ''
|
|
|
+ this.form.improve = this.week.improve || ''
|
|
|
+ this.form.nextChallenge = this.week.nextChallenge || ''
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!this.week) {
|
|
|
+ uni.showToast({ title: '周记录不存在', icon: 'none' })
|
|
|
+ setTimeout(function() { uni.navigateBack() }, 1000)
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ uni.showToast({ title: '加载失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ savePlan() {
|
|
|
+ var self = this
|
|
|
+ submitDoaWeekPlan(this.weekId, this.form.plan).then(function(res) {
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '计划已保存', icon: 'success' })
|
|
|
+ self.week.status = 'planned'
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '保存失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ saveReview() {
|
|
|
+ var self = this
|
|
|
+ var data = {
|
|
|
+ weekId: this.weekId,
|
|
|
+ achieved: this.form.achieved,
|
|
|
+ experience: this.form.experience,
|
|
|
+ worked: this.form.worked,
|
|
|
+ improve: this.form.improve,
|
|
|
+ nextChallenge: this.form.nextChallenge
|
|
|
+ }
|
|
|
+ submitDoaWeekReview(data).then(function(res) {
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '复盘已提交', icon: 'success' })
|
|
|
+ self.week.status = 'reviewed'
|
|
|
+ setTimeout(function() { uni.navigateBack() }, 600)
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '提交失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
|
|
|
+.loading-box { text-align: center; padding: 100rpx 0; color: #94A3B8; font-size: 28rpx; }
|
|
|
+.page-hint { font-size: 26rpx; color: #475569; display: block; margin-bottom: 16rpx; font-weight: 600; }
|
|
|
+.section { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
|
|
+.section-title { font-size: 28rpx; font-weight: 700; color: #1E293B; display: block; }
|
|
|
+.section-desc { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
|
|
|
+.field-textarea {
|
|
|
+ background: #F8FAFC; border: 1rpx solid #E2E8F0; border-radius: 12rpx;
|
|
|
+ padding: 16rpx 18rpx; font-size: 26rpx; color: #1E293B;
|
|
|
+ height: 160rpx; width: 100%; box-sizing: border-box; margin-top: 12rpx;
|
|
|
+}
|
|
|
+.review-field { margin-top: 16rpx; }
|
|
|
+.review-label { font-size: 24rpx; color: #475569; font-weight: 600; display: block; }
|
|
|
+.btn-save {
|
|
|
+ background: #10B981; color: #fff; font-size: 28rpx; font-weight: 600;
|
|
|
+ border-radius: 40rpx; border: none; height: 76rpx; line-height: 76rpx;
|
|
|
+ margin-top: 20rpx;
|
|
|
+}
|
|
|
+.review-btn { background: #6366F1; }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:语法校验**
|
|
|
+
|
|
|
+运行:同任务 6 步骤 2 的 node 校验方法
|
|
|
+预期:无报错
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/doa-week-edit.vue
|
|
|
+git commit -m "feat(doa): 新增doa-week-edit周计划复盘编辑页"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 10:API 文档登记 + 项目规范更新
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
|
|
|
+- 修改:`AGENTS.md`(根)
|
|
|
+- 修改:`cfc-backend/AGENTS.md`
|
|
|
+
|
|
|
+- [ ] **步骤 1:API_REFERENCE.md 登记 /api/doa/* 接口**
|
|
|
+
|
|
|
+在 `docs/superpowers/api/API_REFERENCE.md` 家庭健康互动相关章节后追加:
|
|
|
+
|
|
|
+```markdown
|
|
|
+## DOA 个人目标(家庭打卡个人目标模块)
|
|
|
+
|
|
|
+| 接口 | 路径 | 说明 |
|
|
|
+|------|------|------|
|
|
|
+| 创建阶段 | POST /api/doa/stage/create | 创建阶段(memberId?+title+durationWeeks+goals[1-3]) |
|
|
|
+| 家庭总览 | POST /api/doa/overview | 各成员当前 active 阶段 |
|
|
|
+| 阶段详情 | POST /api/doa/stage/detail | stage + goals + weeks |
|
|
|
+| 取消阶段 | POST /api/doa/stage/cancel | 仅 active 可取消 |
|
|
|
+| 提交周计划 | POST /api/doa/week/plan | A表周行动宣告 |
|
|
|
+| 提交周复盘 | POST /api/doa/week/review | B表五问 |
|
|
|
+| 周记录列表 | POST /api/doa/week/list | 按阶段查周记录 |
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:根 AGENTS.md 记录「孩子=不能登录用户」规范**
|
|
|
+
|
|
|
+在根 `AGENTS.md` 的「UNIQUE STYLES」或「数据库」相关章节追加:
|
|
|
+
|
|
|
+```markdown
|
|
|
+## 用户模型统一口径
|
|
|
+
|
|
|
+- **能登录用户**:`family_members.userId` 非空且关联 User 有 openid(VO 层 `memberType=login`)
|
|
|
+- **不能登录用户**(历史称"孩子"):`family_members.userId` 为空或关联 User 无 openid(VO 层 `memberType=local`),由同家庭能登录用户代管其数据(任务/健康/DOA 等)
|
|
|
+- 新功能禁止再引入"孩子"概念,统一用"能登录用户/不能登录用户"表述
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:cfc-backend/AGENTS.md 同步记录**
|
|
|
+
|
|
|
+在 `cfc-backend/AGENTS.md` 的身份标识约定(userId/memberId/childId 段落)后追加同样口径说明(与根 AGENTS.md 一致)。
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add docs/superpowers/api/API_REFERENCE.md AGENTS.md cfc-backend/AGENTS.md
|
|
|
+git commit -m "docs: 登记DOA接口+统一能登录/不能登录用户口径规范"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 11:整体验证
|
|
|
+
|
|
|
+- [ ] **步骤 1:后端编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 2:前端 API 重复审计**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && node scripts/audit-duplicate-api-calls.js`
|
|
|
+预期:exit code 0(新增的 4 个页面无重复 API 调用:`doa-index` 仅 overview 一次、`doa-stage-create` 仅 member list 一次、`doa-stage-detail` 仅 detail 一次、`doa-week-edit` 仅 detail 一次)
|
|
|
+
|
|
|
+- [ ] **步骤 3:路由唯一性检查**
|
|
|
+
|
|
|
+运行:`grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep '/doa'`
|
|
|
+预期:7 个 doa 路径各出现一次
|
|
|
+
|
|
|
+- [ ] **步骤 4:手动验收场景(本地起后端 9082 + 小程序 HBuilderX 打包)**
|
|
|
+
|
|
|
+按规格第 9 节验收标准逐条验证:
|
|
|
+1. 创建阶段(2/4/8/12 周,1-3 个不同领域目标)
|
|
|
+2. 代管创建(为不能登录成员创建)
|
|
|
+3. 周计划保存(pending → planned)
|
|
|
+4. 周复盘提交(planned → reviewed,二次提交被拒)
|
|
|
+5. 家庭总览显示各成员
|
|
|
+6. 非本人且非代管成员看不到编辑按钮
|