فهرست منبع

docs: add DOA family goal implementation plan (tasks 1-11)

Sisyphus 1 هفته پیش
والد
کامیت
4801689764
1فایلهای تغییر یافته به همراه1959 افزوده شده و 0 حذف شده
  1. 1959 0
      docs/superpowers/plans/2026-09-12-doa-family-goal-implementation.md

+ 1959 - 0
docs/superpowers/plans/2026-09-12-doa-family-goal-implementation.md

@@ -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分钟&#10;2、准备早餐、拍照记录&#10;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. 非本人且非代管成员看不到编辑按钮