2026-08-31-self-check-reminder.md 37 KB

五维家庭自检 · 15 天复检周期 + 首页分数 + 题目轮换 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 为五维家庭自检增加 15 天复检周期、首页显示上次自检分数、中间页二选一(再次自检/忽略)、以及合并题库随机抽题轮换。

架构: 后端新增 self_check_ignores 表 + 两个新接口(/status + /ignore);改造现有 /questions(合并题库抽题)和 /submit(按提交题号计分 + 落库题号);前端新增中间页 self-check-entry.vue,首页入口接入 /status 并跳转中间页。

技术栈: Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / uni-app Vue 2 Options API / MySQL 8.0

规格文档: docs/superpowers/specs/2026-08-31-self-check-reminder-design.md


文件结构

文件 职责 变更类型
cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java 忽略记录实体 新建
cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java 忽略记录 Mapper 新建
cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java 自检记录实体(新增 questionIdsJson) 修改
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java 抽题/状态/忽略/计分改造 修改
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java 新增 /status + /ignore 修改
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java 迁移 269/270 修改
cfc-backend/src/main/resources/schema.sql 表定义同步 修改
cfc-frontend/pages/family/self-check-entry.vue 中间页(结果摘要 + 再次自检/忽略) 新建
cfc-frontend/pages/index-home/index.vue 首页入口显示自检总分 修改
cfc-frontend/pages/home-pages/parent-index.vue 家长首页入口显示自检总分 修改
cfc-frontend/pages/family/self-check-result.vue 「重新自检」改跳中间页 修改
cfc-frontend/pages.json 注册中间页 修改
cfc-frontend/utils/api.js 新增 getSelfCheckStatus / ignoreSelfCheck 修改

任务 1:后端 — 数据库迁移(表 + 列)

文件:

  • 新建:cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java
  • 新建:cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java:9574(migrateCouponStatus 方法后追加迁移 269/270)
  • 修改:cfc-backend/src/main/resources/schema.sql(five_dimension_self_checks 表 + 追加 self_check_ignores 表)

  • [ ] 步骤 1:创建 SelfCheckIgnore 实体

创建文件 cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.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("self_check_ignores")
public class SelfCheckIgnore implements Serializable {

    @TableId(type = IdType.AUTO)
    private Long id;

    private Long userId;

    private Long checkId;

    private Date createdAt;
}
  • 步骤 2:创建 SelfCheckIgnoreMapper

创建文件 cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java:

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.SelfCheckIgnore;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface SelfCheckIgnoreMapper extends BaseMapper<SelfCheckIgnore> {
}
  • 步骤 3:在 DatabaseInitializer 追加迁移 269/270

在 DatabaseInitializer.java 中,于 migrateCouponStatus() 方法结束后、migrateSysMenuReorg() 方法开始前,插入:

// 迁移269: five_dimension_self_checks 表添加 question_ids_json 列(自检轮换用)
ensureColumn("five_dimension_self_checks", "question_ids_json", "VARCHAR(255) COMMENT '本次自检使用的题号JSON: [1,5,9,...]'");

// 迁移270: 创建 self_check_ignores 表(记录用户忽略自检提醒的动作,作为 15 天周期重置依据)
try {
    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS self_check_ignores (" +
            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
            "user_id BIGINT NOT NULL COMMENT '用户ID', " +
            "check_id BIGINT COMMENT '忽略的自检记录ID(可空)', " +
            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间', " +
            "INDEX idx_user_id (user_id)" +
            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录'");
    log.info("已创建 self_check_ignores 表");
} catch (Exception e) {
    log.warn("创建 self_check_ignores 表失败: {}", e.getMessage());
}

ensureColumn 方法已在 DatabaseInitializer 第 4099 行定义,直接复用。

  • 步骤 4:同步 schema.sql

在 cfc-backend/src/main/resources/schema.sql 中找到 five_dimension_self_checks 建表语句(约第 4285 行),在 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 之后追加:

    question_ids_json VARCHAR(255) DEFAULT NULL COMMENT '本次自检使用的题号JSON: [1,5,9,...]',

在 schema.sql 末尾追加 self_check_ignores 建表语句:

-- ============================================================
-- 五维自检忽略提醒记录表(迁移270)
-- ============================================================
CREATE TABLE IF NOT EXISTS self_check_ignores (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL COMMENT '用户ID',
    check_id BIGINT COMMENT '忽略的自检记录ID(可空)',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '忽略时间',
    INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='五维自检忽略提醒记录';
  • [ ] 步骤 5:编译验证

    cd cfc-backend && mvn clean compile -q
    

预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/SelfCheckIgnore.java \
       cfc-backend/src/main/java/com/etotem/cfc/mapper/SelfCheckIgnoreMapper.java \
       cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
       cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(self-check): 新增 self_check_ignores 表和 question_ids_json 列(迁移269/270)"
    

任务 2:后端 — Entity 与 Service 改造

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java(新增字段)
  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java(抽题/状态/忽略/计分改造)

  • [ ] 步骤 1:FiveDimensionSelfCheck 实体新增字段

在 FiveDimensionSelfCheck.java 的 private Date createdAt; 之前追加:

    /** 本次自检使用的题号JSON,如 [1,2,3,101] */
    private String questionIdsJson;
  • 步骤 2:Service 新增私有字段 — 合并题库(34题)

在 FiveDimensionSelfCheckService.java 中,于 SUB_DIMENSION_QUESTION_BANK 字段之后,追加:

    /** 合并题库:主题库(15题) + 子维度附加库(19题),用于轮换抽题 */
    private static final List<SelfCheckQuestionVO> FULL_QUESTION_POOL = buildFullPool();

    @SuppressWarnings("unchecked")
    private static List<SelfCheckQuestionVO> buildFullPool() {
        List<SelfCheckQuestionVO> pool = new ArrayList<>(QUESTION_BANK);
        pool.addAll(SUB_DIMENSION_QUESTION_BANK);
        return pool;
    }

    /** 按 dimension 分组的合并题库 Map<dimension, List<SelfCheckQuestionVO>> */
    private Map<String, List<SelfCheckQuestionVO>> groupByDimension(List<SelfCheckQuestionVO> pool) {
        Map<String, List<SelfCheckQuestionVO>> groups = new LinkedHashMap<>();
        for (SelfCheckQuestionVO q : pool) {
            String dim = q.getDimension();
            if (dim == null) continue;
            groups.computeIfAbsent(dim, k -> new ArrayList<>()).add(q);
        }
        return groups;
    }

    /** 随机抽题:从 pool 中抽 count 题,优先排除 excludeIds */
    private List<SelfCheckQuestionVO> pickQuestions(List<SelfCheckQuestionVO> pool, List<Integer> excludeIds, int count) {
        List<Integer> excludeSet = new ArrayList<>(excludeIds != null ? excludeIds : Collections.emptyList());
        List<SelfCheckQuestionVO> candidates = new ArrayList<>();
        for (SelfCheckQuestionVO q : pool) {
            if (!excludeSet.contains(q.getId())) {
                candidates.add(q);
            }
        }
        // 洗牌
        Collections.shuffle(candidates);
        List<SelfCheckQuestionVO> result = new ArrayList<>();
        int need = Math.min(count, candidates.size());
        for (int i = 0; i < need; i++) {
            result.add(candidates.get(i));
        }
        // 不足时从 excludeSet 补足(保持顺序)
        if (result.size() < count) {
            for (SelfCheckQuestionVO q : pool) {
                if (result.size() >= count) break;
                if (!excludeSet.contains(q.getId())) continue;
                boolean alreadyIn = false;
                for (SelfCheckQuestionVO r : result) {
                    if (r.getId().equals(q.getId())) { alreadyIn = true; break; }
                }
                if (!alreadyIn) result.add(q);
            }
        }
        return result;
    }

同时在文件头部导入 Collections:

import java.util.Collections;
  • 步骤 3:Service 新增 getQuestionsForRetake 方法

在 getQuestions() 方法后追加:

    /**
     * 返回轮换后的题库(用于再次自检):
     * 合并题库按维度分组,每维度随机抽 3 题,优先排除上次使用的题号。
     * body 维度仅 3 题,若排除后不足 3 题则允许复用。
     */
    public List<SelfCheckQuestionVO> getQuestionsForRetake(Long userId) {
        // 查最近一次自检的题号
        LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
                .eq(FiveDimensionSelfCheck::getUserId, userId)
                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
                .last("LIMIT 1");
        SortUtil.applySort(wrapper);
        FiveDimensionSelfCheck lastRecord = selfCheckMapper.selectOne(wrapper);
        List<Integer> lastQuestionIds = parseQuestionIds(lastRecord != null ? lastRecord.getQuestionIdsJson() : null);

        // 按维度分组抽题
        Map<String, List<SelfCheckQuestionVO>> dimGroups = groupByDimension(FULL_QUESTION_POOL);
        String[] dimOrder = {"body", "wisdom", "wealth", "action", "mind"};
        List<SelfCheckQuestionVO> result = new ArrayList<>();
        for (String dim : dimOrder) {
            List<SelfCheckQuestionVO> pool = dimGroups.getOrDefault(dim, Collections.emptyList());
            result.addAll(pickQuestions(pool, lastQuestionIds, 3));
        }
        return result;
    }

    private List<Integer> parseQuestionIds(String json) {
        if (json == null || json.isEmpty()) return Collections.emptyList();
        try {
            return objectMapper.readValue(json, objectMapper.getTypeFactory().constructCollectionType(List.class, Integer.class));
        } catch (Exception e) {
            log.warn("解析 questionIdsJson 失败: {}", e.getMessage());
            return Collections.emptyList();
        }
    }
  • 步骤 4:Service 改造 submitSelfCheck — 按提交题号计分 + 落库题号

将 submitSelfCheck 方法中第 348-380 行的计分逻辑替换为:

        // 构建 题号→题目 映射(从合并池)
        Map<Integer, SelfCheckQuestionVO> questionMap = new LinkedHashMap<>();
        for (SelfCheckQuestionVO q : FULL_QUESTION_POOL) {
            questionMap.put(q.getId(), q);
        }
        // 题目ID → 答案
        Map<Integer, String> answerMap = new LinkedHashMap<>();
        for (SubmitSelfCheckDTO.AnswerItem item : dto.getAnswers()) {
            if (item.getQuestionId() == null || item.getAnswer() == null || item.getAnswer().isEmpty()) {
                continue;
            }
            answerMap.put(item.getQuestionId(), item.getAnswer().trim().toUpperCase());
        }

        // 按维度分组计分
        Map<String, List<SelfCheckQuestionVO>> dimGroups = groupByDimension(FULL_QUESTION_POOL);
        List<SelfCheckResultVO.DimensionScoreVO> dimensions = new ArrayList<>();
        int totalScore = 0;
        List<Integer> submittedQuestionIds = new ArrayList<>();
        for (Map.Entry<String, String[]> entry : DIMENSION_META.entrySet()) {
            String dim = entry.getKey();
            List<SelfCheckQuestionVO> dimQuestions = dimGroups.getOrDefault(dim, Collections.emptyList());
            int sum = 0;
            int answered = 0;
            for (SelfCheckQuestionVO qv : dimQuestions) {
                String ans = answerMap.get(qv.getId());
                if (ans == null || "E".equals(ans)) continue;
                submittedQuestionIds.add(qv.getId());
                sum += scoreOf(ans);
                answered++;
            }
            if (answered == 0) {
                throw new IllegalArgumentException("维度[" + dim + "]至少回答1题");
            }
            int dimScore = new BigDecimal(sum * 3.0 / answered).setScale(0, RoundingMode.HALF_UP).intValue();
            if (dimScore > 9) dimScore = 9;
            totalScore += dimScore;
            dimensions.add(buildDimensionScoreVO(dim, dimScore));
        }

并在落库部分(selfCheckMapper.insert(record) 之前)追加:

        record.setQuestionIdsJson(toJson(submittedQuestionIds));

同时确保 submittedQuestionIds 升序排列:

        Collections.sort(submittedQuestionIds);
  • [ ] 步骤 5:Service 新增 getStatus 方法

    /**
     * 返回自检状态:是否有记录、是否可检、距下次提醒天数、上次题号
     */
    @SuppressWarnings("unchecked")
    public Map<String, Object> getStatus(Long userId) {
        Map<String, Object> result = new HashMap<>();
        // 最近一次自检
        LambdaQueryWrapper<FiveDimensionSelfCheck> checkWrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
                .eq(FiveDimensionSelfCheck::getUserId, userId)
                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
                .last("LIMIT 1");
        SortUtil.applySort(checkWrapper);
        FiveDimensionSelfCheck lastCheck = selfCheckMapper.selectOne(checkWrapper);
        result.put("hasCheck", lastCheck != null);
        result.put("lastResult", lastCheck != null ? toVO(lastCheck) : null);
        result.put("lastQuestionIds", parseQuestionIds(lastCheck != null ? lastCheck.getQuestionIdsJson() : null));
    
        // 最近一次忽略
        LambdaQueryWrapper<SelfCheckIgnore> ignoreWrapper = new LambdaQueryWrapper<SelfCheckIgnore>()
                .eq(SelfCheckIgnore::getUserId, userId)
                .orderByDesc(SelfCheckIgnore::getCreatedAt)
                .last("LIMIT 1");
        SelfCheckIgnore lastIgnore = ignoreMapper.selectOne(ignoreWrapper);
    
        // 最近决定性时间
        Date lastDecisiveTime = null;
        if (lastCheck != null) lastDecisiveTime = lastCheck.getCreatedAt();
        if (lastIgnore != null && (lastDecisiveTime == null || lastIgnore.getCreatedAt().after(lastDecisiveTime))) {
            lastDecisiveTime = lastIgnore.getCreatedAt();
        }
    
        if (lastDecisiveTime == null) {
            // 从未自检/忽略
            result.put("canCheck", true);
            result.put("daysLeft", 0);
        } else {
            long diffDays = (System.currentTimeMillis() - lastDecisiveTime.getTime()) / (1000L * 60 * 60 * 24);
            boolean canCheck = diffDays >= 15;
            int daysLeft = canCheck ? 0 : (int) (15 - diffDays);
            result.put("canCheck", canCheck);
            result.put("daysLeft", daysLeft);
        }
        return result;
    }
    

需要新增 ignoreMapper 注入:

    @Resource
    private SelfCheckIgnoreMapper ignoreMapper;
  • [ ] 步骤 6:Service 新增 ignore 方法

    /**
     * 记录忽略动作(幂等:15 天内重复忽略不插入)
     */
    public Date ignoreSelfCheck(Long userId, Long checkId) {
        // 查最近一次忽略(15 天内)
        LambdaQueryWrapper<SelfCheckIgnore> wrapper = new LambdaQueryWrapper<SelfCheckIgnore>()
                .eq(SelfCheckIgnore::getUserId, userId)
                .orderByDesc(SelfCheckIgnore::getCreatedAt)
                .last("LIMIT 1");
        SelfCheckIgnore lastIgnore = ignoreMapper.selectOne(wrapper);
        if (lastIgnore != null) {
            long diffDays = (System.currentTimeMillis() - lastIgnore.getCreatedAt().getTime()) / (1000L * 60 * 60 * 24);
            if (diffDays < 15) {
                return lastIgnore.getCreatedAt(); // 幂等,返回已有忽略时间
            }
        }
        SelfCheckIgnore ignore = new SelfCheckIgnore();
        ignore.setUserId(userId);
        ignore.setCheckId(checkId);
        ignore.setCreatedAt(new Date());
        ignoreMapper.insert(ignore);
        return ignore.getCreatedAt();
    }
    
  • [ ] 步骤 7:编译验证

    cd cfc-backend && mvn clean compile -q
    

预期:BUILD SUCCESS

  • [ ] 步骤 8:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/FiveDimensionSelfCheck.java \
       cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java
    git commit -m "feat(self-check): Service 层抽题/状态/忽略/计分改造"
    

任务 3:后端 — Controller 新增接口

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java

  • [ ] 步骤 1:注入 ignoreMapper

在 FiveDimensionSelfCheckController.java 中追加字段:

    @Resource
    private com.etotem.cfc.mapper.SelfCheckIgnoreMapper selfCheckIgnoreMapper;
  • 步骤 2:新增 /status 接口

在类中追加方法:

    @Operation(summary = "获取自检状态(是否有记录、是否可检、距下次提醒天数)")
    @PostMapping("/status")
    public Result<Map<String, Object>> getStatus(@RequestAttribute("userId") Long userId) {
        if (userId == null) {
            return Result.error("请先登录");
        }
        return Result.success(selfCheckService.getStatus(userId));
    }
  • [ ] 步骤 3:新增 /ignore 接口

    @Operation(summary = "忽略本次自检提醒(重置 15 天计时)")
    @PostMapping("/ignore")
    public Result<Map<String, Object>> ignore(@RequestBody(required = false) Map<String, Object> body,
                                               @RequestAttribute("userId") Long userId) {
        if (userId == null) {
            return Result.error("请先登录");
        }
        Long checkId = body != null ? ParamUtils.getLong(body.get("checkId")) : null;
        Date ignoredAt = selfCheckService.ignoreSelfCheck(userId, checkId);
        Map<String, Object> resp = new HashMap<>();
        resp.put("nextRemindAt", new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss").format(
                new Date(ignoredAt.getTime() + 15L * 24 * 60 * 60 * 1000)));
        return Result.success(resp);
    }
    
  • [ ] 步骤 4:修改 /questions 接口 — 区分首次/再次

将 /questions 方法改为:

    @Operation(summary = "获取自检问卷题目(首次固定 15 题;再次合并题库随机抽)")
    @PostMapping("/questions")
    public Result<List<SelfCheckQuestionVO>> getQuestions(
            @RequestAttribute("userId") Long userId,
            @RequestBody(required = false) Map<String, Object> body) {
        if (userId == null) {
            return Result.error("请先登录");
        }
        Boolean retake = body != null && body.get("retake") != null && Boolean.TRUE.equals(body.get("retake"));
        if (retake) {
            return Result.success(selfCheckService.getQuestionsForRetake(userId));
        }
        return Result.success(selfCheckService.getQuestions());
    }
  • [ ] 步骤 5:编译验证

    cd cfc-backend && mvn clean compile -q
    

预期:BUILD SUCCESS

  • [ ] 步骤 6:Commit

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/family/FiveDimensionSelfCheckController.java
    git commit -m "feat(self-check): 新增 /status 和 /ignore 接口;/questions 支持 retake 参数"
    

任务 4:前端 — utils/api.js 新增方法

文件:

  • 修改:cfc-frontend/utils/api.js(在自检相关方法后追加)

  • [ ] 步骤 1:追加两个 API 方法

在 cfc-frontend/utils/api.js 中,于 getSelfCheckHistory 方法之后追加:

export const getSelfCheckStatus = () => {
  return request('/api/family/self-check/status', 'POST', {})
}

export const ignoreSelfCheck = (data) => {
  return request('/api/family/self-check/ignore', 'POST', data || {})
}
  • [ ] 步骤 2:语法校验

    node --check cfc-frontend/utils/api.js
    

预期:无输出(语法正确)

  • [ ] 步骤 3:Commit

    git add cfc-frontend/utils/api.js
    git commit -m "feat(self-check): 新增 getSelfCheckStatus 和 ignoreSelfCheck API 方法"
    

任务 5:前端 — 新增中间页 self-check-entry.vue

文件:

  • 新建:cfc-frontend/pages/family/self-check-entry.vue

  • [ ] 步骤 1:创建中间页文件

创建 cfc-frontend/pages/family/self-check-entry.vue:

<template>
  <view class="sce-page">
    <view v-if="loading" class="sce-loading">
      <text class="sce-loading-text">加载中...</text>
    </view>
    <template v-else>
      <!-- 总分横幅 -->
      <view class="sce-hero" :style="{ background: heroGradient }">
        <view class="sce-hero-inner">
          <text class="sce-hero-label">最近自检总分</text>
          <view class="sce-hero-score-row">
            <text class="sce-hero-score">{{ lastResult && lastResult.totalScore || 0 }}</text>
            <text class="sce-hero-total">/45</text>
          </view>
          <text class="sce-hero-date" v-if="lastResult && lastResult.createdAt">
            {{ formatCreatedDate(lastResult.createdAt) }}
          </text>
        </view>
      </view>

      <!-- 维度分数条 -->
      <view class="sce-card" v-if="lastResult && lastResult.dimensions && lastResult.dimensions.length > 0">
        <view class="sce-card-title">
          <text class="sce-card-title-text">五维得分</text>
        </view>
        <view class="sce-dim-list">
          <view class="sce-dim-item" v-for="dim in lastResult.dimensions" :key="dim.dimension">
            <view class="sce-dim-head">
              <view class="sce-dim-label">
                <view class="sce-dim-dot" :style="{ background: dim.color }"></view>
                <text class="sce-dim-name">{{ dim.name }}</text>
              </view>
              <view class="sce-dim-score-wrap">
                <text class="sce-dim-score" :style="{ color: dim.color }">{{ dim.score }}</text>
                <text class="sce-dim-max">/9</text>
              </view>
            </view>
            <view class="sce-dim-bar">
              <view class="sce-dim-bar-fill" :style="{ width: (dim.score / 9 * 100) + '%', background: dim.color }"></view>
            </view>
          </view>
        </view>
      </view>

      <!-- 状态提示 -->
      <view class="sce-status" v-if="!canCheck && lastResult">
        <text class="sce-status-text">距下次自检还有 {{ daysLeft }} 天</text>
      </view>
      <view class="sce-status" v-else-if="canCheck && lastResult">
        <text class="sce-status-text">已满 15 天,可再次自检</text>
      </view>

      <!-- 按钮区域 -->
      <view class="sce-footer">
        <button v-if="canCheck && lastResult" class="sce-btn sce-btn-primary" @click="retake">
          再次自检
        </button>
        <button v-if="canCheck && lastResult" class="sce-btn sce-btn-outline" @click="ignore">
          忽略本次
        </button>
        <button v-if="!lastResult" class="sce-btn sce-btn-primary" @click="retake">
          开始自检
        </button>
        <button v-if="!canCheck && lastResult" class="sce-btn sce-btn-disabled" disabled>
          {{ daysLeft }} 天后可再次自检
        </button>
      </view>
    </template>
  </view>
</template>

<script>
import { getSelfCheckStatus, ignoreSelfCheck } from '@/utils/api'
import { parseDate } from '@/utils/format.js'

export default {
  data() {
    return {
      loading: true,
      lastResult: null,
      canCheck: false,
      daysLeft: 0
    }
  },
  computed: {
    heroGradient: function() {
      if (!this.lastResult || !this.lastResult.dimensions || this.lastResult.dimensions.length === 0) {
        return 'linear-gradient(135deg, #F97316, #FF8C42)'
      }
      var lowest = this.lastResult.dimensions.reduce(function(min, d) {
        return d.score < min.score ? d : min
      }, this.lastResult.dimensions[0])
      return 'linear-gradient(135deg, ' + lowest.color + ', ' + (lowest.color + 'bb') + ')'
    }
  },
  onLoad: function() {
    this.loadStatus()
  },
  methods: {
    loadStatus: function() {
      var self = this
      getSelfCheckStatus().then(function(res) {
        self.loading = false
        if (res.code === 200 && res.data) {
          self.lastResult = res.data.lastResult || null
          self.canCheck = res.data.canCheck
          self.daysLeft = res.data.daysLeft || 0
        } else {
          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
        }
      }).catch(function() {
        self.loading = false
        uni.showToast({ title: '网络异常', icon: 'none' })
      })
    },
    retake: function() {
      uni.navigateTo({ url: '/pages/family/self-check?retake=1' })
    },
    ignore: function() {
      var self = this
      ignoreSelfCheck({ checkId: this.lastResult && this.lastResult.id }).then(function(res) {
        if (res.code === 200) {
          uni.showToast({ title: '已忽略,15 天后再次提醒', icon: 'none' })
          setTimeout(function() {
            uni.navigateBack()
          }, 800)
        } else {
          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
        }
      }).catch(function() {
        uni.showToast({ title: '操作失败', icon: 'none' })
      })
    },
    formatCreatedDate: function(dateStr) {
      if (!dateStr) return ''
      var d = parseDate(dateStr)
      if (!d) return ''
      var pad = function(n) { return n < 10 ? '0' + n : '' + n }
      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
    }
  }
}
</script>

<style scoped>
.sce-page {
  min-height: 100vh;
  background: #F5FAFE;
  padding: 24rpx 30rpx 120rpx;
  box-sizing: border-box;
}
.sce-loading {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 50vh;
}
.sce-loading-text {
  font-size: 28rpx;
  color: #999;
}
.sce-hero {
  background: linear-gradient(135deg, #F97316, #FF8C42);
  border-radius: 24rpx;
  padding: 40rpx 30rpx;
  margin-bottom: 24rpx;
  box-shadow: 0 6rpx 20rpx rgba(249, 114, 22, 0.25);
}
.sce-hero-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.sce-hero-label {
  font-size: 26rpx;
  color: rgba(255, 255, 255, 0.9);
}
.sce-hero-score-row {
  display: flex;
  align-items: baseline;
  margin-top: 10rpx;
}
.sce-hero-score {
  font-size: 88rpx;
  font-weight: 700;
  color: #fff;
}
.sce-hero-total {
  font-size: 30rpx;
  color: rgba(255, 255, 255, 0.7);
  margin-left: 6rpx;
}
.sce-hero-date {
  display: block;
  text-align: center;
  font-size: 22rpx;
  color: rgba(255, 255, 255, 0.8);
  margin-top: 16rpx;
}
.sce-card {
  background: #fff;
  border-radius: 24rpx;
  padding: 30rpx 26rpx;
  margin-bottom: 24rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.sce-card-title {
  margin-bottom: 24rpx;
}
.sce-card-title-text {
  font-size: 32rpx;
  font-weight: 600;
  color: #333;
}
.sce-dim-list {
  display: flex;
  flex-direction: column;
}
.sce-dim-item {
  margin-bottom: 24rpx;
}
.sce-dim-item:last-child {
  margin-bottom: 0;
}
.sce-dim-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.sce-dim-label {
  display: flex;
  align-items: center;
}
.sce-dim-dot {
  width: 28rpx;
  height: 28rpx;
  border-radius: 50%;
  margin-right: 12rpx;
}
.sce-dim-name {
  font-size: 30rpx;
  font-weight: 600;
  color: #333;
}
.sce-dim-score-wrap {
  display: flex;
  align-items: baseline;
}
.sce-dim-score {
  font-size: 40rpx;
  font-weight: 700;
}
.sce-dim-max {
  font-size: 22rpx;
  color: #BBB;
}
.sce-dim-bar {
  height: 12rpx;
  background: #F0F0F0;
  border-radius: 6rpx;
  margin-top: 14rpx;
  overflow: hidden;
}
.sce-dim-bar-fill {
  height: 100%;
  border-radius: 6rpx;
  transition: width 0.5s ease;
}
.sce-status {
  background: #FFF7ED;
  border-radius: 16rpx;
  padding: 24rpx 20rpx;
  margin-bottom: 24rpx;
  text-align: center;
}
.sce-status-text {
  font-size: 26rpx;
  color: #E65100;
}
.sce-footer {
  display: flex;
  flex-direction: column;
  gap: 16rpx;
  margin-top: 20rpx;
}
.sce-btn {
  height: 88rpx;
  line-height: 88rpx;
  font-size: 30rpx;
  border-radius: 44rpx;
  text-align: center;
  border: none;
}
.sce-btn-primary {
  background: linear-gradient(135deg, #F97316, #FF8C42);
  color: #fff;
  font-weight: 600;
}
.sce-btn-outline {
  background: #fff;
  color: #F97316;
  border: 2rpx solid #F97316;
}
.sce-btn-disabled {
  background: #E5E7EB;
  color: #999;
  font-weight: 400;
}
</style>
  • [ ] 步骤 2:语法校验

    node --check cfc-frontend/pages/family/self-check-entry.vue 2>&1 || echo "Vue 文件跳过 node --check"
    

预期:Vue 文件无法直接 node --check,跳过即可;重点检查 script 块语法。

  • [ ] 步骤 3:Commit

    git add cfc-frontend/pages/family/self-check-entry.vue
    git commit -m "feat(self-check): 新增自检中间页 self-check-entry.vue"
    

任务 6:前端 — 注册中间页 + 修改结果页跳转

文件:

  • 修改:cfc-frontend/pages.json
  • 修改:cfc-frontend/pages/family/self-check-result.vue

  • [ ] 步骤 1:pages.json 注册中间页

在 cfc-frontend/pages.json 的 pages/family 分包中找到 self-check-result 注册,在其后追加:

{
  "path": "self-check-entry",
  "style": {
    "navigationBarTitleText": "自检"
  }
}
  • 步骤 2:self-check-result.vue 修改「重新自检」按钮

在 cfc-frontend/pages/family/self-check-result.vue 中,找到 retake 方法(约第 394 行),将其修改为:

retake: function() {
  uni.navigateTo({ url: '/pages/family/self-check-entry' })
}
  • [ ] 步骤 3:Commit

    git add cfc-frontend/pages.json cfc-frontend/pages/family/self-check-result.vue
    git commit -m "feat(self-check): 注册 self-check-entry 页面;结果页「重新自检」改跳中间页"
    

任务 7:前端 — 首页入口接入自检状态

文件:

  • 修改:cfc-frontend/pages/index-home/index.vue
  • 修改:cfc-frontend/pages/home-pages/parent-index.vue

  • [ ] 步骤 1:index-home/index.vue — 加载自检状态 + 显示分数

在 index.vue 的 data() 中追加:

selfCheckScore: null,
selfCheckLoading: false,

在 methods 中追加方法(放入 loadFamilyMembers 调用链之后):

loadSelfCheckStatus: function() {
  var self = this
  getSelfCheckStatus().then(function(res) {
    if (res.code === 200 && res.data && res.data.lastResult) {
      self.selfCheckScore = res.data.lastResult.totalScore
    }
  }).catch(function() {})
}

在 loadDimensionData 方法中追加调用:

loadDimensionData: function() {
  this.loadSandboxData()
  this.loadEnergyOverview()
  this.loadActionArticles()
  this.loadChallenges()
  this.loadSelfCheckStatus()
}

修改 selfTestSub 计算属性(约第 538 行):

selfTestSub: function() {
  if (this.sandboxData) {
    if (this.selfCheckScore != null) {
      return '最近自检得分 ' + this.selfCheckScore + '/45 分 · 点击查看'
    }
    return '最近综合得分 ' + (this.sandboxData.overallScore || 0) + ' 分 · 点击查看'
  }
  return '15 题五维自检 · 约 20 秒'
}

修改自检入口点击事件,改为跳转中间页:

// 找到原 goToSelfCheck 或类似方法,改为:
goToSelfCheck: function() {
  uni.navigateTo({ url: '/pages/family/self-check-entry' })
}

同时确保文件头部已导入 getSelfCheckStatus:

import { getSelfCheckStatus } from '@/utils/api'
  • 步骤 2:home-pages/parent-index.vue — 加载自检状态 + 显示分数

在 data() 中追加:

selfCheckScore: null,

在 methods 中追加:

loadSelfCheckStatus: function() {
  var self = this
  getSelfCheckStatus().then(function(res) {
    if (res.code === 200 && res.data && res.data.lastResult) {
      self.selfCheckScore = res.data.lastResult.totalScore
    }
  }).catch(function() {})
}

在 onLoad 或 onShow 中调用 loadSelfCheckStatus()(找到现有初始化调用处追加)。

修改 selfcheck-subtitle 或相关文案显示逻辑(找到渲染 selfcheck-subtitle 的模板部分),改为:

<text class="selfcheck-subtitle" v-if="selfCheckScore != null">
  最近得分 {{ selfCheckScore }}/45 分
</text>
<text class="selfcheck-subtitle" v-else>
  15题 · 5分钟 · 生成五行相生寻源建议
</text>

修改 goToSelfCheck 方法(约第 1086 行):

goToSelfCheck: function() {
  uni.navigateTo({ url: '/pages/family/self-check-entry' })
}

在文件头部导入 getSelfCheckStatus:

import { getSelfCheckStatus } from '@/utils/api'
  • [ ] 步骤 3:语法校验

    node --check cfc-frontend/pages/index-home/index.vue 2>&1 || echo "跳过"
    node --check cfc-frontend/pages/home-pages/parent-index.vue 2>&1 || echo "跳过"
    
  • [ ] 步骤 4:Commit

    git add cfc-frontend/pages/index-home/index.vue cfc-frontend/pages/home-pages/parent-index.vue
    git commit -m "feat(self-check): 首页入口显示自检总分 + 跳转中间页"
    

任务 8:前端 — self-check.vue 支持 retake 参数

文件:

  • 修改:cfc-frontend/pages/family/self-check.vue

  • [ ] 步骤 1:onLoad 读取 retake 参数并传给 questions 接口

在 cfc-frontend/pages/family/self-check.vue 中,修改 loadQuestions 方法:

loadQuestions: function() {
  var self = this
  var retake = this.$route && this.$route.query && this.$route.query.retake === '1'
  var apiCall = retake ? getSelfCheckQuestions({ retake: true }) : getSelfCheckQuestions()
  apiCall.then(function(res) {
    self.loading = false
    if (res.code === 200 && res.data) {
      self.questions = res.data || []
    } else {
      uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
    }
  }).catch(function() {
    self.loading = false
    uni.showToast({ title: '网络异常,请重试', icon: 'none' })
  })
}

同时修改 api.js 中 getSelfCheckQuestions 支持传参:

export const getSelfCheckQuestions = (params) => {
  return request('/api/family/self-check/questions', 'POST', params || {})
}
  • [ ] 步骤 2:语法校验

    node --check cfc-frontend/utils/api.js
    
  • [ ] 步骤 3:Commit

    git add cfc-frontend/utils/api.js cfc-frontend/pages/family/self-check.vue
    git commit -m "feat(self-check): 支持 retake 参数获取轮换题库"
    

任务 9:最终验证

  • [ ] 步骤 1:后端编译

    cd cfc-backend && mvn clean compile -q
    

预期:BUILD SUCCESS

  • [ ] 步骤 2:前端语法校验

    node --check cfc-frontend/utils/api.js && echo "api.js OK"
    
  • [ ] 步骤 3:提交最终 commit(如有遗漏)

    git add -A
    git diff --cached --stat
    git commit -m "feat(self-check): 五维自检 15 天复检周期全栈实现"
    

自检清单

规格覆盖度:

  • 15 天复检周期(个人维度)→ 任务 2(getStatus)、任务 3(Controller)
  • 首页显示上次自检分数 → 任务 7
  • 进入时显示上次结果 + 二选一 → 任务 5(中间页)
  • 忽略后重置计时 → 任务 2(ignoreSelfCheck)、任务 3(/ignore)
  • 题目轮换 → 任务 2(getQuestionsForRetake + pickQuestions)

占位符扫描: 无"待定"/"TODO"/"后续实现"

类型一致性:

  • SelfCheckIgnore / SelfCheckIgnoreMapper — 任务 1 定义,任务 3 注入
  • getStatus / ignoreSelfCheck / getQuestionsForRetake — 任务 2 定义,任务 3 Controller 调用
  • getSelfCheckStatus / ignoreSelfCheck — 任务 4 定义,任务 5/7 调用