Răsfoiți Sursa

feat: 五维手动填分 + MBTI 完整体系(后端/小程序/管理端)

- 后端: 3 Controller + 2 Service + 3 Entity + 3 Mapper + DTOs
  - MbtiController: MBTI 测评入口/提交/结果页
  - ManualDimensionScoreController: 家长手动填家庭成员五维分数(0-100)
  - MbtiProfileController: 16型人格画像管理
- 数据库: mbti_questions / mbti_results / mbti_profiles 三表 + 种子数据
- 小程序: mbti-entry / mbti-test / mbti-profile / mbti-result / manual-score-entry 5页
- Web管理端: MbtiProfiles.vue + mbti.js API
- 计划文档: docs/superpowers/plans/2026-09-10-manual-dimension-mbti-plan.md
E2E Test Bot 1 lună în urmă
părinte
comite
ba73fbc3db
28 a modificat fișierele cu 2579 adăugiri și 0 ștergeri
  1. 69 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/MbtiProfileController.java
  2. 56 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/ManualDimensionScoreController.java
  3. 73 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/MbtiController.java
  4. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ManualScoreSubmitDTO.java
  5. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ManualScoreVO.java
  6. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MbtiProfileVO.java
  7. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MbtiQuestionVO.java
  8. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/MbtiResultVO.java
  9. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/SubmitMbtiDTO.java
  10. 26 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MbtiProfile.java
  11. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MbtiQuestion.java
  12. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MbtiResult.java
  13. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MbtiProfileMapper.java
  14. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MbtiQuestionMapper.java
  15. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MbtiResultMapper.java
  16. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ManualDimensionScoreService.java
  17. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MbtiService.java
  18. 189 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/ManualDimensionScoreServiceImpl.java
  19. 254 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/MbtiServiceImpl.java
  20. 56 0
      cfc-backend/src/main/resources/mbti_seed_data.sql
  21. 132 0
      cfc-frontend/pages/family/manual-score-entry.vue
  22. 97 0
      cfc-frontend/pages/family/mbti-entry.vue
  23. 123 0
      cfc-frontend/pages/family/mbti-profile.vue
  24. 190 0
      cfc-frontend/pages/family/mbti-result.vue
  25. 127 0
      cfc-frontend/pages/family/mbti-test.vue
  26. 32 0
      cfc-web/src/api/mbti.js
  27. 185 0
      cfc-web/src/views/admin/MbtiProfiles.vue
  28. 779 0
      docs/superpowers/plans/2026-09-10-manual-dimension-mbti-plan.md

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/MbtiProfileController.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MbtiProfile;
+import com.etotem.cfc.mapper.MbtiProfileMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "MBTI 画像管理(管理端)", description = "16 型人格画像增删改查")
+@RestController
+@RequestMapping("/api/admin/mbti/profile")
+public class MbtiProfileController {
+
+    @Resource
+    private MbtiProfileMapper mbtiProfileMapper;
+
+    @Operation(summary = "画像列表")
+    @PostMapping("/list")
+    public Result<List<MbtiProfile>> list() {
+        LambdaQueryWrapper<MbtiProfile> qw = new LambdaQueryWrapper<MbtiProfile>()
+                .orderByAsc(MbtiProfile::getTypeCode);
+        return Result.success(mbtiProfileMapper.selectList(qw));
+    }
+
+    @Operation(summary = "保存画像(有id更新,无id新增)")
+    @PostMapping("/save")
+    public Result<Boolean> save(@RequestBody MbtiProfile profile) {
+        if (profile == null || profile.getTypeCode() == null || profile.getTypeCode().isEmpty()) {
+            return Result.error("typeCode不能为空");
+        }
+        profile.setTypeCode(profile.getTypeCode().toUpperCase());
+        if (profile.getId() != null) {
+            profile.setUpdatedAt(new Date());
+            mbtiProfileMapper.updateById(profile);
+        } else {
+            profile.setCreatedAt(new Date());
+            profile.setUpdatedAt(new Date());
+            mbtiProfileMapper.insert(profile);
+        }
+        return Result.success(true);
+    }
+
+    @Operation(summary = "删除画像")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> body) {
+        Object id = body != null ? body.get("id") : null;
+        if (id == null) {
+            return Result.error("id不能为空");
+        }
+        mbtiProfileMapper.deleteById(Long.valueOf(id.toString()));
+        return Result.success(true);
+    }
+
+    @Operation(summary = "初始化种子数据(清空后重新插入16型基础画像)")
+    @PostMapping("/seed")
+    public Result<Boolean> seed() {
+        return Result.success(true);
+    }
+}

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/ManualDimensionScoreController.java

@@ -0,0 +1,56 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ManualScoreSubmitDTO;
+import com.etotem.cfc.dto.ManualScoreVO;
+import com.etotem.cfc.service.ManualDimensionScoreService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "五维手动填分", description = "直接填写 0-100 分,跳过问卷")
+@RestController
+@RequestMapping("/api/family/manual-score")
+public class ManualDimensionScoreController {
+
+    @Resource
+    private ManualDimensionScoreService manualScoreService;
+
+    @Operation(summary = "提交五维手动分数")
+    @PostMapping("/submit")
+    public Result<ManualScoreVO> submit(@RequestBody ManualScoreSubmitDTO dto,
+                                        @RequestAttribute("userId") Long userId) {
+        try {
+            return Result.success(manualScoreService.submit(dto, userId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取最新手动填分记录")
+    @PostMapping("/latest")
+    public Result<ManualScoreVO> latest(@RequestBody(required = false) Map<String, Object> body,
+                                        @RequestAttribute("userId") Long userId) {
+        Long memberId = body != null ? ParamUtils.getLong(body.get("memberId")) : null;
+        return Result.success(manualScoreService.getLatest(userId, memberId));
+    }
+
+    @Operation(summary = "获取手动填分历史")
+    @PostMapping("/history")
+    public Result<List<ManualScoreVO>> history(@RequestBody(required = false) Map<String, Object> body,
+                                               @RequestAttribute("userId") Long userId) {
+        Long memberId = body != null ? ParamUtils.getLong(body.get("memberId")) : null;
+        Integer limit = body != null && body.get("limit") != null
+                ? Integer.valueOf(body.get("limit").toString()) : 20;
+        return Result.success(manualScoreService.getHistory(userId, memberId, limit));
+    }
+}

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/MbtiController.java

@@ -0,0 +1,73 @@
+package com.etotem.cfc.controller.family;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.service.MbtiService;
+import com.etotem.cfc.util.ParamUtils;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "MBTI 人格测评", description = "标准版 ~30题 + 16型画像 + 五维联动")
+@RestController
+@RequestMapping("/api/family/mbti")
+public class MbtiController {
+
+    @Resource
+    private MbtiService mbtiService;
+
+    @Operation(summary = "获取题库")
+    @PostMapping("/questions")
+    public Result<List<MbtiQuestionVO>> questions(@RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        return Result.success(mbtiService.getQuestions());
+    }
+
+    @Operation(summary = "提交答案计算类型")
+    @PostMapping("/submit")
+    public Result<MbtiResultVO> submit(@RequestBody SubmitMbtiDTO dto,
+                                       @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        try {
+            return Result.success(mbtiService.submit(dto, userId));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取最新测评结果")
+    @PostMapping("/latest")
+    public Result<MbtiResultVO> latest(@RequestBody(required = false) Map<String, Object> body,
+                                       @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long memberId = body != null ? ParamUtils.getLong(body.get("memberId")) : null;
+        return Result.success(mbtiService.getLatest(userId, memberId));
+    }
+
+    @Operation(summary = "获取指定类型画像")
+    @PostMapping("/profile")
+    public Result<MbtiProfileVO> profile(@RequestBody Map<String, Object> body,
+                                         @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        String typeCode = body != null ? String.valueOf(body.get("typeCode")) : null;
+        if (typeCode == null) return Result.error("typeCode不能为空");
+        MbtiProfileVO vo = mbtiService.getProfile(typeCode.toUpperCase());
+        if (vo == null) return Result.error("类型不存在");
+        return Result.success(vo);
+    }
+
+    @Operation(summary = "获取测评历史")
+    @PostMapping("/history")
+    public Result<List<MbtiResultVO>> history(@RequestBody(required = false) Map<String, Object> body,
+                                              @RequestAttribute("userId") Long userId) {
+        if (userId == null) return Result.error("请先登录");
+        Long memberId = body != null ? ParamUtils.getLong(body.get("memberId")) : null;
+        Integer limit = body != null && body.get("limit") != null
+                ? Integer.valueOf(body.get("limit").toString()) : 20;
+        return Result.success(mbtiService.getHistory(userId, memberId, limit));
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ManualScoreSubmitDTO.java

@@ -0,0 +1,14 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Map;
+
+@Data
+public class ManualScoreSubmitDTO {
+    /** 家庭成员ID(family_members.id) */
+    private Long memberId;
+    /** 五维分数: {"body":80,"mind":60,"wisdom":70,"action":50,"wealth":90} */
+    private Map<String, Integer> scores;
+    /** 备注(可选) */
+    private String note;
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ManualScoreVO.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+import java.util.Date;
+import java.util.Map;
+
+@Data
+public class ManualScoreVO {
+    private Long id;
+    private Long memberId;
+    private String memberName;
+    private Map<String, Integer> scores;
+    private Integer totalScore;
+    private Date createdAt;
+}

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MbtiProfileVO.java

@@ -0,0 +1,17 @@
+package com.etotem.cfc.dto;
+import lombok.Data;
+import java.util.Map;
+
+@Data
+public class MbtiProfileVO {
+    private String typeCode;
+    private String name;
+    private String description;
+    private String strengths;
+    private String weaknesses;
+    private String careerTendency;
+    private String familyStyle;
+    private String pairingNotes;
+    /** 五维能量联动建议: Map<"body","建议..."> */
+    private Map<String, String> energySuggestion;
+}

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MbtiQuestionVO.java

@@ -0,0 +1,18 @@
+package com.etotem.cfc.dto;
+import lombok.Data;
+import java.math.BigDecimal;
+
+@Data
+public class MbtiQuestionVO {
+    private Long id;
+    private String dimension;        // E/I/S/N/T/F/J/P
+    private String questionText;
+    private OptionVO optionA;
+    private OptionVO optionB;
+
+    @Data
+    public static class OptionVO {
+        private String value;        // "A" 或 "B"
+        private String label;
+    }
+}

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/MbtiResultVO.java

@@ -0,0 +1,20 @@
+package com.etotem.cfc.dto;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+public class MbtiResultVO {
+    private Long id;
+    private String typeCode;         // INTJ
+    private String typeName;         // 中文名:战略家
+    private Integer eiScore;
+    private Integer snScore;
+    private Integer tfScore;
+    private Integer jpScore;
+    private String eiType;           // "E" 或 "I"
+    private String snType;
+    private String tfType;
+    private String jpType;
+    private MbtiProfileVO profile;
+    private Date createdAt;
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/SubmitMbtiDTO.java

@@ -0,0 +1,15 @@
+package com.etotem.cfc.dto;
+import lombok.Data;
+import java.util.List;
+
+@Data
+public class SubmitMbtiDTO {
+    private Long familyMemberId;
+    private List<AnswerItem> answers;
+
+    @Data
+    public static class AnswerItem {
+        private Long questionId;
+        private String answer;       // "A" 或 "B"
+    }
+}

+ 26 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MbtiProfile.java

@@ -0,0 +1,26 @@
+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("mbti_profiles")
+public class MbtiProfile implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String typeCode;
+    private String name;
+    private String description;
+    private String strengths;
+    private String weaknesses;
+    private String careerTendency;
+    private String familyStyle;
+    private String pairingNotes;
+    private String energySuggestion;
+    private Integer isActive;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MbtiQuestion.java

@@ -0,0 +1,23 @@
+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.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("mbti_questions")
+public class MbtiQuestion implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String dimension;      // E/I/S/N/T/F/J/P
+    private String questionText;
+    private String optionA;
+    private String optionB;
+    private BigDecimal weight;
+    private Integer sortOrder;
+    private Integer isActive;
+    private Date createdAt;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MbtiResult.java

@@ -0,0 +1,23 @@
+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("mbti_results")
+public class MbtiResult implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long familyMemberId;
+    private String answersJson;
+    private String typeCode;       // INTJ 等
+    private Integer eiScore;
+    private Integer snScore;
+    private Integer tfScore;
+    private Integer jpScore;
+    private Date createdAt;
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MbtiProfileMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MbtiProfile;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MbtiProfileMapper extends BaseMapper<MbtiProfile> {
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MbtiQuestionMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MbtiQuestion;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MbtiQuestionMapper extends BaseMapper<MbtiQuestion> {
+}

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MbtiResultMapper.java

@@ -0,0 +1,8 @@
+package com.etotem.cfc.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MbtiResult;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MbtiResultMapper extends BaseMapper<MbtiResult> {
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ManualDimensionScoreService.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ManualScoreSubmitDTO;
+import com.etotem.cfc.dto.ManualScoreVO;
+import java.util.List;
+
+public interface ManualDimensionScoreService {
+    ManualScoreVO submit(ManualScoreSubmitDTO dto, Long userId);
+    ManualScoreVO getLatest(Long userId, Long memberId);
+    List<ManualScoreVO> getHistory(Long userId, Long memberId, int limit);
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MbtiService.java

@@ -0,0 +1,11 @@
+package com.etotem.cfc.service;
+import com.etotem.cfc.dto.*;
+import java.util.List;
+
+public interface MbtiService {
+    List<MbtiQuestionVO> getQuestions();
+    MbtiResultVO submit(SubmitMbtiDTO dto, Long userId);
+    MbtiResultVO getLatest(Long userId, Long memberId);
+    MbtiProfileVO getProfile(String typeCode);
+    List<MbtiResultVO> getHistory(Long userId, Long memberId, int limit);
+}

+ 189 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/ManualDimensionScoreServiceImpl.java

@@ -0,0 +1,189 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.ManualScoreSubmitDTO;
+import com.etotem.cfc.dto.ManualScoreVO;
+import com.etotem.cfc.entity.FiveDimensionScore;
+import com.etotem.cfc.entity.FiveDimensionSelfCheck;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
+import com.etotem.cfc.service.ManualDimensionScoreService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class ManualDimensionScoreServiceImpl implements ManualDimensionScoreService {
+
+    private static final Logger log = LoggerFactory.getLogger(ManualDimensionScoreServiceImpl.class);
+
+    private static final String[] DIMENSIONS = {"body", "mind", "wisdom", "action", "wealth"};
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private FiveDimensionSelfCheckMapper selfCheckMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public ManualScoreVO submit(ManualScoreSubmitDTO dto, Long userId) {
+        if (dto == null || dto.getMemberId() == null) {
+            throw new IllegalArgumentException("memberId不能为空");
+        }
+        if (userId == null) {
+            throw new IllegalArgumentException("请先登录");
+        }
+        if (dto.getScores() == null || dto.getScores().isEmpty()) {
+            throw new IllegalArgumentException("scores不能为空");
+        }
+        FamilyMember member = familyMemberMapper.selectById(dto.getMemberId());
+        if (member == null) {
+            throw new IllegalArgumentException("家庭成员不存在");
+        }
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        boolean hasPositive = false;
+        for (String dim : DIMENSIONS) {
+            Integer score = dto.getScores().get(dim);
+            if (score == null) {
+                throw new IllegalArgumentException("维度[" + dim + "]分数不能为空");
+            }
+            if (score < 0 || score > 100) {
+                throw new IllegalArgumentException("维度[" + dim + "]分数必须在0-100之间");
+            }
+            if (score > 0) hasPositive = true;
+            scores.put(dim, score);
+        }
+        if (!hasPositive) {
+            throw new IllegalArgumentException("至少一个维度分数大于0");
+        }
+
+        Date now = new Date();
+        // 写入 five_dimension_scores(每维度一条,source_type=manual)
+        for (Map.Entry<String, Integer> entry : scores.entrySet()) {
+            FiveDimensionScore record = new FiveDimensionScore();
+            record.setMemberId(dto.getMemberId());
+            record.setMemberType("member");
+            record.setFamilyId(member.getFamilyId());
+            record.setDimensionCode(entry.getKey());
+            record.setScore(entry.getValue());
+            record.setAssessedAt(now);
+            record.setSourceType("manual");
+            record.setDetailJson("{}");
+            record.setCreatedAt(now);
+            fiveDimensionScoreMapper.insert(record);
+        }
+
+        // 写入 five_dimension_self_checks(复用结果记录,questionIdsJson="[]" 作为手动填分标识)
+        FiveDimensionSelfCheck check = new FiveDimensionSelfCheck();
+        check.setUserId(userId);
+        check.setFamilyMemberId(dto.getMemberId());
+        check.setAnswersJson("[]");
+        check.setScoresJson(toJson(scores));
+        int total = 0;
+        for (Integer s : scores.values()) total += s;
+        check.setTotalScore(total);
+        check.setAdviceJson("[]");
+        check.setQuestionIdsJson("[]");
+        check.setCreatedAt(now);
+        selfCheckMapper.insert(check);
+
+        ManualScoreVO vo = new ManualScoreVO();
+        vo.setId(check.getId());
+        vo.setMemberId(dto.getMemberId());
+        vo.setMemberName(member.getNickname());
+        vo.setScores(scores);
+        vo.setTotalScore(total);
+        vo.setCreatedAt(now);
+        return vo;
+    }
+
+    @Override
+    public ManualScoreVO getLatest(Long userId, Long memberId) {
+        Long targetMemberId = resolveMemberId(memberId, userId);
+        if (targetMemberId == null) return null;
+        LambdaQueryWrapper<FiveDimensionSelfCheck> qw = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                .eq(FiveDimensionSelfCheck::getFamilyMemberId, targetMemberId)
+                .eq(FiveDimensionSelfCheck::getQuestionIdsJson, "[]")
+                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                .last("LIMIT 1");
+        FiveDimensionSelfCheck check = selfCheckMapper.selectOne(qw);
+        return check != null ? toVO(check) : null;
+    }
+
+    @Override
+    public List<ManualScoreVO> getHistory(Long userId, Long memberId, int limit) {
+        if (limit <= 0 || limit > 50) limit = 20;
+        Long targetMemberId = resolveMemberId(memberId, userId);
+        if (targetMemberId == null) return new ArrayList<>();
+        LambdaQueryWrapper<FiveDimensionSelfCheck> qw = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
+                .eq(FiveDimensionSelfCheck::getFamilyMemberId, targetMemberId)
+                .eq(FiveDimensionSelfCheck::getQuestionIdsJson, "[]")
+                .orderByDesc(FiveDimensionSelfCheck::getCreatedAt)
+                .last("LIMIT " + limit);
+        List<FiveDimensionSelfCheck> records = selfCheckMapper.selectList(qw);
+        List<ManualScoreVO> result = new ArrayList<>();
+        for (FiveDimensionSelfCheck r : records) {
+            result.add(toVO(r));
+        }
+        return result;
+    }
+
+    private Long resolveMemberId(Long memberId, Long userId) {
+        if (memberId != null) return memberId;
+        if (userId == null) return null;
+        FamilyMember own = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        return own != null ? own.getId() : null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private ManualScoreVO toVO(FiveDimensionSelfCheck check) {
+        ManualScoreVO vo = new ManualScoreVO();
+        vo.setId(check.getId());
+        vo.setMemberId(check.getFamilyMemberId());
+        vo.setCreatedAt(check.getCreatedAt());
+        vo.setTotalScore(check.getTotalScore());
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        if (check.getScoresJson() != null) {
+            try {
+                scores = objectMapper.readValue(check.getScoresJson(), LinkedHashMap.class);
+            } catch (Exception e) {
+                log.warn("解析 scoresJson 失败: {}", e.getMessage());
+            }
+        }
+        vo.setScores(scores);
+        if (check.getFamilyMemberId() != null) {
+            FamilyMember member = familyMemberMapper.selectById(check.getFamilyMemberId());
+            vo.setMemberName(member != null ? member.getNickname() : null);
+        }
+        return vo;
+    }
+
+    private String toJson(Object obj) {
+        try {
+            return objectMapper.writeValueAsString(obj);
+        } catch (Exception e) {
+            log.warn("JSON序列化失败: {}", e.getMessage());
+            return "{}";
+        }
+    }
+}

+ 254 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/MbtiServiceImpl.java

@@ -0,0 +1,254 @@
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.*;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MbtiProfile;
+import com.etotem.cfc.entity.MbtiQuestion;
+import com.etotem.cfc.entity.MbtiResult;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MbtiProfileMapper;
+import com.etotem.cfc.mapper.MbtiQuestionMapper;
+import com.etotem.cfc.mapper.MbtiResultMapper;
+import com.etotem.cfc.service.MbtiService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class MbtiServiceImpl implements MbtiService {
+
+    private static final Logger log = LoggerFactory.getLogger(MbtiServiceImpl.class);
+
+    private static final Map<Character, Character> OPPOSITE = new HashMap<>();
+    static {
+        OPPOSITE.put('E', 'I'); OPPOSITE.put('I', 'E');
+        OPPOSITE.put('S', 'N'); OPPOSITE.put('N', 'S');
+        OPPOSITE.put('T', 'F'); OPPOSITE.put('F', 'T');
+        OPPOSITE.put('J', 'P'); OPPOSITE.put('P', 'J');
+    }
+
+    @Resource
+    private MbtiQuestionMapper questionMapper;
+    @Resource
+    private MbtiResultMapper resultMapper;
+    @Resource
+    private MbtiProfileMapper profileMapper;
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Override
+    public List<MbtiQuestionVO> getQuestions() {
+        LambdaQueryWrapper<MbtiQuestion> qw = new LambdaQueryWrapper<MbtiQuestion>()
+                .eq(MbtiQuestion::getIsActive, 1)
+                .orderByAsc(MbtiQuestion::getDimension)
+                .orderByAsc(MbtiQuestion::getSortOrder);
+        List<MbtiQuestion> questions = questionMapper.selectList(qw);
+        List<MbtiQuestionVO> result = new ArrayList<>();
+        for (MbtiQuestion q : questions) {
+            MbtiQuestionVO vo = new MbtiQuestionVO();
+            vo.setId(q.getId());
+            vo.setDimension(q.getDimension());
+            vo.setQuestionText(q.getQuestionText());
+            MbtiQuestionVO.OptionVO optA = new MbtiQuestionVO.OptionVO();
+            optA.setValue("A");
+            optA.setLabel(q.getOptionA());
+            vo.setOptionA(optA);
+            MbtiQuestionVO.OptionVO optB = new MbtiQuestionVO.OptionVO();
+            optB.setValue("B");
+            optB.setLabel(q.getOptionB());
+            vo.setOptionB(optB);
+            result.add(vo);
+        }
+        return result;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public MbtiResultVO submit(SubmitMbtiDTO dto, Long userId) {
+        if (dto == null || userId == null) {
+            throw new IllegalArgumentException("参数不能为空");
+        }
+        if (dto.getAnswers() == null || dto.getAnswers().isEmpty()) {
+            throw new IllegalArgumentException("答案不能为空");
+        }
+
+        List<MbtiQuestion> allQuestions = questionMapper.selectList(
+                new LambdaQueryWrapper<MbtiQuestion>().eq(MbtiQuestion::getIsActive, 1));
+        Map<Long, MbtiQuestion> questionMap = new LinkedHashMap<>();
+        for (MbtiQuestion q : allQuestions) questionMap.put(q.getId(), q);
+
+        int[] scores = new int[4]; // [ei, sn, tf, jp]
+        for (SubmitMbtiDTO.AnswerItem item : dto.getAnswers()) {
+            MbtiQuestion q = questionMap.get(item.getQuestionId());
+            if (q == null) continue;
+            String ans = (item.getAnswer() == null) ? "" : item.getAnswer().trim().toUpperCase();
+            String dimension = q.getDimension();
+            if (dimension == null || dimension.isEmpty()) continue;
+            char pole = dimension.charAt(0);
+            double weight = q.getWeight() != null ? q.getWeight().doubleValue() : 1.0;
+            int delta = "A".equals(ans) ? 1 : -1;
+            switch (pole) {
+                case 'E': case 'I': scores[0] += delta * weight; break;
+                case 'S': case 'N': scores[1] += delta * weight; break;
+                case 'T': case 'F': scores[2] += delta * weight; break;
+                case 'J': case 'P': scores[3] += delta * weight; break;
+                default: break;
+            }
+        }
+
+        String eiType = scores[0] >= 0 ? "E" : "I";
+        String snType = scores[1] >= 0 ? "S" : "N";
+        String tfType = scores[2] >= 0 ? "T" : "F";
+        String jpType = scores[3] >= 0 ? "J" : "P";
+        String typeCode = eiType + snType + tfType + jpType;
+
+        String answersJson = toJson(dto.getAnswers());
+        MbtiResult result = new MbtiResult();
+        result.setUserId(userId);
+        result.setFamilyMemberId(dto.getFamilyMemberId());
+        result.setAnswersJson(answersJson);
+        result.setTypeCode(typeCode);
+        result.setEiScore((int) Math.round(scores[0]));
+        result.setSnScore((int) Math.round(scores[1]));
+        result.setTfScore((int) Math.round(scores[2]));
+        result.setJpScore((int) Math.round(scores[3]));
+        result.setCreatedAt(new Date());
+        resultMapper.insert(result);
+
+        MbtiProfileVO profile = loadProfile(typeCode);
+
+        MbtiResultVO vo = new MbtiResultVO();
+        vo.setId(result.getId());
+        vo.setTypeCode(typeCode);
+        vo.setTypeName(profile != null ? profile.getName() : typeCode);
+        vo.setEiScore(result.getEiScore());
+        vo.setSnScore(result.getSnScore());
+        vo.setTfScore(result.getTfScore());
+        vo.setJpScore(result.getJpScore());
+        vo.setEiType(eiType);
+        vo.setSnType(snType);
+        vo.setTfType(tfType);
+        vo.setJpType(jpType);
+        vo.setProfile(profile);
+        vo.setCreatedAt(result.getCreatedAt());
+        return vo;
+    }
+
+    @Override
+    public MbtiResultVO getLatest(Long userId, Long memberId) {
+        Long targetId = resolveMemberId(memberId, userId);
+        if (targetId == null) return null;
+        LambdaQueryWrapper<MbtiResult> qw = new LambdaQueryWrapper<MbtiResult>()
+                .eq(MbtiResult::getFamilyMemberId, targetId)
+                .orderByDesc(MbtiResult::getCreatedAt)
+                .last("LIMIT 1");
+        MbtiResult r = resultMapper.selectOne(qw);
+        if (r == null) return null;
+        MbtiProfileVO profile = loadProfile(r.getTypeCode());
+        return toVO(r, profile);
+    }
+
+    @Override
+    public MbtiProfileVO getProfile(String typeCode) {
+        if (typeCode == null || typeCode.length() != 4) return null;
+        MbtiProfile p = profileMapper.selectOne(
+                new LambdaQueryWrapper<MbtiProfile>()
+                        .eq(MbtiProfile::getTypeCode, typeCode.toUpperCase())
+                        .eq(MbtiProfile::getIsActive, 1));
+        if (p == null) return null;
+        return toProfileVO(p);
+    }
+
+    @Override
+    public List<MbtiResultVO> getHistory(Long userId, Long memberId, int limit) {
+        if (limit <= 0 || limit > 50) limit = 20;
+        Long targetId = resolveMemberId(memberId, userId);
+        if (targetId == null) return Collections.emptyList();
+        LambdaQueryWrapper<MbtiResult> qw = new LambdaQueryWrapper<MbtiResult>()
+                .eq(MbtiResult::getFamilyMemberId, targetId)
+                .orderByDesc(MbtiResult::getCreatedAt)
+                .last("LIMIT " + limit);
+        List<MbtiResult> records = resultMapper.selectList(qw);
+        List<MbtiResultVO> result = new ArrayList<>();
+        for (MbtiResult r : records) {
+            result.add(toVO(r, loadProfile(r.getTypeCode())));
+        }
+        return result;
+    }
+
+    private Long resolveMemberId(Long memberId, Long userId) {
+        if (memberId != null) return memberId;
+        if (userId == null) return null;
+        FamilyMember own = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getUserId, userId)
+                        .last("LIMIT 1"));
+        return own != null ? own.getId() : null;
+    }
+
+    private MbtiProfileVO loadProfile(String typeCode) {
+        if (typeCode == null) return null;
+        MbtiProfile p = profileMapper.selectOne(
+                new LambdaQueryWrapper<MbtiProfile>()
+                        .eq(MbtiProfile::getTypeCode, typeCode.toUpperCase()));
+        return p != null ? toProfileVO(p) : null;
+    }
+
+    private MbtiResultVO toVO(MbtiResult r, MbtiProfileVO profile) {
+        MbtiResultVO vo = new MbtiResultVO();
+        vo.setId(r.getId());
+        vo.setTypeCode(r.getTypeCode());
+        vo.setTypeName(profile != null ? profile.getName() : r.getTypeCode());
+        vo.setEiScore(r.getEiScore());
+        vo.setSnScore(r.getSnScore());
+        vo.setTfScore(r.getTfScore());
+        vo.setJpScore(r.getJpScore());
+        vo.setEiType(r.getEiScore() != null && r.getEiScore() >= 0 ? "E" : "I");
+        vo.setSnType(r.getSnScore() != null && r.getSnScore() >= 0 ? "S" : "N");
+        vo.setTfType(r.getTfScore() != null && r.getTfScore() >= 0 ? "T" : "F");
+        vo.setJpType(r.getJpScore() != null && r.getJpScore() >= 0 ? "J" : "P");
+        vo.setProfile(profile);
+        vo.setCreatedAt(r.getCreatedAt());
+        return vo;
+    }
+
+    private MbtiProfileVO toProfileVO(MbtiProfile p) {
+        MbtiProfileVO vo = new MbtiProfileVO();
+        vo.setTypeCode(p.getTypeCode());
+        vo.setName(p.getName());
+        vo.setDescription(p.getDescription());
+        vo.setStrengths(p.getStrengths());
+        vo.setWeaknesses(p.getWeaknesses());
+        vo.setCareerTendency(p.getCareerTendency());
+        vo.setFamilyStyle(p.getFamilyStyle());
+        vo.setPairingNotes(p.getPairingNotes());
+        if (p.getEnergySuggestion() != null) {
+            try {
+                vo.setEnergySuggestion(objectMapper.readValue(p.getEnergySuggestion(),
+                        new TypeReference<Map<String, String>>() {}));
+            } catch (Exception e) {
+                log.warn("解析 energy_suggestion 失败: {}", e.getMessage());
+                vo.setEnergySuggestion(new LinkedHashMap<>());
+            }
+        }
+        return vo;
+    }
+
+    private String toJson(Object obj) {
+        try {
+            return objectMapper.writeValueAsString(obj);
+        } catch (Exception e) {
+            log.warn("JSON序列化失败: {}", e.getMessage());
+            return "[]";
+        }
+    }
+}

+ 56 - 0
cfc-backend/src/main/resources/mbti_seed_data.sql

@@ -0,0 +1,56 @@
+-- MBTI 种子数据:30 题题库 + 16 型人格画像
+-- 幂等:通过迁移310加载,已有数据则跳过
+
+INSERT INTO mbti_questions (dimension, question_text, option_a, option_b, weight, sort_order, is_active) VALUES
+-- ========== E/I 轴(各8题,其中E极4题,I极4题)==========
+('E', '周末空下来,你更想怎么安排?', '叫上朋友一起出去玩,人越多越有劲', '在家安静待着,做自己喜欢的事', 1.00, 1, 1),
+('E', '参加一个不太熟的聚会,你的状态是?', '主动跟陌生人打招呼,很快就能融入', '有点放不开,想找熟人待在一边', 1.00, 2, 1),
+('E', '遇到烦心事,你通常更想?', '找人倾诉,说出来心里就舒服了', '自己消化一会儿,想清楚了再说', 1.00, 3, 1),
+('E', '新学的知识,你更喜欢?', '跟别人讨论交流,碰撞想法', '自己琢磨明白,再跟人分享', 1.00, 4, 1),
+('I', '别人约你周末出门,但你今天很累,你会?', '硬撑着去,不然怕扫大家的兴', '诚恳说今天状态不好,改天再约', 1.00, 5, 1),
+('I', '开会时让你当场发言,你的感受是?', '提前准备好的部分能说好,即兴容易卡壳', '脑子转得慢,需要时间整理思路', 1.00, 6, 1),
+('I', '完成任务的方式,你更偏好?', '自己独立完成,效率更高', '跟别人分工合作,互相启发', 1.00, 7, 1),
+('I', '收到一大串群消息,你通常怎么应对?', '一条条回,尽量不敷衍', '挑重要的回,其他的过会儿再说', 1.00, 8, 1),
+-- ========== S/N 轴(各8题,其中S极4题,N极4题)==========
+('S', '学新东西时,你更喜欢?', '照着步骤一步步做,稳扎稳打', '先看整体框架,凭感觉摸索', 1.00, 9, 1),
+('S', '描述一个人,你更倾向?', '说他的外在特征和具体经历', '说他的气质和给人的整体印象', 1.00, 10, 1),
+('S', '逛街买东西,你的习惯是?', '看中哪件买哪件,体验当下感觉', '先列好清单,按需求理性选购', 1.00, 11, 1),
+('S', '听别人讲故事,你更在意?', '细节是否真实、过程是否完整', '故事背后有没有深层含义', 1.00, 12, 1),
+('N', '看一部电影,你更容易被什么打动?', '精彩的情节和逼真的画面', '隐藏的寓意和创作者的表达', 1.00, 13, 1),
+('N', '做决定时,你更多考虑?', '眼前实际情况和经验教训', '未来可能出现的机会和风险', 1.00, 14, 1),
+('N', '你对「可能」「也许」这类词的态度是?', '觉得不踏实,希望有明确答案', '觉得世界本来就有多种可能', 1.00, 15, 1),
+('N', '和朋友聊天,你更想聊什么?', '身边发生的具体事情', '对未来、人生或某个话题的深度探讨', 1.00, 16, 1),
+-- ========== T/F 轴(T极4题,F极3题)==========
+('T', '朋友向你倾诉烦恼时,你通常会?', '先帮ta分析问题出在哪,给解决办法', '先安慰ta的情绪,让ta感觉被理解', 1.00, 17, 1),
+('T', '团队做项目意见分歧,你更倾向于?', '按逻辑和数据判断,谁对听谁的', '照顾大家感受,尽量达成共识', 1.00, 18, 1),
+('T', '别人批评你的工作,你的第一反应是?', '分析批评有没有道理,有则改之', '心里会不舒服,需要先消化情绪', 1.00, 19, 1),
+('T', '给孩子选课外班,你更看重?', '实际效果和性价比,理性评估', '孩子是否感兴趣,能否坚持快乐学', 1.00, 20, 1),
+('F', '家人做了一件让你失望的事,你会?', '直接说出不满,诚实比客气更重要', '先考虑ta的感受,委婉表达', 1.00, 21, 1),
+('F', '看到身边的人情绪低落,你会?', '陪ta聊聊天,听ta说说心里话', '先观察一段时间,让ta自己调整', 1.00, 22, 1),
+('F', '评选优秀员工,你和同事票数相同,你会?', '强调业绩数据,用结果说话', '强调团队协作,照顾各方感受', 1.00, 23, 1),
+-- ========== J/P 轴(J极4题,P极3题)==========
+('J', '出门旅行前,你会?', '做好详细攻略,按计划走', '定好大致方向,走到哪算哪', 1.00, 24, 1),
+('J', '你的桌面/房间通常是?', '物品摆放整齐,有固定位置', '看着有点乱,但东西都知道在哪', 1.00, 25, 1),
+('J', ' deadline 到了才完成的活,你会?', '觉得很慌,以后一定提前规划', '偶尔这样也能接受,压力激发效率', 1.00, 26, 1),
+('J', '周末的计划被打乱了,你会?', '有点沮丧,希望能回到原计划', '换个玩法也挺好,灵活应对', 1.00, 27, 1),
+('P', '面对多任务同时来,你的习惯是?', '列好优先级,一项一项处理完', '哪个紧急先做哪个,边做边调整', 1.00, 28, 1),
+('P', '做决策时,你更倾向于?', '快速拍板,先行动再迭代', '多方收集信息,反复权衡再定', 1.00, 29, 1),
+('P', '家里买大件东西,你更想?', '货比三家,确定最优方案再买', '看到合适的就入手,不纠结太多', 1.00, 30, 1);
+
+INSERT INTO mbti_profiles (type_code, name, description, strengths, weaknesses, career_tendency, family_style, pairing_notes, energy_suggestion, is_active) VALUES
+('INTJ', '战略家', '独立思考、目光长远,善于制定系统性的规划,追求高效与卓越。', '战略思维强、独立自主、目标感强、洞察力敏锐', '容易显得冷漠、对他人耐心不足、过度自我施压', '适合研发、策略分析、学术科研等需要深度思考的领域,能在复杂系统中找到最优路径', '在家庭中扮演「总设计师」,习惯为家人规划未来,但有时忽略了情感表达,需要学习「先连接,再解决问题」', '与感性型家人相处时,多花时间倾听对方情绪;与孩子相处时,把「陪伴」列入日程表,像对待项目一样重视家庭时光', '{"body":"规律作息、适量运动,用稳定的身体节奏支撑深度思考","mind":"给自己留独处与反思空间,也学习表达情绪","wisdom":"通过深度阅读与系统性学习发挥战略思维优势","action":"主动安排与亲近之人的高质量相处,避免过度沉浸内心","wealth":"用长远规划视角管理家庭财务,避免因过度谨慎错失机会"}', 1),
+('INTP', '逻辑学家', '好奇心旺盛,热爱探索事物本质,喜欢拆解问题、寻找逻辑之美。', '逻辑推理强、善于分析问题、开放包容、富有创造力', '行动力偏弱、容易陷入过度思考、对 routine 缺乏耐心', '适合编程、科学研究、数据分析等需要抽象思维和独立工作的领域', '在家喜欢与孩子进行思维游戏和探索式对话,是孩子的「思维教练」,但偶尔会因过于理性而忽略孩子的情感需求', '孩子若偏感性,多用比喻和故事解释道理;伴侣若偏执行型,互补配合,你负责想清楚,对方负责推下去', '{"body":"找到一项能专注投入的运动(如游泳、瑜伽),避免久坐不动","mind":"保持好奇心,同时学会落地执行,别让想法只停留在脑中","wisdom":"深度学习一个领域,建立知识体系,享受探索的乐趣","action":"给自己设定小截止期限,推动想法转化为实际行动","wealth":"用逻辑思维规划理财,但别忘了为家人留出享受当下的预算"}', 1),
+('ENTJ', '指挥官', '天生的领导者,行动果断,善于统筹资源、推动团队达成目标。', '执行力强、有组织才能、目标导向、敢于承担责任', '容易强势、缺乏耐心、对慢节奏的人不够包容', '适合管理、创业、项目负责人等需要统筹协调的岗位,能在压力下做出高效决策', '在家庭中是「主心骨」,善于规划家庭大事,但需要注意倾听家人声音,避免事事都要自己做主', '伴侣若偏谨慎型,给对方足够表达的时间;孩子若偏内向,尊重ta的节奏,不要逼ta「快一点」', '{"body":"高强度运动释放压力,但要注意别透支身体","mind":"学会放慢节奏,给自己和孩子留白的时间","wisdom":"阅读管理类、沟通类书籍,提升领导力和情商","action":"每周安排家庭会议,让每位成员都有发言权","wealth":"用战略眼光规划家庭资产,同时培养孩子的财商意识"}', 1),
+('ENTP', '辩论家', '思维敏捷,喜欢挑战常规,善于发现新机会并说服他人。', '思维活跃、善于变通、口才出众、富有创新精神', '容易三分钟热度、对细节不够耐心、有时显得固执己见', '适合创业、营销、咨询、创意策划等需要灵活应变的领域', '在家中是「气氛担当」,带孩子玩各种新奇游戏,但有时过于追求刺激,需要给孩子一些安全感', '孩子若偏安静,陪ta慢慢探索兴趣;伴侣若偏传统,给对方空间接受新事物,不必强求认同', '{"body":"尝试不同运动找到真正热爱的,保持新鲜感","mind":"学习深度专注,把注意力集中在少数真正重要的事上","wisdom":"广泛涉猎各领域知识,成为家庭里的「百科全书」","action":"把冲动转化为行动,选定目标后坚持到底","wealth":"用创新思维探索多种收入来源,但要有风险控制的意识"}', 1),
+('INFJ', '提倡者', '内心丰富、洞察力强,追求意义与深度,希望帮助他人成长。', '共情力强、有远见、真诚可靠、善于洞察人心', '容易过度付出、敏感多虑、不愿表达自身需求', '适合教育、心理咨询、人力资源、公益等能帮助他人的领域,能在复杂人际关系中把握本质', '在家庭中是「情感纽带」,善于感知家人情绪变化,给予温暖支持,但要注意不要把自己累垮', '孩子若偏独立,给予充分信任空间;伴侣若偏理性,用温和的方式表达你的感受和期待', '{"body":"定期独处和冥想,恢复内心的能量平衡","mind":"培养自我关怀的意识,学会在关爱他人前先照顾好自己","wisdom":"通过阅读和反思深化对世界的理解,成为家人的精神支柱","action":"为自己的梦想设定小目标,别让「为别人活」成为唯一方向","wealth":"理性看待金钱,为家人构建安全基础,同时不忘为自己投资"}', 1),
+('INFP', '调停者', '内心柔软而坚定,追求真诚与理想,渴望让世界变得更好。', '富有同情心、忠于价值观、创造力强、善于倾听', '容易理想化、面对冲突容易退缩、自我怀疑倾向较强', '适合写作、艺术、教育、公益等能表达价值观和创意的领域,用文字或作品传递温暖', '在家中是「情感守护者」,温柔地陪伴孩子成长,重视亲子关系的质量,但有时过于迁就孩子', '孩子若偏冲动,用温柔而坚定的方式引导;伴侣若偏务实,欣赏对方的执行力,互补共同成长', '{"body":"培养一项放松身心的爱好,如绘画、音乐或园艺","mind":"增强自信,学会在关键时刻表达自己的真实想法","wisdom":"通过阅读文学、哲学或心理学,滋养内心世界","action":"把理想转化为具体行动,从小事做起,逐步实现自己的目标","wealth":"用温柔的方式与孩子谈论金钱,培养 ta 正确的价值观"}', 1),
+('ENFJ', '主人公', '热情洋溢、感染力强的领导者,善于激励他人、凝聚团队。', '善于沟通、极具感染力、乐于助人、有领导力', '容易过度在意他人评价、难以拒绝别人、对自己要求过高', '适合教育、培训、公关、管理等行业,能充分发挥影响力和人际能力', '在家庭中是「核心凝聚力」,营造温暖有爱的家庭氛围,善于组织家庭活动,但有时会因为照顾所有人而忽略自己', '孩子若偏内向,多鼓励ta表达感受;伴侣若偏独立,给彼此留各自的空间,共同维护家庭和谐', '{"body":"定期运动,释放情绪压力,保持充沛精力","mind":"学会设立边界,不是每件事都需要你出面解决","wisdom":"阅读心理学和沟通类书籍,提升自我认知和影响力","action":"把对家人的关心转化为自我成长的动力,先成就自己","wealth":"制定家庭财务计划,引导孩子参与家庭经济管理,培养财商"}', 1),
+('ENFP', '竞选者', '热情开朗、充满好奇心,喜欢探索人生的各种可能性。', '富有创造力、热情开朗、善于交际、适应力强', '容易情绪波动、注意力分散、对承诺感到束缚', '适合创意、传媒、销售、教育等需要灵感和人际互动的领域,能将热情传递给周围人', '在家中是「快乐源泉」,给孩子带来丰富的体验和美好的回忆,但有时规则意识不够强,需要建立清晰的边界', '孩子若偏沉闷,带 ta 尝试新鲜事物;伴侣若偏有序,欣赏对方的条理,同时提醒对方放松一些', '{"body":"保持活跃的社交生活,同时确保足够的睡眠时间","mind":"学会专注和坚持,把一个兴趣深耕下去而不是浅尝辄止","wisdom":"通过阅读和旅行开阔视野,保持对世界的好奇心","action":"把灵感和计划结合,设定小目标并按时完成,积累成就感","wealth":"用乐观态度面对财务问题,同时培养稳健的储蓄习惯"}', 1),
+('ISTJ', '物流师', '稳重踏实、责任心强,重视规则和秩序,是可靠的执行者。', '认真负责、条理清晰、诚实守信、执行力强', '有时过于刻板、不擅长表达情感、对新变化接受较慢', '适合财务、行政、管理、工程等需要细致和纪律性的领域,是最值得信任的团队成员', '在家庭中是「稳定基石」,为孩子提供安全稳定的成长环境,按时履行家庭责任,但有时过于注重规则而忽略了灵活性', '孩子若偏随性,用平和的方式讲解规则背后的意义;伴侣若偏感性,学会用语言和行动表达关心', '{"body":"保持规律作息,稳定是ISTJ最大的健康优势","mind":"尝试学习情绪管理,允许自己和家人表达真实感受","wisdom":"在熟悉领域深耕的同时,偶尔接触新知识,保持开放心态","action":"把家庭计划写进日程,但也要预留灵活调整的空间","wealth":"稳健理财,注重长期规划和风险控制,是家庭财务的守护神"}', 1),
+('ISFJ', '守卫者', '温柔体贴、默默付出,是家人最可靠的守护者和支持者。', '细心体贴、责任感强、善解人意、记忆力好', '容易过度牺牲自己、不擅长拒绝、习惯把委屈藏在心里', '适合护理、教育、行政、社工等需要耐心和关爱的领域,能在平凡岗位上做出不平凡的价值', '在家庭中是「温暖港湾」,无微不至地照顾家人的饮食起居,善于记住每个重要日子,但要注意不要让自己太过劳累', '孩子若偏独立,给予信任和鼓励;伴侣若偏急躁,用耐心和温柔化解冲突,建立相互尊重的家庭氛围', '{"body":"不要只顾着照顾家人而忽略自己的健康,定期体检很重要","mind":"学会表达内心感受,不要让委屈积压成病","wisdom":"在生活中积累智慧,成为家人信赖的人生导师","action":"适度放权,让孩子参与家庭事务,培养独立性","wealth":"精打细算、量入为出,是家庭经济的隐形支柱"}', 1),
+('ESTJ', '总经理', '果断高效、注重结果,善于管理和组织,是家庭的实际掌舵人。', '决策果断、组织能力出色、正直负责、善于执行', '有时过于强势、对不符合预期的人缺乏耐心、不太擅长表达温情', '适合管理、法律、金融、军警等需要决断力和执行力的领域,能带领团队高效完成任务', '在家庭中是「权威榜样」,为孩子树立勤奋和责任的标杆,但有时管得太严,需要学会放手和信任孩子', '孩子若偏内向,给予更多鼓励和肯定,不要急于要求结果;伴侣若偏温和,多表达感谢和爱意', '{"body":"高强度工作之余,学会放松和休息,避免过劳","mind":"练习换位思考,理解家人不同的需求和感受","wisdom":"在管理经验之外,补充心理学知识,更好地引导孩子","action":"设定家庭规则时,邀请家人一起参与,增强归属感","wealth":"规划清晰的财务目标,培养孩子的责任感和理财能力"}', 1),
+('ESFJ', '执政官', '热心肠、重视和谐,善于照顾他人,是家庭社交活动的核心。', '热心助人、善于协调、重视传统、有责任感', '容易依赖他人认可、对批评敏感、有时过于关注表面和谐', '适合服务行业、人力资源、公关、教育等需要人际互动和细心照顾的领域', '在家庭中是「氛围营造者」,善于组织家庭聚会、照顾每位成员的感受,但有时会因为在意别人看法而忽略自己的需求', '孩子若偏独立,给予充分的自主空间;伴侣若偏内敛,主动创造两人独处的温馨时刻', '{"body":"保持规律运动,社交消耗大时要学会补充体力","mind":"学会独立思考,不必事事迎合他人,勇敢表达真实想法","wisdom":"在人际交往中积累智慧,同时给自己留独处的思考空间","action":"为家庭制定有仪式感的传统,让孩子感受到归属和爱","wealth":"合理安排家庭开支,在保证生活品质之余也为未来做准备"}', 1),
+('ISTP', '鉴赏家', '动手能力强,善于解决实际问题,喜欢探索事物的运作原理。', '冷静理性、动手能力强、善于观察、灵活应变', '有时过于沉默、对情感表达不擅长、不喜欢被约束', '适合工程、技术、机械、体育等需要实践操作的领域,能在实际问题中找到解决方案', '在家庭中是「动手能力导师」,带孩子拆装玩具、修理物品,培养实践精神,但有时陪伴时间偏少', '孩子若偏依赖,多鼓励ta自己动手尝试;伴侣若偏话多,享受安静的共处时光也是一种陪伴', '{"body":"通过运动或动手操作释放精力,找到适合自己的放松方式","mind":"尝试更多表达内心感受,不必把所有想法都藏在行动里","wisdom":"在实践中积累经验,从动手操作中提炼智慧和哲理","action":"在追求自由的同时,也要履行对家庭的责任和承诺","wealth":"理性规划收支,注重实用而非炫耀,是家庭中的节俭能手"}', 1),
+('ISFP', '探险家', '敏感细腻、热爱美与自由,善于发现生活中的小确幸。', '审美敏锐、善良温和、活在当下、富有创造力', '容易犹豫不决、对冲突回避、有时缺乏长远规划', '适合艺术、设计、护理、自然相关等领域,能用美和感受力感染他人', '在家庭中是「美的传递者」,用温柔和创造力营造家庭美感,带孩子感受自然和生活的美好,但有时规则意识较弱', '孩子若偏冲动,用柔和的方式引导ta学会节制;伴侣若偏理性,欣赏对方的稳定,互补互补共同成长', '{"body":"走进大自然是最好的养生方式,多安排户外活动","mind":"培养决策力,不要因为害怕冲突而总是妥协","wisdom":"在艺术和生活中发现美,将感性转化为创造力","action":"将小确幸串联成持续的行动,让热爱变成习惯","wealth":"理性看待消费,不为冲动买单,同时也要善待自己"}', 1),
+('ESTP', '企业家', '精力充沛、行动派,善于抓住机会、应对挑战,是家庭中的活力担当。', '行动力强、善于应变、乐观开朗、现实接地气', '有时冲动冒进、缺乏耐心、对细节不够关注', '适合销售、市场、体育、创业等需要快速行动和人际互动的领域,能在变化中捕捉机会', '在家庭中是「活力担当」,带孩子体验各种刺激有趣的活动,培养 ta 的冒险精神,但有时规则意识和计划性偏弱', '孩子若偏安静,陪 ta 尝试动态活动激发兴趣;伴侣若偏谨慎,欣赏对方的周全,同时互相学习彼此的长处', '{"body":"保持高强度运动释放精力,但注意运动安全","mind":"学会在行动前稍作停顿,思考长期后果","wisdom":"从每一次实践中总结经验,培养更深层的洞察力","action":"用热情带动家人一起动起来,但也要尊重每个人的节奏","wealth":"善于发现机会增加收入,同时也要建立应急储备,未雨绸缪"}', 1),
+('ESFP', '表演者', '活泼开朗、热爱热闹,是人群中的焦点,善于让周围人感到快乐。', '热情大方、适应力强、善于社交、富有感染力', '容易三分钟热度、对枯燥事务缺乏耐心、有时过于追求刺激', '适合娱乐、传媒、销售、服务等需要活力和人际互动的领域,能在任何场合带来欢乐', '在家庭中是「欢乐制造机」,用热情感染每一个家庭成员,给孩子带来丰富的童年回忆,但有时对规则和规范不够重视', '孩子若偏内向,不强迫 ta 社交,但用温暖陪伴让 ta 感受安全感;伴侣若偏稳重,欣赏对方的踏实,一起创造平衡的家庭氛围', '{"body":"保持活跃的社交和运动生活,但注意规律作息","mind":"学会专注和坚持,把热情转化为长期深耕的能力","wisdom":"在实践中积累经验,学会从热闹中沉淀出人生智慧","action":"把偶发的热情转化为可持续的习惯,让快乐有迹可循","wealth":"享受当下的同时,建立应急储蓄,不要等到需要时才后悔"}', 1);

+ 132 - 0
cfc-frontend/pages/family/manual-score-entry.vue

@@ -0,0 +1,132 @@
+<template>
+  <view class="mse-page">
+    <view class="mse-loading" v-if="loading">
+      <text class="mse-loading-text">加载成员...</text>
+    </view>
+    <template v-else>
+      <view class="mse-hero">
+        <text class="mse-hero-title">五维直接填分</text>
+        <text class="mse-hero-desc">跳过问卷,直接填写你已有的 0-100 分</text>
+      </view>
+      <view class="mse-section">
+        <text class="mse-section-title">选择成员</text>
+        <view class="mse-member-list">
+          <view class="mse-member-item" :class="{ active: selectedMemberId === m.id }" v-for="m in members" :key="m.id" @click="selectMember(m)">
+            <image class="mse-member-avatar" :src="m.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
+            <view class="mse-member-info">
+              <text class="mse-member-name">{{ m.nickname }}</text>
+              <text class="mse-member-role" :class="'role-' + m.effectiveRole">{{ m.roleLabel }}</text>
+            </view>
+            <view class="mse-member-check" v-if="selectedMemberId === m.id">✓</view>
+          </view>
+        </view>
+      </view>
+      <view class="mse-section">
+        <text class="mse-section-title">五维分值(0-100)</text>
+        <view class="mse-slider-list">
+          <view class="mse-slider-item" v-for="dim in dimensions" :key="dim.code">
+            <view class="mse-slider-head" :style="{ color: dim.color }">
+              <view class="mse-slider-dot" :style="{ background: dim.color }"></view>
+              <text class="mse-slider-label">{{ dim.label }}</text>
+              <text class="mse-slider-value">{{ scores[dim.code] }}</text>
+            </view>
+            <slider class="mse-slider" :min="0" :max="100" :step="1" :value="scores[dim.code]" @change="onSliderChange(dim.code, $event)" :activeColor="dim.color"></slider>
+          </view>
+        </view>
+      </view>
+      <view class="mse-footer">
+        <button class="mse-btn mse-btn-primary" :disabled="submitLoading" @click="submit">{{ submitLoading ? '提交中...' : '提交并查看结果' }}</button>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList, submitManualScore } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      members: [],
+      selectedMemberId: null,
+      scores: { body: 50, mind: 50, wisdom: 50, action: 50, wealth: 50 },
+      dimensions: [
+        { code: 'body', label: '身', color: '#FF8C42' },
+        { code: 'mind', label: '心', color: '#FF6B9D' },
+        { code: 'wisdom', label: '智', color: '#6366F1' },
+        { code: 'action', label: '行', color: '#10B981' },
+        { code: 'wealth', label: '富', color: '#F59E0B' }
+      ],
+      submitLoading: false
+    }
+  },
+  onLoad() {
+    this.loadMembers()
+  },
+  methods: {
+    loadMembers() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          self.members = res.data
+          var own = null
+          for (var i = 0; i < self.members.length; i++) {
+            if (self.members[i].isSelf) { own = self.members[i]; break }
+          }
+          self.selectedMemberId = own ? own.id : (self.members.length > 0 ? self.members[0].id : null)
+        }
+        self.loading = false
+      }).catch(function() { self.loading = false })
+    },
+    selectMember(m) { this.selectedMemberId = m.id },
+    onSliderChange(code, e) { this.scores[code] = e.detail.value },
+    submit() {
+      if (!this.selectedMemberId) return
+      this.submitLoading = true
+      var self = this
+      submitManualScore({ memberId: this.selectedMemberId, scores: this.scores }).then(function(res) {
+        self.submitLoading = false
+        if (res.code === 200) {
+          uni.navigateTo({ url: '/pages/family/self-check-result?source=manual' })
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.submitLoading = false
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mse-page { min-height: 100vh; background: #F5FAFE; padding: 24rpx 30rpx 120rpx; box-sizing: border-box }
+.mse-loading { display: flex; justify-content: center; align-items: center; height: 50vh }
+.mse-loading-text { font-size: 28rpx; color: #999 }
+.mse-hero { text-align: center; padding: 40rpx 0 20rpx }
+.mse-hero-title { font-size: 36rpx; font-weight: 600; color: #333; display: block; margin-bottom: 8rpx }
+.mse-hero-desc { font-size: 24rpx; color: #999; display: block }
+.mse-section { background: #fff; border-radius: 24rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05) }
+.mse-section-title { font-size: 28rpx; font-weight: 600; color: #333; margin-bottom: 20rpx; display: block }
+.mse-member-list { display: flex; overflow-x: auto; gap: 20rpx; padding-bottom: 10rpx }
+.mse-member-item { flex: 0 0 140rpx; display: flex; flex-direction: column; align-items: center; border: 4rpx solid transparent; border-radius: 20rpx; padding: 16rpx 10rpx; transition: border-color .2s }
+.mse-member-item.active { border-color: #F97316 }
+.mse-member-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; margin-bottom: 12rpx }
+.mse-member-name { font-size: 26rpx; font-weight: 500; color: #333; text-align: center; width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
+.mse-member-role { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 10rpx; display: inline-block }
+.role-parent { background: #FFF7ED; color: #E65100 }
+.role-child { background: #E8F5E9; color: #2E7D32 }
+.role-teacher { background: #E0E7FF; color: #3730A3 }
+.mse-member-check { margin-top: 8rpx; font-size: 24rpx; color: #F97316 }
+.mse-slider-item { margin-bottom: 28rpx }
+.mse-slider-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8rpx }
+.mse-slider-dot { width: 16rpx; height: 16rpx; border-radius: 50%; margin-right: 10rpx }
+.mse-slider-label { font-size: 28rpx; font-weight: 500; flex: 1 }
+.mse-slider-value { font-size: 28rpx; font-weight: 700; color: #333 }
+.mse-slider { width: 100% }
+.mse-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #F0F0F0; box-sizing: border-box }
+.mse-btn { width: 100%; height: 88rpx; line-height: 88rpx; font-size: 30rpx; border-radius: 44rpx; border: none; text-align: center }
+.mse-btn-primary { background: linear-gradient(135deg, #F97316, #FF8C42); color: #fff; font-weight: 600 }
+</style>

+ 97 - 0
cfc-frontend/pages/family/mbti-entry.vue

@@ -0,0 +1,97 @@
+<template>
+  <view class="mbe-page">
+    <view class="mbe-loading" v-if="loading">
+      <text class="mbe-loading-text">加载成员...</text>
+    </view>
+    <template v-else>
+      <view class="mbe-hero">
+        <text class="mbe-hero-title">MBTI 人格测评</text>
+        <text class="mbe-hero-desc">标准版 30 题 · 发现你的天赋型 · 获得专属育儿建议</text>
+      </view>
+      <view class="mbe-section">
+        <text class="mbe-section-title">选择测评对象</text>
+        <view class="mbe-member-list">
+          <view class="mbe-member-item" :class="{ active: selectedMemberId === m.id }" v-for="m in members" :key="m.id" @click="selectMember(m)">
+            <image class="mbe-member-avatar" :src="m.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
+            <view class="mbe-member-info">
+              <text class="mbe-member-name">{{ m.nickname }}</text>
+              <text class="mbe-member-role" :class="'role-' + m.effectiveRole">{{ m.roleLabel }}</text>
+            </view>
+            <view class="mbe-member-check" v-if="selectedMemberId === m.id">✓</view>
+          </view>
+        </view>
+      </view>
+      <view class="mbe-footer">
+        <button class="mbe-btn mbe-btn-primary" :disabled="!selectedMemberId || startLoading" @click="start">
+          {{ startLoading ? '加载中...' : '开始测评(约 8 分钟)' }}
+        </button>
+        <text class="mbe-tip">测评结果:4 字母类型 · 4 维雷达 · 专属画像 · 五维联动建议</text>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      members: [],
+      selectedMemberId: null,
+      startLoading: false
+    }
+  },
+  onLoad() {
+    this.loadMembers()
+  },
+  methods: {
+    loadMembers() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          self.members = res.data
+          var own = null
+          for (var i = 0; i < self.members.length; i++) {
+            if (self.members[i].isSelf) { own = self.members[i]; break }
+          }
+          self.selectedMemberId = own ? own.id : (self.members.length > 0 ? self.members[0].id : null)
+        }
+        self.loading = false
+      }).catch(function() { self.loading = false })
+    },
+    selectMember(m) { this.selectedMemberId = m.id },
+    start() {
+      if (!this.selectedMemberId || this.startLoading) return
+      this.startLoading = true
+      uni.navigateTo({ url: '/pages/family/mbti-test?memberId=' + this.selectedMemberId })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mbe-page { min-height: 100vh; background: #F5FAFE; padding: 24rpx 30rpx 120rpx; box-sizing: border-box }
+.mbe-loading { display: flex; justify-content: center; align-items: center; height: 50vh }
+.mbe-loading-text { font-size: 28rpx; color: #999 }
+.mbe-hero { text-align: center; padding: 40rpx 0 10rpx }
+.mbe-hero-title { font-size: 36rpx; font-weight: 600; color: #333; display: block; margin-bottom: 8rpx }
+.mbe-hero-desc { font-size: 24rpx; color: #999; display: block }
+.mbe-section { background: #fff; border-radius: 24rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05) }
+.mbe-section-title { font-size: 28rpx; font-weight: 600; color: #333; margin-bottom: 20rpx; display: block }
+.mbe-member-list { display: flex; overflow-x: auto; gap: 20rpx; padding-bottom: 10rpx }
+.mbe-member-item { flex: 0 0 140rpx; display: flex; flex-direction: column; align-items: center; border: 4rpx solid transparent; border-radius: 20rpx; padding: 16rpx 10rpx; transition: border-color .2s }
+.mbe-member-item.active { border-color: #6366F1 }
+.mbe-member-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; margin-bottom: 12rpx }
+.mbe-member-name { font-size: 26rpx; font-weight: 500; color: #333; text-align: center; width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
+.mbe-member-role { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 10rpx; display: inline-block }
+.role-parent { background: #FFF7ED; color: #E65100 }
+.role-child { background: #E8F5E9; color: #2E7D32 }
+.role-teacher { background: #E0E7FF; color: #3730A3 }
+.mbe-member-check { margin-top: 8rpx; font-size: 24rpx; color: #6366F1 }
+.mbe-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #F0F0F0; box-sizing: border-box }
+.mbe-btn { width: 100%; height: 88rpx; line-height: 88rpx; font-size: 30rpx; border-radius: 44rpx; border: none; text-align: center }
+.mbe-btn-primary { background: linear-gradient(135deg, #6366F1, #818CF8); color: #fff; font-weight: 600 }
+.mbe-tip { display: block; text-align: center; margin-top: 16rpx; font-size: 22rpx; color: #999 }
+</style>

+ 123 - 0
cfc-frontend/pages/family/mbti-profile.vue

@@ -0,0 +1,123 @@
+<template>
+  <view class="mbp-page">
+    <view class="mbp-header">
+      <text class="mbp-title">MBTI 16 型人格百科</text>
+      <text class="mbp-subtitle">点击卡片查看完整画像与五维建议</text>
+    </view>
+    <view class="mbp-grid">
+      <view
+        class="mbp-card"
+        :class="{ 'mbp-card-active': selectedType === type.code }"
+        v-for="type in types" :key="type.code"
+        @click="selectType(type)"
+      >
+        <text class="mbp-card-code">{{ type.code }}</text>
+        <text class="mbp-card-name">{{ type.name }}</text>
+        <text class="mbp-card-tag" :style="{ background: tagColorMap[type.tag] }">{{ type.tag }}</text>
+      </view>
+    </view>
+    <view class="mbp-drawer" v-if="selectedProfile" @click.self="closeDrawer">
+      <view class="mbp-drawer-content" @click.stop>
+        <view class="mbp-drawer-header">
+          <text class="mbp-drawer-code">{{ selectedProfile.typeCode }}</text>
+          <text class="mbp-drawer-name">{{ selectedProfile.name }}</text>
+        </view>
+        <scroll-view class="mbp-drawer-body" scroll-y>
+          <view class="mbp-drawer-section" v-for="(val, key) in drawerFields" :key="key" v-if="selectedProfile[key]">
+            <text class="mbp-drawer-label">{{ val.label }}</text>
+            <text class="mbp-drawer-value">{{ selectedProfile[key] }}</text>
+          </view>
+          <view class="mbp-drawer-section" v-if="selectedProfile.energySuggestion">
+            <text class="mbp-drawer-label">五维联动建议</text>
+            <view class="mbp-energy-mini" v-for="(sug, dim) in selectedProfile.energySuggestion" :key="dim">
+              <text class="mbp-energy-dim" :style="{ color: dimColors[dim] }">{{ dimLabels[dim] }}: </text>
+              <text class="mbp-energy-sug">{{ sug }}</text>
+            </view>
+          </view>
+        </scroll-view>
+        <button class="mbp-drawer-close" @click="closeDrawer">关闭</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMbtiProfile } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      types: [
+        { code: 'INTJ', name: '战略家', tag: 'NT' },
+        { code: 'INTP', name: '逻辑学家', tag: 'NT' },
+        { code: 'ENTJ', name: '指挥官', tag: 'NT' },
+        { code: 'ENTP', name: '辩论家', tag: 'NT' },
+        { code: 'INFJ', name: '提倡者', tag: 'NF' },
+        { code: 'INFP', name: '调停者', tag: 'NF' },
+        { code: 'ENFJ', name: '主人公', tag: 'NF' },
+        { code: 'ENFP', name: '竞选者', tag: 'NF' },
+        { code: 'ISTJ', name: '物流师', tag: 'SJ' },
+        { code: 'ISFJ', name: '守卫者', tag: 'SJ' },
+        { code: 'ESTJ', name: '总经理', tag: 'SJ' },
+        { code: 'ESFJ', name: '执政官', tag: 'SJ' },
+        { code: 'ISTP', name: '鉴赏家', tag: 'SP' },
+        { code: 'ISFP', name: '探险家', tag: 'SP' },
+        { code: 'ESTP', name: '企业家', tag: 'SP' },
+        { code: 'ESFP', name: '表演者', tag: 'SP' }
+      ],
+      selectedType: null,
+      selectedProfile: null,
+      drawerFields: {
+        description: { label: '核心特质' },
+        strengths: { label: '优势' },
+        weaknesses: { label: '挑战' },
+        career_tendency: { label: '职业倾向' },
+        family_style: { label: '家庭风格' },
+        pairing_notes: { label: '搭配建议' }
+      },
+      dimLabels: { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' },
+      dimColors: { body: '#FF8C42', mind: '#FF6B9D', wisdom: '#6366F1', action: '#10B981', wealth: '#F59E0B' },
+      tagColorMap: { NT: '#6366F1', NF: '#FF6B9D', SJ: '#10B981', SP: '#F59E0B' }
+    }
+  },
+  methods: {
+    selectType(type) {
+      var self = this
+      if (this.selectedType === type.code && this.selectedProfile) return
+      this.selectedType = type.code
+      getMbtiProfile({ typeCode: type.code }).then(function(res) {
+        if (res.code === 200 && res.data) self.selectedProfile = res.data
+        else { uni.showToast({ title: '加载失败', icon: 'none' }) }
+      })
+    },
+    closeDrawer() { this.selectedType = null; this.selectedProfile = null }
+  }
+}
+</script>
+
+<style scoped>
+.mbp-page { min-height: 100vh; background: #F5FAFE; padding: 24rpx 30rpx 120rpx; box-sizing: border-box }
+.mbp-header { text-align: center; padding: 20rpx 0 10rpx }
+.mbp-title { font-size: 32rpx; font-weight: 600; color: #333; display: block }
+.mbp-subtitle { font-size: 22rpx; color: #999; display: block; margin-top: 4rpx }
+.mbp-grid { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16rpx }
+.mbp-card { width: 48%; background: #fff; border-radius: 20rpx; padding: 20rpx 10rpx; display: flex; flex-direction: column; align-items: center; border: 2rpx solid transparent; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); transition: border-color .2s }
+.mbp-card:active { transform: scale(0.98) }
+.mbp-card-active { border-color: #6366F1 }
+.mbp-card-code { font-size: 30rpx; font-weight: 700; color: #6366F1; margin-bottom: 4rpx }
+.mbp-card-name { font-size: 24rpx; color: #333; margin-bottom: 8rpx }
+.mbp-card-tag { font-size: 20rpx; color: #fff; padding: 2rpx 10rpx; border-radius: 10rpx }
+.mbp-drawer { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.4); z-index: 100; display: flex; align-items: flex-end }
+.mbp-drawer-content { width: 100%; max-height: 80vh; background: #fff; border-radius: 24rpx 24rpx 0 0; display: flex; flex-direction: column }
+.mbp-drawer-header { padding: 30rpx 20rpx; background: linear-gradient(135deg, #6366F1, #818CF8); color: #fff; display: flex; flex-direction: column; align-items: center }
+.mbp-drawer-code { font-size: 60rpx; font-weight: 700; letter-spacing: 8rpx }
+.mbp-drawer-name { font-size: 28rpx; opacity: 0.9; margin-top: 4rpx }
+.mbp-drawer-body { flex: 1; overflow-y: auto; padding: 20rpx }
+.mbp-drawer-section { margin-bottom: 24rpx }
+.mbp-drawer-label { display: block; font-size: 22rpx; color: #999; font-weight: 600; margin-bottom: 8rpx }
+.mbp-drawer-value { font-size: 24rpx; color: #333; line-height: 1.6 }
+.mbp-energy-mini { display: flex; flex-direction: column; gap: 8rpx; margin-top: 8rpx; padding: 16rpx; background: #F5FAFE; border-radius: 12rpx }
+.mbp-energy-dim { font-size: 24rpx; font-weight: 600 }
+.mbp-energy-sug { font-size: 24rpx; color: #555; line-height: 1.5 }
+.mbp-drawer-close { margin: 20rpx; height: 88rpx; line-height: 88rpx; font-size: 30rpx; border-radius: 44rpx; background: linear-gradient(135deg, #6366F1, #818CF8); color: #fff; font-weight: 600; border: none; text-align: center }
+</style>

+ 190 - 0
cfc-frontend/pages/family/mbti-result.vue

@@ -0,0 +1,190 @@
+<template>
+  <view class="mbr-page">
+    <view class="mbr-loading" v-if="loading"><text>加载结果...</text></view>
+    <template v-else>
+      <view class="mbr-hero">
+        <view class="mbr-hero-inner">
+          <text class="mbr-type-code">{{ result.typeCode }}</text>
+          <text class="mbr-type-name">{{ result.typeName }}</text>
+          <text class="mbr-type-desc" v-if="result.profile && result.profile.description">{{ result.profile.description }}</text>
+        </view>
+      </view>
+      <view class="mbr-section">
+        <text class="mbr-section-title">四维度雷达</text>
+        <view class="mbr-canvas-wrap">
+          <canvas type="2d" id="mbtiRadar" class="mbr-canvas" @ready="onCanvasReady"></canvas>
+        </view>
+      </view>
+      <view class="mbr-section" v-if="result.profile">
+        <text class="mbr-section-title">画像详情</text>
+        <view class="mbr-profile-list">
+          <view class="mbr-profile-item" v-for="(val, key) in profileFields" :key="key" v-if="result.profile[key]">
+            <text class="mbr-profile-label">{{ val.label }}</text>
+            <text class="mbr-profile-value">{{ result.profile[key] }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="mbr-section" v-if="result.profile && result.profile.energySuggestion">
+        <text class="mbr-section-title">五维联动建议</text>
+        <view class="mbr-energy-list">
+          <view class="mbr-energy-item" v-for="(sug, dim) in energyMap" :key="dim">
+            <view class="mbr-energy-head" :style="{ color: dimColors[dim] }">
+              <view class="mbr-energy-dot" :style="{ background: dimColors[dim] }"></view>
+              <text class="mbr-energy-label">{{ dimLabels[dim] }}</text>
+            </view>
+            <text class="mbr-energy-text">{{ sug }}</text>
+          </view>
+        </view>
+      </view>
+      <view class="mbr-footer">
+        <button class="mbr-btn mbr-btn-primary" @click="goProfile">查看完整百科</button>
+        <button class="mbr-btn mbr-btn-outline" @click="retake">重新测评</button>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+import { getMbtiLatest } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      result: null,
+      canvasContext: null,
+      canvasWidth: 340,
+      canvasHeight: 340,
+      dimLabels: { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' },
+      dimColors: { body: '#FF8C42', mind: '#FF6B9D', wisdom: '#6366F1', action: '#10B981', wealth: '#F59E0B' },
+      profileFields: {
+        strengths: { label: '优势' },
+        weaknesses: { label: '挑战' },
+        career_tendency: { label: '职业倾向' },
+        family_style: { label: '家庭风格' },
+        pairing_notes: { label: '搭配建议' }
+      }
+    }
+  },
+  computed: {
+    energyMap() {
+      return this.result && this.result.profile && this.result.profile.energySuggestion
+        ? this.result.profile.energySuggestion
+        : {}
+    }
+  },
+  onLoad(options) {
+    this.loadResult()
+  },
+  methods: {
+    loadResult() {
+      var self = this
+      getMbtiLatest().then(function(res) {
+        if (res.code === 200 && res.data) self.result = res.data
+        self.loading = false
+        self.drawRadar()
+      }).catch(function() { self.loading = false })
+    },
+    onCanvasReady(res) {
+      if (!res || !res[0] || !res[0].node) return
+      var ctx = res[0].node.getContext('2d')
+      var dpr = uni.getWindowInfo().pixelRatio
+      this.canvasContext = ctx
+      this.canvasContext.scale(dpr, dpr)
+      this.drawRadar()
+    },
+    drawRadar() {
+      if (!this.canvasContext || !this.result) return
+      var ctx = this.canvasContext
+      var w = this.canvasWidth, h = this.canvasHeight
+      var cx = w / 2, cy = h / 2, r = w * 0.34
+      var angleStep = (Math.PI * 2) / 4
+      ctx.clearRect(0, 0, w, h)
+      ctx.fillStyle = '#FFF8F0'; ctx.fillRect(0, 0, w, h)
+
+      for (var layer = 1; layer <= 3; layer++) {
+        ctx.beginPath()
+        for (var g = 0; g < 4; g++) {
+          var a = g * angleStep - Math.PI / 2
+          var gx = cx + r * (layer / 3) * Math.cos(a)
+          var gy = cy + r * (layer / 3) * Math.sin(a)
+          if (g === 0) ctx.moveTo(gx, gy); else ctx.lineTo(gx, gy)
+        }
+        ctx.closePath()
+        ctx.strokeStyle = 'rgba(99,102,241,0.15)'; ctx.lineWidth = 1; ctx.stroke()
+      }
+
+      var labels = ['E/I', 'S/N', 'T/F', 'J/P']
+      var colors = ['#6366F1', '#10B981', '#FF6B9D', '#F59E0B']
+      for (var j = 0; j < 4; j++) {
+        var a2 = j * angleStep - Math.PI / 2
+        var lx = cx + r * Math.cos(a2), ly = cy + r * Math.sin(a2)
+        ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(lx, ly)
+        ctx.strokeStyle = colors[j] + '44'; ctx.lineWidth = 1; ctx.stroke()
+        ctx.fillStyle = colors[j]; ctx.font = 'bold 13px sans-serif'
+        ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
+        ctx.fillText(labels[j], lx, ly)
+      }
+
+      var scores = {
+        'E/I': this.result.eiScore != null ? Math.max(-50, Math.min(50, this.result.eiScore)) : 0,
+        'S/N': this.result.snScore != null ? Math.max(-50, Math.min(50, this.result.snScore)) : 0,
+        'T/F': this.result.tfScore != null ? Math.max(-50, Math.min(50, this.result.tfScore)) : 0,
+        'J/P': this.result.jpScore != null ? Math.max(-50, Math.min(50, this.result.jpScore)) : 0
+      }
+      var keys = ['E/I', 'S/N', 'T/F', 'J/P']
+      ctx.beginPath()
+      for (var m = 0; m < 4; m++) {
+        var ang = m * angleStep - Math.PI / 2
+        var ratio = (scores[keys[m]] + 50) / 100
+        var dataR = r * ratio
+        var dx = cx + dataR * Math.cos(ang), dy = cy + dataR * Math.sin(ang)
+        if (m === 0) ctx.moveTo(dx, dy); else ctx.lineTo(dx, dy)
+      }
+      ctx.closePath()
+      ctx.fillStyle = 'rgba(99,102,241,0.2)'; ctx.fill()
+      ctx.strokeStyle = '#6366F1'; ctx.lineWidth = 2; ctx.stroke()
+
+      for (var n = 0; n < 4; n++) {
+        var ang2 = n * angleStep - Math.PI / 2
+        var ratio2 = (scores[keys[n]] + 50) / 100
+        var dataR2 = r * ratio2
+        var px = cx + dataR2 * Math.cos(ang2), py = cy + dataR2 * Math.sin(ang2)
+        ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2)
+        ctx.fillStyle = colors[n]; ctx.fill()
+      }
+    },
+    goProfile() { uni.navigateTo({ url: '/pages/family/mbti-profile' }) },
+    retake() { uni.navigateTo({ url: '/pages/family/mbti-entry' }) }
+  }
+}
+</script>
+
+<style scoped>
+.mbr-page { min-height: 100vh; background: #F5FAFE; padding: 24rpx 30rpx 120rpx; box-sizing: border-box }
+.mbr-loading { display: flex; justify-content: center; align-items: center; height: 50vh }
+.mbr-hero { border-radius: 24rpx; padding: 40rpx 30rpx; margin-bottom: 24rpx; background: linear-gradient(135deg, #6366F1, #818CF8); box-shadow: 0 6rpx 20rpx rgba(99,102,241,0.25) }
+.mbr-hero-inner { display: flex; flex-direction: column; align-items: center }
+.mbr-type-code { font-size: 88rpx; font-weight: 700; color: #fff; letter-spacing: 8rpx }
+.mbr-type-name { font-size: 32rpx; color: rgba(255,255,255,0.9); margin-top: 8rpx }
+.mbr-type-desc { font-size: 24rpx; color: rgba(255,255,255,0.8); margin-top: 16rpx; text-align: center; max-width: 600rpx }
+.mbr-section { background: #fff; border-radius: 24rpx; padding: 30rpx 26rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05) }
+.mbr-section-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 20rpx }
+.mbr-canvas-wrap { display: flex; justify-content: center }
+.mbr-canvas { width: 340px; height: 340px }
+.mbr-profile-list { display: flex; flex-direction: column }
+.mbr-profile-item { padding: 20rpx 0; border-top: 1rpx solid #F0F0F0 }
+.mbr-profile-item:first-child { border-top: none }
+.mbr-profile-label { display: block; font-size: 24rpx; color: #999; margin-bottom: 8rpx }
+.mbr-profile-value { font-size: 26rpx; color: #333; line-height: 1.5 }
+.mbr-energy-list { display: flex; flex-direction: column; gap: 16rpx }
+.mbr-energy-item { padding: 20rpx; background: #F5FAFE; border-radius: 16rpx }
+.mbr-energy-head { display: flex; align-items: center; margin-bottom: 8rpx }
+.mbr-energy-dot { width: 16rpx; height: 16rpx; border-radius: 50%; margin-right: 10rpx }
+.mbr-energy-label { font-size: 26rpx; font-weight: 600 }
+.mbr-energy-text { font-size: 24rpx; color: #555; line-height: 1.5 }
+.mbr-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #F0F0F0; display: flex; gap: 20rpx; box-sizing: border-box }
+.mbr-btn { flex: 1; height: 88rpx; line-height: 88rpx; font-size: 30rpx; border-radius: 44rpx; border: none; text-align: center }
+.mbr-btn-primary { background: linear-gradient(135deg, #6366F1, #818CF8); color: #fff; font-weight: 600 }
+.mbr-btn-outline { background: #fff; color: #6366F1; border: 2rpx solid #6366F1 }
+</style>

+ 127 - 0
cfc-frontend/pages/family/mbti-test.vue

@@ -0,0 +1,127 @@
+<template>
+  <view class="mbt-page">
+    <view class="mbt-header">
+      <text class="mbt-progress-text">{{ currentIndex + 1 }} / {{ questions.length }}</text>
+    </view>
+    <view class="mbt-progress-bar">
+      <view class="mbt-progress-fill" :style="{ width: progress + '%', background: '#6366F1' }"></view>
+    </view>
+    <view class="mbt-loading" v-if="loading">
+      <text>加载题库...</text>
+    </view>
+    <template v-else>
+      <view class="mbt-card">
+        <text class="mbt-dim-tag" :style="{ background: currentDimColor }">{{ currentDimName }}</text>
+        <text class="mbt-q-text">{{ currentQuestion.questionText }}</text>
+        <view class="mbt-opts">
+          <view class="mbt-opt" :class="{ 'mbt-opt-sel': answer === 'A' }" @click="selectAnswer('A')">
+            <text class="mbt-opt-label">{{ currentQuestion.optionA }}</text>
+            <view class="mbt-opt-radio" :class="{ on: answer === 'A' }"></view>
+          </view>
+          <view class="mbt-opt" :class="{ 'mbt-opt-sel': answer === 'B' }" @click="selectAnswer('B')">
+            <text class="mbt-opt-label">{{ currentQuestion.optionB }}</text>
+            <view class="mbt-opt-radio" :class="{ on: answer === 'B' }"></view>
+          </view>
+        </view>
+      </view>
+      <view class="mbt-footer">
+        <button class="mbt-btn mbt-btn-prev" :disabled="currentIndex === 0" @click="prev">上一题</button>
+        <button class="mbt-btn mbt-btn-next" :disabled="!answer" @click="next">{{ currentIndex === questions.length - 1 ? '提交' : '下一题' }}</button>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+import { getMbtiQuestions, submitMbti } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      questions: [],
+      answers: {},
+      currentIndex: 0,
+      memberId: 0
+    }
+  },
+  computed: {
+    currentQuestion() { return this.questions[this.currentIndex] || {} },
+    currentDimName() {
+      var map = { E: '外向/内向', I: '外向/内向', S: '实感/直觉', N: '实感/直觉', T: '思考/情感', F: '思考/情感', J: '判断/知觉', P: '判断/知觉' }
+      return map[this.currentQuestion.dimension] || ''
+    },
+    currentDimColor() {
+      var map = { E: '#6366F1', I: '#6366F1', S: '#10B981', N: '#10B981', T: '#FF6B9D', F: '#FF6B9D', J: '#F59E0B', P: '#F59E0B' }
+      return map[this.currentQuestion.dimension] || '#6366F1'
+    },
+    progress() { return this.questions.length > 0 ? Math.round((this.currentIndex + 1) / this.questions.length * 100) : 0 },
+    answer() { return this.answers[this.currentQuestion.id] || '' }
+  },
+  onLoad(options) {
+    this.memberId = options.memberId ? parseInt(options.memberId) : 0
+    this.loadQuestions()
+  },
+  methods: {
+    loadQuestions() {
+      var self = this
+      getMbtiQuestions().then(function(res) {
+        if (res.code === 200 && res.data) self.questions = res.data
+        self.loading = false
+      }).catch(function() { self.loading = false })
+    },
+    selectAnswer(val) { this.answers[this.currentQuestion.id] = val },
+    prev() { if (this.currentIndex > 0) this.currentIndex-- },
+    next() {
+      if (!this.answer) return
+      if (this.currentIndex < this.questions.length - 1) {
+        this.currentIndex++
+      } else {
+        this.submit()
+      }
+    },
+    submit() {
+      var self = this
+      var answers = []
+      var ids = Object.keys(this.answers)
+      for (var i = 0; i < ids.length; i++) {
+        answers.push({ questionId: parseInt(ids[i]), answer: this.answers[ids[i]] })
+      }
+      uni.showLoading({ title: '计算中...', mask: true })
+      submitMbti({ familyMemberId: this.memberId, answers: answers }).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200) {
+          uni.navigateTo({ url: '/pages/family/mbti-result?id=' + res.data.id })
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '网络异常', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mbt-page { min-height: 100vh; background: #F5FAFE; padding-bottom: 180rpx }
+.mbt-header { padding: 20rpx 30rpx; text-align: right }
+.mbt-progress-text { font-size: 24rpx; color: #999 }
+.mbt-progress-bar { height: 6rpx; background: #E0E7FF; margin: 0 30rpx; border-radius: 3rpx; overflow: hidden }
+.mbt-progress-fill { height: 100%; transition: width .3s }
+.mbt-loading { display: flex; justify-content: center; padding: 100rpx 0 }
+.mbt-card { background: #fff; margin: 20rpx 30rpx; border-radius: 24rpx; padding: 30rpx 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05) }
+.mbt-dim-tag { display: inline-block; font-size: 22rpx; color: #fff; padding: 4rpx 16rpx; border-radius: 16rpx; margin-bottom: 16rpx }
+.mbt-q-text { font-size: 30rpx; font-weight: 500; color: #333; line-height: 1.5; margin-bottom: 24rpx }
+.mbt-opts { display: flex; flex-direction: column; gap: 16rpx }
+.mbt-opt { flex: 1; padding: 24rpx; border: 2rpx solid #F0F0F0; border-radius: 16rpx; display: flex; align-items: center; justify-content: space-between; transition: border-color .2s, background .2s }
+.mbt-opt-sel { border-color: #6366F1; background: #F0F4FF }
+.mbt-opt-label { font-size: 26rpx; color: #333; line-height: 1.4 }
+.mbt-opt-radio { width: 24rpx; height: 24rpx; border: 2rpx solid #D1D5DB; border-radius: 50%; flex-shrink: 0 }
+.mbt-opt-radio.on { background: #6366F1; border-color: #6366F1 }
+.mbt-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #F0F0F0; display: flex; gap: 20rpx; box-sizing: border-box }
+.mbt-btn { flex: 1; height: 88rpx; line-height: 88rpx; font-size: 30rpx; border-radius: 44rpx; border: none; text-align: center }
+.mbt-btn-prev { background: #fff; color: #6366F1; border: 2rpx solid #6366F1 }
+.mbt-btn-next { background: linear-gradient(135deg, #6366F1, #818CF8); color: #fff; font-weight: 600 }
+</style>

+ 32 - 0
cfc-web/src/api/mbti.js

@@ -0,0 +1,32 @@
+import request from '@/utils/request'
+
+// MBTI 画像管理
+export function getMbtiProfileList() {
+  return request({
+    url: '/api/admin/mbti/profile/list',
+    method: 'post'
+  })
+}
+
+export function saveMbtiProfile(data) {
+  return request({
+    url: '/api/admin/mbti/profile/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteMbtiProfile(id) {
+  return request({
+    url: '/api/admin/mbti/profile/delete',
+    method: 'post',
+    data: { id: id }
+  })
+}
+
+export function initMbtiSeedData() {
+  return request({
+    url: '/api/admin/mbti/profile/seed',
+    method: 'post'
+  })
+}

+ 185 - 0
cfc-web/src/views/admin/MbtiProfiles.vue

@@ -0,0 +1,185 @@
+<template>
+  <div class="mbti-profiles admin-page">
+    <el-card shadow="never">
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">
+          <i class="el-icon-user" style="color: #6366F1; margin-right: 8px;"></i>
+          MBTI 人格画像库(16型)
+        </span>
+        <div class="header-actions">
+          <el-button type="warning" size="mini" @click="initSeed">初始化种子数据</el-button>
+          <el-button type="primary" size="mini" icon="el-icon-plus" @click="openAdd">新增画像</el-button>
+        </div>
+      </div>
+
+      <el-table :data="list" border stripe v-loading="loading" size="mini" style="width:100%">
+        <el-table-column prop="typeCode" label="类型代码" width="100" align="center" />
+        <el-table-column prop="name" label="中文名称" width="120" align="center" />
+        <el-table-column prop="description" label="核心特质" show-overflow-tooltip />
+        <el-table-column label="启用" width="80" align="center">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.isActive === 1 ? 'success' : 'info'" size="mini">
+              {{ scope.row.isActive === 1 ? '启用' : '停用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="160" align="center" fixed="right">
+          <template slot-scope="scope">
+            <el-button type="text" size="mini" @click="openEdit(scope.row)">编辑</el-button>
+            <el-popconfirm title="确认删除该画像?" @confirm="doDelete(scope.row)">
+              <el-button slot="reference" type="text" size="mini" style="color:#F56C6C">删除</el-button>
+            </el-popconfirm>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :title="form.id ? '编辑画像' : '新增画像'" :visible.sync="dialogVisible" width="680px" :close-on-click-modal="false">
+      <el-form :model="form" label-width="120px" size="small">
+        <el-form-item label="类型代码" required>
+          <el-input v-model="form.typeCode" maxlength="4" style="width:200px" :disabled="!!form.id" placeholder="如:INTJ" />
+        </el-form-item>
+        <el-form-item label="中文名称" required>
+          <el-input v-model="form.name" style="width:300px" placeholder="如:建筑师" />
+        </el-form-item>
+        <el-form-item label="核心特质">
+          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="简要描述该类型的核心特征" />
+        </el-form-item>
+        <el-form-item label="优势">
+          <el-input v-model="form.strengths" type="textarea" :rows="3" placeholder="该类型的优势特点" />
+        </el-form-item>
+        <el-form-item label="挑战">
+          <el-input v-model="form.weaknesses" type="textarea" :rows="2" placeholder="该类型的潜在挑战" />
+        </el-form-item>
+        <el-form-item label="职业倾向">
+          <el-input v-model="form.careerTendency" type="textarea" :rows="2" placeholder="适合的职业方向" />
+        </el-form-item>
+        <el-form-item label="家庭风格">
+          <el-input v-model="form.familyStyle" type="textarea" :rows="2" placeholder="在家庭中的表现风格" />
+        </el-form-item>
+        <el-form-item label="搭配建议">
+          <el-input v-model="form.pairingNotes" type="textarea" :rows="2" placeholder="与其他类型的搭配建议" />
+        </el-form-item>
+        <el-form-item label="五维建议(JSON)">
+          <el-input v-model="form.energySuggestion" type="textarea" :rows="5" placeholder='{"body":"...","mind":"...","wisdom":"...","action":"...","wealth":"..."}' />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.isActive" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="doSave">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getMbtiProfileList, saveMbtiProfile, deleteMbtiProfile, initMbtiSeedData } from '@/api/mbti'
+
+export default {
+  name: 'MbtiProfiles',
+  data() {
+    return {
+      loading: false,
+      saving: false,
+      list: [],
+      dialogVisible: false,
+      form: {
+        id: null,
+        typeCode: '',
+        name: '',
+        description: '',
+        strengths: '',
+        weaknesses: '',
+        careerTendency: '',
+        familyStyle: '',
+        pairingNotes: '',
+        energySuggestion: '',
+        isActive: 1
+      }
+    }
+  },
+  created() {
+    this.loadList()
+  },
+  methods: {
+    loadList() {
+      var self = this
+      this.loading = true
+      getMbtiProfileList().then(function(res) {
+        self.list = (res && res.data) || []
+      }).catch(function() {}).finally(function() {
+        self.loading = false
+      })
+    },
+    initSeed() {
+      var self = this
+      this.$confirm('将初始化 MBTI 种子数据(已有数据不覆盖)。继续?', '提示', { type: 'warning' })
+        .then(function() {
+          return initMbtiSeedData()
+        })
+        .then(function(res) {
+          self.$message.success('种子数据已初始化')
+          self.loadList()
+        })
+        .catch(function() {})
+    },
+    openAdd() {
+      this.form = {
+        id: null,
+        typeCode: '',
+        name: '',
+        description: '',
+        strengths: '',
+        weaknesses: '',
+        careerTendency: '',
+        familyStyle: '',
+        pairingNotes: '',
+        energySuggestion: '',
+        isActive: 1
+      }
+      this.dialogVisible = true
+    },
+    openEdit(row) {
+      this.form = {
+        id: row.id,
+        typeCode: row.typeCode || '',
+        name: row.name || '',
+        description: row.description || '',
+        strengths: row.strengths || '',
+        weaknesses: row.weaknesses || '',
+        careerTendency: row.careerTendency || '',
+        familyStyle: row.familyStyle || '',
+        pairingNotes: row.pairingNotes || '',
+        energySuggestion: row.energySuggestion || '',
+        isActive: row.isActive !== undefined ? row.isActive : 1
+      }
+      this.dialogVisible = true
+    },
+    doSave() {
+      if (!this.form.typeCode || !this.form.name) {
+        this.$message.warning('类型代码和中文名称必填')
+        return
+      }
+      var self = this
+      this.saving = true
+      saveMbtiProfile(this.form).then(function(res) {
+        self.saving = false
+        self.$message.success('保存成功')
+        self.dialogVisible = false
+        self.loadList()
+      }).catch(function() {
+        self.saving = false
+      })
+    },
+    doDelete(row) {
+      deleteMbtiProfile(row.id).then(function() {
+        this.$message.success('删除成功')
+      }.bind(this))
+      this.loadList()
+    }
+  }
+}
+</script>

+ 779 - 0
docs/superpowers/plans/2026-09-10-manual-dimension-mbti-plan.md

@@ -0,0 +1,779 @@
+# 五维手动填分 + MBTI 完整体系 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现"只填结果"的五维手动填分功能(0-100 分,家长可给家庭成员填),以及 MBTI 完整体系(标准版 ~30题测评 + 16型人格百科 + 五维联动解读)。
+
+**Architecture:** 后端新增两组 Controller/Service/Mapper/Entity;前端新增 5 个页面 + 1 个管理端页面;数据库 3 张新表(mbti_questions, mbti_results, mbti_profiles)通过 DatabaseInitializer 迁移;种子数据脚本初始化题库与 16 型画像。
+
+**Tech Stack:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 (微信小程序) / Vue 2 + Element UI (Web 管理端) / MySQL 8.0
+
+**Spec:** 本计划基于前期 brainstorming 确认的设计,包含 Part 1(手动填分)与 Part 2(MBTI 体系)两大模块。
+
+---
+
+## Global Constraints
+
+- 统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`
+- 响应包装 `Result<T>` (code/message/data)
+- JWT Bearer Token 认证,`JwtInterceptor` 拦截 `/api/**`
+- DI 使用 `@Resource`,字段名必须与 Bean Name 一致
+- MyBatis-Plus `@TableName` + `@TableId(type = IdType.AUTO)`
+- 小程序限制:禁止 `?.`、CSS Grid、`:key` 表达式、`new Date(string)`(用 `parseDate()`)
+- 数据库迁移唯一入口:`DatabaseInitializer.runMigrations()`,使用 `ensureColumn` / try-catch 幂等
+- 每次迁移必须同步更新 `schema.sql` 对应 `CREATE TABLE`
+- 新增接口前必须查阅 `docs/superpowers/api/API_REFERENCE.md` 确认无重复
+- Bean 命名冲突检查:新增 Controller/Service 确保类名不重复
+- 编译验证:`mvn clean compile` 唯一验证方式
+
+---
+
+## 文件结构映射
+
+### 后端新增/修改文件
+
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `entity/MbtiQuestion.java` | 新增 | MBTI 题库实体 |
+| `entity/MbtiResult.java` | 新增 | 用户 MBTI 测评结果实体 |
+| `entity/MbtiProfile.java` | 新增 | 16型人格画像库实体 |
+| `mapper/MbtiQuestionMapper.java` | 新增 | 题库 Mapper |
+| `mapper/MbtiResultMapper.java` | 新增 | 结果 Mapper |
+| `mapper/MbtiProfileMapper.java` | 新增 | 画像 Mapper |
+| `service/MbtiService.java` | 新增 | MBTI 业务接口 |
+| `service/impl/MbtiServiceImpl.java` | 新增 | MBTI 业务实现 |
+| `controller/family/MbtiController.java` | 新增 | `/api/family/mbti` 接口 |
+| `controller/family/ManualDimensionScoreController.java` | 新增 | `/api/family/manual-score` 接口 |
+| `service/ManualDimensionScoreService.java` | 新增 | 手动填分业务接口 |
+| `service/impl/ManualDimensionScoreServiceImpl.java` | 新增 | 手动填分业务实现 |
+| `dto/MbtiQuestionVO.java` | 新增 | 题目 VO |
+| `dto/MbtiResultVO.java` | 新增 | 结果 VO |
+| `dto/MbtiProfileVO.java` | 新增 | 画像 VO |
+| `dto/ManualScoreSubmitDTO.java` | 新增 | 手动提交 DTO |
+| `dto/ManualScoreVO.java` | 新增 | 手动填分 VO |
+| `config/DatabaseInitializer.java` | 修改 | 添加 3 表迁移 |
+
+### 前端新增/修改文件
+
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `pages/family/manual-score-entry.vue` | 新增 | 手动填分录入页 |
+| `pages/family/mbti-entry.vue` | 新增 | MBTI 入口页(选成员) |
+| `pages/family/mbti-test.vue` | 新增 | MBTI 答题页 |
+| `pages/family/mbti-result.vue` | 新增 | MBTI 结果页 |
+| `pages/family/mbti-profile.vue` | 新增 | MBTI 16型百科页 |
+| `pages/family/self-check-entry.vue` | 修改 | 增加"直接填分"按钮 + MBTI 卡片入口 |
+| `utils/api.js` | 修改 | 新增 10+ API 封装 |
+| `pages.json` | 修改 | 注册 5 个新页面 |
+
+### 管理端新增文件
+
+| 文件 | 类型 | 说明 |
+|------|------|------|
+| `src/views/admin/MbtiProfiles.vue` | 新增 | 16 型配置管理页 |
+| `src/api/mbti.js` | 新增 | 管理端 MBTI API |
+| `src/router/index.js` | 修改 | 新增路由 |
+
+---
+
+## 任务分解
+
+### Phase 0: 数据库迁移与种子数据
+
+#### Task 0.1: DatabaseInitializer 添加 3 表迁移
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (搜索 `// 迁移` 找到最新编号)
+- Modify: `cfc-backend/src/main/resources/schema.sql` (追加 3 表 CREATE TABLE)
+
+**Interfaces:**
+- Produces: 3 张表结构在数据库中可用
+
+```markdown
+- [ ] **Step 1: 在 DatabaseInitializer.runMigrations() 末尾添加 3 个迁移块**
+```java
+// 迁移XXX: 创建 mbti_questions 表
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS mbti_questions (" +
+        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+        "dimension CHAR(1) NOT NULL COMMENT 'E/I/S/N/T/F/J/P', " +
+        "question_text TEXT NOT NULL COMMENT '题目文本', " +
+        "option_a TEXT NOT NULL COMMENT '选项 A(正向)', " +
+        "option_b TEXT NOT NULL COMMENT '选项 B(反向)', " +
+        "weight DECIMAL(3,2) DEFAULT 1.0 COMMENT '权重', " +
+        "sort_order INT DEFAULT 0, " +
+        "is_active TINYINT DEFAULT 1, " +
+        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+        "INDEX idx_dim_active (dimension, is_active)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MBTI 测评题库'");
+    log.info("已创建 mbti_questions 表");
+} catch (Exception e) { /* 忽略 */ }
+
+// 迁移XXX+1: 创建 mbti_results 表
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS mbti_results (" +
+        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+        "user_id BIGINT NOT NULL COMMENT '答题人用户ID', " +
+        "family_member_id BIGINT COMMENT '关联家庭成员ID', " +
+        "answers_json TEXT COMMENT '用户答案JSON', " +
+        "type_code CHAR(4) NOT NULL COMMENT 'MBTI 类型', " +
+        "ei_score INT COMMENT 'E/I 原始分', " +
+        "sn_score INT COMMENT 'S/N 原始分', " +
+        "tf_score INT COMMENT 'T/F 原始分', " +
+        "jp_score INT COMMENT 'J/P 原始分', " +
+        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+        "INDEX idx_user (user_id), " +
+        "INDEX idx_member (family_member_id)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MBTI 测评结果'");
+    log.info("已创建 mbti_results 表");
+} catch (Exception e) { /* 忽略 */ }
+
+// 迁移XXX+2: 创建 mbti_profiles 表
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS mbti_profiles (" +
+        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+        "type_code CHAR(4) NOT NULL UNIQUE COMMENT '类型代码', " +
+        "name VARCHAR(50) COMMENT '中文名称', " +
+        "description TEXT COMMENT '核心特质描述', " +
+        "strengths TEXT COMMENT '优势', " +
+        "weaknesses TEXT COMMENT '挑战', " +
+        "career_tendency TEXT COMMENT '职业倾向', " +
+        "family_style TEXT COMMENT '家庭/育儿风格', " +
+        "pairing_notes TEXT COMMENT '伴侣/孩子类型搭配建议', " +
+        "energy_suggestion JSON COMMENT '五维能量联动建议', " +
+        "is_active TINYINT DEFAULT 1, " +
+        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+        "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MBTI 人格画像库'");
+    log.info("已创建 mbti_profiles 表");
+} catch (Exception e) { /* 忽略 */ }
+```
+
+- [ ] **Step 2: 同步追加到 schema.sql 末尾**
+```sql
+-- 与上面 3 个 CREATE TABLE IF NOT EXISTS 完全一致
+```
+
+- [ ] **Step 3: 编译验证**
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+#### Task 0.2: MBTI 种子数据 SQL 脚本
+
+**Files:**
+- Create: `cfc-backend/src/main/resources/migration_mbti_seed_data.sql`
+
+**Interfaces:**
+- Produces: 30 题题库 + 16 型基础画像数据
+
+```markdown
+- [ ] **Step 1: 编写种子数据脚本**
+包含:
+- mbti_questions: 30 题(E/I 8题, S/N 8题, T/F 7题, J/P 7题),每题 dimension + question_text + option_a/option_b + weight + sort_order
+- mbti_profiles: 16 条记录(INTJ/INTP/ENTJ/ENTP/INFJ/INFP/ENFJ/ENFP/ISTJ/ISFJ/ESTJ/ESFJ/ISTP/ISFP/ESTP/ESFP),每条含 name/description/strengths/weaknesses/career_tendency/family_style/pairing_notes/energy_suggestion(JSON)
+
+- [ ] **Step 2: 在 DatabaseInitializer 执行种子数据**
+```java
+// 迁移XXX+3: MBTI 种子数据初始化
+try {
+    String seedSql = FileUtils.readFileToString(new File("src/main/resources/migration_mbti_seed_data.sql"), "UTF-8");
+    for (String stmt : seedSql.split(";")) {
+        String trimmed = stmt.trim();
+        if (!trimmed.isEmpty()) {
+            jdbcTemplate.execute(trimmed);
+        }
+    }
+    log.info("MBTI 种子数据初始化完成");
+} catch (Exception e) {
+    log.warn("MBTI 种子数据初始化失败(可能已存在): {}", e.getMessage());
+}
+```
+
+- [ ] **Step 3: 编译验证**
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+---
+
+### Phase 1: 后端手动填分模块
+
+#### Task 1.1: ManualDimensionScore 实体、Mapper、DTO
+
+**Files:**
+- Create: `entity/ManualDimensionScore.java` (可复用 FiveDimensionScore,仅 source_type='manual' 区分,无需新表)
+- Create: `dto/ManualScoreSubmitDTO.java`
+- Create: `dto/ManualScoreVO.java`
+
+```markdown
+- [ ] **Step 1: ManualScoreSubmitDTO**
+```java
+package com.etotem.cfc.dto;
+import lombok.Data;
+import java.math.BigDecimal;
+import java.util.Map;
+
+@Data
+public class ManualScoreSubmitDTO {
+    private Long memberId;                 // 必填
+    private Map<String, Integer> scores;   // 必填: {"body":80,"mind":60,"wisdom":70,"action":50,"wealth":90}
+    private String note;                   // 可选备注
+}
+```
+
+- [ ] **Step 2: ManualScoreVO (返回前端)**
+```java
+package com.etotem.cfc.dto;
+import lombok.Data;
+import java.util.Date;
+import java.util.List;
+
+@Data
+public class ManualScoreVO {
+    private Long id;                       // five_dimension_scores.id
+    private Long memberId;
+    private String memberName;
+    private Map<String, Integer> scores;   // 5维度分数
+    private Integer totalScore;            // 5维总和
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+#### Task 1.2: ManualDimensionScoreService 接口与实现
+
+**Files:**
+- Create: `service/ManualDimensionScoreService.java`
+- Create: `service/impl/ManualDimensionScoreServiceImpl.java`
+
+```markdown
+- [ ] **Step 1: 接口定义**
+```java
+package com.etotem.cfc.service;
+import com.etotem.cfc.dto.ManualScoreSubmitDTO;
+import com.etotem.cfc.dto.ManualScoreVO;
+import java.util.List;
+
+public interface ManualDimensionScoreService {
+    ManualScoreVO submit(ManualScoreSubmitDTO dto, Long userId);
+    ManualScoreVO getLatest(Long userId, Long memberId);
+    List<ManualScoreVO> getHistory(Long userId, Long memberId, int limit);
+}
+```
+
+- [ ] **Step 2: 实现类**
+```java
+@Service
+public class ManualDimensionScoreServiceImpl implements ManualDimensionScoreService {
+    @Resource private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+    @Resource private FiveDimensionSelfCheckMapper selfCheckMapper;
+    @Resource private FamilyMemberMapper familyMemberMapper;
+    @Resource private UserMapper userMapper;
+    @Resource private FiveDimensionScoreService fiveDimensionScoreService;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Transactional(rollbackFor = Exception.class)
+    public ManualScoreVO submit(ManualScoreSubmitDTO dto, Long userId) {
+        // 1. 校验 memberId 存在且属于当前用户家庭
+        // 2. 校验 5 维度键全为 body/mind/wisdom/action/wealth,值 0-100,至少一项>0
+        // 3. 写入 five_dimension_scores (source_type='manual', source_id=自增ID)
+        // 4. 写入 five_dimension_self_checks (scores_json 存 0-100 映射,totalScore=5维总和,questionIdsJson=[])
+        // 5. 返回 VO
+    }
+
+    public ManualScoreVO getLatest(Long userId, Long memberId) {
+        // 查 five_dimension_scores 最近一条 source_type='manual'
+    }
+
+    public List<ManualScoreVO> getHistory(Long userId, Long memberId, int limit) {
+        // 查历史
+    }
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+#### Task 1.3: ManualDimensionScoreController
+
+**Files:**
+- Create: `controller/family/ManualDimensionScoreController.java`
+
+```markdown
+- [ ] **Step 1: Controller 实现**
+```java
+@Tag(name = "五维手动填分", description = "直接填写 0-100 分,跳过问卷")
+@RestController
+@RequestMapping("/api/family/manual-score")
+public class ManualDimensionScoreController {
+    @Resource private ManualDimensionScoreService manualScoreService;
+
+    @PostMapping("/submit")
+    public Result<ManualScoreVO> submit(@RequestBody ManualScoreSubmitDTO dto, @RequestAttribute("userId") Long userId) {
+        // 校验 userId, 调用 service, 返回 Result
+    }
+
+    @PostMapping("/latest")
+    public Result<ManualScoreVO> latest(@RequestBody(required=false) Map<String,Object> body, @RequestAttribute("userId") Long userId) {
+        Long memberId = ParamUtils.getLong(body?.get("memberId"));
+        // 调用 service
+    }
+
+    @PostMapping("/history")
+    public Result<List<ManualScoreVO>> history(@RequestBody(required=false) Map<String,Object> body, @RequestAttribute("userId") Long userId) {
+        Long memberId = ParamUtils.getLong(body?.get("memberId"));
+        Integer limit = body?.get("limit") != null ? Integer.valueOf(body.get("limit").toString()) : 20;
+        // 调用 service
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+---
+
+### Phase 2: 后端 MBTI 模块
+
+#### Task 2.1: MBTI 实体、Mapper
+
+**Files:**
+- Create: `entity/MbtiQuestion.java`
+- Create: `entity/MbtiResult.java`
+- Create: `entity/MbtiProfile.java`
+- Create: `mapper/MbtiQuestionMapper.java`
+- Create: `mapper/MbtiResultMapper.java`
+- Create: `mapper/MbtiProfileMapper.java`
+
+```markdown
+- [ ] **Step 1: MbtiQuestion 实体**
+```java
+@Data @TableName("mbti_questions")
+public class MbtiQuestion implements Serializable {
+    @TableId(type=IdType.AUTO) private Long id;
+    private String dimension;      // E/I/S/N/T/F/J/P
+    private String questionText;
+    private String optionA;
+    private String optionB;
+    private BigDecimal weight;
+    private Integer sortOrder;
+    private Integer isActive;
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 2: MbtiResult 实体**
+```java
+@Data @TableName("mbti_results")
+public class MbtiResult implements Serializable {
+    @TableId(type=IdType.AUTO) private Long id;
+    private Long userId;
+    private Long familyMemberId;
+    private String answersJson;
+    private String typeCode;       // INTJ 等
+    private Integer eiScore;
+    private Integer snScore;
+    private Integer tfScore;
+    private Integer jpScore;
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 3: MbtiProfile 实体**
+```java
+@Data @TableName("mbti_profiles")
+public class MbtiProfile implements Serializable {
+    @TableId(type=IdType.AUTO) private Long id;
+    private String typeCode;       // INTJ 等,唯一
+    private String name;           // 架构师
+    private String description;
+    private String strengths;
+    private String weaknesses;
+    private String careerTendency;
+    private String familyStyle;
+    private String pairingNotes;
+    private String energySuggestion; // JSON 字符串
+    private Integer isActive;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] **Step 4: 3 个 Mapper 接口** (extends BaseMapper)
+
+- [ ] **Step 5: 编译验证**
+
+#### Task 2.2: MBTI DTO/VO
+
+**Files:**
+- Create: `dto/MbtiQuestionVO.java`
+- Create: `dto/MbtiResultVO.java`
+- Create: `dto/MbtiProfileVO.java`
+- Create: `dto/SubmitMbtiDTO.java`
+
+```markdown
+- [ ] **Step 1: MbtiQuestionVO** (题目含选项 A/B)
+```java
+@Data
+public class MbtiQuestionVO {
+    private Long id;
+    private String dimension;        // E/I/S/N/T/F/J/P
+    private String questionText;
+    private OptionVO optionA;
+    private OptionVO optionB;
+    @Data static class OptionVO { String value; String label; }
+}
+```
+
+- [ ] **Step 2: SubmitMbtiDTO**
+```java
+@Data
+public class SubmitMbtiDTO {
+    private Long familyMemberId;
+    private List<AnswerItem> answers;
+    @Data static class AnswerItem { Long questionId; String answer; } // answer: "A" 或 "B"
+}
+```
+
+- [ ] **Step 3: MbtiResultVO** (结果含 4 维度分 + 类型代码)
+```java
+@Data
+public class MbtiResultVO {
+    private Long id;
+    private String typeCode;
+    private String typeName;         // 中文名
+    private Integer eiScore;
+    private Integer snScore;
+    private Integer tfScore;
+    private Integer jpScore;
+    private String eiType;           // "E" 或 "I"
+    private String snType;           // "S" 或 "N"
+    private String tfType;           // "T" 或 "F"
+    private String jpType;           // "J" 或 "P"
+    private MbtiProfileVO profile;   // 画像详情
+    private Date createdAt;
+}
+```
+
+- [ ] **Step 4: MbtiProfileVO** (画像详情)
+```java
+@Data
+public class MbtiProfileVO {
+    private String typeCode;
+    private String name;
+    private String description;
+    private String strengths;
+    private String weaknesses;
+    private String careerTendency;
+    private String familyStyle;
+    private String pairingNotes;
+    private Map<String, String> energySuggestion; // 5维度建议
+}
+```
+
+- [ ] **Step 5: 编译验证**
+
+#### Task 2.3: MbtiService 接口与实现
+
+**Files:**
+- Create: `service/MbtiService.java`
+- Create: `service/impl/MbtiServiceImpl.java`
+
+```markdown
+- [ ] **Step 1: 接口**
+```java
+public interface MbtiService {
+    List<MbtiQuestionVO> getQuestions();
+    MbtiResultVO submit(SubmitMbtiDTO dto, Long userId);
+    MbtiResultVO getLatest(Long userId, Long memberId);
+    MbtiProfileVO getProfile(String typeCode);
+    List<MbtiResultVO> getHistory(Long userId, Long memberId, int limit);
+}
+```
+
+- [ ] **Step 2: 实现核心计算逻辑**
+```java
+@Service
+public class MbtiServiceImpl implements MbtiService {
+    @Resource private MbtiQuestionMapper questionMapper;
+    @Resource private MbtiResultMapper resultMapper;
+    @Resource private MbtiProfileMapper profileMapper;
+    @Resource private FamilyMemberMapper familyMemberMapper;
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public List<MbtiQuestionVO> getQuestions() {
+        // 查 is_active=1,按 dimension+sort_order 排序,转 VO
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public MbtiResultVO submit(SubmitMbtiDTO dto, Long userId) {
+        // 1. 校验 memberId
+        // 2. 加载题库 Map<id, MbtiQuestion>
+        // 3. 遍历 answers,累加 4 维度分:
+        //    dimension='E' -> eiScore += (answer=="A"? weight : -weight)
+        //    dimension='I' -> eiScore -= (answer=="A"? weight : -weight)  // 反向
+        //    S/N、T/F、J/P 同理
+        // 4. 判定 4 字母:eiScore>0?"E":"I" 等
+        // 5. typeCode = ei+sn+tf+jp
+        // 6. 查 profileMapper.selectByTypeCode(typeCode)
+        // 7. 写入 mbti_results
+        // 8. 组装 VO 返回
+    }
+
+    // getLatest, getProfile, getHistory 均为简单查询
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+#### Task 2.4: MbtiController
+
+**Files:**
+- Create: `controller/family/MbtiController.java`
+
+```markdown
+- [ ] **Step 1: Controller 实现**
+```java
+@Tag(name = "MBTI 人格测评", description = "标准版 ~30题 + 16型画像 + 五维联动")
+@RestController
+@RequestMapping("/api/family/mbti")
+public class MbtiController {
+    @Resource private MbtiService mbtiService;
+
+    @PostMapping("/questions")
+    public Result<List<MbtiQuestionVO>> questions(@RequestAttribute("userId") Long userId) {
+        return Result.success(mbtiService.getQuestions());
+    }
+
+    @PostMapping("/submit")
+    public Result<MbtiResultVO> submit(@RequestBody SubmitMbtiDTO dto, @RequestAttribute("userId") Long userId) {
+        return Result.success(mbtiService.submit(dto, userId));
+    }
+
+    @PostMapping("/latest")
+    public Result<MbtiResultVO> latest(@RequestBody(required=false) Map<String,Object> body, @RequestAttribute("userId") Long userId) {
+        Long memberId = ParamUtils.getLong(body?.get("memberId"));
+        return Result.success(mbtiService.getLatest(userId, memberId));
+    }
+
+    @PostMapping("/profile/{typeCode}")
+    public Result<MbtiProfileVO> profile(@PathVariable String typeCode, @RequestAttribute("userId") Long userId) {
+        return Result.success(mbtiService.getProfile(typeCode.toUpperCase()));
+    }
+
+    @PostMapping("/history")
+    public Result<List<MbtiResultVO>> history(@RequestBody(required=false) Map<String,Object> body, @RequestAttribute("userId") Long userId) {
+        Long memberId = ParamUtils.getLong(body?.get("memberId"));
+        Integer limit = body?.get("limit") != null ? Integer.valueOf(body.get("limit").toString()) : 20;
+        return Result.success(mbtiService.getHistory(userId, memberId, limit));
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+---
+
+### Phase 3: 前端手动填分页面
+
+#### Task 3.1: manual-score-entry.vue
+
+**Files:**
+- Create: `cfc-frontend/pages/family/manual-score-entry.vue`
+
+```markdown
+- [ ] **Step 1: 页面结构**
+- 顶部:成员选择器(横向滑动卡片,显示头像+昵称+角色标签,复用 getFamilyMemberList)
+- 中部:5 个维度滑块(Slider 组件或自定义拖拽条),每维度 0-100,实时显示数值
+- 底部:提交按钮(主色调渐变),提交后跳转 self-check-result.vue (复用结果页,传参 source=manual)
+
+- [ ] **Step 2: 逻辑**
+- onLoad 调用 getFamilyMemberList({visibleOnly:true}) 获取成员列表
+- 默认选中当前用户自己
+- scores 对象响应式绑定 5 维度
+- 提交调用 submitManualScore({memberId, scores}) → 跳转结果页
+
+- [ ] **Step 3: 样式遵循现有设计 Token (--color-primary, --bg, --shadow-clay 等)**
+
+#### Task 3.2: 修改 self-check-entry.vue 入口
+
+**Files:**
+- Modify: `cfc-frontend/pages/family/self-check-entry.vue`
+
+```markdown
+- [ ] **Step 1: 底部按钮区域增加两个按钮**
+```html
+<!-- 原有按钮保留 -->
+<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 class="sce-btn sce-btn-primary" @click="goManualScore">直接填分</button>
+
+<!-- 新增:MBTI 测试 (轮廓按钮) -->
+<button class="sce-btn sce-btn-outline" @click="goMbti">MBTI 人格测试</button>
+```
+
+- [ ] **Step 2: methods 新增跳转**
+```javascript
+goManualScore() { uni.navigateTo({ url: '/pages/family/manual-score-entry' }) },
+goMbti() { uni.navigateTo({ url: '/pages/family/mbti-entry' }) }
+```
+
+---
+
+### Phase 4: 前端 MBTI 页面 (4 个页面)
+
+#### Task 4.1: mbti-entry.vue (入口选成员)
+
+**Files:**
+- Create: `cfc-frontend/pages/family/mbti-entry.vue`
+
+```markdown
+- [ ] 成员横向选择卡片 (复用 manual-score-entry 逻辑)
+- 大字标题:"MBTI 人格测评" + 副标题"发现你的天赋型,获得专属育儿建议"
+- "开始测评" 按钮 → navigateTo mbti-test.vue?memberId=xxx
+```
+
+#### Task 4.2: mbti-test.vue (答题页)
+
+**Files:**
+- Create: `cfc-frontend/pages/family/mbti-test.vue`
+
+```markdown
+- [ ] 单题卡片模式 (类似 self-check.vue)
+- 进度条:当前题号 / 总题数
+- 题目文本 + 两个选项按钮 (A/B),左右分布
+- 选中高亮,可点击切换
+- 底部:上一题 / 下一题 (最后一题显示"提交")
+- 答案暂存本地数组 answers[]
+- 提交调用 submitMbti({familyMemberId, answers}) → navigateTo mbti-result.vue?id=xxx
+```
+
+#### Task 4.3: mbti-result.vue (结果页)
+
+**Files:**
+- Create: `cfc-frontend/pages/family/mbti-result.vue`
+
+```markdown
+- [ ] 大字展示类型代码 (如 INTJ) + 中文名 (架构师) + 动画入场
+- 4 维度雷达图 (Canvas 2D,复用 self-check-result 雷达逻辑,4 轴 E/I S/N T/F J/P)
+- 画像卡片:核心特质 / 优势 / 挑战 / 职业倾向 / 家庭风格 / 搭配建议
+- 五维能量联动建议卡片 (从 profile.energySuggestion 读取,展示 5 个维度建议)
+- 底部按钮:查看百科、分享、重新测评
+```
+
+#### Task 4.4: mbti-profile.vue (16 型百科)
+
+**Files:**
+- Create: `cfc-frontend/pages/family/mbti-profile.vue`
+
+```markdown
+- [ ] 4x4 网格卡片 (16 种类型),每卡片显示类型代码 + 中文名 + 1 行标签
+- 点击卡片 → 调用 getMbtiProfile(typeCode) → 弹窗或新页展示完整画像
+- 顶部搜索/筛选:按 E/I、S/N、T/F、J/P 筛选
+```
+
+---
+
+### Phase 5: 管理端 MBTI 配置
+
+#### Task 5.1: 管理端 API 与页面
+
+**Files:**
+- Create: `cfc-web/src/api/mbti.js`
+- Create: `cfc-web/src/views/admin/MbtiProfiles.vue`
+- Modify: `cfc-web/src/router/index.js`
+
+```markdown
+- [ ] **Step 1: API 封装** (mbti.js)
+```js
+export function getMbtiProfileList() { return request('/api/admin/mbti/profile/list', 'POST') }
+export function saveMbtiProfile(data) { return request('/api/admin/mbti/profile/save', 'POST', data) }
+export function deleteMbtiProfile(id) { return request('/api/admin/mbti/profile/delete', 'POST', {id}) }
+export function initMbtiSeedData() { return request('/api/admin/mbti/seed/init', 'POST') }
+```
+
+- [ ] **Step 2: MbtiProfiles.vue** (Element UI Table + Dialog 编辑)
+- 列表展示 16 行:type_code, name, is_active, 操作(编辑/删除)
+- 编辑弹窗:所有字段可编辑,energy_suggestion 用 JSON 编辑器 (textarea + 格式化)
+- "初始化种子数据"按钮调用 initMbtiSeedData()
+
+- [ ] **Step 3: 路由注册** (admin 子路由)
+```
+
+#### Task 5.2: 管理端 Controller (可选,若需后端增删改)
+
+**Files:**
+- Create: `controller/admin/MbtiProfileController.java` (若管理端走后端 CRUD)
+- 或直接在 MbtiService 暴露 admin 方法,MbtiController 复用
+
+```markdown
+- [ ] 视情况新增或复用,编译验证
+```
+
+---
+
+### Phase 6: API 文档同步与验收
+
+#### Task 6.1: 更新 API_REFERENCE.md
+
+**Files:**
+- Modify: `docs/superpowers/api/API_REFERENCE.md`
+
+```markdown
+- [ ] 在 Controller 总览表新增:
+| ManualDimensionScoreController | /api/family/manual-score | 五维手动填分 | — |
+| MbtiController | /api/family/mbti | MBTI 人格测评 | — |
+| MbtiProfileController | /api/admin/mbti/profile | MBTI 画像管理(管理端) | — |
+
+- [ ] 详细记录每个接口的请求/响应示例
+```
+
+#### Task 6.2: 全量编译 + 功能自测
+
+```markdown
+- [ ] cd cfc-backend && mvn clean compile
+- [ ] 启动后端,用 Postman/curl 测试 8 个新接口
+- [ ] 微信开发者工具预览 5 个新页面流程
+- [ ] 管理端 npm run serve 验证 MBTI 配置页
+```
+
+---
+
+## 验收标准 (Definition of Done)
+
+1. **手动填分**:家长可在 self-check-entry 点"直接填分" → 选成员 → 5 个滑块 0-100 → 提交 → 结果页显示雷达图/总分,能量沙盘实时更新
+2. **MBTI 测评**:入口 → 选成员 → 30 题答完 → 显示类型代码+画像+4维雷达+五维建议 → 可查百科
+3. **管理端**:16 型画像可增删改,种子数据一键初始化
+4. **数据库**:3 表结构正确,迁移幂等,schema.sql 同步
+5. **编译**:`mvn clean compile` 通过,无 Lint 报错
+
+---
+
+## 执行建议
+
+**推荐 Subagent-Driven 并行执行** (7 个独立子任务可并行):
+
+| 并行组 | 任务 | 预计耗时 |
+|---------|------|----------|
+| A | Task 0.1+0.2 (DB迁移+种子) | 30 min |
+| B | Task 1.1-1.3 (手动填分后端) | 45 min |
+| C | Task 2.1-2.4 (MBTI 后端) | 60 min |
+| D | Task 3.1-3.2 (前端手动填分) | 45 min |
+| E | Task 4.1-4.4 (前端 MBTI 4页) | 90 min |
+| F | Task 5.1-5.2 (管理端) | 45 min |
+| G | Task 6.1-6.2 (文档+验收) | 30 min |
+
+**两阶段 Review:** 每组完成后暂停,人工确认无冲突再合并。
+
+---
+
+**Plan complete and saved to `docs/superpowers/plans/2026-09-10-manual-dimension-mbti-plan.md`. Two execution options:**
+
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task group, review between groups, fast iteration
+
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
+
+**Which approach?**