2026-09-12-doa-family-goal-implementation.md 76 KB

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(); 调用之后、方法闭合 } 之前)追加以下代码:

        // 迁移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 文件末尾追加:

-- ============================================================
-- 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

    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 实体

    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 实体

    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 实体

    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

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.DoaStage;
    
    public interface DoaStageMapper extends BaseMapper<DoaStage> {
    }
    
    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.DoaGoal;
    
    public interface DoaGoalMapper extends BaseMapper<DoaGoal> {
    }
    
    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

    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(完整代码)

    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

    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(完整代码)

    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

    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 之后追加:

// ===== 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 条目之后追加:

{
  "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"> 之前插入:

    <!-- 顶部双 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 中新增:

    goToDoa() {
    // 当前即共同挑战页
    },
    goToDoaIndex() {
    uni.navigateTo({ url: '/pages/health/doa-index' })
    }
    
  3. <style scoped> 中新增:

    .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

    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(完整代码)

    <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

    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(完整代码)

    <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

    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(完整代码)

    <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 追加非表字段:

    /** 非表字段:目标成员的 userId(null=不能登录成员,可代管) */
    @TableField(exist = false)
    private Long memberUserId;

在 DoaService.getStageDetail() 中(已有 FamilyMember m = familyMemberMapper.selectById(stage.getMemberId()); 处):

        stage.setMemberNickname(m != null ? m.getNickname() : "成员" + stage.getMemberId());
        stage.setMemberUserId(m != null ? m.getUserId() : null);

在 DoaService.getFamilyOverview() 中(遍历成员循环内):

            memberMap.put("memberType", m.getUserId() == null ? "local" : "login");
            // currentStage 填充时同时设置 memberUserId(fillStageDetail 内无法访问 member,需在外层设置)

getFamilyOverview 中 currentStage 的填充需补一行(在 fillStageDetail(current) 之后):

                current.setMemberUserId(m.getUserId());
  • 步骤 3:编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • [ ] 步骤 4:Commit

    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(完整代码)

    <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

    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 家庭健康互动相关章节后追加:

## 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」或「数据库」相关章节追加:

## 用户模型统一口径

- **能登录用户**:`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

    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. 非本人且非代管成员看不到编辑按钮